From 6d7c2bdf351554be7a4287755c8709104ee963d2 Mon Sep 17 00:00:00 2001
From: nexxo
Date: Wed, 29 Jul 2026 21:06:06 +0200
Subject: [PATCH] Ask whether they are a consumer, and let one change their
mind
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three things the product owed its customers and did not have.
**Who is on the other side.** There was no consumer/business flag anywhere,
and `vat_id` was standing in for one — which it cannot: a business without a
VAT number is an ordinary small business, and a consumer with one does not
exist. It is asked at sign-up now, correctable in the portal, and NULL where
nobody has been asked. Unknown is read as CONSUMER everywhere it decides a
right, because that mistake costs us a refund while the other one takes a
statutory right away from somebody who has it.
Reverse charge asks the recorded type instead of the number: an explicit
consumer is charged the domestic rate whatever `vat_id` says — previously they
were not, and anyone could zero their own VAT by getting a number verified. An
unrecorded type still falls back to the verified number, so no contract that is
already running changes rate.
**The fourteen-day right of withdrawal** (FAGG, §312g BGB), for consumers only,
at every door: the window is stamped on the contract when it is concluded, the
customer exercises it from the portal, an operator records one that arrived by
telephone or post, and both go through one action that refuses a business
customer on the server rather than by hiding a card.
The money follows the paperwork rather than being computed beside it. The
invoice is cancelled by a Storno with its own gapless number — nothing is ever
edited or deleted — a new invoice states the pro-rata value of the service
actually delivered (FAGG §16, by days over the term paid for), and the refund
is exactly the difference between the two documents. Where the consumer never
expressly asked for the service to begin at once, they owe nothing and the whole
amount goes back. `StripeClient::refund()` is new, keyed so a retry cannot send
the money twice. The service ends through EndInstanceService and the
`cancellation_scheduled` machinery that was already there.
**The cancel button for modules.** BookAddon::cancelAtPeriodEnd() had no caller
in the interface at all, so a customer could book a recurring charge in two
clicks and had no way to stop it. It is on the module card now, with the date it
runs until and a way back while the cancellation is still pending — and putting
a module back settles nothing, because the term it was cancelled for was paid
for in advance.
Co-Authored-By: Claude Opus 5
---
app/Actions/BookAddon.php | 45 ++
app/Actions/Fortify/CreateNewUser.php | 8 +
app/Actions/OpenSubscription.php | 16 +
app/Actions/StartCustomerProvisioning.php | 27 +-
app/Actions/SyncStripeAddonItems.php | 25 +-
app/Actions/WithdrawContract.php | 390 +++++++++++++++
.../Controllers/StripeWebhookController.php | 13 +
app/Livewire/Admin/Customers.php | 12 +
app/Livewire/Admin/RecordWithdrawal.php | 106 ++++
app/Livewire/Billing.php | 110 +++++
app/Livewire/ConfirmCancelAddon.php | 68 +++
app/Livewire/ConfirmWithdraw.php | 52 ++
app/Livewire/Settings.php | 104 +++-
app/Models/Customer.php | 53 +-
app/Models/Order.php | 13 +-
app/Models/Subscription.php | 26 +
app/Models/SubscriptionRecord.php | 14 +
app/Services/Billing/AddonCatalogue.php | 19 +-
app/Services/Billing/IssueInvoice.php | 156 +++++-
app/Services/Billing/TaxTreatment.php | 35 +-
app/Services/Billing/WithdrawalRight.php | 187 +++++++
app/Services/Stripe/FakeStripeClient.php | 51 ++
app/Services/Stripe/HttpStripeClient.php | 50 ++
app/Services/Stripe/StripeClient.php | 36 ++
database/factories/CustomerFactory.php | 19 +
database/factories/SubscriptionFactory.php | 23 +
...ecord_whether_a_customer_is_a_consumer.php | 44 ++
...add_the_withdrawal_window_to_contracts.php | 113 +++++
lang/de/auth.php | 39 +-
lang/de/billing.php | 19 +
lang/de/checkout.php | 9 +
lang/de/invoice.php | 5 +
lang/de/settings.php | 7 +
lang/de/withdrawal.php | 45 ++
lang/en/auth.php | 39 +-
lang/en/billing.php | 19 +
lang/en/checkout.php | 8 +
lang/en/invoice.php | 5 +
lang/en/settings.php | 10 +-
lang/en/withdrawal.php | 45 ++
.../views/livewire/admin/customers.blade.php | 12 +
.../admin/record-withdrawal.blade.php | 49 ++
.../views/livewire/auth/register.blade.php | 24 +
resources/views/livewire/billing.blade.php | 37 +-
.../livewire/confirm-cancel-addon.blade.php | 31 ++
.../views/livewire/confirm-withdraw.blade.php | 46 ++
resources/views/livewire/settings.blade.php | 50 ++
tests/Feature/Auth/RegisterTest.php | 35 +-
.../Feature/Billing/AddonCancellationTest.php | 203 ++++++++
tests/Feature/Billing/CustomerTypeTest.php | 113 +++++
tests/Feature/Billing/WithdrawalTest.php | 463 ++++++++++++++++++
51 files changed, 3050 insertions(+), 78 deletions(-)
create mode 100644 app/Actions/WithdrawContract.php
create mode 100644 app/Livewire/Admin/RecordWithdrawal.php
create mode 100644 app/Livewire/ConfirmCancelAddon.php
create mode 100644 app/Livewire/ConfirmWithdraw.php
create mode 100644 app/Services/Billing/WithdrawalRight.php
create mode 100644 database/migrations/2026_07_29_300000_record_whether_a_customer_is_a_consumer.php
create mode 100644 database/migrations/2026_07_29_300001_add_the_withdrawal_window_to_contracts.php
create mode 100644 lang/de/withdrawal.php
create mode 100644 lang/en/withdrawal.php
create mode 100644 resources/views/livewire/admin/record-withdrawal.blade.php
create mode 100644 resources/views/livewire/confirm-cancel-addon.blade.php
create mode 100644 resources/views/livewire/confirm-withdraw.blade.php
create mode 100644 tests/Feature/Billing/AddonCancellationTest.php
create mode 100644 tests/Feature/Billing/CustomerTypeTest.php
create mode 100644 tests/Feature/Billing/WithdrawalTest.php
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. --}}
+