132 lines
6.7 KiB
PHP
132 lines
6.7 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 GROSS total
|
|
* Stripe actually charged. The two can legitimately differ (VAT, and later
|
|
* coupons), 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 — gross, 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.
|
|
//
|
|
// 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;
|
|
}
|
|
}
|