CluPilotCloud/app/Actions/WithdrawContract.php

357 lines
14 KiB
PHP

<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\Operator;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\WithdrawalRight;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* A consumer changes their mind inside fourteen days, and everything that
* follows from it.
*
* One action, because a withdrawal is one event with four consequences and
* three of them are irreversible. Split across a controller, a command and a
* page, they would come apart: a service ended with no credit note, a refund
* sent for a contract still running, a document issued twice by two clicks.
*
* In order, and the order is the design:
*
* 1. **The right is checked**, by WithdrawalRight and nowhere else. A business
* customer is refused here, on the server, whatever the page did or did not
* show — every door into this action is the same door.
* 2. **The withdrawal is claimed**, conditionally, on the contract. Two clicks
* in flight write one withdrawal between them; the loser is told it has
* already happened rather than sending a second refund.
* 3. **The paperwork**: the invoice that was issued is CANCELLED by a Storno
* with its own number — never edited, never deleted. That is the established
* rule for correcting an issued document, and a withdrawal is the clearest
* case there is for it.
* 4. **The money**, which falls out of the paperwork rather than being
* computed beside it: the whole of what the cancelled document said. FAGG
* §16 would let us keep the pro-rata value of the days the cloud ran, and
* the owner has decided not to — a withdrawing consumer gets everything
* back. Two sums that must agree cannot disagree if only one of them is
* ever calculated, and here there is only the one: the Storno's.
* 5. **The service ends**, through App\Actions\EndInstanceService and the
* `cancellation_scheduled` machinery it already reads — the address comes
* down, the DNS record goes, the instance is marked ended. No second way
* for an instance to stop being served.
*
* ## What happens when the money side fails
*
* It is caught, written onto the contract and logged as an error — and the
* withdrawal still stands. The customer's declaration is what ends the
* contract; a payment provider being unreachable does not un-declare it, and
* leaving the service running because a refund timed out would charge them for
* days they have withdrawn from. `withdrawal_refund_error` is where an operator
* finds the ones that need a hand.
*/
class WithdrawContract
{
/** Recorded by the customer themselves, in the portal. */
public const CHANNEL_PORTAL = 'portal';
/** Taken by telephone or post and typed in by an operator. */
public const CHANNEL_OPERATOR = 'operator';
public function __construct(
private EndInstanceService $endService,
private BookAddon $bookAddon,
private IssueInvoice $invoices,
private StripeClient $stripe,
private RecordCommercialEvent $record,
) {}
/**
* @param string $channel one of the CHANNEL_* constants
* @param Operator|null $by the operator who took the declaration, when it did not come from the portal
*
* @throws RuntimeException with the sentence the customer is shown, when the right does not apply
*/
public function __invoke(
Subscription $subscription,
string $channel = self::CHANNEL_PORTAL,
?Operator $by = null,
?Carbon $at = null,
): Subscription {
$at ??= now();
$right = WithdrawalRight::for($subscription);
if (! $right->open) {
// The refusal is the customer's own sentence, not a developer's:
// whatever refuses — the portal, the console, a stale tab — says
// the same thing, in the same words.
throw new RuntimeException((string) ($right->refusal ?? __('withdrawal.refusal_expired')));
}
// The one atomic gate. Everything below happens exactly once because
// exactly one caller can turn this column over.
$claimed = Subscription::query()
->whereKey($subscription->getKey())
->whereNull('withdrawn_at')
->update([
'withdrawn_at' => $at,
'withdrawal_channel' => $channel,
'withdrawal_recorded_by' => $by?->id,
'updated_at' => now(),
]);
$subscription->refresh();
if ($claimed === 0) {
throw new RuntimeException(__('withdrawal.refusal_already'));
}
$refunded = $this->settle($subscription, $at);
$this->endEverything($subscription, $at);
$this->enterInRegister($subscription, $refunded, $at);
return $subscription->refresh();
}
/**
* The paperwork and the money.
*
* @return int what actually went back to the customer, gross, in cents
*/
private function settle(Subscription $subscription, Carbon $at): int
{
try {
$original = $this->openingInvoice($subscription);
// No document was ever issued for this purchase — the company
// details were incomplete when it was made, or it was a grant. There
// is nothing to cancel, so the refund is worked out from what was
// charged instead. Stated here rather than silently skipped: a
// withdrawal without paperwork is a withdrawal an accountant will
// ask about.
if ($original === null) {
return $this->sendBack($subscription, max(0, $subscription->order?->chargedCents() ?? 0), $at);
}
// Taken back in full, with its own gapless number, pointing at the
// one it cancels. The original keeps its number for ever.
$storno = $this->invoices->cancelling($original);
// The refund IS what the cancellation took back — never a second
// figure computed beside it, which would have to agree with the
// document and one day would not. The Storno mirrors the original,
// so this is the whole amount the customer paid.
return $this->sendBack($subscription, max(0, -(int) $storno->gross_cents), $at);
} catch (Throwable $e) {
$this->parkMoneyFailure($subscription, $e);
return 0;
}
}
/**
* Put the money back where it came from.
*
* Stripe refunds a PAYMENT, not a subscription and not an invoice, so the
* handle is whatever the checkout reported — a PaymentIntent if there was
* one, otherwise the invoice that charged the card, resolved through Stripe
* once. A contract with neither was never paid for through Stripe at all (a
* grant, a contract opened by hand), and there is nothing to send back.
*/
private function sendBack(Subscription $subscription, int $grossCents, Carbon $at): int
{
$subscription->update(['withdrawal_refund_cents' => $grossCents]);
if ($grossCents <= 0) {
return 0;
}
$order = $subscription->order;
$reference = $order?->stripe_payment_intent_id;
if ($reference === null && $order?->stripe_invoice_id !== null) {
$reference = $this->stripe->invoicePaymentReference((string) $order->stripe_invoice_id);
}
if ($reference === null) {
throw new RuntimeException(
'No Stripe payment is recorded for this contract, so the refund has to be sent by hand.'
);
}
$refundId = $this->stripe->refund(
$reference,
$grossCents,
// Keyed on the contract, because a contract is withdrawn from once.
// A retry after a timeout that in fact went through replays Stripe's
// first answer rather than sending the money a second time — which
// is the single most expensive mistake this file could make.
idempotencyKey: 'clupilot-withdrawal-'.$subscription->uuid,
);
$subscription->update([
'withdrawal_refund_reference' => $refundId,
'withdrawal_refund_error' => null,
]);
Log::info('Refunded a withdrawal.', [
'subscription' => $subscription->uuid,
'refund' => $refundId,
'gross_cents' => $grossCents,
'withdrawn_at' => $at->toIso8601String(),
]);
return $grossCents;
}
/**
* A withdrawn contract ends: the modules stop, the address comes down and
* the contract is closed.
*
* Through the machinery that already does this, never beside it. The
* instance is put into `cancellation_scheduled` with its service ending NOW
* — which is what a withdrawal means, unlike a cancellation, where the
* customer keeps the term they paid for — and EndInstanceService is asked
* the same question it is asked by the nightly sweep.
*
* The modules are ended outright rather than at the period end, for the same
* reason: there is no term left to keep. The customer has withdrawn from the
* contract the modules hang off, and BookAddon::cancel() takes them off
* Stripe and out of the register.
*/
private function endEverything(Subscription $subscription, Carbon $at): void
{
foreach (SubscriptionAddon::query()->where('subscription_id', $subscription->id)->active()->get() as $addon) {
$this->bookAddon->cancel($addon);
}
$instance = $this->instanceOf($subscription);
if ($instance !== null && $instance->status !== 'ended') {
$instance->update([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => $at,
// Now, not the end of the term. A withdrawal unwinds the
// contract; there is no paid-up period to run out.
'service_ends_at' => $at,
]);
($this->endService)($instance->refresh());
}
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => $at,
]);
}
/**
* The machine this contract pays for.
*
* `subscriptions.instance_id` is the link, and it is written when resources
* are reserved — so a contract withdrawn from before the build got that far
* has none. The order and then the customer are the fallbacks, in that
* order, because an order belongs to exactly one purchase and a customer can
* have more than one instance behind them.
*/
private function instanceOf(Subscription $subscription): ?Instance
{
if ($subscription->instance !== null) {
return $subscription->instance;
}
if ($subscription->order_id !== null) {
$byOrder = Instance::query()->where('order_id', $subscription->order_id)->latest('id')->first();
if ($byOrder !== null) {
return $byOrder;
}
}
return Instance::query()
->where('customer_id', $subscription->customer_id)
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')
->first();
}
/**
* The document issued for the purchase that opened this contract.
*
* Cancellations are excluded by construction — one is not an invoice that
* can be taken back — and so is anything that already has a Storno against
* it, which IssueInvoice::cancelling() refuses in its own right.
*/
private function openingInvoice(Subscription $subscription): ?Invoice
{
if ($subscription->order_id === null) {
return null;
}
return Invoice::query()
->where('order_id', $subscription->order_id)
->whereNull('cancels_invoice_id')
->orderBy('id')
->first();
}
/**
* The refund did not go out. Said on the contract, not only in a log.
*
* What this describes is a customer who has withdrawn, whose service has
* stopped, and who has not had their money back — the one state nobody may
* discover from a log file three weeks later.
*/
private function parkMoneyFailure(Subscription $subscription, Throwable $e): void
{
Log::error('A withdrawal was declared but the money did not go back.', [
'subscription' => $subscription->uuid,
'customer' => $subscription->customer_id,
'error' => $e->getMessage(),
]);
$subscription->update([
'withdrawal_refund_error' => mb_substr($e->getMessage(), 0, 250),
]);
}
/**
* The entry that says this happened, in the register that cannot be edited.
*
* Negative, because it is revenue leaving. The gross is what actually went
* back, so a withdrawal that could not be refunded records a zero and the
* `withdrawal_refund_error` beside it says why — rather than claiming money
* moved that did not.
*/
private function enterInRegister(Subscription $subscription, int $refundedGross, Carbon $at): void
{
($this->record)(
event: SubscriptionRecord::EVENT_WITHDRAWAL,
subscription: $subscription->refresh(),
netCents: -(int) $subscription->price_cents,
at: $at,
extra: ['withdrawal' => [
'channel' => $subscription->withdrawal_channel,
// Nothing is charged for them — the refund is the whole amount —
// but how long the service actually ran is a fact about this
// contract, and the register is where facts about it are kept.
'delivered_days' => WithdrawalRight::deliveredDays($subscription, $at),
'term_days' => WithdrawalRight::termDays($subscription),
'refunded_gross_cents' => $refundedGross,
]],
chargedGrossCents: -$refundedGross,
eventKey: 'withdrawal:'.$subscription->uuid,
);
}
}