Skip to content

Release 0.2.0: Cashier-style Laravel layer + Subscriptions resource#1

Merged
seebaermichi merged 8 commits into
mainfrom
release/0.2.0
May 16, 2026
Merged

Release 0.2.0: Cashier-style Laravel layer + Subscriptions resource#1
seebaermichi merged 8 commits into
mainfrom
release/0.2.0

Conversation

@seebaermichi
Copy link
Copy Markdown

Summary

  • Cashier-style Laravel layer — new Vorgio\Laravel\Billable trait + polymorphic Vorgio-owned tables (vorgio_billables, vorgio_subscriptions, vorgio_operations, vorgio_invoices) + opt-in webhook controller dispatching seven typed Laravel events. Consumer apps never add Vorgio columns to their domain tables.
  • Subscriptions resource with start(), changeCycle(), stop() + new Invoices::cancel() for Stornorechnung issuance.
  • OperationDerivation primitive: one caller-supplied UUIDv7 → stable per-purpose Idempotency-Key, per-index position UUIDs, and snapshot timestamp. Internal retry policy (3 attempts, 200/800/3200 ms backoff) replays the same key + body on transport / 5xx.
  • BREAKING: ?string $idempotencyKey?string $operationId on every POST method. Pre-1.0 SDK so the rename ships on this minor bump. Migration guide in CHANGELOG.md (incl. a UUIDv7-in-WooCommerce-order-meta pattern).
  • Six topical commits; PR is the unit of review.

Test plan

  • vendor/bin/pest — 94 passed (215 assertions)
  • composer format (PHP-CS-Fixer)
  • No new PHPStan errors outside the pre-existing Eloquent dynamic-property class (would need larastan to fully resolve)
  • Smoke-test examples/recurring-billing.php against the vorgio.app sandbox once V's stop-recurring / change-cycle / cancel endpoints ship per accounting-v2/plans/recurring-billing-redesign-vorgio.md
  • Smoke-test the WooCommerce plugin (vorgio-for-woocommerce) against the renamed parameter per its coordinated upgrade plan — note: the plugin currently passes a non-UUIDv7 string and will throw InvalidArgumentException until it bumps; coordinate the plugin's release before tagging this SDK.

Tagging

Once this PR is merged, tag v0.2.0 on main and push the tag — Packagist's webhook will pick it up and publish.

🤖 Generated with Claude Code

Michael Becker and others added 8 commits May 16, 2026 10:02
UUIDv5 is the deterministic SHA-1-based variant that the new
OperationDerivation primitive uses to derive stable per-purpose
idempotency sub-keys and position UUIDs from a single caller-supplied
operation id. extractTimestampMs() reads the embedded ms-prefix back
out of a UUIDv7 so retry callers can snapshot a "now" timestamp that
survives midnight-cross retries.

NAMESPACE_VORGIO_OP is locked in as a public constant — changing it
later would silently invalidate every previously-issued idempotency
key, so it's part of the protocol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OperationDerivation turns one caller-supplied UUIDv7 into three stable
correctness primitives: a per-purpose Idempotency-Key (so chained POSTs
under one operation each independently dedupe against Vorgio's
middleware), per-index position UUIDs, and a snapshotted "now" instant
parsed from the UUIDv7's ms-prefix (so retries crossing midnight
produce bit-identical bodies).

RetryPolicy carries the retry schedule for the upcoming VorgioClient
retry wiring: defaults to three attempts with 200/800/3200 ms backoff,
configurable so tests can opt into zero-delay retries without slowing
the suite. RetryPolicy::disabled() is the explicit one-shot policy
used by the existing mock-client helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…methods

VorgioClient now accepts an optional RetryPolicy and retries up to 3
times on transport-level failures and 5xx responses, replaying the
same Idempotency-Key + body each attempt. The auto-generated UUIDv7
key is computed once outside the retry loop so the Vorgio middleware
sees identical headers across attempts and replays its cached 2xx.
4xx is never retried; 429 keeps its existing Retry-After-aware path.

BREAKING: the ?string \$idempotencyKey parameter on Invoices::create,
Invoices::send, Invoices::markPaid, Clients::create, and
Checkouts::create is renamed to ?string \$operationId. The new
parameter must be a UUIDv7; the SDK derives a stable per-purpose
Idempotency-Key from it via OperationDerivation. SDK is pre-1.0, so
breaking changes on a minor bump are permitted by semver convention.

A new AbstractResource::idempotencyHeader() helper centralises the
derivation so each resource method stays a one-liner. Invoices gains
a cancel() method targeting POST /v1/invoices/{id}/cancel for German
Stornorechnung issuance.

VorgioClient::VERSION bumped to 0.2.0. The vorgioMockClient() test
helper defaults to a no-retry policy so existing single-response tests
continue to observe expected failures; retry-specific tests pass a
zero-backoff RetryPolicy explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new Resource\Subscriptions ships under \$vorgio->subscriptions(),
mapping cleanly onto Vorgio's recurring-template verbs:

  - start()        → POST /v1/checkouts (composite endpoint that
                     already provisions client + invoice + send;
                     the new payload key `every` makes it recurring)
  - changeCycle()  → POST /v1/invoices/{id}/change-cycle
  - stop()         → POST /v1/invoices/{id}/stop-recurring

stop() sets `every = null` server-side without deleting the template,
preserving the operator's audit trail. changeCycle() mutates only
cadence + next_invoice_at; it cannot touch positions or amounts, so
the audit story for the recurring invoice stays clean.

Per-purpose operationId derivation means a caller can chain
start → changeCycle → stop under one operation id without idempotency
key collisions — each gets a different sub-key via OperationDerivation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vorgio-php gains a thicker Laravel integration that absorbs every
correctness primitive integrators were reinventing by hand. Consumer
Eloquent models use Vorgio\Laravel\Billable and get:

  subscribe(string \$every, array \$invoicePayload, array \$clientPayload = []): Subscription
  changeBillingCycle(string \$every, ?string \$nextInvoiceAt = null): Subscription
  cancelSubscription(string \$childInvoiceStrategy = 'stop-only'): void
  createAsVorgioCustomer(array \$clientPayload): VorgioBillable

The operation-id state machine lives entirely inside the trait. A
pending row in vorgio_operations (polymorphically keyed on the
consumer model — not on vorgio_billables, so the first-time subscribe
bootstrap-then-retry case works) carries the UUIDv7 that derives every
Idempotency-Key. Queue retries pick up the same row, reuse the same
operation id, and replay the cached 2xx on the Vorgio side. No
columns are ever added to the consumer's domain tables.

Four auto-loaded migrations create polymorphic Vorgio-owned tables:

  vorgio_billables       morphTo the consumer model → vorgio_client_id
  vorgio_subscriptions   recurring-template state
  vorgio_operations      transient retry-safety state
  vorgio_invoices        local webhook mirror

cancelSubscription accepts 'stop-only' | 'storno-always' |
'storno-if-unpaid' so consumers can map "cancel my subscription"
to whatever semantics their product needs (SaaS default is the
third).

A new Http\WebhookController is registered at the configured route
when VORGIO_WEBHOOK_SECRET is set. It verifies signatures via the
existing Webhooks::constructEvent, upserts the local mirrors, and
dispatches seven typed Laravel events
(VorgioCustomerCreated, VorgioSubscriptionStarted/Stopped,
VorgioBillingCycleChanged, VorgioInvoiceSent/Paid/Cancelled) for
listeners to consume. Re-delivery is idempotent via updateOrCreate.

VorgioServiceProvider now wires the RetryPolicy into the singleton
from config('vorgio.retry'), loads migrations, registers the webhook
route, and publishes both vorgio-config and the new vorgio-migrations
groups. The config file is rewritten with the webhook + retry blocks
and folds in the dogfood-item-5 preset-comment cleanup.

orchestra/testbench is added as a dev dependency so the Laravel-layer
tests can boot a real Laravel application with an in-memory SQLite,
RefreshDatabase, and route registration assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG.md ships in Keep-a-Changelog format with the v0.2.0 entry
covering everything in the release: the Subscriptions resource,
Invoices::cancel(), OperationDerivation, the retry policy, the
Cashier-style Laravel layer, and the breaking idempotencyKey →
operationId rename. The migration guide includes a worked
WooCommerce-style pattern (persist UUIDv7 in order meta) and an
explicit note that the vorgio-for-woocommerce plugin needs a
coordinated release before bumping its SDK constraint to ^0.2.0.

Two new examples:

  examples/recurring-billing.php — framework-agnostic smoke flow
    (start → changeCycle → stop) using one operation id. Mirrors
    the style of examples/checkout.php.

  examples/cashier-style.php — Laravel-trait sketch covering
    subscribe, changeBillingCycle, cancelSubscription, the
    cancellation strategies, and event listening.

README gains a "Recurring billing" section after the existing
Idempotency section that documents the operationId pattern, the
internal retry, and the subscriptions() resource. The Laravel
section is extended with the Cashier-style flow on top of the
existing facade documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add Larastan, phpstan-pest, and Pest's PHPStan extensions so the Laravel
integration code analyses cleanly under level 5. Annotate Eloquent models
with @Property blocks, type the Billable trait's relations with full
generics, and drop now-unneeded @var overrides.

Real bug fixed in Billable::cancelSubscription: redundant
'storno-if-unpaid' === 'storno-if-unpaid' branch already narrowed by the
stop-only early-return.

Move postRawWebhook onto TestCase so its $this->call() is typed, and
drop the unused postSignedWebhook helper.

CLAUDE.md updated to reflect Subscriptions, Support utilities, the
operationId pattern, and the Cashier-style Laravel layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rnel

imsuperlative/phpstan-pest requires PHP 8.4 but the SDK targets 8.2+,
so composer install failed on the 8.2 and 8.3 CI matrix entries.

Replace the typed-$this trick with a global postRawWebhook helper that
builds a Symfony Request and hands it straight to app(Kernel::class).
Larastan's app() return type makes this fully typed without needing
PHPStan to understand Pest's runtime $this binding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@seebaermichi seebaermichi merged commit cb37c2f into main May 16, 2026
5 checks passed
@seebaermichi seebaermichi deleted the release/0.2.0 branch May 16, 2026 08:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant