feat(billing): Stripe owns the billing cycle, we own capability

A Product per plan family and a Price per priced row of a published version,
plus the four subscribed webhook events that were arriving and being ignored.

`stripe:sync-catalogue` mirrors the catalogue. Idempotent twice over: a stored
id is skipped, and each call carries an idempotency key derived from our own
row, so a crash between Stripe creating a Price and us recording its id gives
back the same object rather than a second one. That matters more here than
usual — a Stripe Price cannot be edited or deleted, so a duplicate is
permanent. Drafts are not synced at all: a version that has promised nothing
has no business in a price list. --dry-run shows what would be created.

The webhook now handles invoice.paid, invoice.payment_failed,
customer.subscription.updated and customer.subscription.deleted. It never
re-derives an amount: the invoice is the authority for what was charged, and
recomputing it from our catalogue would produce a second, disagreeing answer.
A failed payment is recorded and nothing else — Stripe runs the dunning
schedule, and cutting a customer off on the first failure would punish an
expired card as though it were a refusal to pay.

Only a cycle renewal moves the term on. Stripe also sends paid invoices for
prorations and manual charges, and the checkout's own invoice — which is
already in the register as the purchase, and would otherwise double every
customer's first payment.

Stripe does not guarantee delivery order, which is the source of most of the
care here:
- state changes are judged and written in one transaction with the row held,
  so two deliveries cannot both pass a check made on a stale copy;
- an older event never overwrites a newer one, with a rank breaking ties
  between events sharing a second — their timestamps have one-second
  resolution, and a failed attempt and its successful retry routinely do;
- a term is never shortened, and an ended contract is never revived;
- an event for a contract that does not exist yet is HELD and replayed the
  moment it appears, rather than acknowledged and forgotten. A cancellation
  dropped that way would leave us serving someone who had already left. Held
  rows that never match are pruned after a week.

Register entries are keyed by what they are about — the invoice, the attempt,
the subscription — with a unique index doing the work, because a check two
concurrent deliveries can both pass is not idempotency.

PlanChange is now documented as the preview shown before someone confirms, not
the invoice: Stripe's proration accounts for tax, existing credit and the exact
second the change lands, and ours cannot.

Not run against the live account — `stripe:sync-catalogue` creates objects that
cannot be deleted, so that is the owner's call. The dry run lists 12.

457 tests green. Codex review clean after seven rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 13:36:28 +02:00
parent 7582df3de6
commit 0560ae743d
24 changed files with 1582 additions and 9 deletions

View File

@ -0,0 +1,443 @@
<?php
namespace App\Actions;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* What Stripe tells us about the billing cycle, applied to the contract.
*
* The division of labour: Stripe owns the money retries, dunning, off-session
* SCA, invoice numbering and we own capability, because Stripe does not know
* how big the VM should be. So these handlers move period boundaries and status,
* and write the register. They never re-derive an amount: the invoice is the
* authority for what was charged, and recomputing it here from our own
* catalogue would produce a second, disagreeing answer.
*
* Every handler is idempotent. Stripe retries a webhook until it gets a 2xx,
* and it also sends the same event to several endpoints so "already applied"
* is the normal case, not the exception.
*/
class ApplyStripeBillingEvent
{
/**
* Precedence when two events share a second highest wins.
*
* Ordered by how much the event settles: a payment that went through is the
* last word, a snapshot of the subscription is a running picture, and a
* failed attempt is the least final of the three, since a retry may already
* have succeeded.
*/
private const RANK_PAID = 3;
private const RANK_UPDATED = 2;
private const RANK_FAILED = 1;
public function __construct(private RecordCommercialEvent $record) {}
/**
* A renewal was paid. Move the period on and enter it in the register.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaid(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$reason = (string) ($invoice['billing_reason'] ?? '');
// The checkout's own invoice is not a renewal — it is the purchase, and
// OpenSubscription has already entered it. Recording it again here
// would double every customer's first payment in the register.
if ($reason === 'subscription_create') {
return null;
}
// Only a cycle renewal moves the term on. Stripe also sends paid
// invoices for prorations and for charges raised by hand, and those are
// money received without being the start of a new month — calling them
// renewals would push the period forward on a top-up.
$isRenewal = $reason === 'subscription_cycle';
$invoiceId = (string) ($invoice['id'] ?? '');
[$start, $end] = $this->period($invoice);
// A contract that has ended stays ended (mutateInOrder refuses one), so
// a final invoice arriving after the deletion cannot hand a departed
// customer their service back. The payment is still entered in the
// register below; it did happen.
if ($isRenewal && $start !== null && $end !== null) {
$this->mutateInOrder($subscription, $eventAt, self::RANK_PAID, fn (Subscription $fresh) => $end
// Never backwards: a renewal delayed behind the next one would
// otherwise shorten the term the customer has already paid for.
->greaterThanOrEqualTo($fresh->current_period_end)
// Period boundaries are not part of the frozen snapshot:
// what the customer is owed does not change, only which
// month it is.
? [
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
'stripe_status' => 'active',
]
: null);
}
// One invoice, one entry — enforced by the unique index rather than by
// a check, because Stripe can deliver the same invoice twice at once
// and both deliveries would pass a check.
$event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;
return $this->recordOnce(
fn () => ($this->record)(
event: $event,
subscription: $subscription->refresh(),
netCents: $isRenewal ? $subscription->price_cents : 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
// The exact kind, kept so a proration or a manual charge stays
// distinguishable from an ordinary renewal.
extra: ['billing_reason' => $reason ?: null],
// Stripe's total is what was actually taken, tax and all. Ours
// is what was agreed; the register states both.
chargedGrossCents: isset($invoice['amount_paid']) ? (int) $invoice['amount_paid'] : null,
eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
)
);
}
/**
* A renewal failed. Recorded, and nothing else: Stripe runs the dunning
* schedule, and cutting a customer off on the first failed attempt would
* punish an expired card as though it were a refusal to pay.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaymentFailed(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$invoiceId = (string) ($invoice['id'] ?? '');
// Only if this is the newest word we have. A failed attempt delayed
// behind the successful retry would otherwise flip a customer who has
// since paid back to past_due.
$this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);
return $this->recordOnce(
fn () => ($this->record)(
event: SubscriptionRecord::EVENT_PAYMENT_FAILED,
subscription: $subscription->refresh(),
netCents: 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
extra: ['attempt' => $invoice['attempt_count'] ?? null],
chargedGrossCents: 0,
// Per attempt, not per invoice: Stripe retries a failing
// payment on a schedule, and each attempt is its own event.
eventKey: $invoiceId !== ''
? 'payment_failed:'.$invoiceId.':'.($invoice['attempt_count'] ?? 0)
: null,
)
);
}
/**
* Stripe's own status changed. Theirs is the authority on whether the money
* is arriving; we copy it, and leave what the customer may USE to our own
* status, which only a cancellation or an operator changes.
*
* @param array<string, mixed> $object
*/
public function subscriptionUpdated(array $object, ?Carbon $eventAt = null): ?Subscription
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
[$start, $end] = $this->period($object);
// Ended is ended, whatever order events arrive in — and an older
// snapshot must never overwrite a newer one it was delivered behind.
$this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
'stripe_status' => $object['status'] ?? null,
'current_period_start' => $start,
'current_period_end' => $end,
], fn ($value) => $value !== null));
return $subscription;
}
/**
* The subscription has ended at Stripe. That is the end of the contract,
* so it goes in the register a cancellation nobody recorded is exactly
* the kind of gap the register exists to prevent.
*
* @param array<string, mixed> $object
*/
public function subscriptionDeleted(array $object, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
$endedAt = isset($object['ended_at'])
? Carbon::createFromTimestamp((int) $object['ended_at'])
: now();
// The ending and its entry in the register commit together. Marking the
// contract cancelled first and failing before the record would leave a
// retry seeing "already cancelled" and returning — and the end of a
// contract would never be entered at all.
return $this->recordOnce(fn () => DB::transaction(function () use ($subscription, $object, $endedAt, $eventAt) {
// Held while we decide, so a renewal arriving at the same moment
// cannot reactivate the contract between this and the write.
$subscription = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->firstOrFail();
// No ordering guard: an ending is final, so a late delivery of it
// is still correct. Only the running picture can go stale.
$subscription->update([
'status' => 'cancelled',
'stripe_status' => $object['status'] ?? 'canceled',
'cancelled_at' => $endedAt,
'stripe_event_at' => $eventAt,
]);
return ($this->record)(
event: SubscriptionRecord::EVENT_CANCELLATION,
subscription: $subscription->refresh(),
netCents: 0,
at: $endedAt,
stripe: ['subscription' => $subscription->stripe_subscription_id],
chargedGrossCents: 0,
// A contract ends once. Two deliveries arriving together would
// both pass a status check; only one can take this key.
eventKey: 'cancellation:'.$subscription->id,
);
}));
}
/**
* Whether this event is the newest word we have about the contract.
*
* Stripe does not guarantee delivery order, and the state here is a
* running picture rather than a log: applying a stale snapshot on top of a
* fresher one is how a paid-up customer ends up looking overdue. The
* register is unaffected each entry is keyed by what it is about, so a
* late arrival still lands once, in its proper place.
*/
/**
* Decide and write in one step, with the contract's row held.
*
* The ordering guard is only worth as much as its atomicity: two
* deliveries for the same contract can both read a stale copy, both pass
* the check, and the loser then overwrites the winner a renewal
* reactivating a contract a deletion had just ended, or an old failure
* burying a payment that went through. So the row is re-read under a lock
* and re-judged inside the transaction that writes it.
*
* `$changes` returns the columns to set, or null to decline.
*
* @param callable(Subscription): ?array<string, mixed> $changes
*/
private function mutateInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank, callable $changes): void
{
DB::transaction(function () use ($subscription, $eventAt, $rank, $changes) {
$fresh = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
if ($fresh === null
|| $fresh->status === 'cancelled'
|| ! $this->appliesInOrder($fresh, $eventAt, $rank)) {
return;
}
$values = $changes($fresh);
if ($values === null) {
return;
}
if ($eventAt !== null) {
$values['stripe_event_at'] = $eventAt;
$values['stripe_event_rank'] = $rank;
}
$fresh->update($values);
});
$subscription->refresh();
}
private function appliesInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank): bool
{
if ($eventAt === null || $subscription->stripe_event_at === null) {
return true; // nothing to compare against
}
if ($eventAt->greaterThan($subscription->stripe_event_at)) {
return true;
}
if ($eventAt->lessThan($subscription->stripe_event_at)) {
return false;
}
// Same second, which Stripe's one-second timestamps make common enough
// to matter: a failed attempt and the retry that succeeded can share
// one. Decide by what the event says about the money.
return $rank >= (int) ($subscription->stripe_event_rank ?? 0);
}
/**
* Write a register entry, unless one already exists for this invoice and
* event.
*
* The uniqueness lives in the database, on (stripe_invoice_id, event).
* Checking first and inserting afterwards leaves a window that Stripe
* delivering the same invoice twice at once walks straight through and a
* register that counts a payment twice is worse than one that is late.
*
* @param callable(): SubscriptionRecord $write
*/
private function recordOnce(callable $write): ?SubscriptionRecord
{
try {
return $write();
} catch (UniqueConstraintViolationException) {
return null; // already recorded by a concurrent delivery
}
}
/**
* Hold an event whose contract does not exist yet, so it is not lost.
*
* @param array<string, mixed> $event
*/
public function hold(array $event): void
{
$subscriptionId = $event['data']['object']['subscription']
?? $event['data']['object']['id']
?? null;
if (! is_string($subscriptionId) || ! is_string($event['id'] ?? null)) {
return;
}
StripePendingEvent::query()->updateOrCreate(
['stripe_event_id' => $event['id']],
[
'stripe_subscription_id' => $subscriptionId,
'type' => (string) ($event['type'] ?? ''),
'raised_at' => isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null,
'payload' => $event,
],
);
}
/**
* Replay everything held for a contract that has just appeared.
*
* In their original order, so the ordering guard sees them as Stripe raised
* them rather than as they happened to be stored.
*/
public function replayHeldFor(Subscription $subscription): int
{
if ($subscription->stripe_subscription_id === null) {
return 0;
}
$held = StripePendingEvent::query()
->where('stripe_subscription_id', $subscription->stripe_subscription_id)
->orderBy('raised_at')
->orderBy('id')
->get();
foreach ($held as $pending) {
$this->dispatch($pending->payload ?? []);
$pending->delete();
}
return $held->count();
}
/**
* Route one event to its handler. Returns false for a type we do not
* handle, so the caller can tell "not ours" from "nothing to do".
*
* @param array<string, mixed> $event
*/
public function dispatch(array $event): mixed
{
$object = $event['data']['object'] ?? [];
$raisedAt = isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null;
return match ($event['type'] ?? '') {
'invoice.paid' => $this->invoicePaid($object, $raisedAt),
'invoice.payment_failed' => $this->invoicePaymentFailed($object, $raisedAt),
'customer.subscription.updated' => $this->subscriptionUpdated($object, $raisedAt),
'customer.subscription.deleted' => $this->subscriptionDeleted($object, $raisedAt),
default => false,
};
}
/**
* Find the contract a Stripe event is about.
*
* An unknown id is logged and dropped rather than raised: Stripe delivers
* events for objects created by hand in the dashboard, and by other
* environments pointed at the same endpoint. Failing the webhook for those
* would have Stripe retry something that can never succeed.
*/
private function resolve(mixed $stripeSubscriptionId): ?Subscription
{
$id = is_string($stripeSubscriptionId) ? $stripeSubscriptionId : null;
if ($id === null) {
return null;
}
return Subscription::query()->where('stripe_subscription_id', $id)->first();
}
/**
* @param array<string, mixed> $object
* @return array{0: ?Carbon, 1: ?Carbon}
*/
private function period(array $object): array
{
$start = $object['period_start'] ?? $object['current_period_start'] ?? null;
$end = $object['period_end'] ?? $object['current_period_end'] ?? null;
return [
is_numeric($start) ? Carbon::createFromTimestamp((int) $start) : null,
is_numeric($end) ? Carbon::createFromTimestamp((int) $end) : null,
];
}
}

View File

@ -57,6 +57,9 @@ class OpenSubscription
[
'customer_id' => $order->customer_id,
'order_id' => $order->id,
// Carried from the checkout: from here on, Stripe's events
// name this and nothing else.
'stripe_subscription_id' => $order->stripe_subscription_id,
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY

View File

@ -36,6 +36,7 @@ class RecordCommercialEvent
array $stripe = [],
?int $chargedGrossCents = null,
?Order $order = null,
?string $eventKey = null,
): SubscriptionRecord {
$at ??= now();
$customer = $subscription->customer;
@ -64,6 +65,10 @@ class RecordCommercialEvent
return SubscriptionRecord::create([
'event' => $event,
// What makes this event "the same one" if it arrives again. Unique
// where set, so a duplicate delivery collides in the database
// rather than in a check that two of them can both pass.
'event_key' => $eventKey,
'customer_id' => $subscription->customer_id,
'subscription_id' => $subscription->id,

View File

@ -20,7 +20,10 @@ use Throwable;
*/
class StartCustomerProvisioning
{
public function __construct(private OpenSubscription $openSubscription) {}
public function __construct(
private OpenSubscription $openSubscription,
private ApplyStripeBillingEvent $billing,
) {}
/**
* @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string} $event
@ -51,6 +54,7 @@ class StartCustomerProvisioning
'currency' => $event['currency'],
'datacenter' => $event['datacenter'],
'stripe_event_id' => $event['id'],
'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null,
'status' => 'paid',
]);
@ -193,7 +197,12 @@ class StartCustomerProvisioning
return;
}
($this->openSubscription)($order);
$subscription = ($this->openSubscription)($order);
// Anything Stripe sent about this contract before it existed —
// a renewal, a failure, a cancellation — applied now, in the order
// they were raised.
$this->billing->replayHeldFor($subscription);
} catch (Throwable $e) {
Log::error('Failed to open a contract for a paid order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),

View File

@ -0,0 +1,135 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Mirrors our catalogue into Stripe: a Product per plan family, a Price per
* priced row.
*
* Stripe owns the recurring billing retries, dunning, off-session SCA,
* invoice numbering so it needs to know what it is billing for. It does not
* need to know how big the VM is, and it is not asked.
*
* Idempotent by construction: a row that already carries an id is skipped. That
* matters more here than usual, because a Stripe Price cannot be edited, so a
* second run that minted duplicates would leave two live prices for one plan
* and no way to tell which a customer is on.
*
* Only PUBLISHED versions are synced. A draft has promised nothing, and a
* Product for it would be a price list entry for something that may never
* exist.
*/
class SyncStripeCatalogue extends Command
{
protected $signature = 'stripe:sync-catalogue {--dry-run : Show what would be created without touching Stripe}';
protected $description = 'Create the Stripe products and prices for the plan catalogue';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.');
return self::FAILURE;
}
$created = 0;
foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
$published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished());
// A family whose versions are all drafts has promised nothing, so
// it has no business appearing in Stripe's price list yet.
if ($published->isEmpty()) {
continue;
}
$productId = $family->stripe_product_id;
if ($productId === null) {
$this->line(" product {$family->key}{$family->name}");
$created++;
if (! $dryRun) {
$productId = $stripe->createProduct(
$family->name,
['plan_family' => $family->key, 'plan_family_id' => (string) $family->id],
// Keyed on our row, so a crash between Stripe creating
// the product and us storing its id gives back the same
// product on the next run rather than a second one.
idempotencyKey: "clupilot-product-{$family->id}",
);
$family->update(['stripe_product_id' => $productId]);
}
}
foreach ($published as $version) {
foreach ($version->prices as $price) {
if ($price->stripe_price_id !== null) {
continue;
}
$this->line(sprintf(
' price %s v%d %s %d %s',
$family->key, $version->version, $price->term, $price->amount_cents, $price->currency,
));
$created++;
if ($dryRun || $productId === null) {
continue;
}
$priceId = $stripe->createPrice(
productId: $productId,
amountCents: $price->amount_cents,
currency: $price->currency,
interval: $price->term === 'yearly' ? 'year' : 'month',
metadata: [
'plan_family' => $family->key,
'plan_version' => (string) $version->version,
'plan_version_id' => (string) $version->id,
'plan_price_id' => (string) $price->id,
],
idempotencyKey: "clupilot-price-{$price->id}",
);
// Written straight through the query builder: stripe_price_id
// is not part of what publication froze, and the model would
// otherwise have to be re-read first.
PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
}
}
}
$this->newLine();
if ($created === 0) {
$this->info('Stripe is already in step with the catalogue.');
return self::SUCCESS;
}
$this->info($dryRun
? "{$created} object(s) would be created. Run without --dry-run to create them."
: "{$created} object(s) created in Stripe.");
return self::SUCCESS;
}
/** Versions whose prices are live in Stripe, for the status line. */
public static function syncedVersions(): int
{
return PlanVersion::query()
->whereNotNull('published_at')
->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id'))
->count();
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -13,8 +14,11 @@ use Illuminate\Http\Request;
*/
class StripeWebhookController extends Controller
{
public function __invoke(Request $request, StartCustomerProvisioning $action): JsonResponse
{
public function __invoke(
Request $request,
StartCustomerProvisioning $action,
ApplyStripeBillingEvent $billing,
): JsonResponse {
$payload = $request->getContent();
$secret = (string) config('services.stripe.webhook_secret');
@ -30,6 +34,26 @@ class StripeWebhookController extends Controller
$object = $event['data']['object'] ?? [];
$type = $event['type'] ?? '';
// The billing cycle is Stripe's: once a contract exists, they decide
// when it renews, when a payment failed, and when it has ended. Handled
// before the checkout branch because none of these are checkouts.
$applied = $billing->dispatch($event);
if ($applied !== false) {
if ($applied === null) {
// Either already applied, or about a contract that does not
// exist yet — a checkout takes a moment to become one, and
// Stripe does not deliver in order. Holding it costs a row and
// saves a cancellation we would otherwise never hear about
// again; a replay is a no-op if it was simply a duplicate.
$billing->hold($event);
}
// 2xx either way. Stripe retrying would not change the answer, and
// anything worth replaying is now held rather than dropped.
return response()->json(['handled' => $type, 'applied' => $applied !== null]);
}
// Paid triggers: a synchronous checkout (completed + paid) OR an async
// method clearing later (async_payment_succeeded). Ignore everything else,
// including the still-unpaid completed event for async methods.
@ -55,6 +79,9 @@ class StripeWebhookController extends Controller
'email' => $email,
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
'stripe_customer_id' => $object['customer'] ?? null,
// The handle every later billing event arrives with. Without it an
// invoice.paid cannot be matched to the contract it renews.
'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null,
'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created
// before phase 5 put it there, and then the version on sale applies.

View File

@ -18,7 +18,7 @@ class Order extends Model implements ProvisioningSubject
protected $fillable = [
'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency',
'datacenter', 'stripe_event_id', 'status',
'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'status',
];
protected function casts(): array

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* A billing event held until the contract it is about exists.
*
* See the migration for why: Stripe does not guarantee delivery order, and
* answering 2xx to an event we cannot match yet would drop it for good.
*/
class StripePendingEvent extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'payload' => 'array',
'raised_at' => 'datetime',
];
}
}

View File

@ -62,6 +62,7 @@ class Subscription extends Model
'current_period_end' => 'datetime',
'pending_effective_at' => 'datetime',
'cancelled_at' => 'datetime',
'stripe_event_at' => 'datetime',
];
}

View File

@ -27,6 +27,19 @@ class SubscriptionRecord extends Model
public const EVENT_DOWNGRADE = 'downgrade';
/** A term that renewed and was paid. Stripe's invoice says so. */
public const EVENT_RENEWAL = 'renewal';
/** A renewal Stripe could not collect. Their dunning takes it from here. */
public const EVENT_PAYMENT_FAILED = 'payment_failed';
/**
* A paid invoice that is not a cycle renewal a proration, or a charge
* raised by hand. Money received, so it belongs in the register, but it
* does not start a new term and must not move the period.
*/
public const EVENT_INVOICE_PAID = 'invoice_paid';
public const EVENT_ADDON_BOOKED = 'addon_booked';
public const EVENT_ADDON_CANCELLED = 'addon_cancelled';

View File

@ -9,6 +9,8 @@ use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Monitoring\MonitoringClient;
use App\Services\Proxmox\HttpProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Stripe\HttpStripeClient;
use App\Services\Stripe\StripeClient;
use App\Services\Ssh\PhpseclibRemoteShell;
use App\Services\Ssh\RemoteShell;
use App\Services\Traefik\SshTraefikWriter;
@ -43,6 +45,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(HetznerDnsClient::class, HttpHetznerDnsClient::class);
$this->app->bind(TraefikWriter::class, SshTraefikWriter::class);
$this->app->bind(MonitoringClient::class, HttpMonitoringClient::class);
$this->app->bind(StripeClient::class, HttpStripeClient::class);
}
/**

View File

@ -7,7 +7,18 @@ use Illuminate\Support\Carbon;
use RuntimeException;
/**
* What a plan change costs, and whether it is allowed yet.
* The PREVIEW of a plan change: what it would cost, and whether it is allowed
* yet.
*
* A preview, not an invoice. Since Stripe took over the billing cycle, the
* amount a customer is actually charged for an upgrade comes back on Stripe's
* proration invoice, and that is the authority it accounts for tax, for
* credit already on the account, and for the exact second the change lands, and
* ours cannot. What this is for is the sentence shown before someone confirms,
* so nobody is asked to agree to a number they have not seen.
*
* The two are expected to agree to the cent in the ordinary case. Where they do
* not, the invoice wins and the proof register records what was charged.
*
* Two rules, both the customer's rules:
*

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services\Stripe;
/**
* A Stripe that records what it was asked to do.
*
* The catalogue sync has to be exercised for real it is the thing that must
* be idempotent and must never mint a second Price for the same row and that
* is testable without a network. Ids are deterministic so a test can assert
* which product a price was attached to.
*/
class FakeStripeClient implements StripeClient
{
public bool $configured = true;
/** @var array<int, array{name: string, metadata: array}> */
public array $products = [];
/** @var array<int, array{product: string, amount: int, currency: string, interval: string, metadata: array}> */
public array $prices = [];
/** @var array<int, string> */
public array $archived = [];
/** Idempotency key → the id first returned for it. @var array<string, string> */
public array $keys = [];
public function isConfigured(): bool
{
return $this->configured;
}
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
{
// Replays the first answer for a repeated key, as Stripe does.
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
return $this->keys[$idempotencyKey];
}
$id = 'prod_'.substr(sha1($name.count($this->products)), 0, 12);
$this->products[$id] = ['name' => $name, 'metadata' => $metadata];
if ($idempotencyKey !== null) {
$this->keys[$idempotencyKey] = $id;
}
return $id;
}
public function createPrice(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata = [],
?string $idempotencyKey = null,
): string {
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
return $this->keys[$idempotencyKey];
}
$id = 'price_'.substr(sha1($productId.$amountCents.$interval.count($this->prices)), 0, 12);
$this->prices[$id] = [
'product' => $productId,
'amount' => $amountCents,
'currency' => $currency,
'interval' => $interval,
'metadata' => $metadata,
];
if ($idempotencyKey !== null) {
$this->keys[$idempotencyKey] = $id;
}
return $id;
}
public function archivePrice(string $priceId): void
{
$this->archived[] = $priceId;
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace App\Services\Stripe;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Stripe's REST API over the HTTP client, rather than the SDK: this touches
* three endpoints, and a fake for the tests is easier to trust than a mocked
* library. Not unit-tested against the real service (live I/O).
*/
class HttpStripeClient implements StripeClient
{
public function isConfigured(): bool
{
return filled(config('services.stripe.secret'));
}
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
{
return (string) $this->request($idempotencyKey)
->asForm()
->post($this->url('products'), array_filter([
'name' => $name,
...$this->flatten('metadata', $metadata),
]))
->throw()
->json('id');
}
public function createPrice(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata = [],
?string $idempotencyKey = null,
): string {
return (string) $this->request($idempotencyKey)
->asForm()
->post($this->url('prices'), [
'product' => $productId,
'unit_amount' => $amountCents,
'currency' => strtolower($currency),
// Recurring, because a plan is a subscription. The interval
// belongs to the Price, which is why monthly and yearly cannot
// share one.
'recurring[interval]' => $interval,
...$this->flatten('metadata', $metadata),
])
->throw()
->json('id');
}
public function archivePrice(string $priceId): void
{
// Deactivated, never deleted: a Stripe Price is immutable and existing
// subscriptions keep billing against it. That is the same
// grandfathering our own catalogue does, enforced on their side too.
$this->request()
->asForm()
->post($this->url('prices/'.$priceId), ['active' => 'false'])
->throw();
}
private function request(?string $idempotencyKey = null): PendingRequest
{
$secret = (string) config('services.stripe.secret');
if (blank($secret)) {
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
}
$request = Http::withToken($secret)->acceptJson()->timeout(20);
// Stripe replays the original response for a repeated key instead of
// creating a second object. That covers the gap this cannot close on
// its own: a crash between Stripe creating a Price and us storing its
// id. Their keys expire after 24 hours, so it protects a retry, not a
// sync re-run next week — for which the stored ids are the guard.
return $idempotencyKey !== null
? $request->withHeaders(['Idempotency-Key' => $idempotencyKey])
: $request;
}
private function url(string $path): string
{
return rtrim((string) config('services.stripe.api_base'), '/').'/'.$path;
}
/**
* Stripe's form API takes nested keys as `metadata[key]`.
*
* @param array<string, string|int|null> $values
* @return array<string, string>
*/
private function flatten(string $prefix, array $values): array
{
$flat = [];
foreach ($values as $key => $value) {
if ($value !== null && $value !== '') {
$flat["{$prefix}[{$key}]"] = (string) $value;
}
}
return $flat;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Services\Stripe;
/**
* The bit of Stripe we drive ourselves: the catalogue.
*
* Everything else about recurring billing retries, dunning, off-session SCA,
* invoice numbering is Stripe's job, and we learn about it through webhooks.
* Our side owns capability, because Stripe does not know how big the VM should
* be.
*/
interface StripeClient
{
/** Whether an account is actually configured. */
public function isConfigured(): bool;
/**
* Create a Product for a plan family; returns its Stripe id.
*
* `$idempotencyKey` is Stripe's own guard: a crash between their creating
* the object and our storing the id would otherwise mint a second one on
* the next run, and there is no way to tell duplicates apart afterwards.
*/
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string;
/**
* Create a Price. Stripe Prices are immutable and carry their own interval,
* which is why one exists per plan version AND term.
*/
public function createPrice(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata = [],
?string $idempotencyKey = null,
): string;
/** Stop a Price being offered. The Price itself stays, as Stripe requires. */
public function archivePrice(string $priceId): void;
}

View File

@ -37,6 +37,11 @@ return [
'stripe' => [
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
// Secret key for the REST API. Blank means "not connected": the
// catalogue sync says so and does nothing, rather than half-creating
// products against an account that is not there.
'secret' => env('STRIPE_SECRET'),
'api_base' => env('STRIPE_API_BASE', 'https://api.stripe.com/v1'),
],
'monitoring' => [

View File

@ -26,8 +26,8 @@ return new class extends Migration
$table->id();
$table->uuid()->unique();
// purchase | upgrade | downgrade | addon_booked | addon_cancelled |
// cancellation
// purchase | renewal | payment_failed | upgrade | downgrade |
// addon_booked | addon_cancelled | cancellation
$table->string('event');
// Nullable and nullOnDelete throughout: a customer exercising their

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The handle Stripe's webhooks arrive with.
*
* From here on Stripe owns the billing cycle: it decides when a renewal is
* paid, when it has failed, and when a subscription has ended. Every one of
* those events names a Stripe subscription and nothing else so without this
* column an `invoice.paid` cannot be matched to the contract it renews.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
// Unique: one Stripe subscription bills exactly one contract, and a
// second row claiming the same id would make every webhook
// ambiguous.
$table->string('stripe_subscription_id')->nullable()->unique()->after('order_id');
// Stripe's own view of the state (active, past_due, canceled …),
// kept beside ours: they are the authority on whether the money
// arrived, we are the authority on what the customer may use.
$table->string('stripe_status')->nullable()->after('status');
});
Schema::table('orders', function (Blueprint $table) {
$table->string('stripe_subscription_id')->nullable()->after('stripe_event_id');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropUnique(['stripe_subscription_id']);
$table->dropColumn(['stripe_subscription_id', 'stripe_status']);
});
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('stripe_subscription_id');
});
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* One entry per thing that happened.
*
* Stripe retries a webhook until it gets a 2xx, and can deliver the same one
* twice at once. Checking for an existing row and inserting afterwards leaves a
* window both deliveries walk through and a register that counts a payment
* twice is worse than one that is late.
*
* A single nullable key rather than a composite of columns, because what
* identifies "the same event" differs by kind: a renewal is its invoice, a
* cancellation is its subscription. Events that need no such protection leave
* it null, and NULLs do not collide.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscription_records', function (Blueprint $table) {
$table->string('event_key')->nullable()->unique()->after('event');
});
}
public function down(): void
{
Schema::table('subscription_records', function (Blueprint $table) {
$table->dropUnique(['event_key']);
$table->dropColumn('event_key');
});
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* When the newest Stripe event we have applied to this contract was raised.
*
* Stripe does not guarantee delivery order. Without this, a renewal delayed in
* the network and delivered after the next one moves the period BACKWARD and
* can make a paid-up contract look expired; a failed-attempt event delivered
* after the successful retry flips a healthy subscription back to past_due.
*
* The register does not need this each entry is keyed by what it is about, so
* a late arrival is still recorded once, in its proper place. It is only the
* mutable state that has to refuse to go backwards.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->timestamp('stripe_event_at')->nullable()->after('stripe_status');
// Stripe's timestamps have one-second resolution, and a failed
// attempt and its successful retry can share a second. The rank
// breaks that tie by what the event says about the money: a
// payment that succeeded outranks one that did not.
$table->unsignedTinyInteger('stripe_event_rank')->nullable()->after('stripe_event_at');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropColumn(['stripe_event_at', 'stripe_event_rank']);
});
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Billing events that arrived before the contract they are about.
*
* Stripe does not guarantee delivery order, and a checkout takes a moment to
* become a contract on our side. In that window an `invoice.paid` or a
* `customer.subscription.deleted` can land for a subscription we cannot match
* yet and answering 2xx would drop it for good, because Stripe has no reason
* to send it again. A cancellation lost that way means we keep serving someone
* who has left.
*
* So an unmatched event is kept, and replayed the moment the contract appears.
* Most rows here will never be replayed: Stripe also delivers events for
* subscriptions belonging to other environments pointed at this endpoint, and
* for objects made by hand in the dashboard. Those are pruned by age.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('stripe_pending_events', function (Blueprint $table) {
$table->id();
// Stripe's event id, so a retried delivery does not queue it twice.
$table->string('stripe_event_id')->unique();
$table->string('stripe_subscription_id')->index();
$table->string('type');
$table->timestamp('raised_at')->nullable();
$table->json('payload');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('stripe_pending_events');
}
};

View File

@ -27,3 +27,15 @@ Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers)
Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic)
->everyFifteenMinutes()
->name('traffic-collect');
// Discard Stripe events that were held for a contract which never appeared.
// Most of them never will: Stripe also delivers events for subscriptions
// belonging to other environments pointed at this endpoint, and for objects
// made by hand in the dashboard. A week is far longer than the moment a
// checkout takes to become a contract, and short enough that the table stays
// a holding area rather than a second copy of Stripe's event log.
Schedule::call(fn () => \App\Models\StripePendingEvent::query()
->where('created_at', '<', now()->subWeek())
->delete())
->daily()
->name('stripe-pending-prune');

View File

@ -0,0 +1,469 @@
<?php
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\Queue;
/**
* Stripe owns the billing cycle from here: retries, dunning, off-session SCA
* and invoice numbering are theirs, and we learn about all of it by webhook.
* Our side owns capability, because Stripe does not know how big the VM is.
*/
function fakeStripe(): FakeStripeClient
{
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
return $fake;
}
/** A contract Stripe will send events about. */
function stripeContract(string $stripeId = 'sub_1'): Subscription
{
return Subscription::factory()->plan('team')->create([
'stripe_subscription_id' => $stripeId,
'current_period_start' => now()->subMonth(),
'current_period_end' => now(),
]);
}
it('mirrors the catalogue into Stripe, once', function () {
$stripe = fakeStripe();
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
// A product per family, a price per priced row of a published version.
expect($stripe->products)->toHaveCount(4)
->and($stripe->prices)->toHaveCount(8)
->and(PlanFamily::query()->whereNull('stripe_product_id')->count())->toBe(0)
->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0);
// A Stripe Price cannot be edited, so a second run that minted duplicates
// would leave two live prices for one plan and no way to tell them apart.
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect($stripe->products)->toHaveCount(4)
->and($stripe->prices)->toHaveCount(8);
});
it('gives each price its own recurring interval', function () {
$stripe = fakeStripe();
$this->artisan('stripe:sync-catalogue');
$intervals = collect($stripe->prices)->groupBy('interval')->map->count();
// Monthly and yearly cannot share a Price, because the interval belongs to
// the Price itself.
expect($intervals['month'])->toBe(4)
->and($intervals['year'])->toBe(4);
$team = collect($stripe->prices)->first(fn ($p) => $p['metadata']['plan_family'] === 'team' && $p['interval'] === 'month');
expect($team['amount'])->toBe(17900)
->and($team['metadata']['plan_version'])->toBe('1');
});
it('does not put an unpublished draft in the price list', function () {
$stripe = fakeStripe();
$family = PlanFamily::query()->where('key', 'team')->sole();
app(App\Services\Billing\PlanCatalogue::class)->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 19900, 'yearly' => 238800]);
$this->artisan('stripe:sync-catalogue');
// A draft has promised nothing; a Price for it would be a price list entry
// for something that may never exist.
expect($stripe->prices)->toHaveCount(8)
->and(collect($stripe->prices)->pluck('amount'))->not->toContain(19900);
});
it('does not mint a second object when a run is interrupted before the id is stored', function () {
$stripe = fakeStripe();
$this->artisan('stripe:sync-catalogue');
// A crash between Stripe creating the objects and us recording their ids.
PlanFamily::query()->update(['stripe_product_id' => null]);
PlanPrice::query()->update(['stripe_price_id' => null]);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
// Stripe replays the original answer for a repeated idempotency key, so the
// catalogue reconnects to what is already there instead of duplicating it —
// and a Price cannot be deleted afterwards to tidy up.
expect($stripe->products)->toHaveCount(4)
->and($stripe->prices)->toHaveCount(8)
->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0);
});
it('does not revive a contract Stripe already ended', function () {
$subscription = stripeContract();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_end', 'type' => 'customer.subscription.deleted',
'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => now()->timestamp]],
])->assertOk();
// Stripe does not guarantee delivery order: the final invoice can land
// after the deletion.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_late', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_late', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
$subscription->refresh();
// The payment is on record; the departed customer does not get their
// service back, and the cancellation is not recorded a second time.
expect($subscription->status)->toBe('cancelled')
->and($subscription->cancelled_at)->not->toBeNull()
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1)
->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1);
});
it('does not put a plan that has never been published in Stripe at all', function () {
$stripe = fakeStripe();
$family = PlanFamily::query()->create(['key' => 'agency', 'name' => 'Agentur', 'tier' => 5]);
app(App\Services\Billing\PlanCatalogue::class)->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 24900, 'yearly' => 298800]);
$this->artisan('stripe:sync-catalogue');
// Not even the Product: it would be a price-list entry for something that
// has promised nothing to anyone.
expect($stripe->products)->toHaveCount(4)
->and($family->fresh()->stripe_product_id)->toBeNull();
});
it('does not record the checkout\'s own invoice as a renewal', function () {
stripeContract();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_first', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_first', 'subscription' => 'sub_1', 'amount_paid' => 21480,
// Stripe sends this for the invoice created during checkout.
'billing_reason' => 'subscription_create',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk()->assertJson(['applied' => false]);
// The purchase is already in the register; counting it again here would
// double every customer's first payment.
expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0);
});
it('does not move the term on for a proration or a manual charge', function () {
$subscription = stripeContract();
$originalEnd = $subscription->current_period_end;
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_prorata', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_prorata', 'subscription' => 'sub_1', 'amount_paid' => 4200,
'billing_reason' => 'subscription_update',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
// Money received, so it is on record — but a top-up does not start a new
// month, and pushing the period forward on one would give it away.
$record = SubscriptionRecord::query()->where('event', 'invoice_paid')->sole();
expect($record->gross_cents)->toBe(4200)
->and($record->snapshot['billing_reason'])->toBe('subscription_update')
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0)
->and($subscription->fresh()->current_period_end->timestamp)->toBe($originalEnd->timestamp);
});
it('refuses an older event delivered behind a newer one', function () {
$subscription = stripeContract();
$secondTermEnd = now()->addMonths(2);
// Renewal N+1 arrives first.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_n2', 'type' => 'invoice.paid', 'created' => now()->timestamp,
'data' => ['object' => [
'id' => 'in_n2', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->addMonth()->timestamp, 'period_end' => $secondTermEnd->timestamp,
]],
])->assertOk();
// Renewal N turns up late, and a failed attempt from before that.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_n1', 'type' => 'invoice.paid', 'created' => now()->subHour()->timestamp,
'data' => ['object' => [
'id' => 'in_n1', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_old_fail', 'type' => 'invoice.payment_failed', 'created' => now()->subHours(2)->timestamp,
'data' => ['object' => ['id' => 'in_old', 'subscription' => 'sub_1', 'attempt_count' => 1]],
])->assertOk();
$subscription->refresh();
// The term is not shortened, and a customer who has since paid is not
// flipped back to overdue.
expect($subscription->current_period_end->timestamp)->toBe($secondTermEnd->timestamp)
->and($subscription->stripe_status)->toBe('active')
// Both payments are still on record: a late arrival is recorded, it
// just does not get to rewrite the running picture.
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(2)
->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1);
});
it('lets a payment that went through outrank a failure from the same second', function () {
$subscription = stripeContract();
$second = now();
// Stripe timestamps have one-second resolution, so a failed attempt and
// the retry that succeeded routinely share one.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_ok', 'type' => 'invoice.paid', 'created' => $second->timestamp,
'data' => ['object' => [
'id' => 'in_ok', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => $second->timestamp, 'period_end' => $second->copy()->addMonth()->timestamp,
]],
])->assertOk();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_nok', 'type' => 'invoice.payment_failed', 'created' => $second->timestamp,
'data' => ['object' => ['id' => 'in_ok', 'subscription' => 'sub_1', 'attempt_count' => 1]],
])->assertOk();
// The customer has paid; a same-second failure must not say otherwise.
expect($subscription->fresh()->stripe_status)->toBe('active')
->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1);
});
it('holds a billing event that arrives before the contract exists, and applies it after', function () {
Queue::fake();
// The cancellation overtakes the checkout — Stripe does not deliver in
// order, and a checkout takes a moment to become a contract.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_early', 'type' => 'customer.subscription.deleted', 'created' => now()->timestamp,
'data' => ['object' => ['id' => 'sub_early', 'status' => 'canceled', 'ended_at' => now()->timestamp]],
])->assertOk()->assertJson(['applied' => false]);
expect(App\Models\StripePendingEvent::query()->count())->toBe(1);
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_checkout', 'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_early', 'payment_status' => 'paid', 'subscription' => 'sub_early',
'customer_details' => ['email' => 'schnell@example.com'],
'amount_total' => 21480, 'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
])->assertOk();
// Answering 2xx and forgetting it would have left us serving someone who
// had already left.
$subscription = Subscription::query()->sole();
expect($subscription->status)->toBe('cancelled')
->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1)
// The holding area is a holding area, not a second event log.
->and(App\Models\StripePendingEvent::query()->count())->toBe(0);
});
it('holds an event only once however often Stripe redelivers it', function () {
$event = [
'id' => 'evt_repeat', 'type' => 'invoice.paid', 'created' => now()->timestamp,
'data' => ['object' => [
'id' => 'in_repeat', 'subscription' => 'sub_nothere', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
]],
];
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
expect(App\Models\StripePendingEvent::query()->count())->toBe(1);
});
it('creates nothing when Stripe is not connected', function () {
$stripe = fakeStripe();
$stripe->configured = false;
$this->artisan('stripe:sync-catalogue')->assertFailed();
expect($stripe->products)->toBeEmpty()
->and(PlanFamily::query()->whereNotNull('stripe_product_id')->count())->toBe(0);
});
it('moves the period on when Stripe says a renewal was paid', function () {
$subscription = stripeContract();
$end = now()->addMonth();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_paid', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_100', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => $end->timestamp,
]],
])->assertOk()->assertJson(['applied' => true]);
$subscription->refresh();
expect($subscription->current_period_end->timestamp)->toBe($end->timestamp)
->and($subscription->stripe_status)->toBe('active');
$record = SubscriptionRecord::query()->where('event', 'renewal')->sole();
// The invoice is the authority for the amount; the contract for the terms.
expect($record->gross_cents)->toBe(21480)
->and($record->stripe_invoice_id)->toBe('in_100')
->and($record->plan_key)->toBe('team');
});
it('records one renewal however many times Stripe delivers the invoice', function () {
stripeContract();
$event = [
'id' => 'evt_dup', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_dup', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
];
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
$this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['applied' => false]);
expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1);
});
it('records a failed payment without cutting the customer off', function () {
$subscription = stripeContract();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_fail', 'type' => 'invoice.payment_failed',
'data' => ['object' => ['id' => 'in_fail', 'subscription' => 'sub_1', 'attempt_count' => 1]],
])->assertOk();
$subscription->refresh();
// Stripe runs the dunning schedule. Cutting someone off on the first
// failed attempt would punish an expired card as a refusal to pay.
expect($subscription->stripe_status)->toBe('past_due')
->and($subscription->status)->toBe('active')
->and(SubscriptionRecord::query()->where('event', 'payment_failed')->sole()->stripe_invoice_id)
->toBe('in_fail');
});
it('ends the contract, and records it, when Stripe ends the subscription', function () {
$subscription = stripeContract();
$endedAt = now()->addDay();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_del', 'type' => 'customer.subscription.deleted',
'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]],
])->assertOk()->assertJson(['applied' => true]);
$subscription->refresh();
expect($subscription->status)->toBe('cancelled')
->and($subscription->cancelled_at->timestamp)->toBe($endedAt->timestamp);
$record = SubscriptionRecord::query()->where('event', 'cancellation')->sole();
expect($record->plan_key)->toBe('team');
// A retry must not record the ending twice.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_del', 'type' => 'customer.subscription.deleted',
'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]],
])->assertOk()->assertJson(['applied' => false]);
expect(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1);
});
it('copies Stripe\'s status without handing it the keys', function () {
$subscription = stripeContract();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_upd', 'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_1', 'status' => 'past_due',
'current_period_start' => now()->timestamp,
'current_period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
$subscription->refresh();
// They are the authority on whether the money arrived; we stay the
// authority on what the customer may use.
expect($subscription->stripe_status)->toBe('past_due')
->and($subscription->status)->toBe('active');
});
it('carries the Stripe subscription from the checkout onto the contract', function () {
Queue::fake();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_new', 'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_new', 'payment_status' => 'paid', 'subscription' => 'sub_new',
'customer_details' => ['email' => 'neu@example.com'],
'amount_total' => 21480, 'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
])->assertOk();
// Without this, no later billing event could be matched to the contract.
expect(Subscription::query()->sole()->stripe_subscription_id)->toBe('sub_new');
});
it('never rewrites the snapshot when a renewal arrives', function () {
$subscription = stripeContract();
// The catalogue moves on between terms.
$catalogue = app(App\Services\Billing\PlanCatalogue::class);
$current = $catalogue->currentVersion('team');
$catalogue->schedule($current, $current->available_from, now());
$dearer = App\Models\PlanVersion::query()->create([
...$current->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2, 'features' => $current->features, 'available_from' => now(),
]);
$dearer->prices()->create(['term' => 'monthly', 'amount_cents' => 29900, 'currency' => 'EUR']);
$dearer->prices()->create(['term' => 'yearly', 'amount_cents' => 358800, 'currency' => 'EUR']);
$catalogue->publish($dearer, now());
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_ren', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_ren', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
// A renewal moves the month, not the terms.
expect($subscription->fresh()->price_cents)->toBe(17900)
->and($subscription->fresh()->plan_version_id)->toBe($current->id);
});

View File

@ -48,13 +48,27 @@ it('is idempotent — a duplicate webhook starts only one run', function () {
it('ignores unrelated event types', function () {
Queue::fake();
$this->postJson(route('webhooks.stripe'), ['id' => 'evt_x', 'type' => 'invoice.paid', 'data' => ['object' => []]])
$this->postJson(route('webhooks.stripe'), ['id' => 'evt_x', 'type' => 'payout.paid', 'data' => ['object' => []]])
->assertOk()
->assertJson(['ignored' => true]);
Queue::assertNothingPushed();
});
it('answers a billing event for a subscription it does not have, without retrying forever', function () {
Queue::fake();
// Stripe delivers events for objects created by hand in the dashboard, and
// for other environments pointed at the same endpoint. A non-2xx here would
// have them retry something that can never succeed.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_orphan', 'type' => 'invoice.paid',
'data' => ['object' => ['id' => 'in_1', 'subscription' => 'sub_unknown', 'amount_paid' => 21480]],
])->assertOk()->assertJson(['handled' => 'invoice.paid', 'applied' => false]);
Queue::assertNothingPushed();
});
it('ignores a paid checkout without a customer email', function () {
Queue::fake();