200 lines
9.4 KiB
PHP
200 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Subscription;
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use App\Services\Billing\PlanPrices;
|
|
use App\Services\Billing\SetupFee;
|
|
use App\Services\Billing\TaxTreatment;
|
|
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,
|
|
PlanPrices $planPrices,
|
|
): 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();
|
|
|
|
// 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();
|
|
}
|
|
}
|