132 lines
6.2 KiB
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);
|
|
}
|
|
}
|