diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php
index 91d3c65..6267d1b 100644
--- a/app/Actions/BookAddon.php
+++ b/app/Actions/BookAddon.php
@@ -217,6 +217,51 @@ class BookAddon
return $addon;
}
+ /**
+ * Take back a cancellation that has not landed yet.
+ *
+ * The customer changed their mind again, which is allowed right up to the
+ * moment the appointment is kept: until then the module is still running and
+ * still theirs, and nothing has been undone that would have to be rebuilt.
+ * After it — `cancelled_at` set — this refuses, because the module has
+ * genuinely stopped and putting it back is a new booking at today's price.
+ *
+ * Claimed conditionally like the cancellation it undoes, so two clicks write
+ * one answer between them.
+ *
+ * **Nothing is charged for it.** The term this module was cancelled for was
+ * paid for in advance and the cancellation raised no credit — that is the
+ * owner's rule — so putting the item back must not raise a charge either.
+ * Left to its own devices the reconciler would add it with
+ * `always_invoice`, which is right for a module being bought in the middle
+ * of a term and wrong here: the customer would be billed a second time for
+ * days they have already paid for. So the reconciler is told, in the one
+ * place where the difference is known, that this addition is a restoration
+ * and not a sale.
+ */
+ public function undoCancelAtPeriodEnd(SubscriptionAddon $addon): SubscriptionAddon
+ {
+ $claimed = SubscriptionAddon::query()
+ ->whereKey($addon->getKey())
+ ->whereNull('cancelled_at')
+ ->whereNotNull('cancels_at')
+ ->update(['cancels_at' => null, 'updated_at' => now()]);
+
+ $addon->refresh();
+
+ if ($claimed === 0) {
+ return $addon;
+ }
+
+ $subscription = $addon->subscription;
+
+ if ($subscription !== null) {
+ app(SyncStripeAddonItems::class)($subscription->refresh(), chargeForAdditions: false);
+ }
+
+ return $addon;
+ }
+
/**
* Stop charging for a module now, without losing what it cost.
*
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
index 71700cc..6d93ab2 100644
--- a/app/Actions/Fortify/CreateNewUser.php
+++ b/app/Actions/Fortify/CreateNewUser.php
@@ -46,6 +46,13 @@ class CreateNewUser implements CreatesNewUsers
// other collision here already gives them.
Rule::unique(Operator::class, 'email'),
],
+ // Asked, never inferred. Everything downstream turns on it — the
+ // fourteen-day right of withdrawal, the wording of the invoice,
+ // whether reverse charge can apply — and a VAT number cannot stand
+ // in for it: plenty of businesses have none, and no consumer has
+ // one. Required, because the alternative is a field that is empty
+ // for most customers and therefore answers nothing.
+ 'customer_type' => ['required', Rule::in(Customer::TYPES)],
'password' => $this->passwordRules(),
])->validate();
@@ -63,6 +70,7 @@ class CreateNewUser implements CreatesNewUsers
'user_id' => $user->id,
'name' => $input['name'],
'email' => $input['email'],
+ 'customer_type' => $input['customer_type'],
'locale' => app()->getLocale(),
'status' => 'active',
]);
diff --git a/app/Actions/OpenSubscription.php b/app/Actions/OpenSubscription.php
index 58ada8c..c7ab6ed 100644
--- a/app/Actions/OpenSubscription.php
+++ b/app/Actions/OpenSubscription.php
@@ -5,6 +5,7 @@ namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
+use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -74,6 +75,21 @@ class OpenSubscription
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
+ // The fourteen days a consumer may withdraw in, stamped at the
+ // moment the contract is concluded rather than derived on every
+ // read. A statutory deadline is a date the customer was told,
+ // and a date that is recomputed is a date that can move.
+ //
+ // Stamped for a business customer too. Whether the right exists
+ // is WithdrawalRight's question and it asks the customer, not
+ // this column — and a customer can correct their recorded type
+ // afterwards, at which point the window has to have been running
+ // all along rather than starting from the correction.
+ 'withdrawal_ends_at' => $start->copy()->addDays(WithdrawalRight::WINDOW_DAYS),
+ // Carried from the purchase: the express request to begin at
+ // once is what makes a withdrawing consumer owe the pro-rata
+ // share, and it was given at the checkout, not here.
+ 'immediate_start_consent_at' => $order->immediate_start_consent_at,
'status' => 'active',
],
// Last, so a grant's price and provenance win over the catalogue
diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php
index 42673f8..cbea15a 100644
--- a/app/Actions/StartCustomerProvisioning.php
+++ b/app/Actions/StartCustomerProvisioning.php
@@ -2,15 +2,15 @@
namespace App\Actions;
+use App\Exceptions\IdentityCollisionException;
use App\Mail\InvoiceMail;
use App\Mail\OrderConfirmationMail;
-use App\Services\Billing\IssueInvoice;
-use App\Exceptions\IdentityCollisionException;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
+use App\Services\Billing\IssueInvoice;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
@@ -31,7 +31,7 @@ class StartCustomerProvisioning
) {}
/**
- * @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string} $event
+ * @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string,immediate_start_consent?:bool} $event
*/
public function fromStripeEvent(array $event): ?Order
{
@@ -74,6 +74,16 @@ class StartCustomerProvisioning
'datacenter' => $event['datacenter'],
'stripe_event_id' => $event['id'],
'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null,
+ // Kept so a withdrawal has something to refund against; see
+ // App\Actions\WithdrawContract.
+ 'stripe_payment_intent_id' => $event['stripe_payment_intent_id'] ?? null,
+ 'stripe_invoice_id' => $event['stripe_invoice_id'] ?? null,
+ // Stamped only where the consumer actually gave it. Null is
+ // the honest default and it is the EXPENSIVE one for us: a
+ // withdrawal without this consent on record refunds the
+ // whole amount, because we cannot charge for days the
+ // customer never agreed to start.
+ 'immediate_start_consent_at' => ($event['immediate_start_consent'] ?? false) === true ? now() : null,
'status' => 'paid',
]);
@@ -295,6 +305,17 @@ class StartCustomerProvisioning
* Find or create the customer, race-safe against the unique email index so a
* concurrent first-time purchase can't create two customers for one email.
*
+ * `customer_type` is deliberately NOT set here, and that is a decision
+ * rather than an omission. Nobody in this code path has asked the question:
+ * a Stripe event carries a name, an address and a payment, and none of those
+ * says whether the person buying is a consumer or a business. Writing either
+ * answer would be inventing one — so the column stays NULL, which every
+ * reader treats as a consumer, which is the protective answer. Ordinarily
+ * there is nothing to invent: the buyer signed up first and was asked then
+ * (App\Actions\Fortify\CreateNewUser). This branch is the customer whose
+ * Stripe email differs from the one they registered with, and the operator
+ * or the customer can record the answer afterwards in the portal settings.
+ *
* @param array{email:string,name:?string,stripe_customer_id:?string} $event
*/
private function resolveCustomer(array $event): Customer
diff --git a/app/Actions/SyncStripeAddonItems.php b/app/Actions/SyncStripeAddonItems.php
index 0fbc542..1bc8616 100644
--- a/app/Actions/SyncStripeAddonItems.php
+++ b/app/Actions/SyncStripeAddonItems.php
@@ -70,8 +70,16 @@ class SyncStripeAddonItems
private AddonPrices $prices,
) {}
- /** @return bool whether Stripe now bills exactly the modules this contract holds */
- public function __invoke(Subscription $subscription): bool
+ /**
+ * @param bool $chargeForAdditions false when an item is being PUT BACK rather than
+ * bought: a cancellation taken back inside the term
+ * restores something the customer has already paid
+ * for, and charging the proration again would bill
+ * them twice for the same days. See
+ * App\Actions\BookAddon::undoCancelAtPeriodEnd().
+ * @return bool whether Stripe now bills exactly the modules this contract holds
+ */
+ public function __invoke(Subscription $subscription, bool $chargeForAdditions = true): bool
{
// A granted package was never sold through Stripe — GrantSubscription
// leaves stripe_subscription_id null on purpose — so there is nothing
@@ -85,7 +93,7 @@ class SyncStripeAddonItems
try {
foreach ($this->groups($subscription) as $group) {
- $this->reconcile($subscription, $group);
+ $this->reconcile($subscription, $group, $chargeForAdditions);
}
$this->settled($subscription);
@@ -128,7 +136,7 @@ class SyncStripeAddonItems
*
* @param Collection $group
*/
- private function reconcile(Subscription $subscription, Collection $group): void
+ private function reconcile(Subscription $subscription, Collection $group, bool $chargeForAdditions = true): void
{
// What Stripe should bill: every booking still running and not already
// on its way out. A module with `cancels_at` set is deliberately absent
@@ -193,7 +201,7 @@ class SyncStripeAddonItems
(string) $subscription->stripe_subscription_id,
$priceId,
$desired,
- StripeClient::PRORATE_IMMEDIATELY,
+ $chargeForAdditions ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
// Keyed on the contract, the module and how many of it: a retry
// after a timeout that in fact went through replays Stripe's
// first answer instead of adding the module a second time.
@@ -210,8 +218,11 @@ class SyncStripeAddonItems
$itemId,
$desired,
// More of a pack is bought now and paid for the days that are
- // left; fewer is a cancellation, which earns no credit.
- $desired > $known ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
+ // left; fewer is a cancellation, which earns no credit. A
+ // restoration is neither and settles nothing.
+ $desired > $known && $chargeForAdditions
+ ? StripeClient::PRORATE_IMMEDIATELY
+ : StripeClient::PRORATE_NONE,
);
}
diff --git a/app/Actions/WithdrawContract.php b/app/Actions/WithdrawContract.php
new file mode 100644
index 0000000..59061a6
--- /dev/null
+++ b/app/Actions/WithdrawContract.php
@@ -0,0 +1,390 @@
+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);
+ $owedNet = WithdrawalRight::owedNetCents($subscription, $at);
+
+ // 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 and nothing to correct, 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) {
+ $paid = (int) ($subscription->order?->amount_cents ?? 0);
+ $owedGross = $owedNet === 0 ? 0 : $this->grossFor($subscription, $owedNet);
+
+ return $this->sendBack($subscription, max(0, $paid - $owedGross), $at);
+ }
+
+ // Taken back in full, with its own gapless number, pointing at the
+ // one it cancels. The original keeps its number for ever.
+ $this->invoices->cancelling($original);
+
+ // What the consumer owes for the days the cloud actually ran. Its
+ // own document, because the money is real and an amount kept with no
+ // invoice behind it is money we cannot account for.
+ $remainder = $owedNet > 0
+ ? $this->invoices->forService(
+ customer: $original->customer,
+ lines: [[
+ 'description' => __('withdrawal.invoice_line', [
+ 'plan' => ucfirst((string) $subscription->plan),
+ ]),
+ 'details' => [__('withdrawal.invoice_detail', [
+ 'days' => WithdrawalRight::deliveredDays($subscription, $at),
+ 'term' => WithdrawalRight::termDays($subscription),
+ ])],
+ 'quantity_milli' => 1000,
+ 'unit' => '',
+ 'unit_net_cents' => $owedNet,
+ ]],
+ currency: (string) $original->currency,
+ )
+ : null;
+
+ // The refund IS the difference between the two documents. Computing
+ // it separately would produce a second figure that has to agree with
+ // the first, and one day would not.
+ return $this->sendBack(
+ $subscription,
+ max(0, (int) $original->gross_cents - (int) ($remainder?->gross_cents ?? 0)),
+ $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();
+ }
+
+ /** Gross for a net figure, at this customer's own treatment. */
+ private function grossFor(Subscription $subscription, int $netCents): int
+ {
+ return TaxTreatment::for($subscription->customer)->grossCents($netCents);
+ }
+
+ /**
+ * 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,
+ 'delivered_days' => WithdrawalRight::deliveredDays($subscription, $at),
+ 'term_days' => WithdrawalRight::termDays($subscription),
+ 'owed_net_cents' => WithdrawalRight::owedNetCents($subscription, $at),
+ 'refunded_gross_cents' => $refundedGross,
+ 'immediate_start_consent_at' => $subscription->immediate_start_consent_at?->toIso8601String(),
+ ]],
+ chargedGrossCents: -$refundedGross,
+ eventKey: 'withdrawal:'.$subscription->uuid,
+ );
+ }
+}
diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php
index fc7df9b..e5fd3c5 100644
--- a/app/Http/Controllers/StripeWebhookController.php
+++ b/app/Http/Controllers/StripeWebhookController.php
@@ -82,6 +82,19 @@ class StripeWebhookController extends Controller
// The handle every later billing event arrives with. Without it an
// invoice.paid cannot be matched to the contract it renews.
'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null,
+ // What a refund would have to be issued against, kept at the only
+ // moment Stripe tells us. A subscription checkout charges through
+ // an invoice and usually reports no PaymentIntent at all, so both
+ // are taken and App\Actions\WithdrawContract uses whichever it has.
+ 'stripe_payment_intent_id' => is_string($object['payment_intent'] ?? null) ? $object['payment_intent'] : null,
+ 'stripe_invoice_id' => is_string($object['invoice'] ?? null) ? $object['invoice'] : null,
+ // The consumer's express request that the service begin inside the
+ // fourteen-day withdrawal window, having been told they would then
+ // owe the pro-rata share of whatever had been delivered (FAGG §16).
+ // Set on the session metadata by the checkout; anything other than
+ // the exact string is a no, because a consent that can be produced
+ // by a typo is not a consent.
+ 'immediate_start_consent' => ($meta['immediate_start'] ?? null) === '1',
'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created
// before phase 5 put it there, and then the version on sale applies.
diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php
index 9b9d882..32fc3b0 100644
--- a/app/Livewire/Admin/Customers.php
+++ b/app/Livewire/Admin/Customers.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
+use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
@@ -55,6 +56,12 @@ class Customers extends Component
// year, and adding that to a monthly column would report twelve
// times the revenue for anyone who paid up front.
$contract = $instance?->subscription;
+
+ // The customer is already loaded on this row, so hand it to the
+ // contract rather than letting WithdrawalRight fetch it again —
+ // that is one query per customer on a page that lists all of them.
+ $contract?->setRelation('customer', $c);
+
$priceCents = (int) ($contract?->monthlyPriceCents()
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
@@ -82,6 +89,11 @@ class Customers extends Component
'instance' => $instance->subdomain ?? '—',
// Only an instance that exists can hand out an admin login.
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
+ // Whether a withdrawal taken by telephone or post could still be
+ // recorded for this customer. Only a hint for the button: the
+ // refusal that counts is made again inside WithdrawContract, so
+ // a stale list cannot let one through.
+ 'withdrawal_open' => WithdrawalRight::for($contract)->open,
'closed' => $c->closed_at !== null || $c->status === 'closed',
'suspended' => $c->status === 'suspended',
'status' => match (true) {
diff --git a/app/Livewire/Admin/RecordWithdrawal.php b/app/Livewire/Admin/RecordWithdrawal.php
new file mode 100644
index 0000000..5268b09
--- /dev/null
+++ b/app/Livewire/Admin/RecordWithdrawal.php
@@ -0,0 +1,106 @@
+authorize('customers.manage');
+
+ $customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
+
+ $this->customerUuid = $uuid;
+ $this->customerName = (string) $customer->name;
+ $this->refusal = WithdrawalRight::for($this->contractOf($customer))->refusal;
+ }
+
+ public function record(): void
+ {
+ $this->authorize('customers.manage');
+
+ $customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
+ $contract = $this->contractOf($customer);
+
+ if ($contract === null) {
+ $this->refusal = __('withdrawal.refusal_no_contract');
+
+ return;
+ }
+
+ try {
+ app(WithdrawContract::class)(
+ $contract,
+ WithdrawContract::CHANNEL_OPERATOR,
+ // Who took it. Part of the evidence that a declaration was made
+ // at all — a refund with nobody's name against it is the one an
+ // auditor asks about.
+ auth('operator')->user(),
+ );
+ } catch (RuntimeException $e) {
+ $this->refusal = $e->getMessage();
+
+ return;
+ }
+
+ $this->dispatch('notify', message: __('withdrawal.recorded', ['name' => $this->customerName]));
+ $this->closeModal();
+ }
+
+ private function contractOf(Customer $customer): ?Subscription
+ {
+ return app(CustomDomainAccess::class)->contractOf($customer);
+ }
+
+ public function render()
+ {
+ $customer = Customer::query()->where('uuid', $this->customerUuid)->first();
+ $contract = $customer === null ? null : $this->contractOf($customer);
+
+ return view('livewire.admin.record-withdrawal', [
+ 'right' => WithdrawalRight::for($contract),
+ 'owedNetCents' => $contract === null ? 0 : WithdrawalRight::owedNetCents($contract),
+ 'deliveredDays' => $contract === null ? 0 : WithdrawalRight::deliveredDays($contract),
+ 'termDays' => $contract === null ? 0 : WithdrawalRight::termDays($contract),
+ ]);
+ }
+}
diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php
index fc973af..536a8ba 100644
--- a/app/Livewire/Billing.php
+++ b/app/Livewire/Billing.php
@@ -2,12 +2,14 @@
namespace App\Livewire;
+use App\Actions\BookAddon;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\Order;
use App\Models\Subscription;
+use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\DowngradeCheck;
@@ -195,6 +197,114 @@ class Billing extends Component
$this->dispatch('notify', message: __('billing.purchased'));
}
+ /**
+ * Cancel a booked module, monthly, to the end of the term already paid for.
+ *
+ * The owner's rule, and the thing the portal could not do at all: "die
+ * Addons können monatlich gekündigt werden" was true of
+ * BookAddon::cancelAtPeriodEnd() and of nothing a customer could reach —
+ * the method had no caller in the interface, so the only way out of a
+ * recurring charge was to write to us.
+ *
+ * Every running booking of that module, not one of them. To a customer,
+ * "Modul kündigen" means the module stops; a pack module where one of two
+ * bookings kept billing would be a cancellation that did not cancel.
+ *
+ * Raised by ConfirmCancelAddon rather than called from the card (R23), and
+ * re-resolved from the signed-in customer here rather than trusted from the
+ * argument: this method is reachable by anybody who can post to
+ * /livewire/update with any key at all, and a booking is only ever looked
+ * for on contracts that belong to the person asking.
+ */
+ #[On('addon-cancel-confirmed')]
+ public function cancelAddon(string $key): void
+ {
+ $customer = $this->requireCustomer();
+
+ if ($customer === null) {
+ return;
+ }
+
+ $bookings = $this->ownBookings($customer, $key)
+ ->filter(fn (SubscriptionAddon $addon) => ! $addon->endsAtPeriodEnd());
+
+ if ($bookings->isEmpty()) {
+ $this->dispatch('notify', message: __('billing.addon_cancel_none'));
+
+ return;
+ }
+
+ $book = app(BookAddon::class);
+
+ foreach ($bookings as $addon) {
+ $book->cancelAtPeriodEnd($addon);
+ }
+
+ // Read back rather than assumed: cancelAtPeriodEnd() ends a module
+ // outright when the contract has no term left to wait for, and telling
+ // the customer it runs until a date that does not exist would be worse
+ // than saying nothing.
+ $endsAt = $this->ownBookings($customer, $key)->pluck('cancels_at')->filter()->min();
+
+ $this->dispatch('notify', message: $endsAt === null
+ ? __('billing.addon_cancelled_now')
+ : __('billing.addon_cancel_done', ['date' => $endsAt->local()->isoFormat('LL')]));
+ }
+
+ /**
+ * Take that cancellation back, while the module is still running.
+ *
+ * No confirmation dialog: this restores what the customer had, charges
+ * nothing extra and can itself be undone by cancelling again. R23 is about
+ * actions with consequences, and the consequence of this one is that
+ * nothing happens.
+ */
+ public function resumeAddon(string $key): void
+ {
+ $customer = $this->requireCustomer();
+
+ if ($customer === null) {
+ return;
+ }
+
+ $bookings = $this->ownBookings($customer, $key)
+ ->filter(fn (SubscriptionAddon $addon) => $addon->endsAtPeriodEnd());
+
+ if ($bookings->isEmpty()) {
+ $this->dispatch('notify', message: __('billing.addon_resume_none'));
+
+ return;
+ }
+
+ $book = app(BookAddon::class);
+
+ foreach ($bookings as $addon) {
+ $book->undoCancelAtPeriodEnd($addon);
+ }
+
+ $this->dispatch('notify', message: __('billing.addon_resumed'));
+ }
+
+ /**
+ * The customer's own running bookings of one module.
+ *
+ * Scoped through the contract to the signed-in customer, always. The key
+ * arrives from a request and a request is not evidence of ownership — a
+ * lookup by key alone would cancel somebody else's module for anyone who
+ * could guess one.
+ *
+ * @return Collection
+ */
+ private function ownBookings(Customer $customer, string $key): Collection
+ {
+ return SubscriptionAddon::query()
+ ->whereHas('subscription', fn ($q) => $q->where('customer_id', $customer->id))
+ ->where('addon_key', $key)
+ ->active()
+ ->orderBy('id')
+ ->get();
+ }
+
#[On('order-removed')]
public function orderRemoved(): void
{
diff --git a/app/Livewire/ConfirmCancelAddon.php b/app/Livewire/ConfirmCancelAddon.php
new file mode 100644
index 0000000..19c9af0
--- /dev/null
+++ b/app/Livewire/ConfirmCancelAddon.php
@@ -0,0 +1,68 @@
+addonKey = $key;
+ }
+
+ public function proceed(): void
+ {
+ $this->dispatch('addon-cancel-confirmed', key: $this->addonKey);
+ $this->closeModal();
+ }
+
+ public function render()
+ {
+ $customer = $this->customer();
+
+ // The module's own bookings, on this customer's contract and nobody
+ // else's — the same scoping the action uses, because a dialog that
+ // described a stranger's booking would be an information leak in the
+ // shape of a date.
+ $endsAt = $customer === null
+ ? null
+ : SubscriptionAddon::query()
+ ->whereHas('subscription', fn ($q) => $q->where('customer_id', $customer->id))
+ ->where('addon_key', $this->addonKey)
+ ->active()
+ ->get()
+ ->map(fn (SubscriptionAddon $addon) => $addon->subscription?->current_period_end)
+ ->filter()
+ ->min();
+
+ return view('livewire.confirm-cancel-addon', [
+ 'moduleName' => app(AddonCatalogue::class)->name($this->addonKey),
+ 'endsAt' => $endsAt,
+ ]);
+ }
+}
diff --git a/app/Livewire/ConfirmWithdraw.php b/app/Livewire/ConfirmWithdraw.php
new file mode 100644
index 0000000..7292c13
--- /dev/null
+++ b/app/Livewire/ConfirmWithdraw.php
@@ -0,0 +1,52 @@
+dispatch('withdrawal-confirmed');
+ $this->closeModal();
+ }
+
+ public function render()
+ {
+ $contract = app(CustomDomainAccess::class)->contractOf($this->customer());
+ $right = WithdrawalRight::for($contract);
+
+ return view('livewire.confirm-withdraw', [
+ 'right' => $right,
+ // What they would owe for the days the cloud has already run, and
+ // therefore what does NOT come back. Zero where the express request
+ // to start at once was never given — then the whole amount returns,
+ // and the dialog says so.
+ 'owedNetCents' => $contract === null ? 0 : WithdrawalRight::owedNetCents($contract),
+ 'deliveredDays' => $contract === null ? 0 : WithdrawalRight::deliveredDays($contract),
+ 'termDays' => $contract === null ? 0 : WithdrawalRight::termDays($contract),
+ ]);
+ }
+}
diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php
index a94504f..13209b6 100644
--- a/app/Livewire/Settings.php
+++ b/app/Livewire/Settings.php
@@ -2,18 +2,31 @@
namespace App\Livewire;
+use App\Actions\WithdrawContract;
+use App\Livewire\Concerns\ChangesOwnPassword;
+use App\Livewire\Concerns\ConfirmsPassword;
use App\Livewire\Concerns\ResolvesCustomer;
+use App\Models\Customer;
+use App\Services\Billing\CustomDomainAccess;
+use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Facades\Storage;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
+use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
+use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
+use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
+use RuntimeException;
class Settings extends Component
{
- use \App\Livewire\Concerns\ChangesOwnPassword;
- use \App\Livewire\Concerns\ConfirmsPassword;
+ use ChangesOwnPassword;
+ use ConfirmsPassword;
/** Shown once, right after setting two-factor up. */
public ?array $recoveryCodes = null;
@@ -31,7 +44,7 @@ class Settings extends Component
{
$this->requireConfirmedPassword();
- app(\Laravel\Fortify\Actions\EnableTwoFactorAuthentication::class)(auth()->user());
+ app(EnableTwoFactorAuthentication::class)(auth()->user());
}
/** Accept a code from the app, which is what actually turns it on. */
@@ -40,11 +53,11 @@ class Settings extends Component
$this->requireConfirmedPassword();
try {
- app(\Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication::class)(
+ app(ConfirmTwoFactorAuthentication::class)(
auth()->user(),
$this->twoFactorCode,
);
- } catch (\Illuminate\Validation\ValidationException $e) {
+ } catch (ValidationException $e) {
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('settings.twofa_code_wrong'));
return;
@@ -62,7 +75,7 @@ class Settings extends Component
{
$this->requireConfirmedPassword();
- app(\Laravel\Fortify\Actions\GenerateNewRecoveryCodes::class)(auth()->user());
+ app(GenerateNewRecoveryCodes::class)(auth()->user());
$this->recoveryCodes = json_decode(decrypt(auth()->user()->refresh()->two_factor_recovery_codes), true);
}
@@ -70,7 +83,7 @@ class Settings extends Component
{
$this->requireConfirmedPassword();
- app(\Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class)(auth()->user());
+ app(DisableTwoFactorAuthentication::class)(auth()->user());
$this->recoveryCodes = null;
$this->dispatch('notify', message: __('settings.twofa_off'));
}
@@ -110,6 +123,17 @@ class Settings extends Component
#[Validate('nullable|string|max:64')]
public string $phone = '';
+ /**
+ * Consumer or business — the answer given at sign-up, correctable here.
+ *
+ * Empty means nobody has ever recorded one, which is a real state and not a
+ * third kind of customer: every customer created from a Stripe event under
+ * an address they did not register with arrives that way. Shown so it can
+ * be answered, and never silently filled in from the VAT field beneath it.
+ */
+ #[Validate('nullable|string|max:16')]
+ public string $customerType = '';
+
#[Validate('nullable|string|max:64')]
public string $vatId = '';
@@ -140,6 +164,7 @@ class Settings extends Component
$this->companyName = $c->name ?? '';
$this->contactName = $c->contact_name ?? '';
$this->phone = $c->phone ?? '';
+ $this->customerType = $c->customer_type ?? '';
$this->vatId = $c->vat_id ?? '';
$this->billingAddress = $c->billing_address ?? '';
$this->brandDisplayName = $c->brand_display_name ?? '';
@@ -160,6 +185,10 @@ class Settings extends Component
'companyName' => 'required|string|max:255',
'contactName' => 'nullable|string|max:255',
'phone' => 'nullable|string|max:64',
+ // Only one of the two real answers may be stored. An empty string
+ // leaves the record as it was — the form can be saved by somebody
+ // who never touched this field — but no third value can ever get in.
+ 'customerType' => ['nullable', Rule::in(Customer::TYPES)],
'vatId' => 'nullable|string|max:64',
'billingAddress' => 'nullable|string|max:2000',
]);
@@ -168,6 +197,11 @@ class Settings extends Component
'name' => $data['companyName'],
'contact_name' => $data['contactName'] ?: null,
'phone' => $data['phone'] ?: null,
+ // Never cleared back to "unrecorded" from here. Once somebody has
+ // answered, the answer stands until it is replaced by the other one:
+ // a form submitted with the field blank must not quietly undo a
+ // recorded consumer into an unknown.
+ 'customer_type' => $data['customerType'] ?: $c->customer_type,
'vat_id' => $data['vatId'] ?: null,
'billing_address' => $data['billingAddress'] ?: null,
]);
@@ -213,6 +247,50 @@ class Settings extends Component
$this->dispatch('notify', message: __('settings.branding_saved'));
}
+ /**
+ * The consumer exercises the fourteen-day right of withdrawal.
+ *
+ * Raised by ConfirmWithdraw rather than called from the card (R23): this
+ * ends the service the same afternoon and cannot be taken back, so it is
+ * confirmed in a dialog this product draws.
+ *
+ * Every check that matters is on the server, inside WithdrawContract, and
+ * not in the markup that leads here. A Livewire action is reachable by
+ * anybody who can POST to /livewire/update — a business customer who has
+ * never seen this card can still name the method — so the refusal is made
+ * where the mutation is, once, in the customer's own words.
+ */
+ #[On('withdrawal-confirmed')]
+ public function withdraw(): void
+ {
+ $customer = $this->requireCustomer();
+
+ if ($customer === null) {
+ return;
+ }
+
+ $contract = app(CustomDomainAccess::class)->contractOf($customer);
+
+ if ($contract === null) {
+ $this->dispatch('notify', message: __('withdrawal.refusal_no_contract'));
+
+ return;
+ }
+
+ try {
+ app(WithdrawContract::class)($contract, WithdrawContract::CHANNEL_PORTAL);
+ } catch (RuntimeException $e) {
+ // The sentence WithdrawalRight refused with, which is already the
+ // one the customer would be shown — a business customer, an expired
+ // window, a second click on a withdrawal that has happened.
+ $this->dispatch('notify', message: $e->getMessage());
+
+ return;
+ }
+
+ $this->dispatch('notify', message: __('withdrawal.done'));
+ }
+
public function removeLogo(): void
{
$c = $this->requireCustomer();
@@ -239,6 +317,13 @@ class Settings extends Component
$user = auth()->user();
+ // Resolved once here so the card and the dialog it opens cannot state
+ // different deadlines for the same contract. A business customer gets
+ // `applies = false` and the card never renders — but the refusal that
+ // matters is the one in withdraw(), on the server.
+ $contract = app(CustomDomainAccess::class)->contractOf($c);
+ $withdrawal = WithdrawalRight::for($contract);
+
return view('livewire.settings', [
// Never the secret itself — only whether it exists, and the SVG
// Fortify renders from it. The secret in a Livewire property would
@@ -255,6 +340,11 @@ class Settings extends Component
'logoUrl' => $this->brandLogoPath ? Storage::disk('public')->url($this->brandLogoPath) : null,
'hasActivePackage' => $active !== null,
'cancellationScheduled' => $active === null && $scheduled !== null,
+ 'withdrawal' => $withdrawal,
+ // Only the two answers, so the form cannot offer "unrecorded" as a
+ // thing somebody can choose. An unrecorded customer sees neither
+ // selected, which is the truth about their record.
+ 'customerTypes' => Customer::TYPES,
]);
}
}
diff --git a/app/Models/Customer.php b/app/Models/Customer.php
index 2360b25..da61ee5 100644
--- a/app/Models/Customer.php
+++ b/app/Models/Customer.php
@@ -15,6 +15,15 @@ use Illuminate\Support\Str;
class Customer extends Model
{
+ /** Somebody buying for private purposes. Has the statutory withdrawal right. */
+ public const TYPE_CONSUMER = 'consumer';
+
+ /** Somebody buying for their business. Has no withdrawal right. */
+ public const TYPE_BUSINESS = 'business';
+
+ /** The two answers a person may give. NULL — never asked — is not one of them. */
+ public const TYPES = [self::TYPE_CONSUMER, self::TYPE_BUSINESS];
+
/**
* The customer behind a signed-in portal account.
*
@@ -61,11 +70,53 @@ class Customer extends Model
&& $this->normalisedVatId() === self::normaliseVatId($this->vat_id_verified_value);
}
+ /**
+ * Has anybody actually asked this customer which they are?
+ *
+ * The question every other answer here is built on. NULL is not a third
+ * kind of customer — it is the absence of a record, and the two methods
+ * below are careful never to let it read as one.
+ */
+ public function hasRecordedType(): bool
+ {
+ return in_array($this->customer_type, self::TYPES, true);
+ }
+
+ /**
+ * A business, and only if somebody wrote it down.
+ *
+ * Deliberately not inferred from `vat_id`. A business without a VAT number
+ * is an ordinary small business, so the field is empty for a great many of
+ * them, and treating "no number" as "not a business" would be wrong in the
+ * one direction that matters — it would hand a business a consumer's
+ * withdrawal right and, worse, would let anyone type a number to acquire
+ * the opposite.
+ */
+ public function isBusiness(): bool
+ {
+ return $this->customer_type === self::TYPE_BUSINESS;
+ }
+
+ /**
+ * A consumer — INCLUDING everyone nobody has asked yet.
+ *
+ * This is the deliberate asymmetry, and it is the whole reason the column
+ * is nullable. A customer whose type was never recorded is treated as the
+ * protected case: they keep the fourteen-day withdrawal right. Getting this
+ * wrong costs us a refund we may not have owed; getting it the other way
+ * round takes a statutory right away from somebody who has it, which is not
+ * a mistake that can be made up to them afterwards.
+ */
+ public function isConsumer(): bool
+ {
+ return ! $this->isBusiness();
+ }
+
/** @use HasFactory */
use HasFactory, HasUuid;
protected $fillable = [
- 'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address',
+ 'user_id', 'name', 'contact_name', 'email', 'customer_type', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address',
'locale', 'stripe_customer_id', 'status', 'closed_at',
'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color',
];
diff --git a/app/Models/Order.php b/app/Models/Order.php
index 4c54b85..40b870c 100644
--- a/app/Models/Order.php
+++ b/app/Models/Order.php
@@ -22,12 +22,21 @@ class Order extends Model implements ProvisioningSubject
protected $fillable = [
'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency',
- 'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'status',
+ 'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'stripe_payment_intent_id',
+ 'stripe_invoice_id', 'status', 'immediate_start_consent_at',
];
protected function casts(): array
{
- return ['amount_cents' => 'integer', 'plan_version_id' => 'integer'];
+ return [
+ 'amount_cents' => 'integer',
+ 'plan_version_id' => 'integer',
+ // When the consumer expressly asked for the service to begin inside
+ // the withdrawal window, having been told they would owe the
+ // pro-rata share of whatever had been delivered if they withdrew.
+ // Null means they were never asked, which is a refusal, not a yes.
+ 'immediate_start_consent_at' => 'datetime',
+ ];
}
/**
diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php
index 87a9100..5a3f7b6 100644
--- a/app/Models/Subscription.php
+++ b/app/Models/Subscription.php
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Support\Carbon;
use RuntimeException;
class Subscription extends Model
@@ -62,6 +63,13 @@ class Subscription extends Model
'current_period_end' => 'datetime',
'pending_effective_at' => 'datetime',
'cancelled_at' => 'datetime',
+ // The fourteen days a consumer may change their mind in, and what
+ // happened inside them. See App\Services\Billing\WithdrawalRight,
+ // which is the one place that decides whether the window is open.
+ 'withdrawal_ends_at' => 'datetime',
+ 'immediate_start_consent_at' => 'datetime',
+ 'withdrawn_at' => 'datetime',
+ 'withdrawal_refund_cents' => 'integer',
'stripe_event_at' => 'datetime',
'stripe_price_synced_at' => 'datetime',
// The swap that has not reached Stripe: the price it was meant to
@@ -287,6 +295,24 @@ class Subscription extends Model
return $this->pending_plan !== null && $this->pending_effective_at !== null;
}
+ /**
+ * When the withdrawal window opened: the moment the contract was concluded.
+ *
+ * `started_at` and not `created_at`, and the two are the same instant today
+ * — but `started_at` is in FROZEN and therefore cannot be moved by anything,
+ * which is the property a statutory deadline needs.
+ */
+ public function withdrawalOpenedAt(): ?Carbon
+ {
+ return $this->started_at;
+ }
+
+ /** Has this contract already been withdrawn from? */
+ public function isWithdrawn(): bool
+ {
+ return $this->withdrawn_at !== null;
+ }
+
public function isYearly(): bool
{
return $this->term === self::TERM_YEARLY;
diff --git a/app/Models/SubscriptionRecord.php b/app/Models/SubscriptionRecord.php
index 0095a62..95f0ac2 100644
--- a/app/Models/SubscriptionRecord.php
+++ b/app/Models/SubscriptionRecord.php
@@ -46,6 +46,20 @@ class SubscriptionRecord extends Model
public const EVENT_CANCELLATION = 'cancellation';
+ /**
+ * A consumer exercised the fourteen-day right of withdrawal.
+ *
+ * Its own event and not a cancellation: a cancellation ends a contract that
+ * was validly concluded and keeps every cent that was paid for the term, a
+ * withdrawal unwinds the contract itself and sends money back. Filing the
+ * two under one name would make the register unable to answer the one
+ * question an auditor asks — how much was refunded, and why.
+ *
+ * `net_cents` is NEGATIVE on this event, like a cancelled module: it is
+ * revenue leaving.
+ */
+ public const EVENT_WITHDRAWAL = 'withdrawal';
+
/** The shape of the `snapshot` column. Bump when it changes. */
public const SNAPSHOT_VERSION = 1;
diff --git a/app/Services/Billing/AddonCatalogue.php b/app/Services/Billing/AddonCatalogue.php
index 863959d..687c338 100644
--- a/app/Services/Billing/AddonCatalogue.php
+++ b/app/Services/Billing/AddonCatalogue.php
@@ -3,6 +3,8 @@
namespace App\Services\Billing;
use App\Models\Subscription;
+use App\Models\SubscriptionAddon;
+use Illuminate\Support\Carbon;
/**
* What the modules cost today, and what THIS customer pays for the ones they
@@ -138,7 +140,7 @@ final class AddonCatalogue
* arbitrary price while charging for all of them would let the page and the
* bill say different things.
*
- * @return array}>
+ * @return array}>
*/
public function forSubscription(?Subscription $subscription): array
{
@@ -175,9 +177,24 @@ final class AddonCatalogue
// "kostenlos" on the very page that sells it to everyone else.
'granted' => $own->isNotEmpty() && $own->every(fn ($addon) => $addon->isGranted()),
'quantity' => (int) $own->sum('quantity'),
+ // A cancellation that has been booked and not yet kept. The
+ // module is still running and still on the bill, which is why
+ // `booked` above stays true — what changes is that the card has
+ // to say until when, and offer the way back rather than the way
+ // out. Only when EVERY running booking is on its way out: a
+ // customer holding two packs who has cancelled one has not
+ // cancelled the module.
+ 'cancelling' => $own->isNotEmpty() && $own->every(
+ fn (SubscriptionAddon $addon) => $addon->endsAtPeriodEnd()
+ ),
+ // The earliest of them, because that is the first day something
+ // the customer can see actually changes.
+ 'cancels_at' => $own->pluck('cancels_at')->filter()->min(),
'bookings' => $own->map(fn ($addon) => [
+ 'uuid' => $addon->uuid,
'price_cents' => $addon->price_cents,
'quantity' => $addon->quantity,
+ 'cancels_at' => $addon->cancels_at,
])->values()->all(),
];
}
diff --git a/app/Services/Billing/IssueInvoice.php b/app/Services/Billing/IssueInvoice.php
index 8d8b36b..c59a107 100644
--- a/app/Services/Billing/IssueInvoice.php
+++ b/app/Services/Billing/IssueInvoice.php
@@ -215,13 +215,6 @@ final class IssueInvoice
);
}
- /**
- * The part every document has in common: the number, the freeze, the copy
- * to the archive.
- *
- * @param array> $lines
- * @param array $attributes what this document is FOR
- */
/**
* One invoice for work that was done, typed by a person.
*
@@ -274,6 +267,95 @@ final class IssueInvoice
);
}
+ /**
+ * Take an issued invoice back, by issuing the document that says so.
+ *
+ * The only lawful way to undo an invoice. Nothing is edited and nothing is
+ * deleted — an issued number has been on a document somebody has, and it can
+ * never be reused or withdrawn — so the original stays exactly as it is and
+ * a SECOND document, in the Storno series with its own gapless number,
+ * states the same figures with the opposite sign. `cancels_invoice_id` is
+ * what ties the pair together.
+ *
+ * Mirrored from the original's own snapshot rather than rebuilt from
+ * today's settings: a cancellation has to state the same issuer, the same
+ * recipient, the same lines and above all the same TAX RATE as the document
+ * it cancels. Recomputing the rate would produce a cancellation that does
+ * not cancel — a 20 % invoice taken back at 21 % leaves a cent standing for
+ * ever.
+ *
+ * Only the meta changes: its own number, today's date, the Storno wording
+ * and a reference to the number being taken back. The reverse-charge note
+ * is carried over with everything else, because a zero-rated cancellation
+ * needs the same justification on it that the zero-rated invoice did.
+ *
+ * Refuses to cancel a cancellation. Two Stornos of one invoice would take
+ * the same money back twice on paper, and a Storno of a Storno is a
+ * re-issue, which is a new invoice and not this.
+ */
+ public function cancelling(Invoice $original, ?InvoiceSeries $series = null): Invoice
+ {
+ $customer = $original->customer;
+
+ if ($customer === null) {
+ throw new RuntimeException('Refusing to cancel an invoice with nobody on it.');
+ }
+
+ if ($original->cancels_invoice_id !== null) {
+ throw new RuntimeException('Refusing to cancel a cancellation: re-issue the document instead.');
+ }
+
+ if (Invoice::query()->where('cancels_invoice_id', $original->id)->exists()) {
+ throw new RuntimeException("Invoice {$original->number} has already been cancelled.");
+ }
+
+ $series ??= InvoiceSeries::query()->where('kind', 'cancellation')->where('active', true)->firstOrFail();
+
+ $snapshot = (array) $original->snapshot;
+ $rate = (int) ($snapshot['totals']['rate_basis_points'] ?? 0);
+
+ // Every line at its own price with the sign turned over. Quantities are
+ // left alone: a negative quantity of a positive price adds up to the
+ // same figure and reads, on the page, as though we had delivered minus
+ // one of something.
+ $lines = array_map(function (array $line) {
+ $line['unit_net_cents'] = -(int) $line['unit_net_cents'];
+
+ return $line;
+ }, (array) ($snapshot['lines'] ?? []));
+
+ $totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate];
+
+ $snapshot['lines'] = $lines;
+ $snapshot['totals'] = $totals;
+ $snapshot['meta'] = (array) ($snapshot['meta'] ?? []);
+ $snapshot['meta']['title'] = __('invoice.cancellation_title');
+ $snapshot['meta']['intro'] = __('invoice.cancellation_intro', ['number' => (string) $original->number]);
+ $snapshot['meta']['issued_on'] = now()->local()->format('d.m.Y');
+ // A Storno asks for no money, so it has no due date and no payment
+ // terms. Leaving the original's there would tell the customer to pay a
+ // negative amount within fourteen days.
+ $snapshot['meta']['due_on'] = null;
+ $snapshot['meta']['payment_terms'] = null;
+
+ return $this->persist(
+ series: $series,
+ customer: $customer,
+ snapshot: $snapshot,
+ totals: $totals,
+ currency: (string) $original->currency,
+ dueOn: null,
+ attributes: [
+ 'cancels_invoice_id' => $original->id,
+ // The same sale, so the same order and contract: a cancellation
+ // that pointed at nothing could not be found from the thing it
+ // undoes.
+ 'order_id' => $original->order_id,
+ 'subscription_id' => $original->subscription_id,
+ ],
+ );
+ }
+
private function issue(
Customer $customer,
array $lines,
@@ -281,17 +363,6 @@ final class IssueInvoice
?InvoiceSeries $series,
array $attributes,
): Invoice {
- $missing = CompanyProfile::missingForInvoicing();
-
- if ($missing !== []) {
- // Refusing is the correct outcome. An invoice without a registered
- // name, an address or a VAT number is not a valid invoice here, and
- // issuing one consumes a number that can never be reused.
- throw new RuntimeException(
- 'Refusing to issue an invoice before the company details are complete: '.implode(', ', $missing)
- );
- }
-
$series ??= InvoiceSeries::query()->where('kind', 'invoice')->where('active', true)->firstOrFail();
$treatment = TaxTreatment::for($customer);
@@ -307,7 +378,52 @@ final class IssueInvoice
'meta' => $this->meta($customer, $treatment),
];
- $invoice = DB::transaction(function () use ($series, $customer, $snapshot, $totals, $currency, $attributes) {
+ return $this->persist(
+ series: $series,
+ customer: $customer,
+ snapshot: $snapshot,
+ totals: $totals,
+ currency: $currency,
+ dueOn: now()->addDays((int) CompanyProfile::get('payment_days', 14))->toDateString(),
+ attributes: $attributes,
+ );
+ }
+
+ /**
+ * The part that is the same for every document there will ever be: refuse
+ * while the company details are incomplete, take the number, write the row,
+ * copy it to the archive.
+ *
+ * One method rather than one per kind, because the number and the row have
+ * to commit together — and a second copy of that rule is how one kind of
+ * document would end up leaving a gap in a series the other kind keeps
+ * intact.
+ *
+ * @param array $snapshot
+ * @param array $totals
+ * @param array $attributes
+ */
+ private function persist(
+ InvoiceSeries $series,
+ Customer $customer,
+ array $snapshot,
+ array $totals,
+ string $currency,
+ ?string $dueOn,
+ array $attributes,
+ ): Invoice {
+ $missing = CompanyProfile::missingForInvoicing();
+
+ if ($missing !== []) {
+ // Refusing is the correct outcome. An invoice without a registered
+ // name, an address or a VAT number is not a valid invoice here, and
+ // issuing one consumes a number that can never be reused.
+ throw new RuntimeException(
+ 'Refusing to issue an invoice before the company details are complete: '.implode(', ', $missing)
+ );
+ }
+
+ $invoice = DB::transaction(function () use ($series, $customer, $snapshot, $totals, $currency, $dueOn, $attributes) {
[$number, $sequence, $year] = $this->numbers->next($series);
// The number is only true once it is on the document, so both are
@@ -324,7 +440,7 @@ final class IssueInvoice
'number_year' => $year,
'number_sequence' => $sequence,
'issued_on' => now()->toDateString(),
- 'due_on' => now()->addDays((int) CompanyProfile::get('payment_days', 14))->toDateString(),
+ 'due_on' => $dueOn,
'snapshot' => $snapshot,
'net_cents' => $totals['net'],
'tax_cents' => $totals['tax'],
diff --git a/app/Services/Billing/TaxTreatment.php b/app/Services/Billing/TaxTreatment.php
index 3703c4f..a6341aa 100644
--- a/app/Services/Billing/TaxTreatment.php
+++ b/app/Services/Billing/TaxTreatment.php
@@ -10,12 +10,29 @@ use App\Support\CompanyProfile;
*
* Only the two clear-cut cases are decided here:
*
- * - a customer whose VERIFIED VAT ID belongs to another EU member state pays
- * no VAT to us; the liability shifts to them (reverse charge), and the
- * invoice has to say so. Unverified means domestic rate: a self-declared
- * string must never be able to zero the tax;
+ * - a BUSINESS customer whose VERIFIED VAT ID belongs to another EU member
+ * state pays no VAT to us; the liability shifts to them (reverse charge),
+ * and the invoice has to say so. Unverified means domestic rate: a
+ * self-declared string must never be able to zero the tax;
* - everyone else is charged the seller's domestic rate.
*
+ * "Business" is the answer the customer gave when they were asked, not
+ * something read out of the VAT field. Reverse charge is a business-to-business
+ * rule and nothing else, so somebody who has recorded themselves as a private
+ * person is charged the domestic rate whatever they have typed into `vat_id` —
+ * previously they were not, and a consumer could zero their own VAT by getting
+ * a number verified.
+ *
+ * A customer nobody has asked keeps the treatment they had: a verified VAT ID
+ * still earns the reverse charge. That is deliberately NOT the protective
+ * default used everywhere else in this codebase, and the reason is that the two
+ * questions have different stakes. Withdrawal is a right, and guessing costs a
+ * consumer something they cannot get back; VAT is a rate, and flipping every
+ * unasked business onto 20 % overnight would put our invoices in disagreement
+ * with contracts that are already running. So this file changes no outcome for
+ * anyone whose type has never been recorded, and the moment it IS recorded, the
+ * recorded answer is the only one that counts.
+ *
* Deliberately NOT handled: cross-border sales to private individuals, which
* are taxed at the customer's own country's rate under the OSS scheme. Doing
* that needs a maintained rate table per member state and a tax adviser's sign
@@ -52,6 +69,16 @@ final readonly class TaxTreatment
return new self($domestic, false);
}
+ // Asked of the recorded type, not of the number. Somebody who has said
+ // they are a private person is a private person, and a VAT ID on their
+ // record — a former sole trader's, a colleague's, a mistake — must not
+ // be able to turn that answer over. Only an explicit "consumer" refuses
+ // here; an unrecorded type falls through and is treated exactly as it
+ // was before this check existed (see the class comment).
+ if ($customer->hasRecordedType() && ! $customer->isBusiness()) {
+ return new self($domestic, false);
+ }
+
$vatId = $customer->normalisedVatId();
$country = substr($vatId, 0, 2);
$seller = strtoupper((string) config('provisioning.tax.seller_country', 'AT'));
diff --git a/app/Services/Billing/WithdrawalRight.php b/app/Services/Billing/WithdrawalRight.php
new file mode 100644
index 0000000..8e1260d
--- /dev/null
+++ b/app/Services/Billing/WithdrawalRight.php
@@ -0,0 +1,187 @@
+withdrawalOpenedAt();
+ $endsAt = $subscription->withdrawal_ends_at
+ // A contract opened before the column existed still had a window;
+ // it is derived here rather than left null so an old contract reads
+ // as "expired" instead of "no window was ever counted".
+ ?? $opensAt?->copy()->addDays(self::WINDOW_DAYS);
+ $consented = $subscription->immediate_start_consent_at !== null;
+
+ // Asked of the CUSTOMER, never of the VAT number. A customer nobody has
+ // asked is a consumer here — see Customer::isConsumer() for why the
+ // unknown case leans this way and not the other.
+ if ($subscription->customer?->isBusiness() === true) {
+ return new self(false, $opensAt, $endsAt, false, __('withdrawal.refusal_business'), $consented);
+ }
+
+ if ($subscription->isWithdrawn()) {
+ return new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_already'), $consented);
+ }
+
+ if ($endsAt === null) {
+ // A contract with no beginning on record. Refused rather than
+ // guessed: inventing the moment a statutory deadline started is the
+ // one thing this class must never do.
+ return new self(true, $opensAt, null, false, __('withdrawal.refusal_no_contract'), $consented);
+ }
+
+ return $endsAt->isFuture()
+ ? new self(true, $opensAt, $endsAt, true, null, $consented)
+ : new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_expired'), $consented);
+ }
+
+ /**
+ * How many whole days of the window are left, for the sentence that says so.
+ *
+ * Rounded UP, because a customer told "1 Tag" on the last afternoon still
+ * has that afternoon, and rounding down would tell them the window had shut
+ * while it was open.
+ */
+ public function daysLeft(?Carbon $at = null): int
+ {
+ if (! $this->open || $this->endsAt === null) {
+ return 0;
+ }
+
+ return max(1, (int) ceil(($at ?? now())->diffInSeconds($this->endsAt, absolute: false) / 86400));
+ }
+
+ /**
+ * The whole days the term the customer paid for is made of.
+ *
+ * From the contract's own boundaries, so a month is 28, 30 or 31 days and a
+ * year is 365 or 366 — a fixed 30 would over- or under-charge every second
+ * customer by a day's worth of service.
+ */
+ public static function termDays(Subscription $subscription): int
+ {
+ $from = $subscription->current_period_start ?? $subscription->started_at;
+ $to = $subscription->current_period_end;
+
+ if ($from === null || $to === null || ! $to->greaterThan($from)) {
+ // Nothing sensible to divide by. One day means the first day of
+ // service is the whole term, which charges the consumer the full
+ // amount — so this branch is only ever reached by a contract that is
+ // already broken, and it fails towards "we keep what we were paid"
+ // rather than towards a division by zero.
+ return 1;
+ }
+
+ return max(1, (int) ceil($from->diffInSeconds($to, absolute: true) / 86400));
+ }
+
+ /**
+ * The days of service actually delivered by the moment of withdrawal.
+ *
+ * A started day counts, so this is never zero: a consumer who withdraws
+ * three hours after buying still had a running cloud for those three hours.
+ * Capped at the term, because a withdrawal after the term has run out cannot
+ * owe more than the term was sold for.
+ */
+ public static function deliveredDays(Subscription $subscription, ?Carbon $at = null): int
+ {
+ $from = $subscription->current_period_start ?? $subscription->started_at;
+ $at ??= now();
+
+ if ($from === null || ! $at->greaterThan($from)) {
+ return 1;
+ }
+
+ $days = (int) ceil($from->diffInSeconds($at, absolute: true) / 86400);
+
+ return max(1, min($days, self::termDays($subscription)));
+ }
+
+ /**
+ * What the consumer owes for the service they actually had, net.
+ *
+ * Zero without the express request to begin at once: the pro-rata liability
+ * is the price of that request, and a consumer who never made it — or who
+ * was never told what it would cost them — owes nothing and gets everything
+ * back.
+ */
+ public static function owedNetCents(Subscription $subscription, ?Carbon $at = null): int
+ {
+ if ($subscription->immediate_start_consent_at === null) {
+ return 0;
+ }
+
+ // The contract's own frozen NET price for the term. Never
+ // Order::amount_cents, which is Stripe's GROSS: mixing the two is how a
+ // pro-rata share ends up with VAT inside it and VAT added on top again.
+ $termNet = (int) $subscription->price_cents;
+
+ if ($termNet <= 0) {
+ return 0;
+ }
+
+ return (int) round($termNet * self::deliveredDays($subscription, $at) / self::termDays($subscription));
+ }
+}
diff --git a/app/Services/Stripe/FakeStripeClient.php b/app/Services/Stripe/FakeStripeClient.php
index cc9a047..0a9a948 100644
--- a/app/Services/Stripe/FakeStripeClient.php
+++ b/app/Services/Stripe/FakeStripeClient.php
@@ -69,6 +69,22 @@ class FakeStripeClient implements StripeClient
*/
public array $invoiceLines = [];
+ /**
+ * Every refund that was sent, in order, so a test can assert the amount as
+ * well as the fact of it.
+ *
+ * @var array
+ */
+ public array $refunds = [];
+
+ /**
+ * What this Stripe would answer when asked which payment an invoice was
+ * settled by: invoice id → payment reference.
+ *
+ * @var array
+ */
+ public array $invoicePayments = [];
+
/**
* Set to make every call fail, the way an outage, a revoked key or a
* network without a route out does.
@@ -242,6 +258,41 @@ class FakeStripeClient implements StripeClient
];
}
+ public function refund(
+ string $paymentReference,
+ ?int $amountCents = null,
+ ?string $idempotencyKey = null,
+ ): string {
+ $this->failIfAsked();
+
+ // Replays the first answer for a repeated key, as Stripe does — which
+ // is the whole point of sending one on a refund.
+ if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
+ return $this->keys[$idempotencyKey];
+ }
+
+ $this->refunds[] = [
+ 'payment' => $paymentReference,
+ 'amount' => $amountCents,
+ 'key' => $idempotencyKey,
+ ];
+
+ $id = 're_'.substr(sha1($paymentReference.count($this->refunds)), 0, 12);
+
+ if ($idempotencyKey !== null) {
+ $this->keys[$idempotencyKey] = $id;
+ }
+
+ return $id;
+ }
+
+ public function invoicePaymentReference(string $invoiceId): ?string
+ {
+ $this->failIfAsked();
+
+ return $this->invoicePayments[$invoiceId] ?? null;
+ }
+
public function invoiceLines(string $invoiceId): array
{
$this->failIfAsked();
diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php
index 588a091..797ace6 100644
--- a/app/Services/Stripe/HttpStripeClient.php
+++ b/app/Services/Stripe/HttpStripeClient.php
@@ -171,6 +171,56 @@ class HttpStripeClient implements StripeClient
->throw();
}
+ public function refund(
+ string $paymentReference,
+ ?int $amountCents = null,
+ ?string $idempotencyKey = null,
+ ): string {
+ // Stripe takes the payment under one key or the other, never both, and
+ // the id says which it is. Guessing wrong is a 400, which is at least
+ // loud — but sending a charge id as a payment intent would refund
+ // nothing on some API versions and everything on others.
+ $key = str_starts_with($paymentReference, 'ch_') ? 'charge' : 'payment_intent';
+
+ return (string) $this->request($idempotencyKey)
+ ->asForm()
+ ->post($this->url('refunds'), array_filter([
+ $key => $paymentReference,
+ // Omitted for a full refund, which is what Stripe's own absence
+ // of an amount means. Passing the full figure explicitly would
+ // be the same thing with one more chance to be wrong.
+ 'amount' => $amountCents,
+ // The only reason we ever have. A withdrawal is the customer
+ // asking for their money back, and Stripe's dispute analytics
+ // read this field.
+ 'reason' => 'requested_by_customer',
+ ], fn ($value) => $value !== null))
+ ->throw()
+ ->json('id');
+ }
+
+ public function invoicePaymentReference(string $invoiceId): ?string
+ {
+ $invoice = $this->request()
+ ->get($this->url('invoices/'.$invoiceId))
+ ->throw()
+ ->json();
+
+ // Both fields, in this order. Which one an invoice carries depends on
+ // the API version the account is pinned to — newer ones dropped
+ // `payment_intent` from the invoice — and reading only one of them
+ // would make refunds work on some installations and not others.
+ foreach (['payment_intent', 'charge'] as $field) {
+ $value = $invoice[$field] ?? null;
+
+ if (is_string($value) && $value !== '') {
+ return $value;
+ }
+ }
+
+ return null;
+ }
+
public function invoiceLines(string $invoiceId): array
{
$lines = [];
diff --git a/app/Services/Stripe/StripeClient.php b/app/Services/Stripe/StripeClient.php
index 6eef0f8..ee61fb3 100644
--- a/app/Services/Stripe/StripeClient.php
+++ b/app/Services/Stripe/StripeClient.php
@@ -149,6 +149,42 @@ interface StripeClient
*/
public function removeSubscriptionItem(string $itemId, string $prorationBehaviour): void;
+ /**
+ * Send money back, against the payment that took it.
+ *
+ * The only outgoing money movement this platform makes, and it exists
+ * because a consumer who withdraws within fourteen days is owed one — see
+ * App\Actions\WithdrawContract. Partial by design: `$amountCents` is what
+ * goes back after the pro-rata value of the service already delivered has
+ * been kept, and only a withdrawal on the very first day ever equals the
+ * whole payment.
+ *
+ * `$paymentReference` is a PaymentIntent (`pi_…`) or a Charge (`ch_…`) —
+ * whichever Stripe reported for the payment. Both are accepted rather than
+ * one normalised: which of the two an account produces depends on the API
+ * version it is pinned to, and a refund that silently does nothing because
+ * the wrong field was read is the worst possible failure here.
+ *
+ * `$idempotencyKey` is not optional in practice. A refund is the one call
+ * whose retry after a timeout would send the customer's money twice.
+ *
+ * @return string the refund's id
+ */
+ public function refund(
+ string $paymentReference,
+ ?int $amountCents = null,
+ ?string $idempotencyKey = null,
+ ): string;
+
+ /**
+ * The payment behind a Stripe invoice, or null if it has none.
+ *
+ * A subscription checkout charges through an invoice, so the invoice id is
+ * usually all we have written down — and a refund cannot be issued against
+ * an invoice. This is the one hop between the two.
+ */
+ public function invoicePaymentReference(string $invoiceId): ?string;
+
/**
* Every line on a Stripe invoice, for the document we owe it.
*
diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php
index 20877f8..5741016 100644
--- a/database/factories/CustomerFactory.php
+++ b/database/factories/CustomerFactory.php
@@ -19,6 +19,25 @@ class CustomerFactory extends Factory
'email' => 'customer-'.$this->faker->unique()->numerify('########').'@example.test',
'locale' => 'de',
'status' => 'active',
+ // `customer_type` is deliberately absent, so it stays NULL: the
+ // factory has not asked, and neither has anybody else. A default
+ // here would be the one thing the column exists to prevent — an
+ // answer nobody gave — and it would also hide the case that most
+ // needs testing, since NULL is what every customer created from a
+ // Stripe event arrives with. Use consumer() or business() when a
+ // test is about the answer.
];
}
+
+ /** Somebody who said they are a private person. Has the withdrawal right. */
+ public function consumer(): static
+ {
+ return $this->state(['customer_type' => Customer::TYPE_CONSUMER]);
+ }
+
+ /** Somebody who said they are a business. Has no withdrawal right. */
+ public function business(): static
+ {
+ return $this->state(['customer_type' => Customer::TYPE_BUSINESS]);
+ }
}
diff --git a/database/factories/SubscriptionFactory.php b/database/factories/SubscriptionFactory.php
index 456f2c0..f2d515e 100644
--- a/database/factories/SubscriptionFactory.php
+++ b/database/factories/SubscriptionFactory.php
@@ -4,6 +4,7 @@ namespace Database\Factories;
use App\Models\Customer;
use App\Models\Subscription;
+use App\Services\Billing\WithdrawalRight;
use Illuminate\Database\Eloquent\Factories\Factory;
class SubscriptionFactory extends Factory
@@ -17,20 +18,42 @@ class SubscriptionFactory extends Factory
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
+ // Every contract opened for real gets one (OpenSubscription), so a
+ // factory contract without it would behave differently from the
+ // thing it stands in for — and every test about the withdrawal
+ // window would have to remember to set it.
+ 'withdrawal_ends_at' => $start->copy()->addDays(WithdrawalRight::WINDOW_DAYS),
'status' => 'active',
]);
}
+ /**
+ * The consumer expressly asked for the service to begin at once, and was
+ * told they would owe the pro-rata share if they withdrew (FAGG §16).
+ *
+ * A state rather than the default, because the absence of this consent is
+ * what makes a withdrawal refund EVERYTHING — and that is the case a test
+ * has to be able to reach without fighting the factory.
+ */
+ public function startedImmediately(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'immediate_start_consent_at' => $attributes['started_at'] ?? now(),
+ ]);
+ }
+
public function plan(string $plan, string $term = Subscription::TERM_MONTHLY): static
{
return $this->state(function () use ($plan, $term) {
$start = now()->startOfMonth();
return array_merge(Subscription::snapshotFrom($plan, $term), [
+ 'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
+ 'withdrawal_ends_at' => $start->copy()->addDays(WithdrawalRight::WINDOW_DAYS),
]);
});
}
diff --git a/database/migrations/2026_07_29_300000_record_whether_a_customer_is_a_consumer.php b/database/migrations/2026_07_29_300000_record_whether_a_customer_is_a_consumer.php
new file mode 100644
index 0000000..975126a
--- /dev/null
+++ b/database/migrations/2026_07_29_300000_record_whether_a_customer_is_a_consumer.php
@@ -0,0 +1,44 @@
+string('customer_type', 16)->nullable()->after('email');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('customers', function (Blueprint $table) {
+ $table->dropColumn('customer_type');
+ });
+ }
+};
diff --git a/database/migrations/2026_07_29_300001_add_the_withdrawal_window_to_contracts.php b/database/migrations/2026_07_29_300001_add_the_withdrawal_window_to_contracts.php
new file mode 100644
index 0000000..06f785b
--- /dev/null
+++ b/database/migrations/2026_07_29_300001_add_the_withdrawal_window_to_contracts.php
@@ -0,0 +1,113 @@
+timestamp('immediate_start_consent_at')->nullable()->after('status');
+
+ // What a refund is actually issued against. Stripe refunds a
+ // payment, not a subscription and not a checkout session, so the
+ // handle has to be kept at the moment the payment is reported.
+ $table->string('stripe_payment_intent_id')->nullable()->after('stripe_subscription_id');
+ $table->string('stripe_invoice_id')->nullable()->after('stripe_payment_intent_id');
+ });
+
+ Schema::table('subscriptions', function (Blueprint $table) {
+ $table->timestamp('withdrawal_ends_at')->nullable()->after('cancelled_at');
+ $table->timestamp('immediate_start_consent_at')->nullable()->after('withdrawal_ends_at');
+ $table->timestamp('withdrawn_at')->nullable()->after('immediate_start_consent_at');
+
+ // 'portal' when the customer did it themselves, 'operator' when it
+ // came in by telephone or by post and somebody typed it in. Which
+ // of the two it was is part of the evidence that a withdrawal was
+ // declared at all.
+ $table->string('withdrawal_channel', 16)->nullable()->after('withdrawn_at');
+ $table->foreignId('withdrawal_recorded_by')->nullable()->after('withdrawal_channel')
+ ->constrained('operators')->nullOnDelete();
+
+ // GROSS, because that is what was taken and what goes back.
+ $table->integer('withdrawal_refund_cents')->nullable()->after('withdrawal_recorded_by');
+ $table->string('withdrawal_refund_reference')->nullable()->after('withdrawal_refund_cents');
+ $table->string('withdrawal_refund_error')->nullable()->after('withdrawal_refund_reference');
+ });
+
+ // Contracts that already exist had a window too — it opened when they
+ // began, whether or not anything in the code was counting. Filled in so
+ // that a contract signed yesterday is not quietly treated as one whose
+ // window never existed; for everything older the date is simply in the
+ // past, which is the correct answer.
+ // Read in full before anything is written. Streaming a cursor over the
+ // table this loop then UPDATEs is exactly the shape that stalls on an
+ // unbuffered MySQL connection, and a migration that hangs on a live
+ // database is worse than one that holds a few thousand rows in memory.
+ //
+ // Fourteen written out rather than read from WithdrawalRight::WINDOW_DAYS:
+ // a migration states what it did on the day it ran, and a constant that
+ // moves later would silently rewrite what this one is recorded as having
+ // done.
+ $rows = DB::table('subscriptions')->whereNotNull('started_at')->select('id', 'started_at')->get();
+
+ foreach ($rows as $row) {
+ DB::table('subscriptions')->where('id', $row->id)->update([
+ 'withdrawal_ends_at' => Carbon::parse($row->started_at)->addDays(14),
+ ]);
+ }
+ }
+
+ public function down(): void
+ {
+ Schema::table('subscriptions', function (Blueprint $table) {
+ $table->dropForeign(['withdrawal_recorded_by']);
+ $table->dropColumn([
+ 'withdrawal_ends_at', 'immediate_start_consent_at', 'withdrawn_at',
+ 'withdrawal_channel', 'withdrawal_recorded_by',
+ 'withdrawal_refund_cents', 'withdrawal_refund_reference', 'withdrawal_refund_error',
+ ]);
+ });
+
+ Schema::table('orders', function (Blueprint $table) {
+ $table->dropColumn(['immediate_start_consent_at', 'stripe_payment_intent_id', 'stripe_invoice_id']);
+ });
+ }
+};
diff --git a/lang/de/auth.php b/lang/de/auth.php
index 4546604..f3739d9 100644
--- a/lang/de/auth.php
+++ b/lang/de/auth.php
@@ -63,19 +63,28 @@ return [
'phishing_note' => 'Sie sind gerade auf :host. Wir fragen Sie nie per E-Mail oder Telefon nach Ihrem Passwort.',
'phishing_link' => 'Echte Adressen erkennen',
- 'forgot_link' => "Passwort vergessen?",
- 'forgot_title' => "Passwort vergessen",
- 'forgot_subtitle' => "Wir schicken Ihnen einen Link, mit dem Sie ein neues vergeben.",
- 'forgot_send' => "Link schicken",
- 'forgot_sent_title' => "Schauen Sie in Ihr Postfach",
- 'forgot_sent_body' => "Wenn es bei uns ein Konto zu :email gibt, ist der Link unterwegs. Er gilt eine Stunde und lässt sich einmal verwenden.",
- 'forgot_sent_hint' => "Nichts angekommen? Sehen Sie im Spam-Ordner nach. Wir sagen aus Sicherheitsgründen nicht, ob es zu dieser Adresse ein Konto gibt.",
- 'back_to_login' => "Zurück zur Anmeldung",
- 'reset_title' => "Neues Passwort vergeben",
- 'reset_subtitle' => "Danach melden Sie sich damit an.",
- 'reset_new' => "Neues Passwort",
- 'reset_repeat' => "Neues Passwort wiederholen",
- 'reset_save' => "Passwort speichern",
- 'reset_done_title' => "Passwort geändert",
- 'reset_done_body' => "Alle anderen offenen Anmeldungen wurden beendet. Melden Sie sich jetzt mit dem neuen Passwort an — absichtlich nicht automatisch: der Link kam per E-Mail, und wer Ihr Postfach lesen kann, bekäme sonst gleich die Sitzung dazu.",
+ 'forgot_link' => 'Passwort vergessen?',
+ 'forgot_title' => 'Passwort vergessen',
+ 'forgot_subtitle' => 'Wir schicken Ihnen einen Link, mit dem Sie ein neues vergeben.',
+ 'forgot_send' => 'Link schicken',
+ 'forgot_sent_title' => 'Schauen Sie in Ihr Postfach',
+ 'forgot_sent_body' => 'Wenn es bei uns ein Konto zu :email gibt, ist der Link unterwegs. Er gilt eine Stunde und lässt sich einmal verwenden.',
+ 'forgot_sent_hint' => 'Nichts angekommen? Sehen Sie im Spam-Ordner nach. Wir sagen aus Sicherheitsgründen nicht, ob es zu dieser Adresse ein Konto gibt.',
+ 'back_to_login' => 'Zurück zur Anmeldung',
+ 'reset_title' => 'Neues Passwort vergeben',
+ 'reset_subtitle' => 'Danach melden Sie sich damit an.',
+ 'reset_new' => 'Neues Passwort',
+ 'reset_repeat' => 'Neues Passwort wiederholen',
+ 'reset_save' => 'Passwort speichern',
+ 'reset_done_title' => 'Passwort geändert',
+ 'reset_done_body' => 'Alle anderen offenen Anmeldungen wurden beendet. Melden Sie sich jetzt mit dem neuen Passwort an — absichtlich nicht automatisch: der Link kam per E-Mail, und wer Ihr Postfach lesen kann, bekäme sonst gleich die Sitzung dazu.',
+
+ // Verbraucher oder Unternehmen — hier gefragt, weil hier der Kundendatensatz
+ // entsteht. Davon hängen das 14-tägige Widerrufsrecht, der Wortlaut der
+ // Rechnung und das Reverse-Charge-Verfahren ab. Eine UID beantwortet das
+ // nicht: viele Kleinunternehmen haben keine, und Verbraucher haben nie eine.
+ 'customer_type' => 'Ich bestelle als',
+ 'customer_type_consumer' => 'Privatperson',
+ 'customer_type_business' => 'Unternehmen',
+ 'customer_type_hint' => 'Als Privatperson steht Ihnen das gesetzliche Widerrufsrecht von 14 Tagen zu.',
];
diff --git a/lang/de/billing.php b/lang/de/billing.php
index 1dc5835..ee0b526 100644
--- a/lang/de/billing.php
+++ b/lang/de/billing.php
@@ -149,4 +149,23 @@ return [
'collabora_pro' => ['name' => 'Collabora Online Pro', 'desc' => 'Erweiterte Office-Funktionen und mehr gleichzeitige Bearbeiter.'],
'custom_domain' => ['name' => 'Eigene Domain', 'desc' => 'Ihre Cloud unter Ihrer eigenen Adresse, samt Zertifikat und Einrichtung.'],
],
+
+ // Module monatlich kündigen. Der Betreiber hat es zugesagt und die Oberfläche
+ // konnte es bis jetzt gar nicht: gekündigt wird zum Ende der bereits
+ // bezahlten Periode, ohne Gutschrift, und bis dahin ist es widerrufbar.
+ 'addon_cancel_cta' => 'Modul kündigen',
+ 'addon_resume_cta' => 'Kündigung zurücknehmen',
+ 'addon_runs_until' => 'Gekündigt, läuft bis :date',
+ 'addon_cancel_title' => ':module kündigen?',
+ 'addon_cancel_body' => 'Das Modul läuft bis zum :date weiter und wird danach nicht mehr berechnet.',
+ 'addon_cancel_body_now' => 'Das Modul endet sofort und wird nicht mehr berechnet.',
+ 'addon_cancel_point_term' => 'Die bereits bezahlte Periode bleibt bestehen — es gibt dafür keine Gutschrift.',
+ 'addon_cancel_point_undo' => 'Bis zum Ende der Periode können Sie die Kündigung wieder zurücknehmen.',
+ 'addon_cancel_keep' => 'Behalten',
+ 'addon_cancel_confirm' => 'Kündigen',
+ 'addon_cancel_done' => 'Gekündigt. Das Modul läuft noch bis zum :date.',
+ 'addon_cancelled_now' => 'Das Modul wurde beendet.',
+ 'addon_cancel_none' => 'Für dieses Modul gibt es nichts zu kündigen.',
+ 'addon_resume_none' => 'Für dieses Modul liegt keine Kündigung vor.',
+ 'addon_resumed' => 'Die Kündigung wurde zurückgenommen. Das Modul läuft weiter.',
];
diff --git a/lang/de/checkout.php b/lang/de/checkout.php
index 0dc1ea7..f7ba793 100644
--- a/lang/de/checkout.php
+++ b/lang/de/checkout.php
@@ -6,4 +6,13 @@ return [
'unavailable' => 'Die Bezahlung ist gerade nicht möglich. Bitte versuchen Sie es in ein paar Minuten noch einmal.',
'plan_gone' => 'Dieses Paket ist derzeit nicht buchbar. Bitte wählen Sie ein anderes.',
'already_customer' => 'Sie haben bereits ein laufendes Paket. Hier wechseln Sie es, statt ein zweites zu kaufen.',
+
+ // Der ausdrückliche Wunsch, dass die Leistung sofort beginnt — die
+ // Voraussetzung dafür, dass ein widerrufender Verbraucher überhaupt etwas
+ // schuldet (FAGG §16). Ohne diese Zustimmung im Datensatz wird bei einem
+ // Widerruf der volle Betrag zurückgezahlt, siehe App\Actions\WithdrawContract.
+ // Der Satz ist der gesetzliche und keine Höflichkeitsfloskel: er muss die
+ // Frist UND die anteilige Zahlungspflicht nennen.
+ 'immediate_start' => 'Ich verlange ausdrücklich, dass Sie mit der Leistung schon vor Ablauf der 14-tägigen Widerrufsfrist beginnen. Mir ist bekannt, dass ich bei einem Widerruf einen anteiligen Betrag für die bis dahin erbrachte Leistung zu zahlen habe.',
+ 'immediate_start_required' => 'Bitte bestätigen Sie, dass die Leistung sofort beginnen soll — sonst können wir Ihre Cloud erst nach Ablauf der Widerrufsfrist einrichten.',
];
diff --git a/lang/de/invoice.php b/lang/de/invoice.php
index 9b37239..3ff7624 100644
--- a/lang/de/invoice.php
+++ b/lang/de/invoice.php
@@ -39,4 +39,9 @@ return [
'payment_default' => 'Zahlbar ohne Abzug innerhalb von :days Tagen auf das unten angeführte Konto.',
'reverse_charge' => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge). Die Umsatzsteuer ist vom Leistungsempfänger zu entrichten.',
'payment_title' => 'Zahlungsbedingungen',
+
+ // Ein Storno. Eigenes Dokument mit eigener Nummer — eine ausgestellte
+ // Rechnung wird nie geändert oder gelöscht.
+ 'cancellation_title' => 'Stornorechnung',
+ 'cancellation_intro' => 'hiermit stornieren wir die Rechnung :number vollständig.',
];
diff --git a/lang/de/settings.php b/lang/de/settings.php
index 0c07866..34e0f37 100644
--- a/lang/de/settings.php
+++ b/lang/de/settings.php
@@ -77,4 +77,11 @@ return [
'twofa_new_codes' => 'Neue Wiederherstellungscodes',
'twofa_codes_title' => 'Wiederherstellungscodes',
'twofa_codes_hint' => 'Jetzt notieren und sicher verwahren. Sie sind der Weg zurück, wenn das Gerät verloren geht — jeder Code gilt einmal.',
+
+ // Steht neben der UID, weil genau dort verwechselt wird: die UID sagt nichts
+ // darüber, ob jemand als Verbraucher oder als Unternehmen bestellt.
+ 'customer_type' => 'Ich bestelle als',
+ 'customer_type_consumer' => 'Privatperson',
+ 'customer_type_business' => 'Unternehmen',
+ 'customer_type_hint' => 'Als Privatperson steht Ihnen bei neuen Verträgen das gesetzliche Widerrufsrecht von 14 Tagen zu. Unternehmen haben es nicht.',
];
diff --git a/lang/de/withdrawal.php b/lang/de/withdrawal.php
new file mode 100644
index 0000000..a32fe0e
--- /dev/null
+++ b/lang/de/withdrawal.php
@@ -0,0 +1,45 @@
+ 'Widerrufsrecht',
+ 'card_sub' => 'Sie können diesen Vertrag noch bis zum :date widerrufen — :days Tag(e). Der Dienst endet dann sofort und Sie erhalten das Entgelt zurück, abzüglich des Anteils für die bereits erbrachte Leistung.',
+ 'cta' => 'Widerruf erklären',
+ 'keep' => 'Abbrechen',
+ 'confirm' => 'Widerruf erklären',
+ 'done' => 'Ihr Widerruf ist erfasst. Der Dienst wurde beendet, die Gutschrift und die Rückzahlung sind unterwegs.',
+
+ 'confirm_title' => 'Vertrag widerrufen?',
+ 'confirm_body' => 'Damit widerrufen Sie den Vertrag innerhalb der gesetzlichen Frist von 14 Tagen. Sie müssen dafür keinen Grund angeben.',
+ 'confirm_point_now' => 'Ihre Cloud wird sofort beendet und ist danach nicht mehr erreichbar.',
+ 'confirm_point_documents' => 'Die Rechnung wird storniert; Sie erhalten die Stornorechnung und, falls etwas offen bleibt, eine korrigierte Rechnung.',
+ 'confirm_point_prorata' => 'Für die bereits genutzten :days von :term Tagen verbleiben :amount netto — dieser Anteil wird nicht zurückgezahlt.',
+ 'confirm_point_full' => 'Sie erhalten den gesamten gezahlten Betrag zurück.',
+
+ 'refusal_no_contract' => 'Zu diesem Konto besteht kein laufender Vertrag, der widerrufen werden könnte.',
+ 'refusal_business' => 'Das gesetzliche Widerrufsrecht steht Verbraucherinnen und Verbrauchern zu. Für Unternehmen gilt die vertragliche Kündigungsregelung.',
+ 'refusal_already' => 'Dieser Vertrag wurde bereits widerrufen.',
+ 'refusal_expired' => 'Die 14-tägige Widerrufsfrist ist abgelaufen. Der Vertrag kann zum Ende der laufenden Periode gekündigt werden.',
+
+ // Die Position auf der korrigierten Rechnung: was der Kunde für die
+ // tatsächlich erbrachte Leistung schuldet.
+ 'invoice_line' => 'Anteilige Leistung :plan bis zum Widerruf',
+ 'invoice_detail' => ':days von :term Tagen erbracht',
+
+ 'admin_action' => 'Widerruf',
+ 'admin_title' => 'Widerruf erfassen',
+ 'admin_sub' => 'Widerruf von :name, telefonisch oder schriftlich erklärt.',
+ 'admin_window' => 'Frist endet am',
+ 'admin_delivered' => 'Erbrachte Tage',
+ 'admin_owed' => 'Anteil netto, der einbehalten wird',
+ 'admin_effect' => 'Der Dienst wird sofort beendet, die Rechnung storniert und der Restbetrag zurückgezahlt.',
+ 'admin_confirm' => 'Widerruf erfassen',
+ 'recorded' => 'Widerruf für :name erfasst.',
+];
diff --git a/lang/en/auth.php b/lang/en/auth.php
index 4f31b5d..73220de 100644
--- a/lang/en/auth.php
+++ b/lang/en/auth.php
@@ -63,19 +63,28 @@ return [
'phishing_note' => 'You are currently on :host. We never ask for your password by email or on the phone.',
'phishing_link' => 'How to recognise our addresses',
- 'forgot_link' => "Forgotten your password?",
- 'forgot_title' => "Forgotten password",
- 'forgot_subtitle' => "We will send you a link to set a new one.",
- 'forgot_send' => "Send the link",
- 'forgot_sent_title' => "Check your inbox",
- 'forgot_sent_body' => "If we have an account for :email, the link is on its way. It is valid for an hour and can be used once.",
- 'forgot_sent_hint' => "Nothing arrived? Check your spam folder. For security we do not say whether an account exists for that address.",
- 'back_to_login' => "Back to sign-in",
- 'reset_title' => "Set a new password",
- 'reset_subtitle' => "Then sign in with it.",
- 'reset_new' => "New password",
- 'reset_repeat' => "Repeat the new password",
- 'reset_save' => "Save the password",
- 'reset_done_title' => "Password changed",
- 'reset_done_body' => "Every other open session has been ended. Sign in now with the new password — deliberately not automatically: the link arrived by email, and whoever can read your mailbox would otherwise get the session with it.",
+ 'forgot_link' => 'Forgotten your password?',
+ 'forgot_title' => 'Forgotten password',
+ 'forgot_subtitle' => 'We will send you a link to set a new one.',
+ 'forgot_send' => 'Send the link',
+ 'forgot_sent_title' => 'Check your inbox',
+ 'forgot_sent_body' => 'If we have an account for :email, the link is on its way. It is valid for an hour and can be used once.',
+ 'forgot_sent_hint' => 'Nothing arrived? Check your spam folder. For security we do not say whether an account exists for that address.',
+ 'back_to_login' => 'Back to sign-in',
+ 'reset_title' => 'Set a new password',
+ 'reset_subtitle' => 'Then sign in with it.',
+ 'reset_new' => 'New password',
+ 'reset_repeat' => 'Repeat the new password',
+ 'reset_save' => 'Save the password',
+ 'reset_done_title' => 'Password changed',
+ 'reset_done_body' => 'Every other open session has been ended. Sign in now with the new password — deliberately not automatically: the link arrived by email, and whoever can read your mailbox would otherwise get the session with it.',
+
+ // Consumer or business — asked here because this is where the customer
+ // record comes into being. The 14-day right of withdrawal, the wording of
+ // the invoice and reverse charge all turn on it. A VAT number does not
+ // answer it: many small businesses have none, and no consumer has one.
+ 'customer_type' => 'I am ordering as',
+ 'customer_type_consumer' => 'A private person',
+ 'customer_type_business' => 'A business',
+ 'customer_type_hint' => 'As a private person you have the statutory 14-day right of withdrawal.',
];
diff --git a/lang/en/billing.php b/lang/en/billing.php
index 3fa521b..d16f43b 100644
--- a/lang/en/billing.php
+++ b/lang/en/billing.php
@@ -149,4 +149,23 @@ return [
'collabora_pro' => ['name' => 'Collabora Online Pro', 'desc' => 'Advanced office features and more concurrent editors.'],
'custom_domain' => ['name' => 'Own domain', 'desc' => 'Your cloud under your own address, certificate and setup included.'],
],
+
+ // Cancelling a module monthly. The owner promised it and the interface could
+ // not do it at all: a cancellation lands at the end of the period already
+ // paid for, earns no credit, and can be taken back until then.
+ 'addon_cancel_cta' => 'Cancel module',
+ 'addon_resume_cta' => 'Undo cancellation',
+ 'addon_runs_until' => 'Cancelled, runs until :date',
+ 'addon_cancel_title' => 'Cancel :module?',
+ 'addon_cancel_body' => 'The module keeps running until :date and is not charged for after that.',
+ 'addon_cancel_body_now' => 'The module ends immediately and is not charged for again.',
+ 'addon_cancel_point_term' => 'The period already paid for stands — there is no credit for it.',
+ 'addon_cancel_point_undo' => 'You can take the cancellation back until the end of the period.',
+ 'addon_cancel_keep' => 'Keep it',
+ 'addon_cancel_confirm' => 'Cancel it',
+ 'addon_cancel_done' => 'Cancelled. The module runs until :date.',
+ 'addon_cancelled_now' => 'The module has ended.',
+ 'addon_cancel_none' => 'There is nothing to cancel for this module.',
+ 'addon_resume_none' => 'There is no cancellation pending for this module.',
+ 'addon_resumed' => 'The cancellation has been taken back. The module keeps running.',
];
diff --git a/lang/en/checkout.php b/lang/en/checkout.php
index 122676e..2cb28d3 100644
--- a/lang/en/checkout.php
+++ b/lang/en/checkout.php
@@ -6,4 +6,12 @@ return [
'unavailable' => 'Payment is not possible right now. Please try again in a few minutes.',
'plan_gone' => 'That package cannot be booked at the moment. Please choose another one.',
'already_customer' => 'You already have a running package. Change it here rather than buying a second one.',
+
+ // The express request that the service begin at once — the precondition for
+ // a withdrawing consumer owing anything at all (FAGG §16). Without this
+ // consent on record a withdrawal refunds the whole amount; see
+ // App\Actions\WithdrawContract. The sentence is the statutory one rather
+ // than a nicety: it has to name the period AND the pro-rata liability.
+ 'immediate_start' => 'I expressly request that you begin providing the service before the 14-day withdrawal period has ended. I understand that if I withdraw I must pay a proportionate amount for the service provided up to that point.',
+ 'immediate_start_required' => 'Please confirm that the service should begin at once — otherwise we can only build your cloud once the withdrawal period has ended.',
];
diff --git a/lang/en/invoice.php b/lang/en/invoice.php
index 5aaaa12..5a37912 100644
--- a/lang/en/invoice.php
+++ b/lang/en/invoice.php
@@ -35,4 +35,9 @@ return [
'payment_default' => 'Payable in full within :days days to the account shown below.',
'reverse_charge' => 'Reverse charge: VAT is to be accounted for by the recipient of the service.',
'payment_title' => 'Payment terms',
+
+ // A cancellation. Its own document with its own number — an issued invoice
+ // is never edited and never deleted.
+ 'cancellation_title' => 'Cancellation invoice',
+ 'cancellation_intro' => 'we hereby cancel invoice :number in full.',
];
diff --git a/lang/en/settings.php b/lang/en/settings.php
index dd28239..f1dab31 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -76,5 +76,13 @@ return [
'twofa_disable' => 'Remove',
'twofa_new_codes' => 'New recovery codes',
'twofa_codes_title' => 'Recovery codes',
- 'twofa_codes_hint' => 'Write these down now and keep them safe. They are the way back in if the device is lost — each works once.'
+ 'twofa_codes_hint' => 'Write these down now and keep them safe. They are the way back in if the device is lost — each works once.',
+
+ // Next to the VAT number, because that is exactly where the two get
+ // confused: a VAT number says nothing about whether somebody is ordering as
+ // a consumer or as a business.
+ 'customer_type' => 'I am ordering as',
+ 'customer_type_consumer' => 'A private person',
+ 'customer_type_business' => 'A business',
+ 'customer_type_hint' => 'As a private person you have the statutory 14-day right of withdrawal on new contracts. Businesses do not.',
];
diff --git a/lang/en/withdrawal.php b/lang/en/withdrawal.php
new file mode 100644
index 0000000..7f228ea
--- /dev/null
+++ b/lang/en/withdrawal.php
@@ -0,0 +1,45 @@
+ 'Right of withdrawal',
+ 'card_sub' => 'You may withdraw from this contract until :date — :days day(s) left. The service ends immediately and you get your money back, less the share for the service already provided.',
+ 'cta' => 'Declare withdrawal',
+ 'keep' => 'Cancel',
+ 'confirm' => 'Declare withdrawal',
+ 'done' => 'Your withdrawal has been recorded. The service has ended; the cancellation document and the refund are on their way.',
+
+ 'confirm_title' => 'Withdraw from this contract?',
+ 'confirm_body' => 'This withdraws the contract within the statutory period of 14 days. You do not have to give a reason.',
+ 'confirm_point_now' => 'Your cloud is ended immediately and will no longer be reachable.',
+ 'confirm_point_documents' => 'The invoice is cancelled; you receive the cancellation document and, where anything remains due, a corrected invoice.',
+ 'confirm_point_prorata' => 'For the :days of :term days already used, :amount net remains payable — that share is not refunded.',
+ 'confirm_point_full' => 'You get the full amount paid back.',
+
+ 'refusal_no_contract' => 'There is no running contract on this account that could be withdrawn from.',
+ 'refusal_business' => 'The statutory right of withdrawal is a consumer right. Business customers are covered by the contractual cancellation terms.',
+ 'refusal_already' => 'This contract has already been withdrawn from.',
+ 'refusal_expired' => 'The 14-day withdrawal period has ended. The contract can be cancelled with effect from the end of the current period.',
+
+ // The line on the corrected invoice: what the customer owes for the service
+ // that was actually provided.
+ 'invoice_line' => 'Pro-rata service :plan up to withdrawal',
+ 'invoice_detail' => ':days of :term days provided',
+
+ 'admin_action' => 'Withdrawal',
+ 'admin_title' => 'Record a withdrawal',
+ 'admin_sub' => 'Withdrawal declared by :name, by telephone or in writing.',
+ 'admin_window' => 'Period ends on',
+ 'admin_delivered' => 'Days provided',
+ 'admin_owed' => 'Net share retained',
+ 'admin_effect' => 'The service ends immediately, the invoice is cancelled and the balance is refunded.',
+ 'admin_confirm' => 'Record withdrawal',
+ 'recorded' => 'Withdrawal recorded for :name.',
+];
diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php
index 5d3b6df..008a650 100644
--- a/resources/views/livewire/admin/customers.blade.php
+++ b/resources/views/livewire/admin/customers.blade.php
@@ -67,6 +67,18 @@
{{ __('admin.grant_action') }}
@endif
+ {{-- A withdrawal that came in by telephone or by post. Only
+ where the window is genuinely open — an operator offered
+ a button that would be refused is an operator who
+ promises a customer something we then take back. --}}
+ @if ($r['withdrawal_open'] && auth()->user()?->can('customers.manage'))
+
+ @endif
{{-- Impersonation borrows the customer's PORTAL session; this
is administrator access to their Nextcloud itself. Two
different things, so two buttons. --}}
diff --git a/resources/views/livewire/admin/record-withdrawal.blade.php b/resources/views/livewire/admin/record-withdrawal.blade.php
new file mode 100644
index 0000000..6439466
--- /dev/null
+++ b/resources/views/livewire/admin/record-withdrawal.blade.php
@@ -0,0 +1,49 @@
+@php
+ $eur = fn (int $cents) => \Illuminate\Support\Number::currency($cents / 100, in: 'EUR', locale: app()->getLocale());
+@endphp
+
+
+ @if ($refusal !== null)
+ {{-- The same sentence the customer would be shown. An operator being
+ told "nicht möglich" in developer's words would then explain it to
+ the customer in their own, and the two would not match. --}}
+
diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php
index a834adf..c76279a 100644
--- a/resources/views/livewire/auth/register.blade.php
+++ b/resources/views/livewire/auth/register.blade.php
@@ -21,6 +21,30 @@
@csrf
+
+ {{-- Asked here and nowhere else, because here is where the
+ customer record comes into being. It decides the
+ fourteen-day right of withdrawal, how the invoice is worded
+ and whether reverse charge can ever apply — none of which a
+ VAT field can answer, since most small businesses have no
+ number and no consumer has one. Two radios and no
+ preselection: a default would be an answer nobody gave. --}}
+
+
{{ __('auth.create_account') }}
diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php
index 5b81da7..6ba8ae9 100644
--- a/resources/views/livewire/billing.blade.php
+++ b/resources/views/livewire/billing.blade.php
@@ -363,7 +363,17 @@
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
@endif
- @if ($addon['booked'])
+ @if ($addon['booked'] && ($addon['cancelling'] ?? false))
+ {{-- Cancelled and still running. The customer has to be able
+ to see both halves of that: the date it actually stops,
+ and that they can still take it back until then. What is
+ NOT offered is a second cancellation — the card that
+ says "gekündigt" must not also say "kündigen". --}}
+
+ @elseif ($addon['booked'])
{{-- Already theirs, at the price they booked it for — which
is why this card does not show today's. --}}
@@ -396,6 +406,31 @@
{{ __('billing.addon_cta') }}
@endif
+
+ {{-- The way out, which the page did not have at all. The owner's
+ rule is that a module can be cancelled monthly, and until
+ now the only way to exercise it was to write to us.
+
+ Not offered on a granted module: nothing is being charged
+ for it, so there is no recurring cost to escape, and letting
+ a customer "cancel" a gift would take away something they
+ were given rather than stopping a payment. --}}
+ @if ($addon['booked'] && ! ($addon['granted'] ?? false))
+ @if ($addon['cancelling'] ?? false)
+
+ @else
+
+ @endif
+ @endif
@endforeach
diff --git a/resources/views/livewire/confirm-cancel-addon.blade.php b/resources/views/livewire/confirm-cancel-addon.blade.php
new file mode 100644
index 0000000..5db525c
--- /dev/null
+++ b/resources/views/livewire/confirm-cancel-addon.blade.php
@@ -0,0 +1,31 @@
+
+ {{-- The pro-rata share, stated before the button and not after it.
+ FAGG §16 lets us keep it only because the customer asked for the
+ service to start at once and was told this would follow — so this
+ is where they are told it again, with the actual figure. Where no
+ such request was recorded the amount is zero and the sentence says
+ the whole amount comes back. --}}
+
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php
index 60f0fdd..7beea0a 100644
--- a/resources/views/livewire/settings.blade.php
+++ b/resources/views/livewire/settings.blade.php
@@ -13,6 +13,26 @@
+ {{-- Beside the VAT number, deliberately, because that is the field
+ people mistake it for. A business without a number is an ordinary
+ small business and a consumer with one does not exist, so the two
+ answer different questions: this one decides the fourteen-day right
+ of withdrawal and whether reverse charge can ever apply. Nothing is
+ preselected for a customer nobody has asked yet. --}}
+