$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 $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; } }