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