300 lines
12 KiB
PHP
300 lines
12 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,
|
|
) {}
|
|
|
|
/** @return bool whether Stripe now bills exactly the modules this contract holds */
|
|
public function __invoke(Subscription $subscription): 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);
|
|
}
|
|
|
|
$this->settled($subscription);
|
|
|
|
return true;
|
|
} catch (Throwable $e) {
|
|
$this->park($subscription, $e);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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): 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,
|
|
StripeClient::PRORATE_IMMEDIATELY,
|
|
// 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.
|
|
$desired > $known ? 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,
|
|
]]);
|
|
}
|
|
}
|