CluPilotCloud/app/Actions/MoveStripeSubscriptionPrice...

219 lines
8.7 KiB
PHP

<?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,
]]);
}
}