219 lines
8.7 KiB
PHP
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,
|
|
]]);
|
|
}
|
|
}
|