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 +
+
+ + + +
+

{{ __('withdrawal.admin_title') }}

+

{{ __('withdrawal.admin_sub', ['name' => $customerName]) }}

+
+
+ + @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. --}} +
+ {{ $refusal }} +
+ @else +
+
+
{{ __('withdrawal.admin_window') }}
+
{{ $right->endsAt?->local()->isoFormat('LL') }}
+
+
+
{{ __('withdrawal.admin_delivered') }}
+
{{ $deliveredDays }} / {{ $termDays }}
+
+
+
{{ __('withdrawal.admin_owed') }}
+
{{ $eur($owedNetCents) }}
+
+
+ +

{{ __('withdrawal.admin_effect') }}

+ @endif + +
+ {{ __('withdrawal.keep') }} + @if ($refusal === null) + + {{ __('withdrawal.admin_confirm') }} + + @endif +
+
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.customer_type') }} +
+ @foreach ([\App\Models\Customer::TYPE_CONSUMER, \App\Models\Customer::TYPE_BUSINESS] as $type) + + @endforeach +
+

{{ __('auth.customer_type_hint') }}

+ @error('customer_type')

{{ $message }}

@enderror +
+ {{ __('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". --}} +

+ + {{ __('billing.addon_runs_until', ['date' => $addon['cancels_at']?->local()->isoFormat('LL')]) }} +

+ @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 @@ +

+
+ + + +
+

{{ __('billing.addon_cancel_title', ['module' => $moduleName]) }}

+

+ {{ $endsAt + ? __('billing.addon_cancel_body', ['date' => $endsAt->local()->isoFormat('LL')]) + : __('billing.addon_cancel_body_now') }} +

+
+
+ +
    +
  • + {{ __('billing.addon_cancel_point_term') }} +
  • +
  • + {{ __('billing.addon_cancel_point_undo') }} +
  • +
+ +
+ {{ __('billing.addon_cancel_keep') }} + + {{ __('billing.addon_cancel_confirm') }} + +
+
diff --git a/resources/views/livewire/confirm-withdraw.blade.php b/resources/views/livewire/confirm-withdraw.blade.php new file mode 100644 index 0000000..711d731 --- /dev/null +++ b/resources/views/livewire/confirm-withdraw.blade.php @@ -0,0 +1,46 @@ +@php + $eur = fn (int $cents) => \Illuminate\Support\Number::currency($cents / 100, in: 'EUR', locale: app()->getLocale()); +@endphp +
+
+ + + +
+

{{ __('withdrawal.confirm_title') }}

+

{{ __('withdrawal.confirm_body') }}

+
+
+ +
    +
  • + {{ __('withdrawal.confirm_point_now') }} +
  • +
  • + {{ __('withdrawal.confirm_point_documents') }} +
  • + {{-- 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. --}} +
  • + + {{ $owedNetCents > 0 + ? __('withdrawal.confirm_point_prorata', [ + 'amount' => $eur($owedNetCents), + 'days' => $deliveredDays, + 'term' => $termDays, + ]) + : __('withdrawal.confirm_point_full') }} +
  • +
+ +
+ {{ __('withdrawal.keep') }} + + {{ __('withdrawal.confirm') }} + +
+
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. --}} +
+ {{ __('settings.customer_type') }} +
+ @foreach ($customerTypes as $type) + + @endforeach +
+

{{ __('settings.customer_type_hint') }}

+ @error('customerType')

{{ $message }}

@enderror +