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 */ 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, ); } }