Ask whether they are a consumer, and let one change their mind
Three things the product owed its customers and did not have. **Who is on the other side.** There was no consumer/business flag anywhere, and `vat_id` was standing in for one — which it cannot: a business without a VAT number is an ordinary small business, and a consumer with one does not exist. It is asked at sign-up now, correctable in the portal, and NULL where nobody has been asked. Unknown is read as CONSUMER everywhere it decides a right, because that mistake costs us a refund while the other one takes a statutory right away from somebody who has it. Reverse charge asks the recorded type instead of the number: an explicit consumer is charged the domestic rate whatever `vat_id` says — previously they were not, and anyone could zero their own VAT by getting a number verified. An unrecorded type still falls back to the verified number, so no contract that is already running changes rate. **The fourteen-day right of withdrawal** (FAGG, §312g BGB), for consumers only, at every door: the window is stamped on the contract when it is concluded, the customer exercises it from the portal, an operator records one that arrived by telephone or post, and both go through one action that refuses a business customer on the server rather than by hiding a card. The money follows the paperwork rather than being computed beside it. The invoice is cancelled by a Storno with its own gapless number — nothing is ever edited or deleted — a new invoice states the pro-rata value of the service actually delivered (FAGG §16, by days over the term paid for), and the refund is exactly the difference between the two documents. Where the consumer never expressly asked for the service to begin at once, they owe nothing and the whole amount goes back. `StripeClient::refund()` is new, keyed so a retry cannot send the money twice. The service ends through EndInstanceService and the `cancellation_scheduled` machinery that was already there. **The cancel button for modules.** BookAddon::cancelAtPeriodEnd() had no caller in the interface at all, so a customer could book a recurring charge in two clicks and had no way to stop it. It is on the module card now, with the date it runs until and a way back while the cancellation is still pending — and putting a module back settles nothing, because the term it was cancelled for was paid for in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus v1.3.33
parent
0e25fe88d4
commit
6d7c2bdf35
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<int, SubscriptionAddon> $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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,390 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Instance;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Operator;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionAddon;
|
||||
use App\Models\SubscriptionRecord;
|
||||
use App\Services\Billing\IssueInvoice;
|
||||
use App\Services\Billing\TaxTreatment;
|
||||
use App\Services\Billing\WithdrawalRight;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* A consumer changes their mind inside fourteen days, and everything that
|
||||
* follows from it.
|
||||
*
|
||||
* One action, because a withdrawal is one event with four consequences and
|
||||
* three of them are irreversible. Split across a controller, a command and a
|
||||
* page, they would come apart: a service ended with no credit note, a refund
|
||||
* sent for a contract still running, a document issued twice by two clicks.
|
||||
*
|
||||
* In order, and the order is the design:
|
||||
*
|
||||
* 1. **The right is checked**, by WithdrawalRight and nowhere else. A business
|
||||
* customer is refused here, on the server, whatever the page did or did not
|
||||
* show — every door into this action is the same door.
|
||||
* 2. **The withdrawal is claimed**, conditionally, on the contract. Two clicks
|
||||
* in flight write one withdrawal between them; the loser is told it has
|
||||
* already happened rather than sending a second refund.
|
||||
* 3. **The paperwork**: the invoice that was issued is CANCELLED by a Storno
|
||||
* with its own number — never edited, never deleted — and if the consumer
|
||||
* owes a pro-rata share, a new invoice states it. That is the established
|
||||
* rule for correcting an issued document, and a withdrawal is the clearest
|
||||
* case there is for it.
|
||||
* 4. **The money**, which falls out of the paperwork rather than being
|
||||
* computed beside it: what was invoiced, less what the new document says is
|
||||
* still owed. Two sums that must agree cannot disagree if only one of them
|
||||
* is ever calculated.
|
||||
* 5. **The service ends**, through App\Actions\EndInstanceService and the
|
||||
* `cancellation_scheduled` machinery it already reads — the address comes
|
||||
* down, the DNS record goes, the instance is marked ended. No second way
|
||||
* for an instance to stop being served.
|
||||
*
|
||||
* ## What happens when the money side fails
|
||||
*
|
||||
* It is caught, written onto the contract and logged as an error — and the
|
||||
* withdrawal still stands. The customer's declaration is what ends the
|
||||
* contract; a payment provider being unreachable does not un-declare it, and
|
||||
* leaving the service running because a refund timed out would charge them for
|
||||
* days they have withdrawn from. `withdrawal_refund_error` is where an operator
|
||||
* finds the ones that need a hand.
|
||||
*/
|
||||
class WithdrawContract
|
||||
{
|
||||
/** Recorded by the customer themselves, in the portal. */
|
||||
public const CHANNEL_PORTAL = 'portal';
|
||||
|
||||
/** Taken by telephone or post and typed in by an operator. */
|
||||
public const CHANNEL_OPERATOR = 'operator';
|
||||
|
||||
public function __construct(
|
||||
private EndInstanceService $endService,
|
||||
private BookAddon $bookAddon,
|
||||
private IssueInvoice $invoices,
|
||||
private StripeClient $stripe,
|
||||
private RecordCommercialEvent $record,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string $channel one of the CHANNEL_* constants
|
||||
* @param Operator|null $by the operator who took the declaration, when it did not come from the portal
|
||||
*
|
||||
* @throws RuntimeException with the sentence the customer is shown, when the right does not apply
|
||||
*/
|
||||
public function __invoke(
|
||||
Subscription $subscription,
|
||||
string $channel = self::CHANNEL_PORTAL,
|
||||
?Operator $by = null,
|
||||
?Carbon $at = null,
|
||||
): Subscription {
|
||||
$at ??= now();
|
||||
|
||||
$right = WithdrawalRight::for($subscription);
|
||||
|
||||
if (! $right->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Actions\WithdrawContract;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\CustomDomainAccess;
|
||||
use App\Services\Billing\WithdrawalRight;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A withdrawal that arrived by telephone or by post, typed in by an operator.
|
||||
*
|
||||
* The law does not require a consumer to use our form. A sentence on the phone,
|
||||
* a letter, an email — any unambiguous declaration inside the fourteen days
|
||||
* ends the contract, and if the only way to record one were the portal button,
|
||||
* the ones that came in every other way would be handled by hand in Stripe and
|
||||
* never appear in the register at all.
|
||||
*
|
||||
* So it goes through the SAME action as the portal, with the channel recorded
|
||||
* and the operator's name on it. Nothing is decided here that is not decided
|
||||
* there: a business customer is refused, an expired window is refused, and the
|
||||
* pro-rata share is worked out by the same arithmetic. An operator cannot
|
||||
* override any of it, which is the point — a withdrawal an operator could
|
||||
* approve out of the window would be a refund with no legal basis and no
|
||||
* document that could justify it.
|
||||
*
|
||||
* `customers.manage` rather than `customers.grant_plan`: this is not a gift an
|
||||
* operator decides to make, it is a declaration the customer has already made
|
||||
* and somebody is writing down. Support answers the telephone, and Support holds
|
||||
* `customers.manage`.
|
||||
*/
|
||||
class RecordWithdrawal extends ModalComponent
|
||||
{
|
||||
public string $customerUuid = '';
|
||||
|
||||
public string $customerName = '';
|
||||
|
||||
/** Why it cannot be recorded, in the customer's own words. Null when it can. */
|
||||
public ?string $refusal = null;
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
// Modals are reachable without the route middleware, so the permission
|
||||
// is checked here as well as on the page that opens this one.
|
||||
$this->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int, SubscriptionAddon>
|
||||
*/
|
||||
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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\SubscriptionAddon;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirmation before a booked module is cancelled (R23).
|
||||
*
|
||||
* A recurring charge stopping is a consequence worth one dialog: the module
|
||||
* keeps running to the end of the term that is already paid for, earns no
|
||||
* credit, and then goes — and for a storage pack, "then goes" is a hundred
|
||||
* gigabytes the customer has to have moved off by that date. That is a sentence
|
||||
* somebody should read before, not after.
|
||||
*
|
||||
* The date is worked out here from the contract rather than passed in from the
|
||||
* card, for the same reason ConfirmBookStorage clamps its own number: a modal is
|
||||
* opened with arguments from markup, and a dialog must not be able to promise
|
||||
* something the action behind it would not do.
|
||||
*
|
||||
* No mutation here. Billing::cancelAddon() keeps the work, the ownership check
|
||||
* and the customer resolution it already has; this only says yes.
|
||||
*/
|
||||
class ConfirmCancelAddon extends ModalComponent
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
public string $addonKey = '';
|
||||
|
||||
public function mount(string $key): void
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Services\Billing\CustomDomainAccess;
|
||||
use App\Services\Billing\WithdrawalRight;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirmation before a consumer withdraws from their contract (R23).
|
||||
*
|
||||
* The heaviest button in the portal: it ends the cloud the same afternoon, takes
|
||||
* the address down and sends the money back, and none of that can be undone by
|
||||
* clicking again. So it is confirmed in a dialog this product draws rather than
|
||||
* one the browser does.
|
||||
*
|
||||
* The figures are read again here, not handed in from the page. A modal is
|
||||
* opened with arguments from markup, and markup is not evidence — a dialog that
|
||||
* says "37,00 € kommen zurück" has to have worked that out from the contract it
|
||||
* is about, or it is a promise nobody made.
|
||||
*
|
||||
* No mutation here. Settings::withdraw() keeps the work and its own server-side
|
||||
* refusal; the confirm button only says yes.
|
||||
*/
|
||||
class ConfirmWithdraw extends ModalComponent
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
public function proceed(): void
|
||||
{
|
||||
$this->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CustomerFactory> */
|
||||
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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, array{key: string, price_cents: ?int, monthly_cents: int, booked: bool, entitlement: bool, quantity: int, bookings: array<int, array{price_cents: int, quantity: int}>}>
|
||||
* @return array<string, array{key: string, price_cents: ?int, monthly_cents: int, booked: bool, entitlement: bool, quantity: int, cancelling: bool, cancels_at: ?Carbon, bookings: array<int, array{uuid: string, price_cents: int, quantity: int, cancels_at: ?Carbon}>}>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<int, array<string, mixed>> $lines
|
||||
* @param array<string, mixed> $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<string, mixed> $snapshot
|
||||
* @param array<string, int> $totals
|
||||
* @param array<string, mixed> $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'],
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* The fourteen-day right of withdrawal, and what it costs when it is used.
|
||||
*
|
||||
* EU distance selling. In Austria the FAGG, in Germany §312g BGB, and the same
|
||||
* substance in every member state: a CONSUMER who concludes a contract at a
|
||||
* distance may withdraw from it within fourteen days without giving a reason,
|
||||
* and gets their money back. A business customer has no such right at all —
|
||||
* which is why nothing here will answer a question about a contract whose
|
||||
* customer is a business, and why `customers.customer_type` had to exist before
|
||||
* any of this could be written.
|
||||
*
|
||||
* ## Why it is not simply "give it all back"
|
||||
*
|
||||
* A cloud is running from the moment it is paid for. FAGG §16 is the rule for
|
||||
* exactly that case: where the consumer EXPRESSLY asked for the service to
|
||||
* begin during the withdrawal period, and was told they would owe a
|
||||
* proportionate amount if they withdrew, they owe the pro-rata value of what
|
||||
* was actually performed up to the moment of withdrawal. Not the whole period —
|
||||
* they withdrew — and not nothing either, because the machine really did run.
|
||||
*
|
||||
* Both halves of that condition are required, and both are on record: the
|
||||
* consent is a timestamp taken at the checkout, and its absence means the
|
||||
* consumer owes NOTHING and the refund is the whole amount. That is the
|
||||
* lawful reading and it is also the safe one: a seller who cannot prove the
|
||||
* consent was given cannot charge for the days.
|
||||
*
|
||||
* ## How the share is measured
|
||||
*
|
||||
* By whole days, over the term the customer actually paid for, and a started day
|
||||
* counts as delivered. Days rather than seconds because the figure ends up on a
|
||||
* document a person reads and has to be able to check with a calendar; a started
|
||||
* day counts because the service was genuinely available on it. The share is
|
||||
* taken of the NET price, and the VAT is put back on top by the document — never
|
||||
* the other way round, for the reason InvoiceMath sets out.
|
||||
*/
|
||||
final readonly class WithdrawalRight
|
||||
{
|
||||
/** Fourteen days, from the conclusion of the contract. The law's number. */
|
||||
public const WINDOW_DAYS = 14;
|
||||
|
||||
private function __construct(
|
||||
/** Does this contract have a withdrawal right at all — is the customer a consumer? */
|
||||
public bool $applies,
|
||||
/** When the window opened: the moment the contract was concluded. */
|
||||
public ?Carbon $opensAt,
|
||||
/** When it closes. Stamped on the contract, not recomputed here. */
|
||||
public ?Carbon $endsAt,
|
||||
/** May it be exercised right now? */
|
||||
public bool $open,
|
||||
/** Why not, in the sentence the customer is shown. Null while it is open. */
|
||||
public ?string $refusal,
|
||||
/** Did the consumer expressly ask for the service to start at once? */
|
||||
public bool $immediateStartConsented,
|
||||
) {}
|
||||
|
||||
public static function for(?Subscription $subscription): self
|
||||
{
|
||||
if ($subscription === null) {
|
||||
return new self(false, null, null, false, __('withdrawal.refusal_no_contract'), false);
|
||||
}
|
||||
|
||||
$opensAt = $subscription->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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int, array{payment: string, amount: ?int, key: ?string}>
|
||||
*/
|
||||
public array $refunds = [];
|
||||
|
||||
/**
|
||||
* What this Stripe would answer when asked which payment an invoice was
|
||||
* settled by: invoice id → payment reference.
|
||||
*
|
||||
* @var array<string, ?string>
|
||||
*/
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Whether the person on the other side is a consumer or a business.
|
||||
*
|
||||
* It was nowhere, and it decides things that are not ours to decide: the
|
||||
* fourteen-day right of withdrawal is a consumer's and only a consumer's, the
|
||||
* invoice is worded differently for each, and reverse charge is a business rule.
|
||||
* All three were being answered from `vat_id`, which cannot answer them — a
|
||||
* business without a VAT number is an ordinary small business, and a consumer
|
||||
* with one does not exist. A field that is empty for half the businesses we sell
|
||||
* to is not the same question.
|
||||
*
|
||||
* Nullable, and deliberately so. NULL means nobody has ever been asked, which is
|
||||
* a different statement from either answer and must not be dressed up as one.
|
||||
* Every reader of this column treats NULL as the MORE PROTECTIVE case — the
|
||||
* customer keeps the withdrawal right — because getting that wrong costs a
|
||||
* customer a statutory right, while getting it wrong the other way costs us a
|
||||
* refund we did not strictly owe.
|
||||
*
|
||||
* A string rather than a boolean: `is_business = false` reads as "we asked and
|
||||
* they said no", and there would be no way left to say "we never asked".
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
// 'consumer' | 'business' | NULL (never recorded).
|
||||
$table->string('customer_type', 16)->nullable()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
$table->dropColumn('customer_type');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* The fourteen days a consumer may change their mind in.
|
||||
*
|
||||
* EU distance selling — in Austria the FAGG, in Germany §312g BGB — gives a
|
||||
* consumer fourteen days from the conclusion of the contract to withdraw from
|
||||
* it without giving a reason and get their money back. A business customer has
|
||||
* no such right, which is exactly why `customers.customer_type` had to exist
|
||||
* first.
|
||||
*
|
||||
* The end of the window is STAMPED, not derived, for the same reason
|
||||
* `pending_effective_at` is: it is a date the customer was told, and a date that
|
||||
* is recomputed on every read is a date that can move. `started_at` is where it
|
||||
* comes from and it is in Subscription::FROZEN, so the opening moment cannot be
|
||||
* rewritten either.
|
||||
*
|
||||
* `immediate_start_consent_at` is the other half of the law and the reason this
|
||||
* is not simply "refund everything". A service the consumer expressly asked us
|
||||
* to begin during the window is a service they owe the pro-rata value of when
|
||||
* they withdraw (FAGG §16) — but only if they expressly asked for it AND were
|
||||
* told they would owe it. Without that consent on record they owe nothing, so
|
||||
* the consent is a timestamp on the purchase, not a flag on a form somewhere.
|
||||
*
|
||||
* The refund columns record what was actually sent back and, if the payment
|
||||
* provider refused, that it was not. A withdrawal that ended a service and
|
||||
* issued documents while silently failing to move the money is the worst of the
|
||||
* possible outcomes, so the failure is on the contract where an operator will
|
||||
* see it rather than in a log line that scrolls away.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
// Recorded at the checkout, where the consumer ticks it, and copied
|
||||
// onto the contract when one is opened. On the purchase rather than
|
||||
// only on the contract because the consent belongs to the ORDER: it
|
||||
// is what was agreed at the moment of buying, and it exists before
|
||||
// there is any contract to hang it on.
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Das gesetzliche Widerrufsrecht (FAGG in Österreich, §312g BGB in Deutschland).
|
||||
*
|
||||
* Die Formulierungen sind bewusst nüchtern und nennen Beträge und Fristen beim
|
||||
* Namen: ein Widerruf ist ein Recht, kein Verlust, und ein Text, der ihn
|
||||
* bedauert oder erschwert, ist rechtlich angreifbar.
|
||||
*/
|
||||
|
||||
return [
|
||||
'card_title' => '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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* The statutory right of withdrawal (FAGG in Austria, §312g BGB in Germany).
|
||||
*
|
||||
* Deliberately plain, and it names the figures and the deadline: a withdrawal
|
||||
* is a right rather than a loss, and wording that regrets it or makes it
|
||||
* awkward is legally exposed.
|
||||
*/
|
||||
|
||||
return [
|
||||
'card_title' => '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.',
|
||||
];
|
||||
|
|
@ -67,6 +67,18 @@
|
|||
{{ __('admin.grant_action') }}
|
||||
</button>
|
||||
@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'))
|
||||
<button type="button" title="{{ __('withdrawal.admin_action') }}"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.record-withdrawal', arguments: { uuid: '{{ $r['uuid'] }}' } })"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs font-semibold text-body hover:border-warning hover:text-warning">
|
||||
<x-ui.icon name="rotate-ccw" class="size-3.5" />
|
||||
{{ __('withdrawal.admin_action') }}
|
||||
</button>
|
||||
@endif
|
||||
{{-- Impersonation borrows the customer's PORTAL session; this
|
||||
is administrator access to their Nextcloud itself. Two
|
||||
different things, so two buttons. --}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
@php
|
||||
$eur = fn (int $cents) => \Illuminate\Support\Number::currency($cents / 100, in: 'EUR', locale: app()->getLocale());
|
||||
@endphp
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
<x-ui.icon name="alert-triangle" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('withdrawal.admin_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('withdrawal.admin_sub', ['name' => $customerName]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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. --}}
|
||||
<div class="mt-4 rounded-lg border border-danger-border bg-danger-bg p-4 text-sm text-danger">
|
||||
{{ $refusal }}
|
||||
</div>
|
||||
@else
|
||||
<dl class="mt-4 space-y-1.5 text-sm">
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted">{{ __('withdrawal.admin_window') }}</dt>
|
||||
<dd class="text-ink">{{ $right->endsAt?->local()->isoFormat('LL') }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted">{{ __('withdrawal.admin_delivered') }}</dt>
|
||||
<dd class="font-mono tabular-nums text-ink">{{ $deliveredDays }} / {{ $termDays }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt class="text-muted">{{ __('withdrawal.admin_owed') }}</dt>
|
||||
<dd class="font-mono tabular-nums text-ink">{{ $eur($owedNetCents) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="mt-4 text-sm text-body">{{ __('withdrawal.admin_effect') }}</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('withdrawal.keep') }}</x-ui.button>
|
||||
@if ($refusal === null)
|
||||
<x-ui.button variant="danger" wire:click="record" wire:loading.attr="disabled">
|
||||
{{ __('withdrawal.admin_confirm') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -21,6 +21,30 @@
|
|||
@csrf
|
||||
<x-ui.input name="name" type="text" :label="__('auth.name')" autocomplete="name" autofocus required value="{{ old('name') }}" />
|
||||
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="email" required value="{{ old('email') }}" />
|
||||
|
||||
{{-- 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. --}}
|
||||
<fieldset class="space-y-1.5">
|
||||
<legend class="block text-sm font-medium text-body">{{ __('auth.customer_type') }}</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@foreach ([\App\Models\Customer::TYPE_CONSUMER, \App\Models\Customer::TYPE_BUSINESS] as $type)
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink transition has-[:checked]:border-ink">
|
||||
<input type="radio" name="customer_type" value="{{ $type }}" required
|
||||
@checked(old('customer_type') === $type)
|
||||
class="size-4 shrink-0 border-line text-ink" />
|
||||
{{ __('auth.customer_type_'.$type) }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="text-xs text-muted">{{ __('auth.customer_type_hint') }}</p>
|
||||
@error('customer_type')<p class="text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</fieldset>
|
||||
|
||||
<x-ui.input name="password" type="password" :label="__('auth.password_label')" autocomplete="new-password" required />
|
||||
<x-ui.input name="password_confirmation" type="password" :label="__('auth.password_confirm')" autocomplete="new-password" required />
|
||||
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.create_account') }}</x-ui.button>
|
||||
|
|
|
|||
|
|
@ -363,7 +363,17 @@
|
|||
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
|
||||
</p>
|
||||
@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". --}}
|
||||
<p class="mt-3 inline-flex items-center gap-1.5 rounded-pill border border-warning-border bg-warning-bg px-2.5 py-0.5 text-xs font-medium text-warning">
|
||||
<x-ui.icon name="calendar" class="size-4" />
|
||||
{{ __('billing.addon_runs_until', ['date' => $addon['cancels_at']?->local()->isoFormat('LL')]) }}
|
||||
</p>
|
||||
@elseif ($addon['booked'])
|
||||
{{-- Already theirs, at the price they booked it for — which
|
||||
is why this card does not show today's. --}}
|
||||
<p class="mt-3 inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
|
||||
|
|
@ -396,6 +406,31 @@
|
|||
{{ __('billing.addon_cta') }}
|
||||
</x-ui.button>
|
||||
@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)
|
||||
<button type="button" wire:click="resumeAddon('{{ $key }}')"
|
||||
wire:target="resumeAddon('{{ $key }}')" wire:loading.attr="disabled"
|
||||
class="mt-3 inline-flex w-full items-center justify-center gap-1.5 text-xs font-semibold text-accent-text hover:underline">
|
||||
<x-ui.icon name="rotate-ccw" class="size-4" />
|
||||
{{ __('billing.addon_resume_cta') }}
|
||||
</button>
|
||||
@else
|
||||
<button type="button"
|
||||
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-addon', arguments: { key: '{{ $key }}' } })"
|
||||
class="mt-3 inline-flex w-full items-center justify-center text-xs font-semibold text-muted hover:text-danger hover:underline">
|
||||
{{ __('billing.addon_cancel_cta') }}
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
<x-ui.icon name="alert-triangle" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('billing.addon_cancel_title', ['module' => $moduleName]) }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{{ $endsAt
|
||||
? __('billing.addon_cancel_body', ['date' => $endsAt->local()->isoFormat('LL')])
|
||||
: __('billing.addon_cancel_body_now') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="mt-4 space-y-1.5 text-sm text-body">
|
||||
<li class="flex items-start gap-2">
|
||||
<x-ui.icon name="check" class="mt-0.5 size-4 shrink-0 text-muted" />{{ __('billing.addon_cancel_point_term') }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<x-ui.icon name="rotate-ccw" class="mt-0.5 size-4 shrink-0 text-muted" />{{ __('billing.addon_cancel_point_undo') }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('billing.addon_cancel_keep') }}</x-ui.button>
|
||||
<x-ui.button variant="danger" wire:click="proceed" wire:loading.attr="disabled">
|
||||
{{ __('billing.addon_cancel_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
@php
|
||||
$eur = fn (int $cents) => \Illuminate\Support\Number::currency($cents / 100, in: 'EUR', locale: app()->getLocale());
|
||||
@endphp
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
<x-ui.icon name="alert-triangle" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('withdrawal.confirm_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('withdrawal.confirm_body') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="mt-4 space-y-1.5 text-sm text-body">
|
||||
<li class="flex items-start gap-2">
|
||||
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />{{ __('withdrawal.confirm_point_now') }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<x-ui.icon name="check" class="mt-0.5 size-4 shrink-0 text-muted" />{{ __('withdrawal.confirm_point_documents') }}
|
||||
</li>
|
||||
{{-- 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. --}}
|
||||
<li class="flex items-start gap-2">
|
||||
<x-ui.icon name="receipt" class="mt-0.5 size-4 shrink-0 text-muted" />
|
||||
{{ $owedNetCents > 0
|
||||
? __('withdrawal.confirm_point_prorata', [
|
||||
'amount' => $eur($owedNetCents),
|
||||
'days' => $deliveredDays,
|
||||
'term' => $termDays,
|
||||
])
|
||||
: __('withdrawal.confirm_point_full') }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('withdrawal.keep') }}</x-ui.button>
|
||||
<x-ui.button variant="danger" wire:click="proceed" wire:loading.attr="disabled">
|
||||
{{ __('withdrawal.confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -13,6 +13,26 @@
|
|||
<x-ui.input name="phone" wire:model="phone" :label="__('settings.phone')" />
|
||||
<x-ui.input name="vatId" wire:model="vatId" :label="__('settings.vat_id')" />
|
||||
</div>
|
||||
{{-- 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. --}}
|
||||
<fieldset class="space-y-1.5">
|
||||
<legend class="text-sm font-medium text-body">{{ __('settings.customer_type') }}</legend>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
@foreach ($customerTypes as $type)
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
|
||||
<input type="radio" wire:model="customerType" value="{{ $type }}"
|
||||
class="size-4 shrink-0 border-line text-ink" />
|
||||
{{ __('settings.customer_type_'.$type) }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="text-xs text-muted">{{ __('settings.customer_type_hint') }}</p>
|
||||
@error('customerType')<p class="text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</fieldset>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="billingAddress">{{ __('settings.billing_address') }}</label>
|
||||
<textarea id="billingAddress" wire:model="billingAddress" rows="3"
|
||||
|
|
@ -214,6 +234,36 @@
|
|||
<p class="text-sm text-muted">{{ __('settings.no_package') }}</p>
|
||||
@endif
|
||||
|
||||
{{-- The fourteen-day right of withdrawal. Shown only where it exists:
|
||||
a business customer has none and never sees this block — and the
|
||||
server refuses them again in Settings::withdraw(), because a card
|
||||
that is not rendered has never stopped anybody who can post to
|
||||
/livewire/update.
|
||||
|
||||
Separate from the cancellation above it, and not a variant of it.
|
||||
A cancellation ends a contract that was validly concluded and keeps
|
||||
the term the customer paid for; a withdrawal unwinds the contract
|
||||
itself, ends the service the same day and sends the money back. --}}
|
||||
@if ($withdrawal->applies && $withdrawal->open)
|
||||
<div class="border-t border-line pt-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-ink">{{ __('withdrawal.card_title') }}</p>
|
||||
<p class="mt-0.5 max-w-xl text-sm text-muted">
|
||||
{{ __('withdrawal.card_sub', [
|
||||
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
|
||||
'days' => $withdrawal->daysLeft(),
|
||||
]) }}
|
||||
</p>
|
||||
</div>
|
||||
<x-ui.button variant="secondary" size="sm"
|
||||
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
|
||||
{{ __('withdrawal.cta') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="border-t border-line pt-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ it('registers a new account and signs in', function () {
|
|||
$this->post(route('register.store'), [
|
||||
'name' => 'New Co',
|
||||
'email' => 'new@signup.test',
|
||||
'customer_type' => Customer::TYPE_BUSINESS,
|
||||
'password' => 'password1234',
|
||||
'password_confirmation' => 'password1234',
|
||||
])->assertRedirect();
|
||||
|
|
@ -29,7 +30,39 @@ it('registers a new account and signs in', function () {
|
|||
|
||||
// A linked customer is created so the portal has something to work with.
|
||||
$customer = Customer::query()->where('user_id', $user->id)->first();
|
||||
expect($customer)->not->toBeNull()->and($customer->email)->toBe('new@signup.test');
|
||||
expect($customer)->not->toBeNull()
|
||||
->and($customer->email)->toBe('new@signup.test')
|
||||
// The answer they gave, stored as given. Everything downstream — the
|
||||
// withdrawal right, the invoice wording, reverse charge — reads this
|
||||
// and not the VAT field.
|
||||
->and($customer->customer_type)->toBe(Customer::TYPE_BUSINESS)
|
||||
->and($customer->isBusiness())->toBeTrue();
|
||||
});
|
||||
|
||||
it('refuses a signup that does not say whether it is a consumer or a business', function () {
|
||||
// Required rather than optional-with-a-default. A default would be an
|
||||
// answer nobody gave, and the whole point of the column is that it holds a
|
||||
// recorded answer or nothing at all.
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Unanswered',
|
||||
'email' => 'unanswered@signup.test',
|
||||
'password' => 'password1234',
|
||||
'password_confirmation' => 'password1234',
|
||||
])->assertSessionHasErrors('customer_type');
|
||||
|
||||
expect(User::query()->where('email', 'unanswered@signup.test')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('refuses a customer type that is neither of the two answers', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Sneaky',
|
||||
'email' => 'sneaky@signup.test',
|
||||
'customer_type' => 'charity',
|
||||
'password' => 'password1234',
|
||||
'password_confirmation' => 'password1234',
|
||||
])->assertSessionHasErrors('customer_type');
|
||||
|
||||
expect(Customer::query()->where('email', 'sneaky@signup.test')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('refuses to register with an existing customer email (account-claim guard)', function () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\BookAddon;
|
||||
use App\Livewire\Billing;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionAddon;
|
||||
use App\Models\User;
|
||||
use App\Services\Stripe\FakeStripeClient;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Cancelling a booked module from the portal.
|
||||
*
|
||||
* The owner's rule was already true of the action — "die Addons können monatlich
|
||||
* gekündigt werden", BookAddon::cancelAtPeriodEnd() — and false of the product:
|
||||
* the method had no caller anywhere in the interface, so a customer could book a
|
||||
* recurring charge in two clicks and had no way at all to stop it.
|
||||
*
|
||||
* Three things have to hold at once, and they pull against each other:
|
||||
* the module keeps running to the end of the term already paid for; Stripe stops
|
||||
* billing it from the NEXT cycle; and until the term ends the customer can change
|
||||
* their mind without being charged for the privilege.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-03-10 09:00:00');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
/** A Stripe that answers, with the catalogue mirrored into it. */
|
||||
function cancellableStripe(): FakeStripeClient
|
||||
{
|
||||
$fake = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
app(Kernel::class)->call('stripe:sync-catalogue');
|
||||
|
||||
return $fake;
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-in customer on a contract with one module booked.
|
||||
*
|
||||
* @return array{0: User, 1: Subscription, 2: SubscriptionAddon}
|
||||
*/
|
||||
function contractWithModule(string $key = 'priority_support'): array
|
||||
{
|
||||
$customer = Customer::factory()->consumer()->create(['email' => 'modul@example.test']);
|
||||
$user = User::factory()->create(['email' => $customer->email]);
|
||||
$customer->update(['user_id' => $user->id]);
|
||||
|
||||
$contract = Subscription::factory()->plan('team')->create([
|
||||
'customer_id' => $customer->id,
|
||||
'stripe_subscription_id' => 'sub_module',
|
||||
'stripe_item_id' => 'si_plan',
|
||||
'current_period_start' => Carbon::parse('2026-03-01 09:00:00'),
|
||||
'current_period_end' => Carbon::parse('2026-04-01 09:00:00'),
|
||||
]);
|
||||
|
||||
$instance = Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'plan' => 'team',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$contract->update(['instance_id' => $instance->id]);
|
||||
|
||||
$addon = app(BookAddon::class)($contract->refresh(), $key);
|
||||
|
||||
return [$user, $contract->refresh(), $addon->refresh()];
|
||||
}
|
||||
|
||||
it('cancels a module from the portal, and it runs to the end of the paid term', function () {
|
||||
$stripe = cancellableStripe();
|
||||
[$user, $contract, $addon] = contractWithModule();
|
||||
|
||||
// Booked and billed: Stripe carries an item for it.
|
||||
expect($stripe->itemsOn('sub_module'))->toHaveCount(1)
|
||||
->and($addon->stripe_item_id)->not->toBeNull();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Billing::class)
|
||||
->call('cancelAddon', 'priority_support')
|
||||
->assertDispatched('notify', message: __('billing.addon_cancel_done', [
|
||||
'date' => $contract->current_period_end->local()->isoFormat('LL'),
|
||||
]));
|
||||
|
||||
$addon->refresh();
|
||||
|
||||
// Still running — the customer paid for this term and keeps it.
|
||||
expect($addon->isActive())->toBeTrue()
|
||||
->and($addon->cancelled_at)->toBeNull()
|
||||
->and($addon->endsAtPeriodEnd())->toBeTrue()
|
||||
->and($addon->cancels_at->toDateTimeString())->toBe('2026-04-01 09:00:00');
|
||||
});
|
||||
|
||||
it('takes the module off the next cycle invoice without raising a credit', function () {
|
||||
$stripe = cancellableStripe();
|
||||
[$user] = contractWithModule();
|
||||
|
||||
Livewire::actingAs($user)->test(Billing::class)->call('cancelAddon', 'priority_support');
|
||||
|
||||
// Stripe is what raises the cycle invoice, and it no longer has an item for
|
||||
// this module — so the next one carries no such line. That is the mechanism,
|
||||
// and it is why the item comes off NOW rather than at the boundary: waiting
|
||||
// would be a race against Stripe generating that very invoice.
|
||||
expect($stripe->itemsOn('sub_module'))->toBeEmpty();
|
||||
|
||||
$removal = collect($stripe->itemCalls)->last();
|
||||
|
||||
// No credit for the term in progress: it was paid in advance and the
|
||||
// customer keeps the module through it, so nothing is settled.
|
||||
expect($removal['call'])->toBe('remove')
|
||||
->and($removal['proration'])->toBe(StripeClient::PRORATE_NONE);
|
||||
});
|
||||
|
||||
it('lets the customer take the cancellation back while the module is still running', function () {
|
||||
$stripe = cancellableStripe();
|
||||
[$user] = contractWithModule();
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Billing::class);
|
||||
$component->call('cancelAddon', 'priority_support');
|
||||
|
||||
$component->call('resumeAddon', 'priority_support')
|
||||
->assertDispatched('notify', message: __('billing.addon_resumed'));
|
||||
|
||||
$addon = SubscriptionAddon::query()->where('addon_key', 'priority_support')->firstOrFail();
|
||||
|
||||
expect($addon->cancels_at)->toBeNull()
|
||||
->and($addon->isActive())->toBeTrue()
|
||||
// Billed again from the next cycle: the item is back on the
|
||||
// subscription.
|
||||
->and($stripe->itemsOn('sub_module'))->toHaveCount(1);
|
||||
|
||||
// And not charged for it. The term was already paid for, so putting the
|
||||
// item back must settle nothing — `always_invoice` here would bill the
|
||||
// customer a second time for days they have already bought.
|
||||
$restore = collect($stripe->itemCalls)->last();
|
||||
|
||||
expect($restore['call'])->toBe('add')
|
||||
->and($restore['proration'])->toBe(StripeClient::PRORATE_NONE);
|
||||
});
|
||||
|
||||
it('shows the date it runs until, and the way back, on the card', function () {
|
||||
cancellableStripe();
|
||||
[$user, $contract] = contractWithModule();
|
||||
|
||||
Livewire::actingAs($user)->test(Billing::class)
|
||||
->assertSee(__('billing.addon_cancel_cta'))
|
||||
->call('cancelAddon', 'priority_support')
|
||||
->assertSee(__('billing.addon_runs_until', [
|
||||
'date' => $contract->current_period_end->local()->isoFormat('LL'),
|
||||
]))
|
||||
->assertSee(__('billing.addon_resume_cta'))
|
||||
// The card that says "gekündigt" must not also offer to cancel it again.
|
||||
->assertDontSee(__('billing.addon_cancel_cta'));
|
||||
});
|
||||
|
||||
it('will not take a cancellation back once the module has actually ended', function () {
|
||||
cancellableStripe();
|
||||
[$user, , $addon] = contractWithModule();
|
||||
|
||||
Livewire::actingAs($user)->test(Billing::class)->call('cancelAddon', 'priority_support');
|
||||
|
||||
// The appointment is kept by the scheduled command, exactly as it is in
|
||||
// production — not by a hand-written update that would prove nothing.
|
||||
Carbon::setTestNow('2026-04-01 09:00:01');
|
||||
app(Kernel::class)->call('clupilot:end-cancelled-addons');
|
||||
|
||||
expect($addon->refresh()->cancelled_at)->not->toBeNull();
|
||||
|
||||
Livewire::actingAs($user)->test(Billing::class)
|
||||
->call('resumeAddon', 'priority_support')
|
||||
->assertDispatched('notify', message: __('billing.addon_resume_none'));
|
||||
|
||||
expect($addon->refresh()->cancels_at)->not->toBeNull()
|
||||
->and($addon->isActive())->toBeFalse();
|
||||
});
|
||||
|
||||
it('never cancels a module that belongs to somebody else', function () {
|
||||
cancellableStripe();
|
||||
[, , $addon] = contractWithModule();
|
||||
|
||||
// A second customer who can post to /livewire/update with any key at all.
|
||||
$stranger = Customer::factory()->consumer()->create(['email' => 'fremd@example.test']);
|
||||
$strangerUser = User::factory()->create(['email' => $stranger->email]);
|
||||
$stranger->update(['user_id' => $strangerUser->id]);
|
||||
|
||||
Livewire::actingAs($strangerUser)
|
||||
->test(Billing::class)
|
||||
->call('cancelAddon', 'priority_support')
|
||||
->assertDispatched('notify', message: __('billing.addon_cancel_none'));
|
||||
|
||||
expect($addon->refresh()->cancels_at)->toBeNull()
|
||||
->and($addon->isActive())->toBeTrue();
|
||||
});
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Settings;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\TaxTreatment;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Consumer or business, recorded rather than guessed.
|
||||
*
|
||||
* The flag did not exist and `vat_id` was being asked to stand in for it, which
|
||||
* it cannot: a business without a VAT number is an ordinary small business, and
|
||||
* a consumer with one does not exist. Three separate things turn on the answer —
|
||||
* the fourteen-day withdrawal right, the wording of the invoice, and reverse
|
||||
* charge — and only the last of them was ever answered at all.
|
||||
*/
|
||||
it('treats a customer nobody has asked as a consumer, and says so out loud', function () {
|
||||
$customer = Customer::factory()->create();
|
||||
|
||||
expect($customer->customer_type)->toBeNull()
|
||||
->and($customer->hasRecordedType())->toBeFalse()
|
||||
// The protective reading. Unknown is never business.
|
||||
->and($customer->isConsumer())->toBeTrue()
|
||||
->and($customer->isBusiness())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not let a recorded consumer zero their own VAT with a verified number', function () {
|
||||
// Before the recorded type existed this was reverse charge: a verified
|
||||
// number was the whole test, so anybody who got one verified stopped being
|
||||
// charged VAT. Reverse charge is a business-to-business rule.
|
||||
$customer = Customer::factory()->consumer()->create([
|
||||
'vat_id' => 'DE811907980',
|
||||
'vat_id_verified_at' => now(),
|
||||
'vat_id_verified_value' => 'DE811907980',
|
||||
]);
|
||||
|
||||
$treatment = TaxTreatment::for($customer);
|
||||
|
||||
expect($treatment->reverseCharge)->toBeFalse()
|
||||
->and($treatment->rate)->toBe(0.2);
|
||||
});
|
||||
|
||||
it('still applies reverse charge to a recorded business in another member state', function () {
|
||||
$customer = Customer::factory()->business()->create([
|
||||
'vat_id' => 'DE811907980',
|
||||
'vat_id_verified_at' => now(),
|
||||
'vat_id_verified_value' => 'DE811907980',
|
||||
]);
|
||||
|
||||
expect(TaxTreatment::for($customer)->reverseCharge)->toBeTrue();
|
||||
});
|
||||
|
||||
it('changes nothing for a customer whose type has never been recorded', function () {
|
||||
// Deliberately NOT the protective default used for the withdrawal right.
|
||||
// Flipping every unasked business onto the domestic rate would put our
|
||||
// invoices in disagreement with contracts that are already running, and a
|
||||
// rate is not a right — see the note on TaxTreatment.
|
||||
$customer = Customer::factory()->create([
|
||||
'vat_id' => 'DE811907980',
|
||||
'vat_id_verified_at' => now(),
|
||||
'vat_id_verified_value' => 'DE811907980',
|
||||
]);
|
||||
|
||||
expect($customer->hasRecordedType())->toBeFalse()
|
||||
->and(TaxTreatment::for($customer)->reverseCharge)->toBeTrue();
|
||||
});
|
||||
|
||||
it('lets a customer record the answer in the portal', function () {
|
||||
$customer = Customer::factory()->create(['name' => 'Frau Berger', 'email' => 'typ@example.test']);
|
||||
$user = User::factory()->create(['email' => $customer->email]);
|
||||
$customer->update(['user_id' => $user->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Settings::class)
|
||||
->set('companyName', 'Frau Berger')
|
||||
->set('customerType', Customer::TYPE_BUSINESS)
|
||||
->call('saveProfile');
|
||||
|
||||
expect($customer->refresh()->customer_type)->toBe(Customer::TYPE_BUSINESS);
|
||||
});
|
||||
|
||||
it('never turns a recorded answer back into an unknown', function () {
|
||||
$customer = Customer::factory()->consumer()->create(['name' => 'Herr Weiss', 'email' => 'bleibt@example.test']);
|
||||
$user = User::factory()->create(['email' => $customer->email]);
|
||||
$customer->update(['user_id' => $user->id]);
|
||||
|
||||
// Somebody saves the company block without touching this field. That must
|
||||
// not quietly demote a recorded consumer to "nobody asked" — and with it
|
||||
// the evidence that they were told about their withdrawal right.
|
||||
Livewire::actingAs($user)
|
||||
->test(Settings::class)
|
||||
->set('companyName', 'Herr Weiss')
|
||||
->set('customerType', '')
|
||||
->call('saveProfile');
|
||||
|
||||
expect($customer->refresh()->customer_type)->toBe(Customer::TYPE_CONSUMER);
|
||||
});
|
||||
|
||||
it('refuses a customer type that is neither answer', function () {
|
||||
$customer = Customer::factory()->consumer()->create(['name' => 'Herr Weiss', 'email' => 'ungueltig@example.test']);
|
||||
$user = User::factory()->create(['email' => $customer->email]);
|
||||
$customer->update(['user_id' => $user->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Settings::class)
|
||||
->set('companyName', 'Herr Weiss')
|
||||
->set('customerType', 'charity')
|
||||
->call('saveProfile')
|
||||
->assertHasErrors('customerType');
|
||||
|
||||
expect($customer->refresh()->customer_type)->toBe(Customer::TYPE_CONSUMER);
|
||||
});
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\WithdrawContract;
|
||||
use App\Livewire\Admin\RecordWithdrawal;
|
||||
use App\Livewire\Settings;
|
||||
use App\Models\Customer;
|
||||
use App\Models\DnsRecord;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceSeries;
|
||||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionRecord;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\IssueInvoice;
|
||||
use App\Services\Billing\WithdrawalRight;
|
||||
use App\Services\Stripe\FakeStripeClient;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use App\Services\Traefik\FakeTraefikWriter;
|
||||
use App\Services\Traefik\TraefikWriter;
|
||||
use App\Support\CompanyProfile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The fourteen-day right of withdrawal — who has it, what it costs, and what it
|
||||
* leaves behind.
|
||||
*
|
||||
* The concrete example these tests are built on, once, so the arithmetic can be
|
||||
* checked by hand rather than recomputed from the implementation:
|
||||
*
|
||||
* contract concluded 1 March 2026, 09:00
|
||||
* term 1 March → 1 April = 31 days
|
||||
* price 49,00 € net, 20 % VAT → 58,80 € gross, invoiced and paid
|
||||
* withdrawal 8 March 2026, 09:00 — 7 days of service delivered
|
||||
*
|
||||
* owed net = round(4900 × 7 / 31) = 1106 (11,06 €)
|
||||
* owed gross = 1106 + round(1106 × 0,20) = 1327 (13,27 €)
|
||||
* refund = 5880 − 1327 = 4553 (45,53 €)
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-03-01 09:00:00');
|
||||
|
||||
// A withdrawal takes an address down, and taking one down is SSH and DNS.
|
||||
// Without the fakes bound the teardown dials a random public IP on port 22
|
||||
// and sits there — no test in this suite may reach the network.
|
||||
fakeServices();
|
||||
|
||||
CompanyProfile::put([
|
||||
'name' => 'CluPilot Cloud e.U.',
|
||||
'address' => 'Dreherstraße 66/1/8',
|
||||
'postcode' => '1110',
|
||||
'city' => 'Wien',
|
||||
'vat_id' => 'ATU00000000',
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
/**
|
||||
* A paid contract, invoiced, with a running machine behind it.
|
||||
*
|
||||
* @return array{0: Subscription, 1: Invoice, 2: Instance}
|
||||
*/
|
||||
function withdrawableContract(array $customerState = [], bool $consented = true): array
|
||||
{
|
||||
$customer = Customer::factory()->create($customerState + [
|
||||
'name' => 'Frau Berger',
|
||||
'email' => 'berger@example.test',
|
||||
]);
|
||||
|
||||
$order = Order::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'plan' => 'team',
|
||||
// What the payment provider took, and what the document is written from.
|
||||
'amount_cents' => 4900,
|
||||
'currency' => 'EUR',
|
||||
'status' => 'paid',
|
||||
'stripe_event_id' => 'cs_withdraw',
|
||||
'stripe_payment_intent_id' => 'pi_withdraw',
|
||||
]);
|
||||
|
||||
$factory = Subscription::factory();
|
||||
|
||||
if ($consented) {
|
||||
$factory = $factory->startedImmediately();
|
||||
}
|
||||
|
||||
$contract = $factory->create([
|
||||
'customer_id' => $customer->id,
|
||||
'order_id' => $order->id,
|
||||
'price_cents' => 4900,
|
||||
'currency' => 'EUR',
|
||||
'started_at' => now(),
|
||||
'current_period_start' => now(),
|
||||
'current_period_end' => now()->addMonth(),
|
||||
'withdrawal_ends_at' => now()->addDays(WithdrawalRight::WINDOW_DAYS),
|
||||
]);
|
||||
|
||||
$host = Host::factory()->create();
|
||||
|
||||
$instance = Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'order_id' => $order->id,
|
||||
'host_id' => $host->id,
|
||||
'plan' => 'team',
|
||||
'status' => 'active',
|
||||
'route_written' => true,
|
||||
]);
|
||||
|
||||
$contract->update(['instance_id' => $instance->id]);
|
||||
|
||||
// The router and the record that would leak if a withdrawal left them
|
||||
// standing — the whole point of EndInstanceService.
|
||||
app(TraefikWriter::class)->write(
|
||||
(string) ($host->wg_ip ?? $host->public_ip),
|
||||
(string) $instance->subdomain,
|
||||
[$instance->subdomain.'.clupilot.cloud'],
|
||||
'http://10.0.0.2:80',
|
||||
);
|
||||
|
||||
DnsRecord::query()->create([
|
||||
'instance_id' => $instance->id,
|
||||
'provider' => 'hetzner',
|
||||
'record_id' => 'rec_withdraw',
|
||||
'fqdn' => $instance->subdomain.'.clupilot.cloud',
|
||||
'type' => 'A',
|
||||
'value' => '203.0.113.9',
|
||||
]);
|
||||
|
||||
$invoice = app(IssueInvoice::class)->forOrders($customer, collect([$order]));
|
||||
|
||||
return [$contract->refresh(), $invoice, $instance->refresh()];
|
||||
}
|
||||
|
||||
/** A Stripe that answers, so the refund has somewhere to go. */
|
||||
function withdrawalStripe(): FakeStripeClient
|
||||
{
|
||||
$fake = new FakeStripeClient;
|
||||
app()->instance(StripeClient::class, $fake);
|
||||
|
||||
return $fake;
|
||||
}
|
||||
|
||||
it('lets a consumer withdraw inside fourteen days', function () {
|
||||
$stripe = withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
app(WithdrawContract::class)($contract);
|
||||
|
||||
expect($contract->refresh()->withdrawn_at)->not->toBeNull()
|
||||
->and($contract->withdrawal_channel)->toBe(WithdrawContract::CHANNEL_PORTAL)
|
||||
->and($contract->withdrawal_refund_error)->toBeNull()
|
||||
->and($stripe->refunds)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('refuses a business customer, at the action and at the portal alike', function () {
|
||||
withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_BUSINESS]);
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
// The door the console and any future caller comes through.
|
||||
expect(fn () => app(WithdrawContract::class)($contract))
|
||||
->toThrow(RuntimeException::class, __('withdrawal.refusal_business'));
|
||||
|
||||
// And the door a browser comes through. A Livewire action is reachable by
|
||||
// anybody who can POST to /livewire/update, so hiding the card proves
|
||||
// nothing — this calls the method directly, exactly as that request would.
|
||||
$user = User::factory()->create(['email' => $contract->customer->email]);
|
||||
$contract->customer->update(['user_id' => $user->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Settings::class)
|
||||
->call('withdraw')
|
||||
->assertDispatched('notify', message: __('withdrawal.refusal_business'));
|
||||
|
||||
expect($contract->refresh()->withdrawn_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('treats a customer whose type was never recorded as a consumer', function () {
|
||||
$stripe = withdrawalStripe();
|
||||
|
||||
// No `customer_type` at all: the state every customer created from a Stripe
|
||||
// event arrives in. NULL is not "business" and must never be read as one.
|
||||
[$contract] = withdrawableContract();
|
||||
|
||||
expect($contract->customer->customer_type)->toBeNull()
|
||||
->and($contract->customer->hasRecordedType())->toBeFalse();
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
app(WithdrawContract::class)($contract);
|
||||
|
||||
expect($contract->refresh()->withdrawn_at)->not->toBeNull()
|
||||
->and($stripe->refunds)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('refuses a withdrawal once the fourteen days are up', function () {
|
||||
$stripe = withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
// One second past the deadline. The window closed at 15 March 09:00.
|
||||
Carbon::setTestNow('2026-03-15 09:00:01');
|
||||
|
||||
expect(WithdrawalRight::for($contract->refresh())->open)->toBeFalse();
|
||||
|
||||
expect(fn () => app(WithdrawContract::class)($contract))
|
||||
->toThrow(RuntimeException::class, __('withdrawal.refusal_expired'));
|
||||
|
||||
expect($contract->refresh()->withdrawn_at)->toBeNull()
|
||||
->and($stripe->refunds)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('refunds the amount paid less the pro-rata value of the service delivered', function () {
|
||||
$stripe = withdrawalStripe();
|
||||
[$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
// The document the customer actually has: 49,00 € net, 20 % VAT.
|
||||
expect($invoice->net_cents)->toBe(4900)
|
||||
->and($invoice->tax_cents)->toBe(980)
|
||||
->and($invoice->gross_cents)->toBe(5880);
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
expect(WithdrawalRight::deliveredDays($contract->refresh()))->toBe(7)
|
||||
->and(WithdrawalRight::termDays($contract))->toBe(31)
|
||||
->and(WithdrawalRight::owedNetCents($contract))->toBe(1106);
|
||||
|
||||
app(WithdrawContract::class)($contract);
|
||||
|
||||
// The corrected document states exactly what is kept…
|
||||
$remainder = Invoice::query()
|
||||
->where('customer_id', $contract->customer_id)
|
||||
->whereNull('cancels_invoice_id')
|
||||
->where('id', '>', $invoice->id)
|
||||
->firstOrFail();
|
||||
|
||||
expect($remainder->net_cents)->toBe(1106)
|
||||
->and($remainder->tax_cents)->toBe(221)
|
||||
->and($remainder->gross_cents)->toBe(1327);
|
||||
|
||||
// …and the money is the difference between the two documents, to the cent.
|
||||
expect($stripe->refunds)->toHaveCount(1)
|
||||
->and($stripe->refunds[0]['amount'])->toBe(4553)
|
||||
->and($stripe->refunds[0]['payment'])->toBe('pi_withdraw')
|
||||
->and($contract->refresh()->withdrawal_refund_cents)->toBe(4553);
|
||||
|
||||
expect(5880 - 1327)->toBe(4553);
|
||||
});
|
||||
|
||||
it('refunds everything where no express request to start at once was recorded', function () {
|
||||
$stripe = withdrawalStripe();
|
||||
|
||||
// FAGG §16 lets us keep the pro-rata share only where the consumer asked
|
||||
// for the service to begin inside the window AND was told what that would
|
||||
// cost. Without that on record they owe nothing.
|
||||
[$contract, $invoice] = withdrawableContract(
|
||||
['customer_type' => Customer::TYPE_CONSUMER],
|
||||
consented: false,
|
||||
);
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
expect(WithdrawalRight::owedNetCents($contract->refresh()))->toBe(0);
|
||||
|
||||
app(WithdrawContract::class)($contract);
|
||||
|
||||
expect($stripe->refunds[0]['amount'])->toBe($invoice->gross_cents)
|
||||
->and($stripe->refunds[0]['amount'])->toBe(5880);
|
||||
});
|
||||
|
||||
it('ends the service through the existing machinery, and the instance stops being served', function () {
|
||||
withdrawalStripe();
|
||||
[$contract, , $instance] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
/** @var FakeTraefikWriter $traefik */
|
||||
$traefik = app(TraefikWriter::class);
|
||||
|
||||
expect($traefik->routes)->toHaveKey($instance->subdomain);
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
app(WithdrawContract::class)($contract);
|
||||
|
||||
$instance->refresh();
|
||||
|
||||
// Marked ended by EndInstanceService, through `cancellation_scheduled` and
|
||||
// a service end of NOW — a withdrawal leaves no paid-up term to run out.
|
||||
expect($instance->status)->toBe('ended')
|
||||
->and($instance->route_written)->toBeFalse()
|
||||
->and($instance->service_ends_at?->toDateTimeString())->toBe('2026-03-08 09:00:00');
|
||||
|
||||
// The address really is gone: no router, no DNS record.
|
||||
expect($traefik->routes)->not->toHaveKey($instance->subdomain)
|
||||
->and(DnsRecord::query()->where('instance_id', $instance->id)->exists())->toBeFalse();
|
||||
|
||||
expect($contract->refresh()->status)->toBe('cancelled');
|
||||
});
|
||||
|
||||
it('cancels the invoice with its own document and never reuses a number', function () {
|
||||
withdrawalStripe();
|
||||
[$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
$originalNumber = $invoice->number;
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
app(WithdrawContract::class)($contract);
|
||||
|
||||
// The original is untouched. Not edited, not deleted, same number.
|
||||
$invoice->refresh();
|
||||
expect($invoice->exists)->toBeTrue()
|
||||
->and($invoice->number)->toBe($originalNumber)
|
||||
->and($invoice->gross_cents)->toBe(5880);
|
||||
|
||||
// A cancellation in its own series, pointing at what it takes back and
|
||||
// stating the same figures with the opposite sign.
|
||||
$storno = Invoice::query()->where('cancels_invoice_id', $invoice->id)->firstOrFail();
|
||||
$series = InvoiceSeries::query()->where('kind', 'cancellation')->firstOrFail();
|
||||
|
||||
expect($storno->invoice_series_id)->toBe($series->id)
|
||||
->and($storno->number)->toStartWith($series->prefix)
|
||||
->and($storno->net_cents)->toBe(-4900)
|
||||
->and($storno->tax_cents)->toBe(-980)
|
||||
->and($storno->gross_cents)->toBe(-5880)
|
||||
->and($storno->snapshot['meta']['title'])->toBe(__('invoice.cancellation_title'));
|
||||
|
||||
// Every number issued is distinct, and each series only ever went forwards.
|
||||
$numbers = Invoice::query()->pluck('number');
|
||||
expect($numbers->unique()->count())->toBe($numbers->count());
|
||||
|
||||
// A second Storno of the same invoice would take the same money back twice
|
||||
// on paper, so it is refused outright.
|
||||
expect(fn () => app(IssueInvoice::class)->cancelling($invoice))
|
||||
->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
it('records the withdrawal in the proof register', function () {
|
||||
withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
app(WithdrawContract::class)($contract);
|
||||
|
||||
$entry = SubscriptionRecord::query()
|
||||
->where('subscription_id', $contract->id)
|
||||
->where('event', SubscriptionRecord::EVENT_WITHDRAWAL)
|
||||
->firstOrFail();
|
||||
|
||||
// Revenue leaving, with what actually went back and the days it was worked
|
||||
// out from beside it.
|
||||
expect($entry->gross_cents)->toBe(-4553)
|
||||
->and($entry->snapshot['withdrawal']['delivered_days'])->toBe(7)
|
||||
->and($entry->snapshot['withdrawal']['term_days'])->toBe(31)
|
||||
->and($entry->snapshot['withdrawal']['owed_net_cents'])->toBe(1106)
|
||||
->and($entry->snapshot['withdrawal']['refunded_gross_cents'])->toBe(4553);
|
||||
});
|
||||
|
||||
it('withdraws once however many times it is asked', function () {
|
||||
$stripe = withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
app(WithdrawContract::class)($contract);
|
||||
|
||||
expect(fn () => app(WithdrawContract::class)($contract->refresh()))
|
||||
->toThrow(RuntimeException::class, __('withdrawal.refusal_already'));
|
||||
|
||||
// One refund, one cancellation document. A second of either would be money
|
||||
// and a number we can never get back.
|
||||
expect($stripe->refunds)->toHaveCount(1)
|
||||
->and(Invoice::query()->whereNotNull('cancels_invoice_id')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('lets a consumer withdraw from the portal', function () {
|
||||
$stripe = withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
$user = User::factory()->create(['email' => $contract->customer->email]);
|
||||
$contract->customer->update(['user_id' => $user->id]);
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Settings::class)
|
||||
->call('withdraw')
|
||||
->assertDispatched('notify', message: __('withdrawal.done'));
|
||||
|
||||
expect($contract->refresh()->withdrawn_at)->not->toBeNull()
|
||||
->and($contract->withdrawal_channel)->toBe(WithdrawContract::CHANNEL_PORTAL)
|
||||
->and($stripe->refunds)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('lets an operator record a withdrawal that came in by telephone', function () {
|
||||
$stripe = withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
$support = operator('Support');
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
Livewire::actingAs($support, 'operator')
|
||||
->test(RecordWithdrawal::class, ['uuid' => $contract->customer->uuid])
|
||||
->call('record')
|
||||
->assertDispatched('notify');
|
||||
|
||||
$contract->refresh();
|
||||
|
||||
// The same action, the same arithmetic — with the channel and the name of
|
||||
// whoever took the declaration on it.
|
||||
expect($contract->withdrawn_at)->not->toBeNull()
|
||||
->and($contract->withdrawal_channel)->toBe(WithdrawContract::CHANNEL_OPERATOR)
|
||||
->and($contract->withdrawal_recorded_by)->toBe($support->id)
|
||||
->and($stripe->refunds[0]['amount'])->toBe(4553);
|
||||
});
|
||||
|
||||
it('will not let an operator record a withdrawal a business customer does not have', function () {
|
||||
$stripe = withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_BUSINESS]);
|
||||
|
||||
Carbon::setTestNow('2026-03-08 09:00:00');
|
||||
|
||||
Livewire::actingAs(operator('Support'), 'operator')
|
||||
->test(RecordWithdrawal::class, ['uuid' => $contract->customer->uuid])
|
||||
->call('record')
|
||||
->assertSet('refusal', __('withdrawal.refusal_business'));
|
||||
|
||||
expect($contract->refresh()->withdrawn_at)->toBeNull()
|
||||
->and($stripe->refunds)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('keeps the console withdrawal behind a permission', function () {
|
||||
withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
// A modal is reachable without the route middleware of the page that opens
|
||||
// it, so the check lives on the component itself.
|
||||
Livewire::actingAs(operator('Read-only'), 'operator')
|
||||
->test(RecordWithdrawal::class, ['uuid' => $contract->customer->uuid])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('shows the card to a consumer and never to a business', function () {
|
||||
withdrawalStripe();
|
||||
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
|
||||
$user = User::factory()->create(['email' => $contract->customer->email]);
|
||||
$contract->customer->update(['user_id' => $user->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(Settings::class)->assertSee(__('withdrawal.cta'));
|
||||
|
||||
$contract->customer->update(['customer_type' => Customer::TYPE_BUSINESS]);
|
||||
|
||||
Livewire::actingAs($user)->test(Settings::class)->assertDontSee(__('withdrawal.cta'));
|
||||
});
|
||||
Loading…
Reference in New Issue