validate([ 'plan' => ['required', 'string', 'max:64'], 'term' => ['nullable', 'in:'.Subscription::TERM_MONTHLY.','.Subscription::TERM_YEARLY], // The consumer's express request that we begin inside the // fourteen-day withdrawal window (FAGG §16, §356 BGB). Required, // because the cloud starts being built the moment the payment // lands: without this on record a withdrawal on day thirteen gets // the WHOLE amount back, however many days the machine actually // ran. A seller who cannot prove the consent was given cannot // charge for the days — and the proof is a timestamp taken here. 'immediate_start' => ['accepted'], ], [ 'immediate_start.accepted' => __('checkout.immediate_start_required'), ]); $term = $data['term'] ?? Subscription::TERM_MONTHLY; $user = $request->user(); // Read once and handed to everything below that has to know who is // buying. Which of a package's two Stripe Prices this session uses is // decided from it, and a second lookup is a second chance to decide it // differently. $customer = $user === null ? null : Customer::query()->where('email', $user->email)->first(); // Somebody who already has a running contract is not buying a second // one by accident: changing package is a plan change, which prorates // and keeps their data where it is. A second checkout would build them // a second, empty cloud and bill for both. if ($this->hasLiveContract($customer)) { return redirect()->route('billing')->with('status', __('checkout.already_customer')); } if (! $stripe->isConfigured()) { // Nothing to send them to. Better a plain sentence than a redirect // into an error page on somebody else's domain. Log::error('Checkout attempted while Stripe is not configured.'); return back()->withErrors(['plan' => __('checkout.unavailable')]); } try { $version = $catalogue->currentVersion($data['plan']); $price = $version->prices->firstWhere('term', $term); } catch (Throwable $e) { // An unknown, withdrawn or overlapping plan. The catalogue throws // by design; a shop must not guess which of two versions was meant. Log::warning('Checkout for an unavailable plan', ['plan' => $data['plan'], 'exception' => $e]); return back()->withErrors(['plan' => __('checkout.plan_gone')]); } // WHO is buying decides WHICH of the package's two Stripe Prices this // session uses: the domestic gross for everybody who is charged VAT, the // bare net for a business in another member state whose VAT id is // verified, because reverse charge means no VAT is owed to us at all. // Decided by TaxTreatment and read from PlanPrices — nothing about the // customer is examined here, or there would be two answers to one // question and no way to tell which an invoice was written against. $treatment = TaxTreatment::for($customer); $priceId = $price === null ? null : $planPrices->liveFor($price, $treatment); // A Price Stripe has never been told about. Sending the customer on would // open a checkout for nothing — and for a reverse-charge business, refusing // is the only safe answer even though a Price does exist: the domestic one // would take a fifth more than they owe, which is the overcharge this whole // rule exists to end. A refused checkout is loud, one command cures it, and // the log line names the command. if ($priceId === null) { Log::error('Checkout for a plan that is not on sale in Stripe. Run stripe:sync-catalogue.', [ 'plan' => $data['plan'], 'term' => $term, 'reverse_charge' => $treatment->reverseCharge, ]); return back()->withErrors(['plan' => __('checkout.plan_gone')]); } // The one-off setup fee as this customer is charged it, or null where the // operator has set none. Advertised on the price sheet and on the booking // page for months and charged to nobody; it rides along as a second, // non-recurring line on this session, which Stripe puts on the initial // invoice only. Subject to the same rule as the package: a reverse-charge // business pays the bare net of it. $setup = SetupFee::checkoutLine((string) $price->currency, $treatment); try { $url = $stripe->createCheckoutSession( priceId: $priceId, successUrl: route('checkout.done'), cancelUrl: route('order'), // Exactly the keys StripeWebhookController reads back. The // VERSION goes along, so the contract is opened on what the // customer was shown even if a new version is published while // they are typing their card number. metadata: [ 'plan' => $version->family->key, 'plan_version_id' => (string) $version->id, 'datacenter' => app(HostCapacity::class)->preferredDatacenter(), // Exactly '1', because StripeWebhookController compares // against exactly that: a consent that can be produced by a // typo is not a consent. It travels through Stripe rather // than being stamped here — the contract only exists once // the payment does, and a consent recorded for a checkout // somebody abandoned would be a consent to nothing. 'immediate_start' => '1', // What part of Stripe's amount_total is the setup fee, so the // webhook can tell the two apart again: the package's charge // is what the register holds against the contract price, and // the fee is a line of its own on the first invoice. 'setup_fee_cents' => (string) ($setup['amount_cents'] ?? 0), ], customerEmail: $user?->email, oneOff: $setup, ); } catch (Throwable $e) { Log::error('Stripe would not open a checkout session', ['exception' => $e]); return back()->withErrors(['plan' => __('checkout.unavailable')]); } return redirect()->away($url); } /** * Where Stripe sends them back to. * * Deliberately says "we are building it", not "you have paid": this page is * a redirect target, and a redirect target is a URL anybody can type. What * the customer actually sees underneath is the provisioning card on their * dashboard, which is driven by a run that only the webhook can start. */ public function done(): RedirectResponse { return redirect()->route('dashboard')->with('status', __('checkout.thanks')); } /** Does this customer already have a contract that is being billed? */ private function hasLiveContract(?Customer $customer): bool { return $customer !== null && Subscription::query() ->where('customer_id', $customer->id) ->whereIn('status', ['active', 'past_due']) ->exists(); } }