diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 517b1f6..db3c781 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,32 @@ jobs: restore-keys: ${{ runner.os }}-php-${{ matrix.php }}- - name: Install dependencies - run: composer install --prefer-dist --no-interaction --no-progress + # phpnomad/phpstan-rules (dev-only static-analysis tooling, never loaded + # at test runtime) requires PHP ^8.2; the library itself supports 8.1. + # The 8.1 job ignores the platform check so the SDK + PHPUnit still run + # on the oldest supported PHP. The lint job runs on 8.3 with full checks. + run: composer install --prefer-dist --no-interaction --no-progress ${{ matrix.php == '8.1' && '--ignore-platform-req=php' || '' }} - name: Run test suite run: composer test + + lint: + name: Lint (PHPStan) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: json + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run PHPStan + run: composer lint diff --git a/AGENTS.md b/AGENTS.md index b1e56b2..1e7f523 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ after every change and leave them green: ```bash composer install # ci.setup composer test # ci.test +composer lint # ci.lint ``` What "done" means (PR audit, testing tiers, UAT proof) is defined in the KB: diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cbe4d..52a0c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Typed taxonomy exports so integrations use SDK constants instead of magic + strings: `EventSlug` (built-in ingestion slugs) and the status vocabularies + `ConversionStatus`, `TransactionStatus`, `ObligationStatus`, `PayoutStatus`, + `FulfillmentStatus`, `OpportunityStatus`, `ApiKeyStatus`, and + `WebhookSubscriptionStatus`. +- `WebhookEventType`: added the missing `CREDIT_ISSUED`, `CREDIT_REDEEMED`, + `CURRENCY_CREATED`, and `CURRENCY_DELETED` events (also added to + `openapi.yaml`), matching the full set Siren dispatches. +- PHPStan static analysis (level 6) with the PHPNomad stack-elevator ruleset, + wired as `composer lint` and a CI lint job. + ## [0.1.0] - 2026-07-11 ### Added diff --git a/README.md b/README.md index 09c3471..12db904 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,13 @@ $secret = $subscription['signingSecret']; - **Reconciliation readers** — paginated iterators over conversions, transactions, obligations, and payouts. - **Automatic retries** — network errors, 429s, and 5xx are retried with exponential backoff on idempotent operations; management writes are never auto-retried. - **Typed exceptions** — every error extends `Siren\Sdk\Exception\SirenException` and carries the status code, error code, and error data. +- **Typed taxonomy** — Siren's domain vocabulary as constants, so no magic strings cross the boundary: `WebhookEventType`, `EventSlug`, and the status vocabularies (`ConversionStatus`, `TransactionStatus`, `ObligationStatus`, `PayoutStatus`, `FulfillmentStatus`, `OpportunityStatus`, `ApiKeyStatus`, `WebhookSubscriptionStatus`). + +```php +use Siren\Sdk\ConversionStatus; + +$approved = $siren->conversions->list(['status' => ConversionStatus::APPROVED]); +``` ### Configuration diff --git a/composer.json b/composer.json index afe959c..9164798 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,8 @@ "guzzlehttp/guzzle": "^7.8" }, "require-dev": { + "phpnomad/phpstan-rules": "^2.1", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5" }, "autoload": { @@ -45,7 +47,8 @@ } }, "scripts": { - "test": "phpunit" + "test": "phpunit", + "lint": "phpstan analyse --no-progress" }, "config": { "sort-packages": true diff --git a/navigator.yaml b/navigator.yaml index 299602e..48b691b 100644 --- a/navigator.yaml +++ b/navigator.yaml @@ -2,6 +2,7 @@ initiative: siren ci: setup: composer install test: composer test + lint: composer lint knowledgeDependencies: - initiative: phpnomad description: "Org-wide engineering doctrine" diff --git a/openapi.yaml b/openapi.yaml index 7476aab..117ab6e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -403,6 +403,10 @@ components: - conversion.rejected - conversion.renewed - coupon.applied + - credit.issued + - credit.redeemed + - currency.created + - currency.deleted - distribution.completed - engagement.awarded - engagement.completed diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..9929173 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,17 @@ +includes: + # PHPNomad layer-purity ruleset (Stack Elevator: Core -> Service -> Platform). + # This SDK is a flat library (Siren\Sdk, no Core/Service segments), so the + # layer rules have nothing to flag today — they are wired in so that any + # future layered namespace is checked from day one. + - vendor/phpnomad/phpstan-rules/stack-elevator.neon + +parameters: + level: 6 + paths: + - src + treatPhpDocTypesAsCertain: false + phpNomadStackElevator: + namespaceRoots: + - Siren + - Novatorius + - PHPNomad diff --git a/src/ApiKeyStatus.php b/src/ApiKeyStatus.php new file mode 100644 index 0000000..966f56b --- /dev/null +++ b/src/ApiKeyStatus.php @@ -0,0 +1,18 @@ + */ public function json(): array { diff --git a/src/ConversionStatus.php b/src/ConversionStatus.php new file mode 100644 index 0000000..b2915ee --- /dev/null +++ b/src/ConversionStatus.php @@ -0,0 +1,24 @@ +|null $errorData + */ public function __construct( string $message, int $statusCode = 429, diff --git a/src/Exception/SirenException.php b/src/Exception/SirenException.php index 0b5f1a3..f054f79 100644 --- a/src/Exception/SirenException.php +++ b/src/Exception/SirenException.php @@ -13,6 +13,9 @@ */ class SirenException extends \RuntimeException { + /** + * @param array|null $errorData + */ public function __construct( string $message, private readonly int $statusCode = 0, @@ -35,7 +38,11 @@ public function getErrorCode(): ?string return $this->errorCode; } - /** Structured `error.data` from the response body, when present. */ + /** + * Structured `error.data` from the response body, when present. + * + * @return array|null + */ public function getErrorData(): ?array { return $this->errorData; diff --git a/src/FulfillmentStatus.php b/src/FulfillmentStatus.php new file mode 100644 index 0000000..ba3456d --- /dev/null +++ b/src/FulfillmentStatus.php @@ -0,0 +1,21 @@ +total; } + /** @return \ArrayIterator> */ public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->data); diff --git a/src/ObligationStatus.php b/src/ObligationStatus.php new file mode 100644 index 0000000..671814c --- /dev/null +++ b/src/ObligationStatus.php @@ -0,0 +1,25 @@ + + */ + private function constantValues(string $class): array + { + $values = array_values((new ReflectionClass($class))->getConstants()); + sort($values); + + return $values; + } + + public function testWebhookEventTypeMatchesCanonicalSet(): void + { + $expected = [ + '*', + 'allocation.completed', + 'collaborator.created', + 'collaborator.registered', + 'conversion.approved', + 'conversion.created', + 'conversion.rejected', + 'conversion.renewed', + 'coupon.applied', + 'credit.issued', + 'credit.redeemed', + 'currency.created', + 'currency.deleted', + 'distribution.completed', + 'engagement.awarded', + 'engagement.completed', + 'engagement.created', + 'fulfillment.created', + 'fulfillment.updated', + 'lead.created', + 'metrics.updated', + 'obligation.completed', + 'obligation.created', + 'opportunity.created', + 'opportunity.invalidated', + 'payout.created', + 'payout.paid', + 'refund.created', + 'renewal.created', + 'sale.created', + 'transaction.completed', + 'transaction.created', + ]; + sort($expected); + + $this->assertSame($expected, $this->constantValues(WebhookEventType::class)); + } + + public function testWebhookEventTypeHasNoDuplicateValues(): void + { + $values = $this->constantValues(WebhookEventType::class); + + $this->assertSame($values, array_values(array_unique($values))); + } + + public function testEventSlugMatchesBuiltInIngestionSlugs(): void + { + $this->assertSame(['refund', 'sale', 'site-visited'], $this->constantValues(EventSlug::class)); + } + + public function testConversionStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['approved', 'deleted', 'expired', 'pending', 'rejected'], + $this->constantValues(ConversionStatus::class) + ); + } + + public function testTransactionStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['cancelled', 'complete', 'refunded'], + $this->constantValues(TransactionStatus::class) + ); + } + + public function testObligationStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['cancelled', 'complete', 'fulfilled', 'pending'], + $this->constantValues(ObligationStatus::class) + ); + } + + public function testPayoutStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['failed', 'paid', 'processing', 'unpaid'], + $this->constantValues(PayoutStatus::class) + ); + } + + public function testFulfillmentStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['complete', 'failed', 'pending', 'processing'], + $this->constantValues(FulfillmentStatus::class) + ); + } + + public function testOpportunityStatusMatchesCanonicalSet(): void + { + $this->assertSame( + ['active', 'inactive', 'invalid'], + $this->constantValues(OpportunityStatus::class) + ); + } + + public function testApiKeyStatusMatchesCanonicalSet(): void + { + $this->assertSame(['active', 'revoked'], $this->constantValues(ApiKeyStatus::class)); + } + + public function testWebhookSubscriptionStatusMatchesCanonicalSet(): void + { + $this->assertSame(['active', 'paused'], $this->constantValues(WebhookSubscriptionStatus::class)); + } +} diff --git a/tests/WebhooksTest.php b/tests/WebhooksTest.php index cc4e53a..c13c5d4 100644 --- a/tests/WebhooksTest.php +++ b/tests/WebhooksTest.php @@ -214,7 +214,8 @@ public function testEventTypeCatalogIsComplete(): void $constants = (new \ReflectionClass(WebhookEventType::class))->getConstants(); $this->assertSame('*', $constants['ALL']); - $this->assertCount(28, $constants, 'Expected 27 event types plus ALL.'); + // The full canonical set is pinned in TaxonomyTest. + $this->assertCount(32, $constants, 'Expected 31 event types plus ALL.'); foreach ($constants as $name => $value) { if ($name === 'ALL') {