CluPilotCloud/app/Http/Controllers/CheckoutController.php

162 lines
7.1 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Provisioning\HostCapacity;
use App\Services\Stripe\StripeClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Buying a package, without anyone having to write an email about it.
*
* The site used to end every path in "anfragen": a mailto: link and a promise
* to answer the same working day. That is a person's afternoon per customer,
* for a product whose entire point is that the machine does the work — and it
* scales exactly as far as the operator's inbox does.
*
* So: a signed-in customer picks a package, pays, and provisioning starts by
* itself. Where no host has room the order is parked rather than refused (see
* ReserveResources) — that is the ONE case that still needs a human, and it
* needs them to buy a server, not to answer a mail.
*
* This class does not take money and does not create a contract. It opens a
* Stripe hosted checkout and gets out of the way; the purchase becomes real
* when Stripe says so, on the webhook, which is the only place that may believe
* a payment happened. A "thank you" page proves nothing — the customer can open
* it by typing the URL.
*/
class CheckoutController extends Controller
{
/**
* Send the customer to Stripe for one package.
*
* Everything here is re-derived from the catalogue: the plan key arrives
* from a form, and a form field is a string somebody can retype. The price
* charged is the one the catalogue holds for the version on sale right now,
* never a figure carried in the request.
*/
public function start(Request $request, StripeClient $stripe, PlanCatalogue $catalogue): RedirectResponse
{
$data = $request->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();
// 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($user?->email)) {
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')]);
}
// A price with no Stripe id was never synced. Sending the customer on
// would open a checkout for nothing.
if ($price === null || blank($price->stripe_price_id)) {
Log::error('Checkout for a plan that is not on sale in Stripe', [
'plan' => $data['plan'],
'term' => $term,
]);
return back()->withErrors(['plan' => __('checkout.plan_gone')]);
}
try {
$url = $stripe->createCheckoutSession(
priceId: (string) $price->stripe_price_id,
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',
],
customerEmail: $user?->email,
);
} 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 address already have a contract that is being billed? */
private function hasLiveContract(?string $email): bool
{
if ($email === null) {
return false;
}
$customer = Customer::query()->where('email', $email)->first();
return $customer !== null && Subscription::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'past_due'])
->exists();
}
}