CluPilotCloud/app/Services/Stripe/StripeClient.php

263 lines
11 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.
*
* `$oneOff` is the setup fee: a second line item with no recurrence, whose
* `amount_cents` is GROSS like every other figure this platform charges.
* 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.
*/
public function createPrice(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata = [],
?string $idempotencyKey = null,
): string;
/** Stop a Price being offered. The Price itself stays, as Stripe requires. */
public function archivePrice(string $priceId): void;
/**
* 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;
}