460 lines
18 KiB
PHP
460 lines
18 KiB
PHP
<?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;
|
|
}
|
|
|
|
// Only what we could not match. A handler returns null for several
|
|
// reasons — already recorded, or deliberately skipped, like every
|
|
// checkout's own invoice — and holding those would fill the table with
|
|
// rows that replayHeldFor() can never revisit, because their contract
|
|
// is right there. Nothing to wait for means nothing to hold.
|
|
if (Subscription::query()->where('stripe_subscription_id', $subscriptionId)->exists()) {
|
|
// Unless it appeared just now: the contract can be created between
|
|
// the handler missing it and this check, and the replay that would
|
|
// have collected it has then already been and gone. Apply it here
|
|
// instead of dropping it. Safe to repeat — the handlers are
|
|
// idempotent, so an event that was a no-op stays one.
|
|
$this->dispatch($event);
|
|
|
|
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,
|
|
];
|
|
}
|
|
}
|