CluPilotCloud/app/Actions/OpenSubscription.php

135 lines
7.0 KiB
PHP

<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Opens the contract a paid order bought.
*
* This is the moment the catalogue stops applying. Everything the customer is
* owed — price, quotas, seats, the hardware behind the plan — is copied onto
* the subscription here and never read from the catalogue again. Provisioning
* sizes the machine from this row, so an operator editing a plan afterwards
* cannot reach a customer who has already paid.
*
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
* against — deliberately not `Order::amount_cents`, which holds the total Stripe
* actually charged, whichever of a package's two Prices this customer was sold on.
* The two can legitimately differ (VAT, and later coupons) — and for a
* reverse-charge business they legitimately agree, because no VAT is owed to us at
* all — and reconciling them is not this action's job: the proof register
* records what was charged per event, and Stripe's invoice is the authority for
* the amount. Copying a gross total into this net field would silently corrupt
* every pro-rata calculation that reads it.
*
* `$overrides` exists for exactly one caller, GrantSubscription: a grant opens
* a contract through this same action rather than a second one, but needs to
* set what the customer actually pays (0, or less than the catalogue price)
* and stamp who granted it. Passed straight through to Subscription::create()
* on top of the catalogue snapshot — empty for every ordinary purchase, which
* is the whole reason this stays a parameter and not a new method.
*/
class OpenSubscription
{
public function __construct(private RecordCommercialEvent $record) {}
/** @param array<string, mixed> $overrides */
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY, array $overrides = []): Subscription
{
// A retried webhook must not open a second contract for one purchase.
$existing = Subscription::query()->where('order_id', $order->id)->first();
if ($existing !== null) {
return $existing;
}
$start = now();
// The contract and its entry in the register commit together. If the
// record could fail after the subscription is in, the next webhook
// retry would find the contract, return early, and leave a paid sale
// permanently missing from the evidence.
return DB::transaction(fn () => $this->open($order, $term, $start, $overrides));
}
/** @param array<string, mixed> $overrides */
private function open(Order $order, string $term, Carbon $start, array $overrides = []): Subscription
{
$subscription = Subscription::create(array_merge(
// The version the order carries, when the checkout recorded one:
// what the customer saw beats what happens to be on sale by the
// time their payment reaches us.
Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
[
'customer_id' => $order->customer_id,
'order_id' => $order->id,
// Carried from the checkout: from here on, Stripe's events
// name this and nothing else.
'stripe_subscription_id' => $order->stripe_subscription_id,
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
// The fourteen days a consumer may withdraw in, stamped at the
// moment the contract is concluded rather than derived on every
// read. A statutory deadline is a date the customer was told,
// and a date that is recomputed is a date that can move.
//
// Stamped for a business customer too. Whether the right exists
// is WithdrawalRight's question and it asks the customer, not
// this column — and a customer can correct their recorded type
// afterwards, at which point the window has to have been running
// all along rather than starting from the correction.
'withdrawal_ends_at' => $start->copy()->addDays(WithdrawalRight::WINDOW_DAYS),
// Carried from the purchase, where it was given. Nothing reads
// it to decide anything any more — a withdrawing consumer gets
// the whole amount back — so this is a record of what was agreed
// at the checkout and not a gate. See Order's own cast.
'immediate_start_consent_at' => $order->immediate_start_consent_at,
'status' => 'active',
],
// Last, so a grant's price and provenance win over the catalogue
// snapshot above rather than the other way round.
$overrides,
));
// The sale, entered in the register the moment it happens. Written from
// the contract that was just frozen, so the evidence and the contract
// cannot describe different purchases.
($this->record)(
event: SubscriptionRecord::EVENT_PURCHASE,
subscription: $subscription,
netCents: $subscription->price_cents,
at: $start,
stripe: ['event' => $order->stripe_event_id],
// The order carries what Stripe actually took, including whatever tax
// or discount applied on the day. The contract price is what was
// agreed; this is what was paid, and the register has to be able to
// state both — which for a reverse-charge business is the same figure,
// because their Price carries the bare net.
//
// Keyed on the Stripe id, not on the amount being non-zero: a fully
// discounted checkout charges zero, and reading that as "no amount
// recorded" would file a free sale as though it had been paid for
// in full. An order without a Stripe id was never charged through
// a checkout at all, and has nothing to report.
// packageChargedCents(), not the whole total: a first purchase can
// carry the one-off setup fee on the same Stripe session, and that fee
// buys the setting-up rather than the package. Held against the
// contract price it would report every sale with a fee on it as charged
// at the wrong amount, which is precisely what the register's flag is
// for and precisely why it must not fire on a correct sale.
chargedGrossCents: $order->stripe_event_id !== null ? $order->packageChargedCents() : null,
);
return $subscription;
}
}