CluPilotCloud/app/Actions/SyncStripeAddonItems.php

407 lines
17 KiB
PHP

<?php
namespace App\Actions;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonPrices;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Telling Stripe which modules a contract is paying for.
*
* A booked module was a row here and a line on the customer's first invoice, and
* nothing at all on the Stripe subscription — so it was charged once, in the
* month it was booked, and never again. This is what closes that: every module
* still running becomes an item on the subscription, and Stripe bills it beside
* the package on the same invoice, every cycle, for as long as the customer
* keeps it.
*
* **It reconciles rather than replays.** Not "booking adds an item, cancelling
* removes one": the desired state is derivable from the bookings themselves —
* which modules are running, at which frozen price, how many of each — and the
* state Stripe is in is stamped on those same rows. So this can be run at any
* moment, from a booking, from a cancellation or from the hourly sweep, and it
* settles on the same answer. That is also what makes the retry honest: a
* booking that could not reach Stripe leaves an active row with no item id, and
* there is nothing else to remember.
*
* **Proration, per direction, chosen here and nowhere else.**
*
* - **Booking** — and taking more of a pack — settles immediately
* (`always_invoice`). The owner has decided a mid-period booking is prorated
* by days, and `always_invoice` is the behaviour that does exactly that AND
* charges it there and then: Stripe raises its own invoice for the days that
* are left and takes the money. That invoice comes back as `invoice.paid`
* and is the fourth document in the owner's example. The alternative,
* `create_prorations`, would park the amount as a credit line on the next
* cycle invoice, and the customer would get one document a month later
* carrying two different things.
* - **Cancelling** — and taking fewer of a pack — settles nothing (`none`).
* The customer keeps the module to the end of the term they have already paid
* for and gets no credit, so there is nothing to settle; the item comes off
* now and the next cycle is simply smaller.
*
* **Why the item comes off at cancellation rather than at the period end.**
* Stripe can cancel a SUBSCRIPTION at a period end; an item has no such thing.
* Waiting for the boundary and removing it then is a race against Stripe's own
* invoice generation, which happens at that same instant — lose it and the
* customer is charged a full month for a module they cancelled, which is the one
* mistake that costs real money. Removing it now with `none` moves no money at
* all: the current term is already paid for, no credit is raised, and the next
* cycle invoice simply has no such line. What the customer KEEPS until the
* period end is the module itself, and that is held on the booking as
* `cancels_at` and ended by clupilot:end-cancelled-addons.
*
* **Never throws.** The booking has already been made and delivered to the
* machine by the time this runs — a storage pack is a bigger disk before Stripe
* hears a word about it — and Stripe being unreachable must not undo any of
* that. The failure is parked on the contract in `stripe_addon_sync`, logged,
* and retried by clupilot:sync-stripe-subscriptions beside the plan swaps.
*/
class SyncStripeAddonItems
{
public function __construct(
private StripeClient $stripe,
private AddonPrices $prices,
) {}
/**
* @param bool $chargeForAdditions false when an item is being PUT BACK rather than
* bought: a cancellation taken back inside the term
* restores something the customer has already paid
* for, and charging the proration again would bill
* them twice for the same days. See
* App\Actions\BookAddon::undoCancelAtPeriodEnd().
* @return bool whether Stripe now bills exactly the modules this contract holds
*/
public function __invoke(Subscription $subscription, bool $chargeForAdditions = true): bool
{
// A granted package was never sold through Stripe — GrantSubscription
// leaves stripe_subscription_id null on purpose — so there is nothing
// there to put an item on and nothing has gone wrong. Answered before
// anything else, so no call is made and no failure is recorded.
if ($subscription->stripe_subscription_id === null) {
$this->settled($subscription);
return true;
}
try {
foreach ($this->groups($subscription) as $group) {
$this->reconcile($subscription, $group, $chargeForAdditions);
}
$this->settled($subscription);
return true;
} catch (Throwable $e) {
$this->park($subscription, $e);
return false;
}
}
/**
* Move every module item this contract carries onto the Price that charges
* today's figure for it.
*
* The catalogue moved from pushing NET amounts to Stripe to pushing GROSS
* ones, and a Stripe Price cannot be edited — so every running module is on a
* Price that takes too little. __invoke() above will not find them: the item
* exists and the quantity is right, so it has nothing to reconcile. This is
* the deliberate second pass, run by stripe:reprice-subscriptions.
*
* PRORATE_NONE throughout. The customer has already paid for the term they
* are in, at whatever was taken then; charging the difference for days
* already served would be a bill nobody agreed to. From the next cycle
* Stripe simply takes the right amount.
*
* The module's own frozen `price_cents` is untouched, because that is what
* the customer agreed to pay net and it is what every pro-rata sum reads.
*
* @return array<int, array{addon: string, from: ?string, to: ?string}> what moved, for the command's output
*/
public function reprice(Subscription $subscription, bool $dryRun = false): array
{
if ($subscription->stripe_subscription_id === null) {
return [];
}
$moved = [];
foreach ($this->groups($subscription) as $group) {
$billable = $group->filter(
fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null
);
$stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null);
$itemId = $stamped->first()?->stripe_item_id;
$first = $billable->first();
// Nothing Stripe is billing, or nothing it should be: __invoke()
// owns both of those cases and would undo whatever this did.
if ($itemId === null || $first === null || (int) $first->price_cents <= 0) {
continue;
}
$target = $this->prices->liveFor(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
);
$current = $stamped->first()?->stripe_price_id;
if ($target !== null && $target === $current) {
continue;
}
$moved[] = ['addon' => (string) $first->addon_key, 'from' => $current, 'to' => $target];
if ($dryRun) {
continue;
}
$this->guardConfigured();
// Minted here where the sweep left a figure the catalogue no longer
// pre-creates — a grandfathered booking, or a discounted grant.
$target ??= $this->prices->ensure(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
);
if ($target === null) {
// Nothing to bill for it after all — a module given away. Taken
// back out of the report rather than announced as a move.
array_pop($moved);
continue;
}
$moved[count($moved) - 1]['to'] = $target;
$this->stripe->updateSubscriptionPrice(
(string) $subscription->stripe_subscription_id,
$itemId,
$target,
StripeClient::PRORATE_NONE,
);
$this->stamp($group, $itemId, $target);
}
return $moved;
}
/**
* The bookings that matter, grouped into the items they should be.
*
* By module AND by frozen price, because that pair is what a Stripe Price
* is: two packs booked at the same money are one item with a quantity of
* two, and a third booked after the catalogue moved is a second item at the
* price that customer agreed to. Grouping by module alone would have to pick
* one of two prices and charge everybody that.
*
* Cancelled bookings are in here as long as they still carry an item id —
* that is the only record of what Stripe was last told, and without it the
* quantity could never be brought back down.
*
* @return Collection<string, Collection<int, SubscriptionAddon>>
*/
private function groups(Subscription $subscription): Collection
{
return SubscriptionAddon::query()
->where('subscription_id', $subscription->id)
->where(fn ($q) => $q->whereNull('cancelled_at')->orWhereNotNull('stripe_item_id'))
->orderBy('id')
->get()
->groupBy(fn (SubscriptionAddon $addon) => $addon->addon_key.'@'.$addon->price_cents);
}
/**
* Bring one module's item into step with what the contract holds.
*
* @param Collection<int, SubscriptionAddon> $group
*/
private function reconcile(Subscription $subscription, Collection $group, bool $chargeForAdditions = true): void
{
// What Stripe should bill: every booking still running and not already
// on its way out. A module with `cancels_at` set is deliberately absent
// — the customer keeps it, nobody is billed for it again.
$billable = $group->filter(
fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null
);
// What Stripe was last told, read off the rows that carry the item id.
$stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null);
$desired = (int) $billable->sum('quantity');
$known = (int) $stamped->sum('quantity');
$itemId = $stamped->first()?->stripe_item_id;
$first = $group->first();
// A module given away costs nothing, so there is nothing for Stripe to
// bill — and a zero-amount item would put a line saying so on every
// invoice the customer ever gets. Treated exactly like a module that is
// no longer held: if one was ever billed, it comes off.
if ($desired === 0 || (int) $first->price_cents <= 0) {
if ($itemId !== null) {
$this->guardConfigured();
$this->stripe->removeSubscriptionItem($itemId, StripeClient::PRORATE_NONE);
}
$this->stamp($group, null, null);
return;
}
// Stripe already bills exactly this. Only the bookings are restamped —
// one may have been cancelled while another was booked in the same
// breath, which leaves the quantity right and the rows out of date.
if ($itemId !== null && $known === $desired) {
$this->stamp($group, $itemId, $stamped->first()?->stripe_price_id);
return;
}
// Asked for only now that something is going to be said to Stripe: an
// unconfigured account must fail here, where it is parked and retried,
// rather than while resolving a Price nobody is going to use.
$this->guardConfigured();
$priceId = $this->prices->ensure(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
);
if ($priceId === null) {
$this->stamp($group, null, null);
return;
}
if ($itemId === null) {
$itemId = $this->stripe->addSubscriptionItem(
(string) $subscription->stripe_subscription_id,
$priceId,
$desired,
$chargeForAdditions ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
// Keyed on the contract, the module and how many of it: a retry
// after a timeout that in fact went through replays Stripe's
// first answer instead of adding the module a second time.
idempotencyKey: sprintf(
'clupilot-addon-item-%d-%s-%d-%d',
$subscription->id,
$first->addon_key,
$first->price_cents,
$desired,
),
);
} else {
$this->stripe->updateSubscriptionItemQuantity(
$itemId,
$desired,
// More of a pack is bought now and paid for the days that are
// left; fewer is a cancellation, which earns no credit. A
// restoration is neither and settles nothing.
$desired > $known && $chargeForAdditions
? StripeClient::PRORATE_IMMEDIATELY
: StripeClient::PRORATE_NONE,
);
}
$this->stamp($group, $itemId, $priceId);
}
/**
* Write down what Stripe now bills, on the bookings it bills.
*
* Every running booking in the group carries the item id, so a second pack
* booked tomorrow can see that it is joining an item rather than starting
* one. A booking that has stopped gives its id back, because the quantity
* has already been taken down by it and counting it again would take the
* same pack off twice.
*
* Through the query builder: none of these columns is part of what the
* booking froze, and the model would otherwise have to be re-read first.
*
* @param Collection<int, SubscriptionAddon> $group
*/
private function stamp(Collection $group, ?string $itemId, ?string $priceId): void
{
foreach ($group as $addon) {
$billed = $itemId !== null && $addon->cancelled_at === null && $addon->cancels_at === null;
$values = [
'stripe_item_id' => $billed ? $itemId : null,
'stripe_price_id' => $billed ? $priceId : null,
];
if ($addon->stripe_item_id === $values['stripe_item_id']
&& $addon->stripe_price_id === $values['stripe_price_id']) {
continue;
}
SubscriptionAddon::query()->whereKey($addon->getKey())->update($values + ['updated_at' => now()]);
$addon->forceFill($values)->syncOriginal();
}
}
private function guardConfigured(): void
{
if (! $this->stripe->isConfigured()) {
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
}
}
/** Stripe bills exactly what the contract holds; nothing is outstanding. */
private function settled(Subscription $subscription): void
{
if ($subscription->stripe_addon_sync === null) {
return;
}
$subscription->update(['stripe_addon_sync' => null]);
}
/**
* A module was booked or cancelled here and Stripe was not told.
*
* On the contract rather than only in a log line, for the same reason as
* `stripe_price_sync` beside it: what this describes is a customer holding a
* module nobody is billing them for — or being billed for one they gave up —
* and a log line scrolls away.
*/
private function park(Subscription $subscription, Throwable $e): void
{
$parked = (array) ($subscription->stripe_addon_sync ?? []);
$attempts = (int) ($parked['attempts'] ?? 0) + 1;
Log::error('A module booking landed but did not reach Stripe: this contract is not billed for what it holds.', [
'subscription' => $subscription->uuid,
'stripe_subscription' => $subscription->stripe_subscription_id,
'attempts' => $attempts,
'error' => $e->getMessage(),
]);
$subscription->update(['stripe_addon_sync' => [
'error' => mb_substr($e->getMessage(), 0, 250),
'failed_at' => now()->toIso8601String(),
'attempts' => $attempts,
]]);
}
}