549 lines
23 KiB
PHP
549 lines
23 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\Collection;
|
|
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**: EVERY invoice raised inside the window 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. Every one of them, and not just the opening
|
|
* purchase: a module booked on day three and a renewal that fell inside the
|
|
* fourteen days are charges too, and they used to be left standing because
|
|
* they carry no `order_id` — see chargesInsideTheWindow().
|
|
* 4. **The money**, which falls out of the paperwork rather than being
|
|
* computed beside it: the whole of what each cancelled document said, sent
|
|
* back against the payment that actually took it. 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 Stornos'.
|
|
* 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. Stripe is told to stop billing in
|
|
* the same breath, immediately: a withdrawal unwinds the contract, so there
|
|
* is no paid-up term to run out and no next cycle to raise.
|
|
*
|
|
* ## 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, for every charge inside the window.
|
|
*
|
|
* One Storno per document and one refund per payment, because that is what
|
|
* each of the two things IS: a Storno mirrors exactly one invoice and carries
|
|
* its number, and a Stripe refund goes against exactly one payment. A single
|
|
* refund for the whole sum, sent against the opening payment, would be
|
|
* refused by Stripe for exceeding it — which is the trap the old one-invoice
|
|
* version was hiding behind.
|
|
*
|
|
* Each charge is settled in its own try, so a module invoice whose payment
|
|
* cannot be reached does not stop the opening purchase from being refunded.
|
|
* What actually went back is totalled from the refunds that succeeded, never
|
|
* from what was intended — a contract claiming money moved that did not is
|
|
* the one state nobody may discover three weeks later.
|
|
*
|
|
* @return int what actually went back to the customer, gross, in cents
|
|
*/
|
|
private function settle(Subscription $subscription, Carbon $at): int
|
|
{
|
|
$charges = $this->chargesInsideTheWindow($subscription, $at);
|
|
$refunded = 0;
|
|
$references = [];
|
|
$failed = false;
|
|
|
|
if ($charges->isEmpty()) {
|
|
// 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.
|
|
try {
|
|
$gross = max(0, $subscription->order?->chargedCents() ?? 0);
|
|
|
|
$reference = $this->refund(
|
|
$subscription,
|
|
$gross,
|
|
$this->paymentBehind(null, $subscription),
|
|
'clupilot-withdrawal-'.$subscription->uuid,
|
|
$at,
|
|
);
|
|
|
|
if ($reference !== null) {
|
|
$refunded += $gross;
|
|
$references[] = $reference;
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->parkMoneyFailure($subscription, $e);
|
|
$failed = true;
|
|
}
|
|
}
|
|
|
|
foreach ($charges as $invoice) {
|
|
try {
|
|
// 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($invoice);
|
|
|
|
// 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.
|
|
$gross = max(0, -(int) $storno->gross_cents);
|
|
|
|
$reference = $this->refund(
|
|
$subscription,
|
|
$gross,
|
|
$this->paymentBehind($invoice, $subscription),
|
|
// Keyed per DOCUMENT and not per contract. One key for the
|
|
// whole withdrawal would have Stripe replay the first
|
|
// refund's answer for the second, and the second charge
|
|
// would silently never be sent back at all.
|
|
'clupilot-withdrawal-'.$subscription->uuid.'-invoice-'.$invoice->id,
|
|
$at,
|
|
);
|
|
|
|
if ($reference !== null) {
|
|
$refunded += $gross;
|
|
$references[] = $reference;
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->parkMoneyFailure($subscription, $e);
|
|
$failed = true;
|
|
}
|
|
}
|
|
|
|
$subscription->update([
|
|
'withdrawal_refund_cents' => $refunded,
|
|
// Every reference, because there is a refund per charge and an
|
|
// operator reconciling this against Stripe needs all of them.
|
|
'withdrawal_refund_reference' => $references === []
|
|
? null
|
|
: mb_substr(implode(',', $references), 0, 250),
|
|
// Cleared only when nothing went wrong. parkMoneyFailure() has
|
|
// already written the reason otherwise, and clearing it here would
|
|
// erase the one note saying this withdrawal needs a hand.
|
|
...($failed ? [] : ['withdrawal_refund_error' => null]),
|
|
]);
|
|
|
|
return $refunded;
|
|
}
|
|
|
|
/**
|
|
* Every charge this contract raised inside the withdrawal window.
|
|
*
|
|
* `invoices.order_id` alone was the defect: only the opening purchase carries
|
|
* one. A renewal and a Stripe-invoice document are written against the
|
|
* CONTRACT and leave `order_id` null on purpose — see
|
|
* IssueInvoice::forBilledPeriod() and forStripeInvoice(), which say why — so a
|
|
* customer who booked a storage pack on day three and withdrew on day ten had
|
|
* that pack neither cancelled nor refunded. The owner's rule is that a
|
|
* withdrawing consumer gets EVERYTHING back, so every document has to be in
|
|
* this list.
|
|
*
|
|
* Both links, therefore, and both are needed: the opening invoice has an
|
|
* `order_id` and no `subscription_id`, and everything after it is the other
|
|
* way round.
|
|
*
|
|
* Cancellations are excluded by construction — one is not an invoice that can
|
|
* be taken back — and so is anything that already carries a Storno.
|
|
* IssueInvoice::cancelling() refuses the latter in its own right; excluding it
|
|
* here as well means a second withdrawal attempt finds nothing to do rather
|
|
* than raising an exception that would be parked as a money failure.
|
|
*
|
|
* Bounded at the moment of withdrawal rather than at fourteen days. It is the
|
|
* same set — a withdrawal can only be declared inside the window, so every
|
|
* document this contract has is inside it — and the bound that matters is
|
|
* "before the customer changed their mind", which is what `$at` is.
|
|
*
|
|
* @return Collection<int, Invoice>
|
|
*/
|
|
private function chargesInsideTheWindow(Subscription $subscription, Carbon $at): Collection
|
|
{
|
|
return Invoice::query()
|
|
->where(function ($query) use ($subscription) {
|
|
$query->where('subscription_id', $subscription->id);
|
|
|
|
if ($subscription->order_id !== null) {
|
|
$query->orWhere('order_id', $subscription->order_id);
|
|
}
|
|
})
|
|
->whereNull('cancels_invoice_id')
|
|
->whereNotIn(
|
|
'id',
|
|
Invoice::query()->whereNotNull('cancels_invoice_id')->select('cancels_invoice_id')
|
|
)
|
|
->where('created_at', '<=', $at)
|
|
->orderBy('id')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* The Stripe payment a charge was settled by.
|
|
*
|
|
* Stripe refunds a PAYMENT, not a subscription and not an invoice, and each
|
|
* charge inside the window was a different payment: the checkout, a renewal,
|
|
* a module prorated in the middle of a term. So every document is sent back
|
|
* against the money that actually paid for it.
|
|
*
|
|
* The opening purchase is the one document with no Stripe invoice of its own —
|
|
* it is written from the ORDER, which is where the checkout's PaymentIntent
|
|
* was recorded, falling back to 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 paymentBehind(?Invoice $invoice, Subscription $subscription): ?string
|
|
{
|
|
if ($invoice?->stripe_invoice_id !== null) {
|
|
return $this->stripe->invoicePaymentReference((string) $invoice->stripe_invoice_id);
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
return $reference;
|
|
}
|
|
|
|
/**
|
|
* Put one charge's money back where it came from.
|
|
*
|
|
* @return string|null Stripe's reference for the refund, or null when there
|
|
* was nothing to send back
|
|
*/
|
|
private function refund(
|
|
Subscription $subscription,
|
|
int $grossCents,
|
|
?string $reference,
|
|
string $idempotencyKey,
|
|
Carbon $at,
|
|
): ?string {
|
|
if ($grossCents <= 0) {
|
|
return null;
|
|
}
|
|
|
|
if ($reference === null) {
|
|
throw new RuntimeException(
|
|
'No Stripe payment is recorded for this charge, so the refund has to be sent by hand.'
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
$refundId = $this->stripe->refund($reference, $grossCents, idempotencyKey: $idempotencyKey);
|
|
|
|
Log::info('Refunded a withdrawal.', [
|
|
'subscription' => $subscription->uuid,
|
|
'refund' => $refundId,
|
|
'gross_cents' => $grossCents,
|
|
'withdrawn_at' => $at->toIso8601String(),
|
|
]);
|
|
|
|
return $refundId;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* The order of the two Stripe calls is the reason the subscription is stopped
|
|
* LAST and not first. BookAddon::cancel() takes each module's item off the
|
|
* subscription, and an item cannot be removed from a subscription that no
|
|
* longer exists — so cancelling the whole thing first would make every one of
|
|
* those removals fail and park a sync error on a contract that is already
|
|
* over.
|
|
*/
|
|
private function endEverything(Subscription $subscription, Carbon $at): void
|
|
{
|
|
foreach (SubscriptionAddon::query()->where('subscription_id', $subscription->id)->active()->get() as $addon) {
|
|
$this->bookAddon->cancel($addon);
|
|
}
|
|
|
|
$this->stopBilling($subscription);
|
|
|
|
$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,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Tell Stripe to stop, now.
|
|
*
|
|
* The call nothing in this application used to make. A withdrawal marked the
|
|
* contract cancelled, ended the service and sent the money back, and the
|
|
* subscription at Stripe went on charging the card every month — for a
|
|
* machine that had been switched off, with `invoice.paid` arriving here each
|
|
* time and drawing a real invoice number for it.
|
|
*
|
|
* Immediately and not at the period end, which is the whole difference
|
|
* between this and a customer's own cancellation: a withdrawal unwinds the
|
|
* contract from the beginning, the entire amount has just gone back, and
|
|
* there is no paid-up term left for the customer to use up.
|
|
*
|
|
* A failure does not un-declare the withdrawal — same principle as the refund,
|
|
* for the same reason — but it is the most urgent thing on this contract, so
|
|
* it is said on the contract as well as in the log. Written after settle(),
|
|
* so the note cannot be cleared by a refund that succeeded.
|
|
*/
|
|
private function stopBilling(Subscription $subscription): void
|
|
{
|
|
// Nothing to stop, and nothing has gone wrong. A granted package was
|
|
// never sold through Stripe — GrantSubscription leaves the id null on
|
|
// purpose — and neither was a contract opened by hand.
|
|
if ($subscription->stripe_subscription_id === null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->stripe->cancelSubscription(
|
|
(string) $subscription->stripe_subscription_id,
|
|
StripeClient::CANCEL_IMMEDIATELY,
|
|
// Keyed on the contract, because a contract is withdrawn from
|
|
// once. A retry after a timeout replays Stripe's first answer.
|
|
idempotencyKey: 'clupilot-withdrawal-stop-'.$subscription->uuid,
|
|
);
|
|
} catch (Throwable $e) {
|
|
Log::error('A withdrawal was declared and Stripe was not told to stop charging for it.', [
|
|
'subscription' => $subscription->uuid,
|
|
'stripe_subscription' => $subscription->stripe_subscription_id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
$this->note($subscription, 'Stripe was not told to stop: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 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(),
|
|
]);
|
|
|
|
$this->note($subscription, $e->getMessage());
|
|
}
|
|
|
|
/**
|
|
* Write a sentence an operator has to read onto the contract itself.
|
|
*
|
|
* Appended rather than overwritten, because a withdrawal can go wrong in more
|
|
* than one way at once — a module invoice whose payment could not be reached
|
|
* AND an order to stop billing that Stripe refused — and an operator who read
|
|
* only the last of them would send the money back and leave the customer
|
|
* subscribed.
|
|
*/
|
|
private function note(Subscription $subscription, string $sentence): void
|
|
{
|
|
$existing = trim((string) $subscription->withdrawal_refund_error);
|
|
|
|
$subscription->update([
|
|
'withdrawal_refund_error' => mb_substr(
|
|
$existing === '' ? $sentence : $existing.' | '.$sentence,
|
|
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,
|
|
);
|
|
}
|
|
}
|