341 lines
15 KiB
PHP
341 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Stripe;
|
|
|
|
/**
|
|
* The bit of Stripe we drive ourselves: the catalogue.
|
|
*
|
|
* Everything else about recurring billing — retries, dunning, off-session SCA,
|
|
* invoice numbering — is Stripe's job, and we learn about it through webhooks.
|
|
* Our side owns capability, because Stripe does not know how big the VM should
|
|
* be.
|
|
*/
|
|
interface StripeClient
|
|
{
|
|
/**
|
|
* 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';
|
|
|
|
/**
|
|
* Stop at the end of the term the customer has already paid for.
|
|
*
|
|
* What a CANCELLATION means. They keep the service to the boundary they have
|
|
* been billed to, no further cycle is ever raised, and Stripe ends the
|
|
* subscription itself at that moment — which arrives here as
|
|
* `customer.subscription.deleted` and is what ApplyStripeBillingEvent reads
|
|
* as the end of the contract.
|
|
*/
|
|
public const CANCEL_AT_PERIOD_END = 'at_period_end';
|
|
|
|
/**
|
|
* Stop now.
|
|
*
|
|
* What a WITHDRAWAL means. The contract is unwound rather than ended, the
|
|
* whole amount goes back, and there is no paid-up term left to run out — see
|
|
* App\Actions\WithdrawContract.
|
|
*/
|
|
public const CANCEL_IMMEDIATELY = 'immediately';
|
|
|
|
/** Whether an account is actually configured. */
|
|
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.
|
|
*
|
|
* `$priceId` is one of the TWO Prices the catalogue sells that package on —
|
|
* the domestic gross or the bare net a reverse-charge business owes — and which
|
|
* one it is has been decided by TaxTreatment before this is called. Nothing
|
|
* here computes tax, and `automatic_tax` is deliberately never enabled: a
|
|
* second rate worked out by Stripe would charge a German consumer 19 % while
|
|
* our own document said 20 %.
|
|
*
|
|
* `$oneOff` is the setup fee: a second line item with no recurrence, whose
|
|
* `amount_cents` is what this same customer is charged for it, formed the same
|
|
* way and by the same authority as the Price above.
|
|
* Stripe allows one-time prices in a subscription-mode session and puts them
|
|
* on the INITIAL invoice only, which is precisely what a setup fee is — so it
|
|
* is charged once, with the first month, and never again. Null means there is
|
|
* no such fee and no second line is sent at all: a zero-amount line on a
|
|
* checkout is a line telling the customer they owe nothing for something.
|
|
*
|
|
* `label` is what that line is CALLED on Stripe's own page and on the receipt
|
|
* Stripe sends. It arrives from the caller rather than being written here
|
|
* because it is customer-facing wording, and that belongs in the language
|
|
* files with the rest of it. `currency` has to match the recurring Price's, so
|
|
* it comes from the same catalogue the Price was formed from.
|
|
*
|
|
* @param array{amount_cents: int, label: string, currency: string}|null $oneOff
|
|
*/
|
|
public function createCheckoutSession(
|
|
string $priceId,
|
|
string $successUrl,
|
|
string $cancelUrl,
|
|
array $metadata = [],
|
|
?string $customerEmail = null,
|
|
?string $idempotencyKey = null,
|
|
?array $oneOff = null,
|
|
): string;
|
|
|
|
/**
|
|
* Create a Product for a plan family; returns its Stripe id.
|
|
*
|
|
* `$idempotencyKey` is Stripe's own guard: a crash between their creating
|
|
* the object and our storing the id would otherwise mint a second one on
|
|
* the next run, and there is no way to tell duplicates apart afterwards.
|
|
*/
|
|
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string;
|
|
|
|
/**
|
|
* Create a Price. Stripe Prices are immutable and carry their own interval,
|
|
* which is why one exists per plan version AND term — and, since a
|
|
* reverse-charge business is charged the bare net while everybody else pays
|
|
* the gross, two per priced row on each term.
|
|
*/
|
|
public function createPrice(
|
|
string $productId,
|
|
int $amountCents,
|
|
string $currency,
|
|
string $interval,
|
|
array $metadata = [],
|
|
?string $idempotencyKey = null,
|
|
): string;
|
|
|
|
/**
|
|
* Every active recurring Price of a Product, as Stripe holds them, with the
|
|
* properties that decide what each one CHARGES.
|
|
*
|
|
* `interval_count` multiplies the period — 3 bills every three months while
|
|
* `interval` still reads 'month'. `usage_type` other than 'licensed' bills
|
|
* metered usage. `transform_quantity` divides the quantity before charging,
|
|
* and modules are billed BY quantity, so a divide_by of 10 would charge a
|
|
* customer holding three for one. `billing_scheme` 'tiered' keeps the money
|
|
* in tiers and leaves `unit_amount` null, which reads as 0 here.
|
|
*
|
|
* All four are REPORTED, not filtered. Which of them is acceptable is the
|
|
* caller's decision, and the caller is App\Services\Billing\AdoptStripePrice
|
|
* — the class whose docblock promises that adoption cannot move money. That
|
|
* promise has to be testable in the class that makes it, and it was not
|
|
* while these fields were dropped here: FakeStripeClient cannot express
|
|
* them.
|
|
*
|
|
* An absent key is Stripe's own default — 1, 'licensed', none, 'per_unit'.
|
|
*
|
|
* Active ones only, because the question being asked is "is there something
|
|
* here I can sell on?". `currency` comes back UPPER CASE, the way our own
|
|
* tables hold it.
|
|
*
|
|
* @return array<int, array{id: string, unit_amount: int, currency: string, interval: string, interval_count: int, usage_type: string, transform_quantity: bool, billing_scheme: string, created: int, metadata: array<string, string>}>
|
|
*/
|
|
public function activePricesFor(string $productId): array;
|
|
|
|
/**
|
|
* Every active Product on the account, with its metadata.
|
|
*
|
|
* The other half of the orphan question, and the more consequential one. A
|
|
* run that created a Product and died before storing its id leaves an orphan
|
|
* exactly as a Price does — but a second Product is worse than a duplicate
|
|
* Price, because activePricesFor() is then asked about the wrong one and the
|
|
* Price recognition goes blind for that whole family.
|
|
*
|
|
* The WHOLE active list, with no metadata filter: Stripe cannot search
|
|
* metadata, and an account holds a handful of Products. Which of them is
|
|
* ours is App\Services\Billing\AdoptStripeProduct's decision.
|
|
*
|
|
* @return array<int, array{id: string, name: string, created: int, metadata: array<string, string>}>
|
|
*/
|
|
public function activeProducts(): array;
|
|
|
|
/**
|
|
* Write metadata onto a Price that already exists.
|
|
*
|
|
* Metadata is one of the few fields a Stripe Price lets you change — the
|
|
* same property activatePrice() rests on, and the reason the metadata format
|
|
* is NOT part of a Price's identity. An adopted orphan is brought up to
|
|
* today's metadata rather than replaced, which is exactly what was done by
|
|
* hand after 2026-07-29 and keeps Stripe's own dashboard readable.
|
|
*/
|
|
public function updatePriceMetadata(string $priceId, array $metadata): void;
|
|
|
|
/** Stop a Price being offered. The Price itself stays, as Stripe requires. */
|
|
public function archivePrice(string $priceId): void;
|
|
|
|
/**
|
|
* Offer an archived Price again.
|
|
*
|
|
* The other half of archivePrice(), and it was missing. A rate that moves and
|
|
* then moves back finds the Price for the figure it has come back to already
|
|
* in our register: the sync brings that row back rather than minting a second
|
|
* Price for one amount, which is right — but the Price was archived AT STRIPE,
|
|
* and only this makes it sellable again. Without it the catalogue pointed at
|
|
* an inactive Price and the failure was a customer meeting a refused checkout.
|
|
*/
|
|
public function activatePrice(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 item a subscription bills its PACKAGE through, 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.
|
|
* The first item is the package, because that is the one the checkout
|
|
* created; modules are added afterwards and carry their own ids, which are
|
|
* stored on the bookings themselves.
|
|
*/
|
|
public function subscriptionItemId(string $subscriptionId): ?string;
|
|
|
|
/**
|
|
* Put a module on the subscription as an item of its own, and return the
|
|
* item's id.
|
|
*
|
|
* `$prorationBehaviour` is passed explicitly for the same reason as above.
|
|
* A module booked in the middle of a term is charged PRORATE_IMMEDIATELY:
|
|
* Stripe works out the days that are left, raises an invoice for exactly
|
|
* that and takes the money there and then — which is the invoice the
|
|
* customer is owed a document for.
|
|
*/
|
|
public function addSubscriptionItem(
|
|
string $subscriptionId,
|
|
string $priceId,
|
|
int $quantity,
|
|
string $prorationBehaviour,
|
|
?string $idempotencyKey = null,
|
|
): string;
|
|
|
|
/**
|
|
* Change how many of a module the subscription bills for.
|
|
*
|
|
* A pack is sold by the unit, so a second one is a quantity of two on one
|
|
* item rather than a second item at the same price — two items on one price
|
|
* is a thing Stripe allows and nothing could later tell apart.
|
|
*/
|
|
public function updateSubscriptionItemQuantity(
|
|
string $itemId,
|
|
int $quantity,
|
|
string $prorationBehaviour,
|
|
): void;
|
|
|
|
/**
|
|
* Take a module off the subscription.
|
|
*
|
|
* Removed with PRORATE_NONE by everything that calls it: a cancelled module
|
|
* is kept until the end of the term the customer has already paid for and
|
|
* earns no credit, so nothing is to be settled — only the next cycle is
|
|
* smaller.
|
|
*/
|
|
public function removeSubscriptionItem(string $itemId, string $prorationBehaviour): void;
|
|
|
|
/**
|
|
* Tell Stripe to stop charging for a contract that is over.
|
|
*
|
|
* The one call this platform could not make. Nothing here cancelled a Stripe
|
|
* subscription at all: a customer's cancellation wrote a date on the
|
|
* instance, a withdrawal marked the contract cancelled and sent the money
|
|
* back, and in both cases Stripe went on taking the next month, and the next,
|
|
* for a service that had ended — and every one of those payments arrived as
|
|
* `invoice.paid` and drew a number out of the gapless Austrian series.
|
|
*
|
|
* `$when` is one of the CANCEL_* constants and is always passed explicitly,
|
|
* for the same reason `$prorationBehaviour` is above: the two are different
|
|
* promises to the customer, and which one a caller means has to be legible at
|
|
* the call site rather than inherited from a default written down here.
|
|
*
|
|
* `$idempotencyKey` because a retry after a timeout cannot tell whether the
|
|
* first attempt landed. Cancelling twice is harmless at Stripe, but the same
|
|
* key makes the retry provably a no-op instead of merely a likely one.
|
|
*/
|
|
public function cancelSubscription(
|
|
string $subscriptionId,
|
|
string $when,
|
|
?string $idempotencyKey = null,
|
|
): void;
|
|
|
|
/**
|
|
* Send money back, against the payment that took it.
|
|
*
|
|
* The only outgoing money movement this platform makes, and it exists
|
|
* because a consumer who withdraws within fourteen days is owed one — see
|
|
* App\Actions\WithdrawContract. `$amountCents` is the whole of what was
|
|
* taken: the owner has decided a withdrawing consumer gets everything back,
|
|
* not the amount less the days the cloud ran. It stays an explicit figure
|
|
* rather than "refund the payment" because a Storno states an amount and the
|
|
* money has to be the amount the Storno states.
|
|
*
|
|
* `$paymentReference` is a PaymentIntent (`pi_…`) or a Charge (`ch_…`) —
|
|
* whichever Stripe reported for the payment. Both are accepted rather than
|
|
* one normalised: which of the two an account produces depends on the API
|
|
* version it is pinned to, and a refund that silently does nothing because
|
|
* the wrong field was read is the worst possible failure here.
|
|
*
|
|
* `$idempotencyKey` is not optional in practice. A refund is the one call
|
|
* whose retry after a timeout would send the customer's money twice.
|
|
*
|
|
* @return string the refund's id
|
|
*/
|
|
public function refund(
|
|
string $paymentReference,
|
|
?int $amountCents = null,
|
|
?string $idempotencyKey = null,
|
|
): string;
|
|
|
|
/**
|
|
* The payment behind a Stripe invoice, or null if it has none.
|
|
*
|
|
* A subscription checkout charges through an invoice, so the invoice id is
|
|
* usually all we have written down — and a refund cannot be issued against
|
|
* an invoice. This is the one hop between the two.
|
|
*/
|
|
public function invoicePaymentReference(string $invoiceId): ?string;
|
|
|
|
/**
|
|
* Every line on a Stripe invoice, for the document we owe it.
|
|
*
|
|
* The webhook payload carries the lines already; this is for the invoice
|
|
* that has more of them than Stripe puts in an event. A document missing a
|
|
* line that was charged is worse than one nobody sent, so the complete list
|
|
* is worth an extra call.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function invoiceLines(string $invoiceId): array;
|
|
}
|