Invoice every renewal, and tell Stripe when the package changes
Two things Stripe was doing on its own, without us. **A renewal produced no invoice and no mail.** InvoiceMail went out from exactly one place — the first purchase — so a customer paying every month for a year received one invoice, for month one. Stripe already says when the money lands: invoice.paid with billing_reason subscription_cycle. What was missing was the document. A renewal has no Order behind it, and one was not invented for the occasion: an Order is something a customer bought, and writing a purchase nobody made would corrupt the record of what they have actually ordered. IssueInvoice::forBilledPeriod() issues from the CONTRACT instead, at its frozen net price, for the term Stripe billed. Idempotent against the Stripe invoice id, which is unique in the database rather than checked in PHP — and because the number is drawn inside the same transaction as the row, a redelivery takes its number back with it and the series keeps its sequence. Nothing in the paperwork can fail the webhook: issuing and mailing are both caught and logged, exactly as confirmByMail() does. Only a cycle renewal gets a document. The checkout's own invoice already has one; an upgrade's proration was worked out against Stripe's boundaries and would not match a document written from our snapshot; a charge raised by hand is for something this catalogue cannot describe. All three stay in the register, and are logged once as money no document of ours covers. **After a plan change, Stripe went on billing the old price.** There was no way to move a subscription onto another price at all, so a customer who upgraded paid for the smaller package every month afterwards. The client can now swap one, the item id it needs is learnt from Stripe's own events (and asked for once, for contracts older than the column), and the behaviour is chosen per direction: an upgrade settles immediately, so the cycle invoice stays exactly the contract price the renewal document states; a downgrade lands at the period boundary and settles nothing. A granted contract has no Stripe subscription and is left untouched. A change that reaches the machine and not Stripe is never rolled back — it is parked on the contract with the error and the behaviour it needed, logged as an error, and retried hourly by clupilot:sync-stripe-subscriptions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus
parent
7265799881
commit
35b312d5ef
|
|
@ -10,6 +10,7 @@ use App\Models\SubscriptionRecord;
|
||||||
use App\Provisioning\Jobs\AdvanceRunJob;
|
use App\Provisioning\Jobs\AdvanceRunJob;
|
||||||
use App\Services\Billing\CustomDomainAccess;
|
use App\Services\Billing\CustomDomainAccess;
|
||||||
use App\Services\Billing\PlanChange;
|
use App\Services\Billing\PlanChange;
|
||||||
|
use App\Services\Stripe\StripeClient;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
@ -35,7 +36,10 @@ use Throwable;
|
||||||
* own domain, which a smaller package can take away;
|
* own domain, which a smaller package can take away;
|
||||||
* - the MACHINE is resized, through a provisioning run like every other piece
|
* - the MACHINE is resized, through a provisioning run like every other piece
|
||||||
* of remote work in this application, so it is retried, logged and visible in
|
* of remote work in this application, so it is retried, logged and visible in
|
||||||
* the console instead of happening in a web request.
|
* the console instead of happening in a web request;
|
||||||
|
* - STRIPE is moved onto the new price, because it owns the billing cycle and
|
||||||
|
* would otherwise keep charging for the package the customer has left. Last,
|
||||||
|
* outside the transaction, and unable to fail the change — see below.
|
||||||
*
|
*
|
||||||
* Idempotent from both ends. A contract already on the target version is a
|
* Idempotent from both ends. A contract already on the target version is a
|
||||||
* no-op rather than an error — the order is consumed and nothing else moves —
|
* no-op rather than an error — the order is consumed and nothing else moves —
|
||||||
|
|
@ -172,6 +176,24 @@ class ApplyPlanChange
|
||||||
// there would be two runs writing one router.
|
// there would be two runs writing one router.
|
||||||
PlanChange::settleCustomDomain($subscription);
|
PlanChange::settleCustomDomain($subscription);
|
||||||
|
|
||||||
|
// Stripe bills the next term, so it has to be told which package that
|
||||||
|
// is — without this a customer who upgraded goes on paying for the one
|
||||||
|
// they left, every month, for as long as the contract runs.
|
||||||
|
//
|
||||||
|
// After the transaction and never inside it. The change has already
|
||||||
|
// landed on the contract and on the machine, and an unreachable or
|
||||||
|
// unconfigured Stripe must not roll any of that back: the action never
|
||||||
|
// throws, and parks what it could not do on the contract for the hourly
|
||||||
|
// sweep to pick up. A contract with no Stripe subscription behind it —
|
||||||
|
// a granted package — is left entirely alone.
|
||||||
|
//
|
||||||
|
// Resolved from the container here rather than injected, so a test that
|
||||||
|
// swaps the client in after this action exists still gets its fake.
|
||||||
|
app(MoveStripeSubscriptionPrice::class)(
|
||||||
|
$subscription,
|
||||||
|
$change->isUpgrade ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
|
||||||
|
);
|
||||||
|
|
||||||
if ($run !== null) {
|
if ($run !== null) {
|
||||||
AdvanceRunJob::dispatch($run->uuid); // after commit
|
AdvanceRunJob::dispatch($run->uuid); // after commit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,23 @@ use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What Stripe tells us about the billing cycle, applied to the contract.
|
* What Stripe tells us about the billing cycle, applied to the contract.
|
||||||
*
|
*
|
||||||
* The division of labour: Stripe owns the money — retries, dunning, off-session
|
* The division of labour: Stripe owns the money — retries, dunning, off-session
|
||||||
* SCA, invoice numbering — and we own capability, because Stripe does not know
|
* SCA — and we own capability, because Stripe does not know how big the VM
|
||||||
* how big the VM should be. So these handlers move period boundaries and status,
|
* should be. So these handlers move period boundaries and status, and write the
|
||||||
* and write the register. They never re-derive an amount: the invoice is the
|
* register. They never re-derive an amount: the invoice is the authority for
|
||||||
* authority for what was charged, and recomputing it here from our own
|
* what was charged, and recomputing it here from our own catalogue would produce
|
||||||
* catalogue would produce a second, disagreeing answer.
|
* a second, disagreeing answer.
|
||||||
|
*
|
||||||
|
* The one thing Stripe does NOT own is the document. Their invoice numbering is
|
||||||
|
* theirs and is not a lawful Austrian invoice series; ours is, and it is gapless
|
||||||
|
* because it has to be. So a renewal Stripe reports as paid produces a document
|
||||||
|
* of our own and one mail carrying it — see App\Actions\IssueRenewalInvoice, and
|
||||||
|
* invoicePaid() below for which paid invoices get one and which do not.
|
||||||
*
|
*
|
||||||
* Every handler is idempotent. Stripe retries a webhook until it gets a 2xx,
|
* 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"
|
* and it also sends the same event to several endpoints — so "already applied"
|
||||||
|
|
@ -40,7 +47,10 @@ class ApplyStripeBillingEvent
|
||||||
|
|
||||||
private const RANK_FAILED = 1;
|
private const RANK_FAILED = 1;
|
||||||
|
|
||||||
public function __construct(private RecordCommercialEvent $record) {}
|
public function __construct(
|
||||||
|
private RecordCommercialEvent $record,
|
||||||
|
private IssueRenewalInvoice $issueRenewalInvoice,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A renewal was paid. Move the period on and enter it in the register.
|
* A renewal was paid. Move the period on and enter it in the register.
|
||||||
|
|
@ -99,7 +109,7 @@ class ApplyStripeBillingEvent
|
||||||
// and both deliveries would pass a check.
|
// and both deliveries would pass a check.
|
||||||
$event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;
|
$event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;
|
||||||
|
|
||||||
return $this->recordOnce(
|
$record = $this->recordOnce(
|
||||||
fn () => ($this->record)(
|
fn () => ($this->record)(
|
||||||
event: $event,
|
event: $event,
|
||||||
subscription: $subscription->refresh(),
|
subscription: $subscription->refresh(),
|
||||||
|
|
@ -117,6 +127,70 @@ class ApplyStripeBillingEvent
|
||||||
eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
|
eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($isRenewal && $start !== null && $end !== null) {
|
||||||
|
$this->invoiceRenewal($subscription, $invoiceId, $start, $end);
|
||||||
|
} elseif ($record !== null) {
|
||||||
|
// Money that gets no document from us, said out loud once — when it
|
||||||
|
// is first recorded, not on every redelivery.
|
||||||
|
//
|
||||||
|
// Which paid invoices those are, and why none of them is invoiced
|
||||||
|
// from here:
|
||||||
|
//
|
||||||
|
// - `subscription_create` is the checkout's own invoice. The
|
||||||
|
// purchase already has a document, issued the moment the money
|
||||||
|
// landed; a second one would put two invoices on one sale. (It
|
||||||
|
// never reaches this line — the handler returns above.)
|
||||||
|
// - `subscription_update` is an upgrade's proration. Stripe worked
|
||||||
|
// the amount out against ITS period boundaries and its own view
|
||||||
|
// of what is already on the account, and a document written from
|
||||||
|
// our contract snapshot would state a sum that is not the one
|
||||||
|
// charged. The move itself is in the register as an `upgrade`,
|
||||||
|
// carrying the pro-rata the customer was quoted.
|
||||||
|
// - a charge raised by hand in Stripe's dashboard is for something
|
||||||
|
// this catalogue knows nothing about. We cannot describe what was
|
||||||
|
// sold, and an invoice line that is a guess is worse than none.
|
||||||
|
//
|
||||||
|
// All three are in the register with the amount, which is what the
|
||||||
|
// console's payments panel reads. This is the trail for whoever has
|
||||||
|
// to raise the paper.
|
||||||
|
Log::warning('A payment was received that no document of ours covers.', [
|
||||||
|
'subscription' => $subscription->uuid,
|
||||||
|
'stripe_invoice' => $invoiceId ?: null,
|
||||||
|
'billing_reason' => $reason ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renewal's document and the one mail that carries it.
|
||||||
|
*
|
||||||
|
* Outside recordOnce() on purpose. The register entry and the invoice are
|
||||||
|
* two different obligations, and tying the second to the first succeeding
|
||||||
|
* would mean that a delivery which recorded the payment but could not issue
|
||||||
|
* the document — an incomplete company profile, a mail server that was down
|
||||||
|
* — could never be finished: every retry would find the register entry
|
||||||
|
* already there, return, and the customer would never get an invoice. The
|
||||||
|
* issuing is idempotent on its own, keyed on the Stripe invoice id.
|
||||||
|
*
|
||||||
|
* Caught here as well as inside the action. The action guards what it does;
|
||||||
|
* this guards the rest, because the rule is that no paperwork of ours may
|
||||||
|
* ever cost Stripe a 2xx — a retried webhook is a second attempt at
|
||||||
|
* everything, and one of those things moves the customer's term.
|
||||||
|
*/
|
||||||
|
private function invoiceRenewal(Subscription $subscription, string $invoiceId, Carbon $start, Carbon $end): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
($this->issueRenewalInvoice)($subscription->refresh(), $invoiceId, $start, $end);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('Failed to invoice a renewal that was paid.', [
|
||||||
|
'subscription' => $subscription->uuid,
|
||||||
|
'stripe_invoice' => $invoiceId ?: null,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -178,6 +252,8 @@ class ApplyStripeBillingEvent
|
||||||
|
|
||||||
[$start, $end] = $this->period($object);
|
[$start, $end] = $this->period($object);
|
||||||
|
|
||||||
|
$this->rememberItem($subscription, $object);
|
||||||
|
|
||||||
// Ended is ended, whatever order events arrive in — and an older
|
// Ended is ended, whatever order events arrive in — and an older
|
||||||
// snapshot must never overwrite a newer one it was delivered behind.
|
// snapshot must never overwrite a newer one it was delivered behind.
|
||||||
$this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
|
$this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
|
||||||
|
|
@ -189,6 +265,38 @@ class ApplyStripeBillingEvent
|
||||||
return $subscription;
|
return $subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the id of the item this subscription bills through.
|
||||||
|
*
|
||||||
|
* Stripe names it only on the subscription object, and moving a contract
|
||||||
|
* onto another price is impossible without it — the price hangs off the
|
||||||
|
* item, not off the subscription. Learnt here because this event arrives on
|
||||||
|
* its own for every status change and every renewal, so the ordinary running
|
||||||
|
* of a contract fills it in without anyone having to ask Stripe.
|
||||||
|
*
|
||||||
|
* Written outside the ordering guard on purpose: this is not part of the
|
||||||
|
* running picture. An item id does not go stale, does not change when the
|
||||||
|
* price on it does, and a stale event carries exactly the same one.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $object
|
||||||
|
*/
|
||||||
|
private function rememberItem(Subscription $subscription, array $object): void
|
||||||
|
{
|
||||||
|
if ($subscription->stripe_item_id !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemId = $object['items']['data'][0]['id'] ?? null;
|
||||||
|
|
||||||
|
// One item, because one contract bills one package. A subscription with
|
||||||
|
// several would need a rule for which of them the package is, and there
|
||||||
|
// is nothing here to build one from — better to have none than to swap
|
||||||
|
// the price on whichever happened to be first.
|
||||||
|
if (is_string($itemId) && $itemId !== '' && count($object['items']['data'] ?? []) === 1) {
|
||||||
|
$subscription->update(['stripe_item_id' => $itemId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The subscription has ended at Stripe. That is the end of the contract,
|
* 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
|
* so it goes in the register — a cancellation nobody recorded is exactly
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Mail\InvoiceMail;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Services\Billing\IssueInvoice;
|
||||||
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document a renewal owes the customer, and the one mail that carries it.
|
||||||
|
*
|
||||||
|
* The first purchase has had this since it was built — StartCustomerProvisioning
|
||||||
|
* issues an invoice the moment the money lands and mails it. A renewal had
|
||||||
|
* nothing: the payment went into the proof register, the term moved on, and a
|
||||||
|
* customer paying every month for a year received exactly one invoice, for month
|
||||||
|
* one. This is the missing half, and it is deliberately shaped like
|
||||||
|
* confirmByMail(): money landed → a document → one mail.
|
||||||
|
*
|
||||||
|
* **Never allowed to fail the webhook.** Stripe retries anything that is not a
|
||||||
|
* 2xx, and there is nothing a retry could fix about an incomplete company
|
||||||
|
* profile or a mail server that is down — it would simply arrive again, fail
|
||||||
|
* again, and eventually be given up on, taking the register entry's retry with
|
||||||
|
* it. So everything here is caught and logged. The payment is recorded whatever
|
||||||
|
* happens to the paperwork; a missing document is recoverable, a lost payment is
|
||||||
|
* not.
|
||||||
|
*
|
||||||
|
* **Idempotent against the Stripe invoice.** An invoice number comes out of a
|
||||||
|
* gapless series and can never be handed out twice, so the guard is the unique
|
||||||
|
* `invoices.stripe_invoice_id` rather than a check — two deliveries arriving
|
||||||
|
* together would both pass a check. The number is drawn inside the same
|
||||||
|
* transaction the row is written in, so a collision takes the number back with
|
||||||
|
* it and the series keeps its sequence.
|
||||||
|
*
|
||||||
|
* Issuing and mailing are separate steps on purpose. `sent_at` records that the
|
||||||
|
* mail was handed to the mailer, so a delivery arriving after the document was
|
||||||
|
* written but before the mail went out finishes the job instead of skipping it —
|
||||||
|
* and one that arrives afterwards does neither twice.
|
||||||
|
*/
|
||||||
|
class IssueRenewalInvoice
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return Invoice|null the document, or null when there is not one and
|
||||||
|
* could not be one
|
||||||
|
*/
|
||||||
|
public function __invoke(Subscription $subscription, string $stripeInvoiceId, Carbon $from, Carbon $to): ?Invoice
|
||||||
|
{
|
||||||
|
if ($stripeInvoiceId === '') {
|
||||||
|
// Without it there is no way to tell a redelivery from a second
|
||||||
|
// renewal, and guessing wrong costs an invoice number.
|
||||||
|
Log::warning('Not issuing a renewal invoice: the Stripe invoice carries no id, so issuing it could not be made idempotent.', [
|
||||||
|
'subscription' => $subscription->uuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice = $this->issue($subscription, $stripeInvoiceId, $from, $to);
|
||||||
|
|
||||||
|
if ($invoice === null || $invoice->sent_at !== null) {
|
||||||
|
return $invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->send($subscription, $invoice);
|
||||||
|
|
||||||
|
return $invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issue(Subscription $subscription, string $stripeInvoiceId, Carbon $from, Carbon $to): ?Invoice
|
||||||
|
{
|
||||||
|
$existing = Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return app(IssueInvoice::class)->forBilledPeriod($subscription, $from, $to, $stripeInvoiceId);
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
// A concurrent delivery won the race. Its document is the one to
|
||||||
|
// send; this one's invoice number went back into the series when the
|
||||||
|
// transaction rolled back.
|
||||||
|
return Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Refuses while the company details are incomplete, which is the
|
||||||
|
// correct outcome — see IssueInvoice. The renewal itself stands: the
|
||||||
|
// term has moved and the payment is in the register, and the
|
||||||
|
// document can be issued once the details are there.
|
||||||
|
Log::warning('Could not issue an invoice for this renewal.', [
|
||||||
|
'subscription' => $subscription->uuid,
|
||||||
|
'stripe_invoice' => $stripeInvoiceId,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function send(Subscription $subscription, Invoice $invoice): void
|
||||||
|
{
|
||||||
|
$customer = $subscription->customer;
|
||||||
|
$address = $customer?->email;
|
||||||
|
|
||||||
|
if (! is_string($address) || $address === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Mail::to($address)->queue(new InvoiceMail($invoice, (string) $customer->name));
|
||||||
|
|
||||||
|
// Only once it is actually with the mailer. Stamped so a redelivery
|
||||||
|
// does not send the same invoice a second time, and left unstamped
|
||||||
|
// on a failure so that one still can.
|
||||||
|
$invoice->update(['sent_at' => now()]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Could not queue the invoice mail for this renewal.', [
|
||||||
|
'invoice' => $invoice->number,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Models\PlanPrice;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Services\Stripe\StripeClient;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telling Stripe that the contract now bills for a different package.
|
||||||
|
*
|
||||||
|
* A plan change moved the contract, the entitlements and the machine, and left
|
||||||
|
* Stripe billing the old price — so a customer who upgraded in March went on
|
||||||
|
* paying for the smaller package every month afterwards, and one who downgraded
|
||||||
|
* went on paying for the bigger one. This is what closes that.
|
||||||
|
*
|
||||||
|
* **Proration, per direction, chosen here and nowhere else.**
|
||||||
|
*
|
||||||
|
* - An **upgrade** is settled immediately (`always_invoice`). PlanChange's own
|
||||||
|
* docblock already says Stripe's proration invoice is the authority for what
|
||||||
|
* an upgrade costs, and that is only true if there IS one — the alternative,
|
||||||
|
* letting the proration ride along on the next cycle invoice, would make the
|
||||||
|
* cycle charge bigger than the renewal document we issue from the contract's
|
||||||
|
* frozen price, and a document that does not match the money taken is not a
|
||||||
|
* document. Immediate also matches what the customer was shown: they get the
|
||||||
|
* bigger package now and pay for the days that are left.
|
||||||
|
* - A **downgrade** settles nothing (`none`). It lands exactly at the period
|
||||||
|
* end, so there is nothing left of the term to prorate; anything other than
|
||||||
|
* `none` at a boundary risks a stray credit line for a fraction of a day.
|
||||||
|
* From the next cycle Stripe simply bills the smaller price, which is what
|
||||||
|
* the renewal document will say.
|
||||||
|
*
|
||||||
|
* Between them the register and Stripe cannot claim different money: the upgrade
|
||||||
|
* row records what was AGREED (with nothing charged), Stripe's proration invoice
|
||||||
|
* comes back through the webhook as an `invoice_paid` row recording what was
|
||||||
|
* TAKEN, and the cycle invoice stays exactly the contract price on both sides.
|
||||||
|
*
|
||||||
|
* **Never throws.** The change has already been applied to the contract and to
|
||||||
|
* the machine by the time this runs, and Stripe being unreachable, unconfigured
|
||||||
|
* or out of step with the catalogue must not undo any of that. What it must not
|
||||||
|
* do either is disappear: the failure is parked on the contract in
|
||||||
|
* `stripe_price_sync`, logged as an error, and retried hourly by
|
||||||
|
* clupilot:sync-stripe-subscriptions.
|
||||||
|
*/
|
||||||
|
class MoveStripeSubscriptionPrice
|
||||||
|
{
|
||||||
|
public function __construct(private StripeClient $stripe) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $behaviour one of StripeClient::PRORATE_*
|
||||||
|
* @return bool whether Stripe now agrees with the contract
|
||||||
|
*/
|
||||||
|
public function __invoke(Subscription $subscription, string $behaviour): bool
|
||||||
|
{
|
||||||
|
// A granted package was never sold through Stripe — GrantSubscription
|
||||||
|
// leaves stripe_subscription_id null on purpose — so there is nothing
|
||||||
|
// there to move and nothing has gone wrong. Answered before anything
|
||||||
|
// else so that no call is made and no failure is recorded.
|
||||||
|
if ($subscription->stripe_subscription_id === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceId = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$priceId = $this->targetPrice($subscription);
|
||||||
|
|
||||||
|
if ($priceId === null) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'The catalogue has no Stripe price for this plan version on this term. Run stripe:sync-catalogue.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already billing it. A retry of a swap that in fact went through,
|
||||||
|
// or a second change back onto the price Stripe is on: either way
|
||||||
|
// asking again would raise a proration for a move that is not one.
|
||||||
|
if ($subscription->stripe_price_id === $priceId) {
|
||||||
|
$this->settled($subscription, $priceId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->stripe->isConfigured()) {
|
||||||
|
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemId = $this->itemId($subscription);
|
||||||
|
|
||||||
|
if ($itemId === null) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Stripe reports no item on this subscription, so there is nothing to put another price on.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stripe->updateSubscriptionPrice(
|
||||||
|
$subscription->stripe_subscription_id,
|
||||||
|
$itemId,
|
||||||
|
$priceId,
|
||||||
|
$behaviour,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->settled($subscription, $priceId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->park($subscription, $priceId, $behaviour, $e);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try a parked swap again.
|
||||||
|
*
|
||||||
|
* The target price is worked out afresh from the contract rather than read
|
||||||
|
* back from the parked record: another change may have landed in between,
|
||||||
|
* and the contract is the authority on which package the customer is on.
|
||||||
|
* The BEHAVIOUR is the one that was chosen at the time, because it belongs
|
||||||
|
* to the direction the move went, which cannot be read off the contract
|
||||||
|
* once it has moved.
|
||||||
|
*
|
||||||
|
* A retried upgrade prorates from the moment Stripe finally hears about it,
|
||||||
|
* not from the moment the change landed here — so a long outage charges the
|
||||||
|
* customer for slightly fewer days than they had the bigger package. In
|
||||||
|
* their favour, and preferable to the alternative of back-dating a charge.
|
||||||
|
*/
|
||||||
|
public function retry(Subscription $subscription): bool
|
||||||
|
{
|
||||||
|
$parked = (array) ($subscription->stripe_price_sync ?? []);
|
||||||
|
|
||||||
|
return $this($subscription, (string) ($parked['behaviour'] ?? StripeClient::PRORATE_NONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Stripe Price for the package the contract is on now.
|
||||||
|
*
|
||||||
|
* By plan VERSION and term, which is what a Stripe Price is: one per priced
|
||||||
|
* row, written by stripe:sync-catalogue. Resolving it by plan name would
|
||||||
|
* hand back today's terms for a contract that is grandfathered on older
|
||||||
|
* ones.
|
||||||
|
*/
|
||||||
|
private function targetPrice(Subscription $subscription): ?string
|
||||||
|
{
|
||||||
|
$priceId = PlanPrice::query()
|
||||||
|
->where('plan_version_id', $subscription->plan_version_id)
|
||||||
|
->where('term', $subscription->term)
|
||||||
|
->value('stripe_price_id');
|
||||||
|
|
||||||
|
return is_string($priceId) && $priceId !== '' ? $priceId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item the subscription bills through.
|
||||||
|
*
|
||||||
|
* Stored when we have it — a `customer.subscription.updated` event carries
|
||||||
|
* it and ApplyStripeBillingEvent keeps it. A contract older than that column
|
||||||
|
* has none, so Stripe is asked once and the answer is kept: the item id
|
||||||
|
* survives a price swap, so this happens at most once per contract.
|
||||||
|
*/
|
||||||
|
private function itemId(Subscription $subscription): ?string
|
||||||
|
{
|
||||||
|
if ($subscription->stripe_item_id !== null) {
|
||||||
|
return $subscription->stripe_item_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemId = $this->stripe->subscriptionItemId((string) $subscription->stripe_subscription_id);
|
||||||
|
|
||||||
|
if ($itemId !== null) {
|
||||||
|
$subscription->update(['stripe_item_id' => $itemId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stripe and the contract agree; nothing is outstanding. */
|
||||||
|
private function settled(Subscription $subscription, string $priceId): void
|
||||||
|
{
|
||||||
|
$subscription->update([
|
||||||
|
'stripe_price_id' => $priceId,
|
||||||
|
'stripe_price_synced_at' => now(),
|
||||||
|
'stripe_price_sync' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The change landed here and did not reach Stripe.
|
||||||
|
*
|
||||||
|
* Recorded on the contract rather than only logged: a log line scrolls away,
|
||||||
|
* and what this describes is a customer being charged for a package they are
|
||||||
|
* no longer on. The row is what the retry sweep reads and what an operator
|
||||||
|
* can be shown.
|
||||||
|
*/
|
||||||
|
private function park(Subscription $subscription, ?string $priceId, string $behaviour, Throwable $e): void
|
||||||
|
{
|
||||||
|
$parked = (array) ($subscription->stripe_price_sync ?? []);
|
||||||
|
$attempts = (int) ($parked['attempts'] ?? 0) + 1;
|
||||||
|
|
||||||
|
Log::error('A plan change landed but did not reach Stripe: this contract is still being billed at the old price.', [
|
||||||
|
'subscription' => $subscription->uuid,
|
||||||
|
'plan' => $subscription->plan,
|
||||||
|
'stripe_subscription' => $subscription->stripe_subscription_id,
|
||||||
|
'stripe_price' => $priceId,
|
||||||
|
'attempts' => $attempts,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subscription->update(['stripe_price_sync' => [
|
||||||
|
'price' => $priceId,
|
||||||
|
'behaviour' => $behaviour,
|
||||||
|
'error' => mb_substr($e->getMessage(), 0, 250),
|
||||||
|
'failed_at' => now()->toIso8601String(),
|
||||||
|
'attempts' => $attempts,
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Actions\MoveStripeSubscriptionPrice;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sweep behind the plan change.
|
||||||
|
*
|
||||||
|
* Moving Stripe onto the new price at the moment the change lands is right and
|
||||||
|
* it is not enough. Stripe can be unreachable, the key can have been rotated,
|
||||||
|
* the catalogue can be a sync behind — and the change has already happened to
|
||||||
|
* the contract and to the machine by then, so it is not rolled back. What is
|
||||||
|
* left is a customer being charged for a package they are no longer on, and
|
||||||
|
* nothing about the contract would ever say so again.
|
||||||
|
*
|
||||||
|
* ApplyPlanChange parks exactly that on the contract. This picks it up, however
|
||||||
|
* long ago it happened.
|
||||||
|
*
|
||||||
|
* Deliberately hands the work back to the action rather than doing it here: the
|
||||||
|
* action owns which price, which item and which proration behaviour, and two
|
||||||
|
* code paths that both move a subscription is one more than the number that can
|
||||||
|
* be kept in agreement.
|
||||||
|
*/
|
||||||
|
class SyncStripeSubscriptions extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'clupilot:sync-stripe-subscriptions {--limit=100}';
|
||||||
|
|
||||||
|
protected $description = 'Retry the plan changes that landed here but never reached Stripe';
|
||||||
|
|
||||||
|
public function handle(MoveStripeSubscriptionPrice $move): int
|
||||||
|
{
|
||||||
|
// Active contracts only. A cancelled one is not billed again, so moving
|
||||||
|
// it onto another price would be housekeeping on something Stripe has
|
||||||
|
// already finished with — and an upgrade's proration raised against a
|
||||||
|
// contract that has ended would charge a departed customer.
|
||||||
|
$parked = Subscription::query()
|
||||||
|
->whereNotNull('stripe_price_sync')
|
||||||
|
->whereNotNull('stripe_subscription_id')
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('id')
|
||||||
|
->limit((int) $this->option('limit'))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$moved = 0;
|
||||||
|
|
||||||
|
foreach ($parked as $subscription) {
|
||||||
|
if ($move->retry($subscription)) {
|
||||||
|
$moved++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The action has already logged why and counted the attempt. Said
|
||||||
|
// again here because somebody is watching this command's output
|
||||||
|
// and not the log.
|
||||||
|
$this->warn(sprintf(
|
||||||
|
'Contract %s: still billed at the old price — %s',
|
||||||
|
$subscription->uuid,
|
||||||
|
(string) ($subscription->fresh()?->stripe_price_sync['error'] ?? 'unknown'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("{$parked->count()} contract(s) out of step with Stripe, {$moved} moved.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Models;
|
||||||
use App\Models\Concerns\HasUuid;
|
use App\Models\Concerns\HasUuid;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An issued invoice, frozen.
|
* An issued invoice, frozen.
|
||||||
|
|
@ -20,7 +21,8 @@ class Invoice extends Model
|
||||||
use HasUuid;
|
use HasUuid;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'invoice_series_id', 'customer_id', 'order_id', 'number', 'number_year', 'number_sequence',
|
'invoice_series_id', 'customer_id', 'order_id', 'subscription_id', 'stripe_invoice_id',
|
||||||
|
'number', 'number_year', 'number_sequence',
|
||||||
'issued_on', 'due_on', 'snapshot', 'net_cents', 'tax_cents', 'gross_cents', 'currency',
|
'issued_on', 'due_on', 'snapshot', 'net_cents', 'tax_cents', 'gross_cents', 'currency',
|
||||||
'cancels_invoice_id', 'sent_at',
|
'cancels_invoice_id', 'sent_at',
|
||||||
];
|
];
|
||||||
|
|
@ -53,8 +55,19 @@ class Invoice extends Model
|
||||||
return $this->belongsTo(Order::class);
|
return $this->belongsTo(Order::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract this document billed a term of.
|
||||||
|
*
|
||||||
|
* Set on a renewal, which has no order behind it — nobody buys a month
|
||||||
|
* rolling over. Null on a purchase, where `order` is what it is for.
|
||||||
|
*/
|
||||||
|
public function subscription(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Subscription::class);
|
||||||
|
}
|
||||||
|
|
||||||
/** Where this document has been copied to, and where it has not. */
|
/** Where this document has been copied to, and where it has not. */
|
||||||
public function exports(): \Illuminate\Database\Eloquent\Relations\HasMany
|
public function exports(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(InvoiceExport::class);
|
return $this->hasMany(InvoiceExport::class);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ class Subscription extends Model
|
||||||
'pending_effective_at' => 'datetime',
|
'pending_effective_at' => 'datetime',
|
||||||
'cancelled_at' => 'datetime',
|
'cancelled_at' => 'datetime',
|
||||||
'stripe_event_at' => 'datetime',
|
'stripe_event_at' => 'datetime',
|
||||||
|
'stripe_price_synced_at' => 'datetime',
|
||||||
|
// The swap that has not reached Stripe: the price it was meant to
|
||||||
|
// move to, the proration behaviour its direction chose, the error
|
||||||
|
// and how often it has been tried. Null once Stripe agrees with us.
|
||||||
|
'stripe_price_sync' => 'array',
|
||||||
'granted_at' => 'datetime',
|
'granted_at' => 'datetime',
|
||||||
'granted_until' => 'datetime',
|
'granted_until' => 'datetime',
|
||||||
'catalogue_price_cents' => 'integer',
|
'catalogue_price_cents' => 'integer',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ use App\Models\ExportTarget;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use App\Models\InvoiceSeries;
|
use App\Models\InvoiceSeries;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
|
use App\Models\Subscription;
|
||||||
use App\Support\CompanyProfile;
|
use App\Support\CompanyProfile;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
@ -53,6 +55,112 @@ final class IssueInvoice
|
||||||
throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
|
throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$lines = $orders->map(fn (Order $order) => [
|
||||||
|
'description' => $order->label(),
|
||||||
|
'details' => array_values(array_filter([
|
||||||
|
$order->isRecurring() ? __('invoice.line_recurring') : __('invoice.line_once'),
|
||||||
|
])),
|
||||||
|
'quantity_milli' => 1000,
|
||||||
|
'unit' => '',
|
||||||
|
'unit_net_cents' => (int) $order->amount_cents,
|
||||||
|
])->all();
|
||||||
|
|
||||||
|
return $this->issue(
|
||||||
|
customer: $customer,
|
||||||
|
lines: $lines,
|
||||||
|
currency: strtoupper((string) ($orders->first()->currency ?: 'EUR')),
|
||||||
|
series: $series,
|
||||||
|
attributes: ['order_id' => $orders->first()->id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One invoice for a term that was billed — a renewal.
|
||||||
|
*
|
||||||
|
* Deliberately NOT forOrders() with an Order made up for the occasion. An
|
||||||
|
* Order is something a customer bought: it is created at a checkout, it sits
|
||||||
|
* in their cart until it is paid, and provisioning and plan changes consume
|
||||||
|
* it. A month rolling over is none of those things, and writing a purchase
|
||||||
|
* nobody made would corrupt the one record that says what this customer has
|
||||||
|
* actually ordered. The document points at the CONTRACT instead, which is
|
||||||
|
* what a renewal renews.
|
||||||
|
*
|
||||||
|
* The line is the contract's own frozen price — `price_cents`, the
|
||||||
|
* catalogue's NET figure — and not Stripe's `amount_paid`, which is a gross
|
||||||
|
* total. What the customer is owed is what they agreed to; the register
|
||||||
|
* records beside it what was actually taken, and `stripe_invoice_id` on the
|
||||||
|
* row is how the two are reconciled afterwards.
|
||||||
|
*
|
||||||
|
* Booked modules are NOT on it. They are not items on the Stripe
|
||||||
|
* subscription, so this renewal did not charge for them — putting them on
|
||||||
|
* the document would invoice money nobody was asked for. (That they are
|
||||||
|
* never billed after the month they were booked in is a real gap, and it is
|
||||||
|
* a gap in the booking, not in this document.)
|
||||||
|
*
|
||||||
|
* `$stripeInvoiceId` is what makes issuing this idempotent: the column is
|
||||||
|
* unique, so a redelivered webhook collides in the database and the
|
||||||
|
* transaction takes its invoice number back with it.
|
||||||
|
*/
|
||||||
|
public function forBilledPeriod(
|
||||||
|
Subscription $subscription,
|
||||||
|
Carbon $from,
|
||||||
|
Carbon $to,
|
||||||
|
?string $stripeInvoiceId = null,
|
||||||
|
?InvoiceSeries $series = null,
|
||||||
|
): Invoice {
|
||||||
|
$customer = $subscription->customer;
|
||||||
|
|
||||||
|
if ($customer === null) {
|
||||||
|
throw new RuntimeException('Refusing to issue an invoice for a contract with nobody on it.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = [[
|
||||||
|
'description' => __('billing.cart.plan', ['plan' => ucfirst((string) $subscription->plan)]),
|
||||||
|
'details' => [__('invoice.line_period', [
|
||||||
|
'from' => $from->local()->format('d.m.Y'),
|
||||||
|
// Stripe's period_end is the EXCLUSIVE boundary — the first
|
||||||
|
// moment of the next term, which the next invoice will claim in
|
||||||
|
// turn. Printed as it comes, the same day would appear on two
|
||||||
|
// consecutive documents.
|
||||||
|
'to' => $to->greaterThan($from)
|
||||||
|
? $to->copy()->subDay()->local()->format('d.m.Y')
|
||||||
|
: $to->local()->format('d.m.Y'),
|
||||||
|
])],
|
||||||
|
'quantity_milli' => 1000,
|
||||||
|
'unit' => '',
|
||||||
|
'unit_net_cents' => (int) $subscription->price_cents,
|
||||||
|
]];
|
||||||
|
|
||||||
|
return $this->issue(
|
||||||
|
customer: $customer,
|
||||||
|
lines: $lines,
|
||||||
|
currency: strtoupper((string) ($subscription->currency ?: 'EUR')),
|
||||||
|
series: $series,
|
||||||
|
attributes: [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
// Left null on purpose. The contract's opening order is the
|
||||||
|
// FIRST purchase and already has its own invoice; pointing this
|
||||||
|
// one at it would file two documents under one sale.
|
||||||
|
'order_id' => null,
|
||||||
|
'stripe_invoice_id' => $stripeInvoiceId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The part every document has in common: the number, the freeze, the copy
|
||||||
|
* to the archive.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $lines
|
||||||
|
* @param array<string, mixed> $attributes what this document is FOR
|
||||||
|
*/
|
||||||
|
private function issue(
|
||||||
|
Customer $customer,
|
||||||
|
array $lines,
|
||||||
|
string $currency,
|
||||||
|
?InvoiceSeries $series,
|
||||||
|
array $attributes,
|
||||||
|
): Invoice {
|
||||||
$missing = CompanyProfile::missingForInvoicing();
|
$missing = CompanyProfile::missingForInvoicing();
|
||||||
|
|
||||||
if ($missing !== []) {
|
if ($missing !== []) {
|
||||||
|
|
@ -69,16 +177,6 @@ final class IssueInvoice
|
||||||
$treatment = TaxTreatment::for($customer);
|
$treatment = TaxTreatment::for($customer);
|
||||||
$rate = (int) round($treatment->rate * 10000);
|
$rate = (int) round($treatment->rate * 10000);
|
||||||
|
|
||||||
$lines = $orders->map(fn (Order $order) => [
|
|
||||||
'description' => $order->label(),
|
|
||||||
'details' => array_values(array_filter([
|
|
||||||
$order->isRecurring() ? __('invoice.line_recurring') : __('invoice.line_once'),
|
|
||||||
])),
|
|
||||||
'quantity_milli' => 1000,
|
|
||||||
'unit' => '',
|
|
||||||
'unit_net_cents' => (int) $order->amount_cents,
|
|
||||||
])->all();
|
|
||||||
|
|
||||||
$totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate];
|
$totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate];
|
||||||
|
|
||||||
$snapshot = [
|
$snapshot = [
|
||||||
|
|
@ -89,17 +187,19 @@ final class IssueInvoice
|
||||||
'meta' => $this->meta($customer, $treatment),
|
'meta' => $this->meta($customer, $treatment),
|
||||||
];
|
];
|
||||||
|
|
||||||
$invoice = DB::transaction(function () use ($series, $customer, $orders, $snapshot, $totals) {
|
$invoice = DB::transaction(function () use ($series, $customer, $snapshot, $totals, $currency, $attributes) {
|
||||||
[$number, $sequence, $year] = $this->numbers->next($series);
|
[$number, $sequence, $year] = $this->numbers->next($series);
|
||||||
|
|
||||||
// The number is only true once it is on the document, so both are
|
// The number is only true once it is on the document, so both are
|
||||||
// written inside the same transaction the counter was advanced in.
|
// written inside the same transaction the counter was advanced in.
|
||||||
|
// That is also what keeps a redelivered Stripe invoice from eating a
|
||||||
|
// number: the unique index rejects the row, the transaction rolls
|
||||||
|
// back, and the counter comes back with it.
|
||||||
$snapshot['meta']['number'] = $number;
|
$snapshot['meta']['number'] = $number;
|
||||||
|
|
||||||
return Invoice::create([
|
return Invoice::create([
|
||||||
'invoice_series_id' => $series->id,
|
'invoice_series_id' => $series->id,
|
||||||
'customer_id' => $customer->id,
|
'customer_id' => $customer->id,
|
||||||
'order_id' => $orders->first()->id,
|
|
||||||
'number' => $number,
|
'number' => $number,
|
||||||
'number_year' => $year,
|
'number_year' => $year,
|
||||||
'number_sequence' => $sequence,
|
'number_sequence' => $sequence,
|
||||||
|
|
@ -109,7 +209,8 @@ final class IssueInvoice
|
||||||
'net_cents' => $totals['net'],
|
'net_cents' => $totals['net'],
|
||||||
'tax_cents' => $totals['tax'],
|
'tax_cents' => $totals['tax'],
|
||||||
'gross_cents' => $totals['gross'],
|
'gross_cents' => $totals['gross'],
|
||||||
'currency' => strtoupper((string) ($orders->first()->currency ?: 'EUR')),
|
'currency' => $currency,
|
||||||
|
...$attributes,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace App\Services\Stripe;
|
namespace App\Services\Stripe;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Stripe that records what it was asked to do.
|
* A Stripe that records what it was asked to do.
|
||||||
*
|
*
|
||||||
|
|
@ -26,11 +28,61 @@ class FakeStripeClient implements StripeClient
|
||||||
/** Idempotency key → the id first returned for it. @var array<string, string> */
|
/** Idempotency key → the id first returned for it. @var array<string, string> */
|
||||||
public array $keys = [];
|
public array $keys = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Stripe would answer when asked which item a subscription bills
|
||||||
|
* through: subscription id → item id.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
public array $items = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every price swap that was asked for, in order, so a test can assert the
|
||||||
|
* proration behaviour as well as the price.
|
||||||
|
*
|
||||||
|
* @var array<int, array{subscription: string, item: string, price: string, proration: string}>
|
||||||
|
*/
|
||||||
|
public array $priceChanges = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set to make every call fail, the way an outage, a revoked key or a
|
||||||
|
* network without a route out does.
|
||||||
|
*/
|
||||||
|
public ?string $failWith = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every checkout that was opened, in order.
|
||||||
|
*
|
||||||
|
* @var array<int, array{price: string, success: string, cancel: string, metadata: array, email: ?string}>
|
||||||
|
*/
|
||||||
|
public array $checkouts = [];
|
||||||
|
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
{
|
{
|
||||||
return $this->configured;
|
return $this->configured;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createCheckoutSession(
|
||||||
|
string $priceId,
|
||||||
|
string $successUrl,
|
||||||
|
string $cancelUrl,
|
||||||
|
array $metadata = [],
|
||||||
|
?string $customerEmail = null,
|
||||||
|
?string $idempotencyKey = null,
|
||||||
|
): string {
|
||||||
|
$this->failIfAsked();
|
||||||
|
|
||||||
|
$this->checkouts[] = [
|
||||||
|
'price' => $priceId,
|
||||||
|
'success' => $successUrl,
|
||||||
|
'cancel' => $cancelUrl,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'email' => $customerEmail,
|
||||||
|
];
|
||||||
|
|
||||||
|
return 'https://checkout.stripe.test/session/'.count($this->checkouts);
|
||||||
|
}
|
||||||
|
|
||||||
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
|
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
|
||||||
{
|
{
|
||||||
// Replays the first answer for a repeated key, as Stripe does.
|
// Replays the first answer for a repeated key, as Stripe does.
|
||||||
|
|
@ -80,4 +132,34 @@ class FakeStripeClient implements StripeClient
|
||||||
{
|
{
|
||||||
$this->archived[] = $priceId;
|
$this->archived[] = $priceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateSubscriptionPrice(
|
||||||
|
string $subscriptionId,
|
||||||
|
string $itemId,
|
||||||
|
string $priceId,
|
||||||
|
string $prorationBehaviour,
|
||||||
|
): void {
|
||||||
|
$this->failIfAsked();
|
||||||
|
|
||||||
|
$this->priceChanges[] = [
|
||||||
|
'subscription' => $subscriptionId,
|
||||||
|
'item' => $itemId,
|
||||||
|
'price' => $priceId,
|
||||||
|
'proration' => $prorationBehaviour,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subscriptionItemId(string $subscriptionId): ?string
|
||||||
|
{
|
||||||
|
$this->failIfAsked();
|
||||||
|
|
||||||
|
return $this->items[$subscriptionId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failIfAsked(): void
|
||||||
|
{
|
||||||
|
if ($this->failWith !== null) {
|
||||||
|
throw new RuntimeException($this->failWith);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@
|
||||||
|
|
||||||
namespace App\Services\Stripe;
|
namespace App\Services\Stripe;
|
||||||
|
|
||||||
|
use App\Services\Secrets\SecretVault;
|
||||||
use Illuminate\Http\Client\PendingRequest;
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stripe's REST API over the HTTP client, rather than the SDK: this touches
|
* Stripe's REST API over the HTTP client, rather than the SDK: this touches a
|
||||||
* three endpoints, and a fake for the tests is easier to trust than a mocked
|
* handful of endpoints, and a fake for the tests is easier to trust than a
|
||||||
* library. Not unit-tested against the real service (live I/O).
|
* mocked library. Not unit-tested against the real service (live I/O).
|
||||||
*/
|
*/
|
||||||
class HttpStripeClient implements StripeClient
|
class HttpStripeClient implements StripeClient
|
||||||
{
|
{
|
||||||
|
|
@ -18,6 +19,36 @@ class HttpStripeClient implements StripeClient
|
||||||
return filled($this->secret());
|
return filled($this->secret());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createCheckoutSession(
|
||||||
|
string $priceId,
|
||||||
|
string $successUrl,
|
||||||
|
string $cancelUrl,
|
||||||
|
array $metadata = [],
|
||||||
|
?string $customerEmail = null,
|
||||||
|
?string $idempotencyKey = null,
|
||||||
|
): string {
|
||||||
|
return (string) $this->request($idempotencyKey)
|
||||||
|
->asForm()
|
||||||
|
->post($this->url('checkout/sessions'), array_filter([
|
||||||
|
'mode' => 'subscription',
|
||||||
|
'line_items[0][price]' => $priceId,
|
||||||
|
'line_items[0][quantity]' => 1,
|
||||||
|
'success_url' => $successUrl,
|
||||||
|
'cancel_url' => $cancelUrl,
|
||||||
|
// Prefilled, not fixed: Stripe still lets the customer correct
|
||||||
|
// it, and whatever they confirm is what customer_details.email
|
||||||
|
// carries back — which is the address the credentials go to.
|
||||||
|
'customer_email' => $customerEmail,
|
||||||
|
...$this->flatten('metadata', $metadata),
|
||||||
|
// The same values on the subscription, because a renewal or a
|
||||||
|
// payment failure months from now arrives with the subscription
|
||||||
|
// and not with the session that opened it.
|
||||||
|
...$this->flatten('subscription_data[metadata]', $metadata),
|
||||||
|
], fn ($value) => $value !== null && $value !== ''))
|
||||||
|
->throw()
|
||||||
|
->json('url');
|
||||||
|
}
|
||||||
|
|
||||||
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
|
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
|
||||||
{
|
{
|
||||||
return (string) $this->request($idempotencyKey)
|
return (string) $this->request($idempotencyKey)
|
||||||
|
|
@ -65,6 +96,35 @@ class HttpStripeClient implements StripeClient
|
||||||
->throw();
|
->throw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateSubscriptionPrice(
|
||||||
|
string $subscriptionId,
|
||||||
|
string $itemId,
|
||||||
|
string $priceId,
|
||||||
|
string $prorationBehaviour,
|
||||||
|
): void {
|
||||||
|
// The item is named as well as the price. Without its id Stripe adds a
|
||||||
|
// SECOND item rather than replacing the first, and the customer is then
|
||||||
|
// billed for both packages at once.
|
||||||
|
$this->request()
|
||||||
|
->asForm()
|
||||||
|
->post($this->url('subscriptions/'.$subscriptionId), [
|
||||||
|
'items[0][id]' => $itemId,
|
||||||
|
'items[0][price]' => $priceId,
|
||||||
|
'proration_behavior' => $prorationBehaviour,
|
||||||
|
])
|
||||||
|
->throw();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subscriptionItemId(string $subscriptionId): ?string
|
||||||
|
{
|
||||||
|
$id = $this->request()
|
||||||
|
->get($this->url('subscriptions/'.$subscriptionId))
|
||||||
|
->throw()
|
||||||
|
->json('items.data.0.id');
|
||||||
|
|
||||||
|
return is_string($id) && $id !== '' ? $id : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function request(?string $idempotencyKey = null): PendingRequest
|
private function request(?string $idempotencyKey = null): PendingRequest
|
||||||
{
|
{
|
||||||
$secret = (string) $this->secret();
|
$secret = (string) $this->secret();
|
||||||
|
|
@ -95,7 +155,7 @@ class HttpStripeClient implements StripeClient
|
||||||
*/
|
*/
|
||||||
private function secret(): ?string
|
private function secret(): ?string
|
||||||
{
|
{
|
||||||
return app(\App\Services\Secrets\SecretVault::class)->get('stripe.secret');
|
return app(SecretVault::class)->get('stripe.secret');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function url(string $path): string
|
private function url(string $path): string
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,49 @@ namespace App\Services\Stripe;
|
||||||
*/
|
*/
|
||||||
interface StripeClient
|
interface StripeClient
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Settle an upgrade at once: Stripe works out the days left in the term,
|
||||||
|
* raises its own invoice for the difference and charges it there and then.
|
||||||
|
*
|
||||||
|
* Chosen over letting the proration ride on the next cycle invoice because
|
||||||
|
* the cycle invoice is the one WE issue a document for, from the contract's
|
||||||
|
* frozen price. A renewal invoice that said one figure while Stripe took
|
||||||
|
* that figure plus a proration would be a document that does not match the
|
||||||
|
* money — see App\Actions\MoveStripeSubscriptionPrice.
|
||||||
|
*/
|
||||||
|
public const PRORATE_IMMEDIATELY = 'always_invoice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the price and settle nothing. What the next cycle is billed at
|
||||||
|
* changes; no credit and no charge is raised for the switch itself.
|
||||||
|
*/
|
||||||
|
public const PRORATE_NONE = 'none';
|
||||||
|
|
||||||
/** Whether an account is actually configured. */
|
/** Whether an account is actually configured. */
|
||||||
public function isConfigured(): bool;
|
public function isConfigured(): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a hosted checkout for one plan Price and return the URL to send the
|
||||||
|
* customer to.
|
||||||
|
*
|
||||||
|
* Hosted, not a payment form of ours: card details, 3-D Secure and every
|
||||||
|
* local payment method are Stripe's problem, and the moment a page of ours
|
||||||
|
* touches a card number the whole installation is in PCI scope.
|
||||||
|
*
|
||||||
|
* `$metadata` is what comes back on the webhook and decides what is built —
|
||||||
|
* plan, plan version and datacenter. It is set on the SESSION and on the
|
||||||
|
* subscription: the session's copy is what StripeWebhookController reads,
|
||||||
|
* the subscription's is what any later billing event carries.
|
||||||
|
*/
|
||||||
|
public function createCheckoutSession(
|
||||||
|
string $priceId,
|
||||||
|
string $successUrl,
|
||||||
|
string $cancelUrl,
|
||||||
|
array $metadata = [],
|
||||||
|
?string $customerEmail = null,
|
||||||
|
?string $idempotencyKey = null,
|
||||||
|
): string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Product for a plan family; returns its Stripe id.
|
* Create a Product for a plan family; returns its Stripe id.
|
||||||
*
|
*
|
||||||
|
|
@ -39,4 +79,31 @@ interface StripeClient
|
||||||
|
|
||||||
/** Stop a Price being offered. The Price itself stays, as Stripe requires. */
|
/** Stop a Price being offered. The Price itself stays, as Stripe requires. */
|
||||||
public function archivePrice(string $priceId): void;
|
public function archivePrice(string $priceId): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a subscription onto another Price.
|
||||||
|
*
|
||||||
|
* The ITEM is what carries a price, not the subscription, so the swap needs
|
||||||
|
* both ids. `$prorationBehaviour` is one of the constants above and is
|
||||||
|
* always passed explicitly: Stripe's own default settles a proration
|
||||||
|
* silently, and a default deciding what a customer is charged is exactly
|
||||||
|
* the kind of thing that must be written down at the call site.
|
||||||
|
*/
|
||||||
|
public function updateSubscriptionPrice(
|
||||||
|
string $subscriptionId,
|
||||||
|
string $itemId,
|
||||||
|
string $priceId,
|
||||||
|
string $prorationBehaviour,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id of the single item on a subscription, or null if it has none.
|
||||||
|
*
|
||||||
|
* For contracts opened before the item id was stored: Stripe knows it, we
|
||||||
|
* did not ask, and asking once is cheaper than being unable to move them.
|
||||||
|
* One item, because one contract bills one package — a subscription with
|
||||||
|
* several would need a rule for which of them the package is, and there is
|
||||||
|
* no such thing to build one from.
|
||||||
|
*/
|
||||||
|
public function subscriptionItemId(string $subscriptionId): ?string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a renewal's invoice hangs off, since a renewal has no order.
|
||||||
|
*
|
||||||
|
* An Order is a thing somebody bought: it is created at a checkout, it sits in
|
||||||
|
* a cart, it is consumed by provisioning or by a plan change. A month rolling
|
||||||
|
* over is none of that — nobody ordered it, and manufacturing an Order to
|
||||||
|
* squeeze a renewal through IssueInvoice::forOrders() would put a purchase in
|
||||||
|
* the shop's own records that no customer ever made. So the document points at
|
||||||
|
* the CONTRACT it billed instead, and `order_id` stays null for it.
|
||||||
|
*
|
||||||
|
* `stripe_invoice_id` is what makes issuing idempotent. Stripe retries a webhook
|
||||||
|
* until it gets a 2xx and delivers the same invoice to several endpoints, so
|
||||||
|
* "already issued" is the ordinary case — and an invoice number, once handed
|
||||||
|
* out, can never be handed out again. Unique in the database rather than checked
|
||||||
|
* in PHP, because two deliveries arriving together would both pass a check.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('invoices', function (Blueprint $table) {
|
||||||
|
$table->foreignId('subscription_id')->nullable()->after('order_id')
|
||||||
|
->constrained()->nullOnDelete();
|
||||||
|
|
||||||
|
$table->string('stripe_invoice_id')->nullable()->unique()->after('subscription_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('invoices', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['stripe_invoice_id']);
|
||||||
|
$table->dropColumn('stripe_invoice_id');
|
||||||
|
$table->dropConstrainedForeignId('subscription_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Stripe needs before a plan change can reach it.
|
||||||
|
*
|
||||||
|
* A subscription is not billed at a price directly: it is billed through an
|
||||||
|
* ITEM, and moving a contract onto another package means swapping the price on
|
||||||
|
* that item. Only the subscription id was ever stored, so there was no handle to
|
||||||
|
* swap anything with — a customer who upgraded went on being charged for the
|
||||||
|
* package they had left, every month, for as long as the contract ran.
|
||||||
|
*
|
||||||
|
* `stripe_item_id` is that handle. It is filled from whatever tells us first:
|
||||||
|
* a `customer.subscription.updated` event carries it, and a contract that
|
||||||
|
* predates this column is asked about at Stripe the moment a change needs it
|
||||||
|
* (see App\Actions\MoveStripeSubscriptionPrice). The item id survives a price
|
||||||
|
* swap, so it is learnt once and kept.
|
||||||
|
*
|
||||||
|
* `stripe_price_id` is what Stripe is billing as far as we know, and
|
||||||
|
* `stripe_price_sync` is the swap that has NOT reached them: the price it was
|
||||||
|
* meant to move to, the proration behaviour that was chosen for its direction,
|
||||||
|
* the error, and how often it has been tried. A change is applied to the machine
|
||||||
|
* and to the contract before Stripe is told, and it must not be rolled back
|
||||||
|
* because Stripe was unreachable — so the part that failed is parked here, where
|
||||||
|
* clupilot:sync-stripe-subscriptions picks it up again and where an operator can
|
||||||
|
* see money that is still being taken at the old rate.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('subscriptions', function (Blueprint $table) {
|
||||||
|
$table->string('stripe_item_id')->nullable()->after('stripe_subscription_id');
|
||||||
|
$table->string('stripe_price_id')->nullable()->after('stripe_item_id');
|
||||||
|
$table->timestamp('stripe_price_synced_at')->nullable()->after('stripe_price_id');
|
||||||
|
$table->json('stripe_price_sync')->nullable()->after('stripe_price_synced_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('subscriptions', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'stripe_item_id', 'stripe_price_id', 'stripe_price_synced_at', 'stripe_price_sync',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -27,6 +27,7 @@ return [
|
||||||
|
|
||||||
'line_recurring' => 'monatlich',
|
'line_recurring' => 'monatlich',
|
||||||
'line_once' => 'einmalig',
|
'line_once' => 'einmalig',
|
||||||
|
'line_period' => 'Leistungszeitraum :from – :to',
|
||||||
'salutation' => 'Sehr geehrte Damen und Herren,',
|
'salutation' => 'Sehr geehrte Damen und Herren,',
|
||||||
'intro' => 'wir erlauben uns, für die im Folgenden aufgeführten Leistungen in Rechnung zu stellen.',
|
'intro' => 'wir erlauben uns, für die im Folgenden aufgeführten Leistungen in Rechnung zu stellen.',
|
||||||
'payment_default' => 'Zahlbar ohne Abzug innerhalb von :days Tagen auf das unten angeführte Konto.',
|
'payment_default' => 'Zahlbar ohne Abzug innerhalb von :days Tagen auf das unten angeführte Konto.',
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ return [
|
||||||
|
|
||||||
'line_recurring' => 'monthly',
|
'line_recurring' => 'monthly',
|
||||||
'line_once' => 'one-off',
|
'line_once' => 'one-off',
|
||||||
|
'line_period' => 'Service period :from – :to',
|
||||||
'salutation' => 'Dear Sir or Madam,',
|
'salutation' => 'Dear Sir or Madam,',
|
||||||
'intro' => 'we are pleased to invoice the services listed below.',
|
'intro' => 'we are pleased to invoice the services listed below.',
|
||||||
'payment_default' => 'Payable in full within :days days to the account shown below.',
|
'payment_default' => 'Payable in full within :days days to the account shown below.',
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,18 @@ Schedule::command('clupilot:archive-invoices')
|
||||||
->hourly()
|
->hourly()
|
||||||
->withoutOverlapping();
|
->withoutOverlapping();
|
||||||
|
|
||||||
|
// Plan changes that landed here and never reached Stripe. The change is applied
|
||||||
|
// to the contract and to the machine before Stripe is told, and it is not rolled
|
||||||
|
// back when Stripe is away — so what is left is a customer being billed for a
|
||||||
|
// package they are no longer on, and nobody would ever go and look.
|
||||||
|
//
|
||||||
|
// Hourly, like the archive sweep and for the same reason: the failure it repairs
|
||||||
|
// lasts as long as an outage lasts, and hammering an API that is down helps
|
||||||
|
// nobody.
|
||||||
|
Schedule::command('clupilot:sync-stripe-subscriptions')
|
||||||
|
->hourly()
|
||||||
|
->withoutOverlapping();
|
||||||
|
|
||||||
// Empty a handover directory of folders past their keep-by. Only where a
|
// Empty a handover directory of folders past their keep-by. Only where a
|
||||||
// destination asks for it, and only folders whose name is a date this
|
// destination asks for it, and only folders whose name is a date this
|
||||||
// application wrote — see the command for why it leaves everything else alone.
|
// application wrote — see the command for why it leaves everything else alone.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Mail\InvoiceMail;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\InvoiceSeries;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\SubscriptionRecord;
|
||||||
|
use App\Support\CompanyProfile;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The invoice a renewal owes, which for a long time nobody sent.
|
||||||
|
*
|
||||||
|
* InvoiceMail went out from exactly one place — the first purchase — so a
|
||||||
|
* customer paying every month for a year received one invoice, for month one.
|
||||||
|
* Stripe already says when the money landed: `invoice.paid` with
|
||||||
|
* `billing_reason: subscription_cycle`. What was missing was the document.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
// Fixed, because the document states a service period and a number that
|
||||||
|
// carries the year, and both are the point of these tests.
|
||||||
|
Carbon::setTestNow('2026-08-01 09:00:00');
|
||||||
|
|
||||||
|
CompanyProfile::put([
|
||||||
|
'name' => 'CluPilot Cloud e.U.',
|
||||||
|
'address' => 'Dreherstraße 66/1/8',
|
||||||
|
'postcode' => '1110',
|
||||||
|
'city' => 'Wien',
|
||||||
|
'vat_id' => 'ATU00000000',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
Carbon::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A contract Stripe bills, belonging to somebody with an address to write to. */
|
||||||
|
function renewingContract(string $stripeId = 'sub_ren'): Subscription
|
||||||
|
{
|
||||||
|
$customer = Customer::factory()->create([
|
||||||
|
'name' => 'Berger GmbH',
|
||||||
|
'email' => 'kundin@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Subscription::factory()->plan('team')->create([
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'stripe_subscription_id' => $stripeId,
|
||||||
|
'current_period_start' => now()->subMonth(),
|
||||||
|
'current_period_end' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The event Stripe raises when a term rolls over and is paid. */
|
||||||
|
function renewalPaid(string $invoiceId = 'in_ren'): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => 'evt_'.$invoiceId, 'type' => 'invoice.paid',
|
||||||
|
'data' => ['object' => [
|
||||||
|
'id' => $invoiceId, 'subscription' => 'sub_ren', 'amount_paid' => 21480,
|
||||||
|
'billing_reason' => 'subscription_cycle',
|
||||||
|
'period_start' => now()->timestamp,
|
||||||
|
'period_end' => now()->addMonth()->timestamp,
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('issues exactly one invoice for a renewal and sends exactly one mail', function () {
|
||||||
|
Mail::fake();
|
||||||
|
$contract = renewingContract();
|
||||||
|
|
||||||
|
$this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk();
|
||||||
|
|
||||||
|
$invoice = Invoice::query()->sole();
|
||||||
|
|
||||||
|
expect($invoice->stripe_invoice_id)->toBe('in_ren')
|
||||||
|
// The contract is what a renewal renews. There is no order behind it,
|
||||||
|
// and inventing one would put a purchase nobody made in the shop's own
|
||||||
|
// records.
|
||||||
|
->and($invoice->subscription_id)->toBe($contract->id)
|
||||||
|
->and($invoice->order_id)->toBeNull()
|
||||||
|
// The contract's frozen NET price, not Stripe's gross total: what the
|
||||||
|
// customer is owed is what they agreed to.
|
||||||
|
->and($invoice->net_cents)->toBe(17900)
|
||||||
|
->and($invoice->tax_cents)->toBe(3580)
|
||||||
|
->and($invoice->number)->toBe('RE-2026-0001')
|
||||||
|
->and($invoice->sent_at)->not->toBeNull();
|
||||||
|
|
||||||
|
// One line, saying which term it covers. Stripe's period_end is the first
|
||||||
|
// moment of the NEXT term, so printing it would put the same day on two
|
||||||
|
// consecutive invoices.
|
||||||
|
expect($invoice->snapshot['lines'])->toHaveCount(1)
|
||||||
|
->and($invoice->snapshot['lines'][0]['details'][0])
|
||||||
|
->toBe(__('invoice.line_period', ['from' => '01.08.2026', 'to' => '31.08.2026']));
|
||||||
|
|
||||||
|
Mail::assertQueuedCount(1);
|
||||||
|
Mail::assertQueued(InvoiceMail::class, fn (InvoiceMail $mail) => $mail->hasTo('kundin@example.com')
|
||||||
|
&& $mail->invoice->is($invoice));
|
||||||
|
|
||||||
|
// And the payment is still in the register, where it always was.
|
||||||
|
expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('issues one document and consumes one number however often Stripe delivers the invoice', function () {
|
||||||
|
Mail::fake();
|
||||||
|
renewingContract();
|
||||||
|
|
||||||
|
$this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk();
|
||||||
|
$this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk();
|
||||||
|
|
||||||
|
// An invoice number comes out of a gapless series and can never be handed
|
||||||
|
// out twice — so the second delivery must not take one, not even one it
|
||||||
|
// then throws away.
|
||||||
|
expect(Invoice::query()->count())->toBe(1)
|
||||||
|
->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(2);
|
||||||
|
|
||||||
|
Mail::assertQueuedCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fail the webhook when the invoice cannot be issued, and keeps the payment', function () {
|
||||||
|
Mail::fake();
|
||||||
|
renewingContract();
|
||||||
|
|
||||||
|
// An invoice without a VAT number is not a valid invoice here, so issuing
|
||||||
|
// refuses — which is correct, and must not cost Stripe its 2xx.
|
||||||
|
Settings::forget('company.vat_id');
|
||||||
|
|
||||||
|
$this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk();
|
||||||
|
|
||||||
|
expect(Invoice::query()->count())->toBe(0)
|
||||||
|
// A retry would re-enter this and fail again; the money is what must
|
||||||
|
// not be lost.
|
||||||
|
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->count())->toBe(1)
|
||||||
|
->and(Subscription::query()->sole()->current_period_end->timestamp)->toBe(now()->addMonth()->timestamp);
|
||||||
|
|
||||||
|
Mail::assertNothingQueued();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fail the webhook when the mail cannot be sent, and leaves the invoice unsent', function () {
|
||||||
|
renewingContract();
|
||||||
|
|
||||||
|
// A mail server that is down, mid-send.
|
||||||
|
Mail::shouldReceive('to')->andThrow(new RuntimeException('Connection refused'));
|
||||||
|
|
||||||
|
$this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk();
|
||||||
|
|
||||||
|
$invoice = Invoice::query()->sole();
|
||||||
|
|
||||||
|
// The document is issued and the payment recorded; only the sending failed,
|
||||||
|
// and it says so rather than claiming to have gone out.
|
||||||
|
expect($invoice->sent_at)->toBeNull()
|
||||||
|
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the invoice a second delivery finds unsent, without issuing a second document', function () {
|
||||||
|
renewingContract();
|
||||||
|
|
||||||
|
$mailer = Mail::getFacadeRoot();
|
||||||
|
Mail::shouldReceive('to')->once()->andThrow(new RuntimeException('Connection refused'));
|
||||||
|
$this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk();
|
||||||
|
|
||||||
|
// Stripe redelivers, and the mail server is back. The register entry is
|
||||||
|
// already there, so tying the paperwork to it would mean this customer
|
||||||
|
// never got their invoice.
|
||||||
|
Mail::swap($mailer);
|
||||||
|
Mail::fake();
|
||||||
|
$this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk();
|
||||||
|
|
||||||
|
expect(Invoice::query()->count())->toBe(1)
|
||||||
|
->and(Invoice::query()->sole()->sent_at)->not->toBeNull();
|
||||||
|
|
||||||
|
Mail::assertQueuedCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invoices nothing for a proration or a charge raised by hand', function () {
|
||||||
|
Mail::fake();
|
||||||
|
renewingContract();
|
||||||
|
|
||||||
|
$this->postJson(route('webhooks.stripe'), [
|
||||||
|
'id' => 'evt_prorata', 'type' => 'invoice.paid',
|
||||||
|
'data' => ['object' => [
|
||||||
|
'id' => 'in_prorata', 'subscription' => 'sub_ren', 'amount_paid' => 4200,
|
||||||
|
'billing_reason' => 'subscription_update',
|
||||||
|
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
|
||||||
|
]],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
// Stripe worked that amount out against ITS boundaries and its own view of
|
||||||
|
// the account. A document written from our contract snapshot would state a
|
||||||
|
// sum that is not the one charged. The money is in the register either way.
|
||||||
|
expect(Invoice::query()->count())->toBe(0)
|
||||||
|
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_INVOICE_PAID)->sole()->gross_cents)
|
||||||
|
->toBe(4200);
|
||||||
|
|
||||||
|
Mail::assertNothingQueued();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still gives the first purchase exactly one invoice and one mail', function () {
|
||||||
|
Mail::fake();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->postJson(route('webhooks.stripe'), [
|
||||||
|
'id' => 'evt_checkout', 'type' => 'checkout.session.completed',
|
||||||
|
'data' => ['object' => [
|
||||||
|
'id' => 'cs_first', 'payment_status' => 'paid', 'subscription' => 'sub_first',
|
||||||
|
'customer_details' => ['email' => 'neu@example.com', 'name' => 'Neu GmbH'],
|
||||||
|
'amount_total' => 21480, 'currency' => 'eur',
|
||||||
|
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
|
||||||
|
]],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
// Stripe then sends the checkout's OWN invoice. It is not a renewal, and
|
||||||
|
// invoicing it here would send the same customer a second document for one
|
||||||
|
// purchase in the same minute.
|
||||||
|
$this->postJson(route('webhooks.stripe'), [
|
||||||
|
'id' => 'evt_first_invoice', 'type' => 'invoice.paid',
|
||||||
|
'data' => ['object' => [
|
||||||
|
'id' => 'in_first', 'subscription' => 'sub_first', 'amount_paid' => 21480,
|
||||||
|
'billing_reason' => 'subscription_create',
|
||||||
|
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
|
||||||
|
]],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
expect(Invoice::query()->count())->toBe(1)
|
||||||
|
->and(Invoice::query()->sole()->order_id)->not->toBeNull()
|
||||||
|
->and(Invoice::query()->sole()->stripe_invoice_id)->toBeNull();
|
||||||
|
|
||||||
|
Mail::assertQueuedCount(1);
|
||||||
|
Mail::assertQueued(InvoiceMail::class);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\ApplyPlanChange;
|
||||||
|
use App\Actions\GrantSubscription;
|
||||||
|
use App\Actions\MoveStripeSubscriptionPrice;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\PlanPrice;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\SubscriptionRecord;
|
||||||
|
use App\Services\Stripe\FakeStripeClient;
|
||||||
|
use App\Services\Stripe\StripeClient;
|
||||||
|
use Illuminate\Contracts\Console\Kernel;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plan change that reaches Stripe.
|
||||||
|
*
|
||||||
|
* The contract moved, the machine was resized, the register was written — and
|
||||||
|
* Stripe went on billing the old price, because nothing ever told it. A customer
|
||||||
|
* who upgraded in March paid for the smaller package every month afterwards.
|
||||||
|
*
|
||||||
|
* Two things were missing besides the call itself: the SUBSCRIPTION ITEM id,
|
||||||
|
* which is what a price hangs off and which nothing stored, and a decision about
|
||||||
|
* proration per direction.
|
||||||
|
*/
|
||||||
|
function stripeCatalogue(): FakeStripeClient
|
||||||
|
{
|
||||||
|
$fake = new FakeStripeClient;
|
||||||
|
app()->instance(StripeClient::class, $fake);
|
||||||
|
|
||||||
|
// The plan prices need their Stripe ids before anything can be moved onto
|
||||||
|
// one — that is what stripe:sync-catalogue is for.
|
||||||
|
app(Kernel::class)->call('stripe:sync-catalogue');
|
||||||
|
|
||||||
|
return $fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A paying contract Stripe knows about, with the item it bills through. */
|
||||||
|
function stripeContractOn(string $plan, ?string $itemId = 'si_pc'): Subscription
|
||||||
|
{
|
||||||
|
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]);
|
||||||
|
|
||||||
|
$order->subscription->update([
|
||||||
|
'stripe_subscription_id' => 'sub_pc',
|
||||||
|
'stripe_item_id' => $itemId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $order->subscription->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Stripe Price a package is sold at on this term. */
|
||||||
|
function stripePriceFor(string $plan, string $term = 'monthly'): string
|
||||||
|
{
|
||||||
|
return (string) PlanPrice::query()
|
||||||
|
->where('plan_version_id', Subscription::snapshotFrom($plan, $term)['plan_version_id'])
|
||||||
|
->where('term', $term)
|
||||||
|
->value('stripe_price_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('moves an upgrade onto the new price and has Stripe settle it at once', function () {
|
||||||
|
fakeServices();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$stripe = stripeCatalogue();
|
||||||
|
$contract = stripeContractOn('team');
|
||||||
|
|
||||||
|
app(ApplyPlanChange::class)($contract, 'business');
|
||||||
|
|
||||||
|
expect($stripe->priceChanges)->toHaveCount(1);
|
||||||
|
|
||||||
|
// always_invoice, not a proration riding on the next cycle invoice: the
|
||||||
|
// cycle invoice is the one WE issue a document for, from the contract's
|
||||||
|
// frozen price, and it has to be exactly what Stripe took.
|
||||||
|
expect($stripe->priceChanges[0])->toBe([
|
||||||
|
'subscription' => 'sub_pc',
|
||||||
|
'item' => 'si_pc',
|
||||||
|
'price' => stripePriceFor('business'),
|
||||||
|
'proration' => StripeClient::PRORATE_IMMEDIATELY,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fresh = $contract->fresh();
|
||||||
|
|
||||||
|
expect($fresh->stripe_price_id)->toBe(stripePriceFor('business'))
|
||||||
|
->and($fresh->stripe_price_synced_at)->not->toBeNull()
|
||||||
|
->and($fresh->stripe_price_sync)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a downgrade onto the smaller price and has Stripe settle nothing', function () {
|
||||||
|
fakeServices();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$stripe = stripeCatalogue();
|
||||||
|
$contract = stripeContractOn('business');
|
||||||
|
|
||||||
|
// A downgrade lands exactly at the period end, which is the only moment
|
||||||
|
// PlanChange allows one.
|
||||||
|
Carbon::setTestNow($contract->current_period_end->copy()->addHour());
|
||||||
|
|
||||||
|
app(ApplyPlanChange::class)($contract, 'team');
|
||||||
|
|
||||||
|
// Nothing to prorate at a boundary, and anything but `none` risks a stray
|
||||||
|
// credit line for a fraction of a day. The next cycle simply bills less.
|
||||||
|
expect($stripe->priceChanges)->toHaveCount(1)
|
||||||
|
->and($stripe->priceChanges[0]['price'])->toBe(stripePriceFor('team'))
|
||||||
|
->and($stripe->priceChanges[0]['proration'])->toBe(StripeClient::PRORATE_NONE);
|
||||||
|
|
||||||
|
Carbon::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a granted contract alone and does not call Stripe at all', function () {
|
||||||
|
fakeServices();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$stripe = stripeCatalogue();
|
||||||
|
|
||||||
|
// Any call at all would throw and be recorded as a failure, so a clean
|
||||||
|
// contract afterwards is proof that none was made.
|
||||||
|
$stripe->failWith = 'Stripe must not be called for a granted contract.';
|
||||||
|
|
||||||
|
$customer = Customer::factory()->create();
|
||||||
|
$granted = app(GrantSubscription::class)($customer, admin(), 'team', 'monthly', 'fsn', 0);
|
||||||
|
|
||||||
|
app(ApplyPlanChange::class)($granted->fresh(), 'business');
|
||||||
|
|
||||||
|
$fresh = $granted->fresh();
|
||||||
|
|
||||||
|
// The change lands exactly as it does for anyone else…
|
||||||
|
expect($fresh->plan)->toBe('business')
|
||||||
|
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
|
||||||
|
// …and nothing was asked of Stripe, nor recorded as owing it.
|
||||||
|
->and($stripe->priceChanges)->toBeEmpty()
|
||||||
|
->and($fresh->stripe_price_sync)->toBeNull()
|
||||||
|
->and($fresh->stripe_price_id)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not undo a change Stripe could not be told about, and says where it stopped', function () {
|
||||||
|
fakeServices();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$stripe = stripeCatalogue();
|
||||||
|
$contract = stripeContractOn('team');
|
||||||
|
|
||||||
|
$stripe->failWith = 'Connection timed out';
|
||||||
|
|
||||||
|
app(ApplyPlanChange::class)($contract, 'business');
|
||||||
|
|
||||||
|
$fresh = $contract->fresh();
|
||||||
|
|
||||||
|
// The contract and the machine have already moved. Rolling that back
|
||||||
|
// because an API was away would leave the customer on a package their
|
||||||
|
// machine no longer matches.
|
||||||
|
expect($fresh->plan)->toBe('business')
|
||||||
|
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
|
||||||
|
->and($fresh->stripe_price_id)->toBeNull();
|
||||||
|
|
||||||
|
// And what did not happen is on the contract, not only in a log line that
|
||||||
|
// scrolls away: this customer is still being charged the old price.
|
||||||
|
expect($fresh->stripe_price_sync['error'])->toContain('Connection timed out')
|
||||||
|
->and($fresh->stripe_price_sync['price'])->toBe(stripePriceFor('business'))
|
||||||
|
->and($fresh->stripe_price_sync['behaviour'])->toBe(StripeClient::PRORATE_IMMEDIATELY)
|
||||||
|
->and($fresh->stripe_price_sync['attempts'])->toBe(1);
|
||||||
|
|
||||||
|
// The hourly sweep finishes it once Stripe answers again, with the
|
||||||
|
// behaviour the direction chose at the time.
|
||||||
|
$stripe->failWith = null;
|
||||||
|
$this->artisan('clupilot:sync-stripe-subscriptions')->assertSuccessful();
|
||||||
|
|
||||||
|
expect($stripe->priceChanges)->toHaveCount(1)
|
||||||
|
->and($stripe->priceChanges[0]['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY)
|
||||||
|
->and($contract->fresh()->stripe_price_sync)->toBeNull()
|
||||||
|
->and($contract->fresh()->stripe_price_id)->toBe(stripePriceFor('business'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parks the change when the catalogue has never been mirrored into Stripe', function () {
|
||||||
|
fakeServices();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
// No stripe:sync-catalogue: the prices carry no Stripe id, so there is
|
||||||
|
// nothing to move the subscription onto.
|
||||||
|
$stripe = new FakeStripeClient;
|
||||||
|
app()->instance(StripeClient::class, $stripe);
|
||||||
|
|
||||||
|
$contract = stripeContractOn('team');
|
||||||
|
|
||||||
|
app(ApplyPlanChange::class)($contract, 'business');
|
||||||
|
|
||||||
|
expect($stripe->priceChanges)->toBeEmpty()
|
||||||
|
->and($contract->fresh()->plan)->toBe('business')
|
||||||
|
->and($contract->fresh()->stripe_price_sync['error'])->toContain('stripe:sync-catalogue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('learns the subscription item from Stripe’s own events', function () {
|
||||||
|
$contract = stripeContractOn('team', itemId: null);
|
||||||
|
|
||||||
|
$this->postJson(route('webhooks.stripe'), [
|
||||||
|
'id' => 'evt_items', 'type' => 'customer.subscription.updated',
|
||||||
|
'data' => ['object' => [
|
||||||
|
'id' => 'sub_pc', 'status' => 'active',
|
||||||
|
'items' => ['data' => [['id' => 'si_learned', 'price' => ['id' => 'price_x']]]],
|
||||||
|
'current_period_start' => now()->timestamp,
|
||||||
|
'current_period_end' => now()->addMonth()->timestamp,
|
||||||
|
]],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
// Nothing stored an item id before, and a price hangs off the item — so
|
||||||
|
// without this a plan change had no handle to swap anything with.
|
||||||
|
expect($contract->fresh()->stripe_item_id)->toBe('si_learned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks Stripe for the item a contract older than the column never recorded', function () {
|
||||||
|
fakeServices();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$stripe = stripeCatalogue();
|
||||||
|
$stripe->items['sub_pc'] = 'si_fetched';
|
||||||
|
|
||||||
|
$contract = stripeContractOn('team', itemId: null);
|
||||||
|
|
||||||
|
app(ApplyPlanChange::class)($contract, 'business');
|
||||||
|
|
||||||
|
// Asked once and kept: the item id survives a price swap, so no later
|
||||||
|
// change has to ask again.
|
||||||
|
expect($stripe->priceChanges[0]['item'])->toBe('si_fetched')
|
||||||
|
->and($contract->fresh()->stripe_item_id)->toBe('si_fetched');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not ask Stripe to move a subscription that is already on the price', function () {
|
||||||
|
fakeServices();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$stripe = stripeCatalogue();
|
||||||
|
$contract = stripeContractOn('team');
|
||||||
|
|
||||||
|
app(ApplyPlanChange::class)($contract, 'business');
|
||||||
|
expect($stripe->priceChanges)->toHaveCount(1);
|
||||||
|
|
||||||
|
// Straight back through, as a retried trigger or the sweep would come. A
|
||||||
|
// second swap onto the same price would raise a proration for a move that
|
||||||
|
// is not one.
|
||||||
|
app(MoveStripeSubscriptionPrice::class)($contract->fresh(), StripeClient::PRORATE_IMMEDIATELY);
|
||||||
|
|
||||||
|
expect($stripe->priceChanges)->toHaveCount(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue