CluPilotCloud/app/Observers/OrderObserver.php

132 lines
6.2 KiB
PHP

<?php
namespace App\Observers;
use App\Actions\ApplyPlanChange;
use App\Actions\BookAddon;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The moment a paid order becomes the thing that was bought.
*
* StartCustomerProvisioning turns a paid FIRST purchase into work from the
* webhook that announced the payment. Nothing sold from the portal's shop has
* such a webhook: **no code path in this application marks a cart order paid.**
* That is stated here rather than implied, because it is the one fact a reader of
* this file needs and cannot see from it — the shop writes the order at
* `pending`, tells the customer "wir schalten ihn nach der Zahlung frei", and
* there it stops until a second checkout, an operator or a seeder moves it on.
*
* So the trigger is hung on the FACT — the order becoming paid — rather than on
* whichever call site happens to exist today. Whatever eventually marks one paid
* finds the fulfilment already written, for every type of order that can be sold,
* and does not have to remember to call four different actions in the right
* order.
*
* Only an UPDATE, never a create. Every order in this codebase is created either
* already-paid by provisioning and by a grant — both of which do their own
* fulfilment, because a grant is not a payment — or pending by the shop. Firing
* on create would make every fixture that builds a paid order provision, book and
* bill.
*
* A downgrade is deliberately not here. It costs nothing, is never paid, and
* lands at the end of the term the customer already paid for —
* clupilot:apply-due-plan-changes carries those from the date stamped on the
* contract.
*/
class OrderObserver
{
public function updated(Order $order): void
{
if (! $order->wasChanged('status') || $order->status !== 'paid') {
return;
}
try {
match ($order->type) {
'upgrade' => app(ApplyPlanChange::class)->forOrder($order),
// A module and a storage pack are the same purchase to the
// contract: one booking, at the price the customer was shown,
// charged onward as an item on the Stripe subscription. Storage
// is sold in packs and the shop writes one order per pack, so
// each order books exactly one — which is also what the unique
// index on (order_id, addon_key) enforces.
'addon' => $this->bookAddon($order, (string) $order->addon_key),
'storage' => $this->bookAddon($order, AddonCatalogue::STORAGE),
// A traffic pack is the one thing sold here that nothing can
// deliver yet, and it is left loud rather than quietly dropped.
// The allowance TrafficMeter meters against is a standing count
// of packs on the instance (`traffic_addons`), while a top-up is
// sold as a one-off for the month that is running out — so
// incrementing it would hand the customer the extra thousand
// gigabytes every month for ever off one payment, and doing
// nothing leaves them paying for nothing. Which of the two the
// owner means is a commercial decision, not a guess to be made
// here, and until it is made a paid traffic order must be
// impossible to miss.
'traffic' => Log::error('A paid traffic pack has no fulfilment path: nothing raises the metered allowance.', [
'order_id' => $order->id, 'customer_id' => $order->customer_id,
]),
// The first purchase of a package ('new') is delivered by the
// webhook that announced its payment, not from here; a downgrade
// is never paid at all. Named rather than left to the default so
// that a type nobody has thought about still falls through it.
'new', 'downgrade' => null,
default => null,
};
} catch (Throwable $e) {
// Never allowed to fail the write that marked the order paid. That
// write is the record that money changed hands, and undoing it over a
// delivery would erase the payment and leave nothing to retry from. A
// delivery that did not land is loud in its own right: the order stays
// unconsumed and the contract still says what it said, both of which
// somebody can see.
Log::error('Failed to deliver what a paid order bought.', [
'order_id' => $order->id, 'type' => $order->type,
'plan' => $order->plan, 'addon' => $order->addon_key,
'error' => $e->getMessage(),
]);
}
}
/**
* Book the module this order paid for onto the customer's contract.
*
* The contract is resolved through CustomDomainAccess::contractOf(), which is
* already the one place that answers "which contract is this customer on" —
* the same resolution ApplyPlanChange::forOrder() makes, and for the same
* reason: a second copy of that query is the one that would disagree the day
* the rule changes.
*
* BookAddon is idempotent per order, refuses a module the package may not
* have, freezes the price and adds the Stripe item; none of those rules are
* repeated here, because a rule enforced in two places is a rule that will
* disagree with itself.
*/
private function bookAddon(Order $order, string $addonKey): void
{
if ($addonKey === '') {
Log::error('A paid module order names no module.', ['order_id' => $order->id]);
return;
}
$subscription = app(CustomDomainAccess::class)->contractOf($order->customer);
if (! $subscription instanceof Subscription) {
Log::warning('No contract to book a paid module onto.', [
'order_id' => $order->id, 'addon' => $addonKey,
]);
return;
}
app(BookAddon::class)($subscription, $addonKey, 1, $order);
}
}