Charge the price the website shows, and hand a withdrawal back in full
tests / pest (push) Failing after 9m21s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

Stripe was charging the catalogue's NET figure while the document added the
domestic rate on top: a customer quoted 214,80 paid 179,00 and was then invoiced
for VAT nobody had collected. Under para 11(12) UStG that VAT is owed to the tax
office whether or not it ever arrived, so every document issued created a
liability against revenue that did not contain it.

The Stripe Price now carries the GROSS figure. price_cents stays net - it is
frozen onto every contract and PlanChange prorates against it, so redefining it
would corrupt every pro-rata sum ever computed. Only the amount at the till
moved, and it is formed by the one call TaxTreatment already answers for the
price sheet and for the invoice, so displayed, charged and invoiced cannot drift
apart.

Stripe's automatic_tax is deliberately not used. TaxTreatment is the single tax
authority here, and a second rate computed by Stripe would take 19 % from a
German consumer while our document said 20 %.

A Stripe Price cannot be edited, so a changed figure means a new Price and the
old one archived - which stops it being SOLD and leaves every subscription
already on it billing the old amount for ever. stripe:reprice-subscriptions is
the second half, and a command of its own because it touches live money.

A withdrawing consumer is refunded in full, as the owner decided. That is more
generous than FAGG para 16 requires, and it makes the express-request consent the
statute hinges on irrelevant - so the gate is gone rather than left looking
load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-29 22:42:02 +02:00
parent 33566cd404
commit f02e86769b
32 changed files with 1251 additions and 394 deletions

View File

@ -208,7 +208,18 @@ class IssueStripeInvoice
return null;
}
return app(IssueInvoice::class)->forBilledPeriod($subscription, $from, $to, $stripeInvoiceId);
// The total Stripe took, where it said so. A document has to add up to
// the money, and the contract's own price at today's rate is only the
// next best answer — it would miss a cycle billed before a rate change.
$total = $stripeInvoice['total'] ?? $stripeInvoice['amount_paid'] ?? null;
return app(IssueInvoice::class)->forBilledPeriod(
$subscription,
$from,
$to,
$stripeInvoiceId,
chargedCents: is_numeric($total) ? (int) $total : null,
);
}
/**
@ -237,11 +248,18 @@ class IssueStripeInvoice
/**
* Say out loud when the document and the money do not agree.
*
* Our catalogue is pushed to Stripe as NET, so the line amounts that come
* back are net and the document adds VAT on top: Stripe's total should be
* our gross. It may legitimately be our NET instead, on an account that does
* not collect tax on our behalf. A figure that is neither is one nobody can
* explain, and it is exactly what an operator needs to be told about.
* Our catalogue is pushed to Stripe as GROSS, so the line amounts that come
* back are what the customer paid and the document divides that sum rather
* than adding a rate to it. Stripe's total and our gross must therefore be
* the SAME NUMBER, to the cent, for every customer including a
* reverse-charge one, whose whole charged amount is stated as net at 0 %.
*
* This used to accept our net as an alternative, on the grounds that an
* account might not collect tax for us. That tolerance is what let the
* defect run: 179,00 taken against a document for 214,80 matched the
* "net" branch and was never reported. Anything but equality is now worth a
* warning, and the likeliest cause is a Price that stripe:sync-catalogue has
* replaced with nobody having run stripe:reprice-subscriptions.
*
* Never an exception. The document is issued and the payment is recorded;
* raising here would only cost Stripe its 2xx and change nothing.
@ -258,11 +276,11 @@ class IssueStripeInvoice
$total = (int) $total;
if ($total === $invoice->gross_cents || $total === $invoice->net_cents) {
if ($total === $invoice->gross_cents) {
return;
}
Log::warning('An invoice was issued for a sum that is neither the net nor the gross Stripe reported.', [
Log::warning('An invoice was issued for a sum Stripe did not charge.', [
'invoice' => $invoice->number,
'stripe_invoice' => $invoice->stripe_invoice_id,
'stripe_total' => $total,

View File

@ -86,9 +86,10 @@ class OpenSubscription
// 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.
// Carried from the purchase, where it was given. Nothing reads
// it to decide anything any more — a withdrawing consumer gets
// the whole amount back — so this is a record of what was agreed
// at the checkout and not a gate. See Order's own cast.
'immediate_start_consent_at' => $order->immediate_start_consent_at,
'status' => 'active',
],

View File

@ -78,11 +78,11 @@ class StartCustomerProvisioning
// 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.
// Stamped only where the consumer actually gave it. Nothing
// turns on it any more: a withdrawal refunds the whole
// amount whether it is there or not, because the owner has
// decided not to charge for the days the cloud ran. Kept as
// a record of what the customer was shown and ticked.
'immediate_start_consent_at' => ($event['immediate_start_consent'] ?? false) === true ? now() : null,
'status' => 'paid',
]);

View File

@ -106,6 +106,102 @@ class SyncStripeAddonItems
}
}
/**
* Move every module item this contract carries onto the Price that charges
* today's figure for it.
*
* The catalogue moved from pushing NET amounts to Stripe to pushing GROSS
* ones, and a Stripe Price cannot be edited so every running module is on a
* Price that takes too little. __invoke() above will not find them: the item
* exists and the quantity is right, so it has nothing to reconcile. This is
* the deliberate second pass, run by stripe:reprice-subscriptions.
*
* PRORATE_NONE throughout. The customer has already paid for the term they
* are in, at whatever was taken then; charging the difference for days
* already served would be a bill nobody agreed to. From the next cycle
* Stripe simply takes the right amount.
*
* The module's own frozen `price_cents` is untouched, because that is what
* the customer agreed to pay net and it is what every pro-rata sum reads.
*
* @return array<int, array{addon: string, from: ?string, to: ?string}> what moved, for the command's output
*/
public function reprice(Subscription $subscription, bool $dryRun = false): array
{
if ($subscription->stripe_subscription_id === null) {
return [];
}
$moved = [];
foreach ($this->groups($subscription) as $group) {
$billable = $group->filter(
fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null
);
$stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null);
$itemId = $stamped->first()?->stripe_item_id;
$first = $billable->first();
// Nothing Stripe is billing, or nothing it should be: __invoke()
// owns both of those cases and would undo whatever this did.
if ($itemId === null || $first === null || (int) $first->price_cents <= 0) {
continue;
}
$target = $this->prices->liveFor(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
);
$current = $stamped->first()?->stripe_price_id;
if ($target !== null && $target === $current) {
continue;
}
$moved[] = ['addon' => (string) $first->addon_key, 'from' => $current, 'to' => $target];
if ($dryRun) {
continue;
}
$this->guardConfigured();
// Minted here where the sweep left a figure the catalogue no longer
// pre-creates — a grandfathered booking, or a discounted grant.
$target ??= $this->prices->ensure(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
);
if ($target === null) {
// Nothing to bill for it after all — a module given away. Taken
// back out of the report rather than announced as a move.
array_pop($moved);
continue;
}
$moved[count($moved) - 1]['to'] = $target;
$this->stripe->updateSubscriptionPrice(
(string) $subscription->stripe_subscription_id,
$itemId,
$target,
StripeClient::PRORATE_NONE,
);
$this->stamp($group, $itemId, $target);
}
return $moved;
}
/**
* The bookings that matter, grouped into the items they should be.
*

View File

@ -9,7 +9,6 @@ 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;
@ -35,14 +34,15 @@ use Throwable;
* 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
* with its own number never edited, never deleted. 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.
* computed beside it: the whole of what the cancelled document said. FAGG
* §16 would let us keep the pro-rata value of the days the cloud ran, and
* the owner has decided not to a withdrawing consumer gets everything
* back. Two sums that must agree cannot disagree if only one of them is
* ever calculated, and here there is only the one: the Storno's.
* 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
@ -132,55 +132,26 @@ class WithdrawContract
{
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.
// is nothing to cancel, so the refund is worked out from what was
// charged instead. Stated here rather than silently skipped: a
// withdrawal without paperwork is a withdrawal an accountant will
// ask about.
if ($original === null) {
$paid = (int) ($subscription->order?->amount_cents ?? 0);
$owedGross = $owedNet === 0 ? 0 : $this->grossFor($subscription, $owedNet);
return $this->sendBack($subscription, max(0, $paid - $owedGross), $at);
return $this->sendBack($subscription, max(0, $subscription->order?->chargedCents() ?? 0), $at);
}
// Taken back in full, with its own gapless number, pointing at the
// one it cancels. The original keeps its number for ever.
$this->invoices->cancelling($original);
$storno = $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,
);
// The refund IS what the cancellation took back — never a second
// figure computed beside it, which would have to agree with the
// document and one day would not. The Storno mirrors the original,
// so this is the whole amount the customer paid.
return $this->sendBack($subscription, max(0, -(int) $storno->gross_cents), $at);
} catch (Throwable $e) {
$this->parkMoneyFailure($subscription, $e);
@ -334,12 +305,6 @@ class WithdrawContract
->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.
*
@ -377,11 +342,12 @@ class WithdrawContract
at: $at,
extra: ['withdrawal' => [
'channel' => $subscription->withdrawal_channel,
// Nothing is charged for them — the refund is the whole amount —
// but how long the service actually ran is a fact about this
// contract, and the register is where facts about it are kept.
'delivered_days' => WithdrawalRight::deliveredDays($subscription, $at),
'term_days' => WithdrawalRight::termDays($subscription),
'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,

View File

@ -0,0 +1,149 @@
<?php
namespace App\Console\Commands;
use App\Actions\MoveStripeSubscriptionPrice;
use App\Actions\SyncStripeAddonItems;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Move contracts that are still billing on a superseded Stripe Price.
*
* stripe:sync-catalogue mints the Price that charges today's figure and archives
* the one it replaces and that is all it does. Archiving a Price in Stripe
* stops it being SOLD; every subscription already on it goes on being billed at
* the old amount for ever. This is the second half, and it is a command of its
* own because it touches live contracts and money, which is a decision an owner
* makes deliberately rather than a side effect of syncing a price list.
*
* Run it after every stripe:sync-catalogue that reported a change:
*
* php artisan stripe:sync-catalogue --dry-run
* php artisan stripe:sync-catalogue
* php artisan stripe:reprice-subscriptions --dry-run
* php artisan stripe:reprice-subscriptions
*
* **Nothing is prorated.** The customer has already paid for the term they are
* in, at whatever was taken at the time; charging the difference for days already
* served would be a bill nobody agreed to, and Stripe's own default would raise
* exactly that. From the next cycle the right amount is taken.
*
* **Safe to re-run.** Both halves ask whether the contract is already on the
* right Price and do nothing when it is, so a second run is free and a run
* interrupted halfway finishes on the next attempt. The package half never
* throws MoveStripeSubscriptionPrice parks its failures on the contract and
* clupilot:sync-stripe-subscriptions retries them hourly.
*
* The contract's own `price_cents` is not touched by any of this. It is the
* catalogue's NET figure, it is frozen, and PlanChange prorates against it.
*/
class RepriceStripeSubscriptions extends Command
{
protected $signature = 'stripe:reprice-subscriptions
{--dry-run : Show which contracts would move without touching Stripe}
{--limit=500 : How many contracts to look at in one run}';
protected $description = 'Move live contracts onto the Stripe prices the catalogue now sells';
public function handle(
StripeClient $stripe,
MoveStripeSubscriptionPrice $move,
SyncStripeAddonItems $modules,
): int {
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was moved.');
return self::FAILURE;
}
// Active contracts Stripe actually bills. A cancelled one is not billed
// again, and a granted one has no Stripe subscription at all — moving
// either would be housekeeping on something that charges nobody.
$contracts = Subscription::query()
->whereNotNull('stripe_subscription_id')
->where('status', 'active')
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
$packages = 0;
$items = 0;
$failed = 0;
foreach ($contracts as $contract) {
$target = $this->packagePrice($contract);
if ($target !== null && $contract->stripe_price_id !== $target) {
$this->line(sprintf(
' package %s %s → %s',
$contract->uuid,
$contract->stripe_price_id ?? '(unknown)',
$target,
));
$packages++;
// PRORATE_NONE: the term in progress is paid for. See the class
// comment — this must never raise a charge for days already
// served at the old figure.
if (! $dryRun && ! $move($contract, StripeClient::PRORATE_NONE)) {
$failed++;
}
}
foreach ($modules->reprice($contract, $dryRun) as $item) {
$this->line(sprintf(
' module %s %s %s → %s',
$contract->uuid,
$item['addon'],
$item['from'] ?? '(none)',
$item['to'] ?? '(to be created)',
));
$items++;
}
}
$this->newLine();
if ($packages === 0 && $items === 0) {
$this->info('Every live contract is already on the price the catalogue sells.');
return self::SUCCESS;
}
$this->info($dryRun
? "{$packages} package(s) and {$items} module item(s) would move. Run without --dry-run to move them."
: "{$packages} package(s) and {$items} module item(s) moved.");
if ($failed > 0) {
// Parked on the contract by the action and retried hourly by
// clupilot:sync-stripe-subscriptions, so this is a warning and not a
// failure — but somebody watching the output has to be told.
$this->warn("{$failed} package move(s) did not reach Stripe and are parked for the hourly retry.");
}
return self::SUCCESS;
}
/**
* The Stripe Price the catalogue sells this contract's package on now.
*
* By plan VERSION and term, exactly as MoveStripeSubscriptionPrice resolves
* it resolving it by plan name would hand a grandfathered contract today's
* terms. Null where the version was never synced, which is a contract this
* command has nothing to say about.
*/
private function packagePrice(Subscription $subscription): ?string
{
$priceId = PlanPrice::query()
->where('plan_version_id', $subscription->plan_version_id)
->where('term', $subscription->term)
->value('stripe_price_id');
return is_string($priceId) && $priceId !== '' ? $priceId : null;
}
}

View File

@ -5,9 +5,11 @@ namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\StripePlanPrice;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
@ -19,10 +21,28 @@ use Illuminate\Console\Command;
* invoice numbering so it needs to know what it is billing for. It does not
* need to know how big the VM is, and it is not asked.
*
* Idempotent by construction: a row that already carries an id is skipped. That
* matters more here than usual, because a Stripe Price cannot be edited, so a
* second run that minted duplicates would leave two live prices for one plan
* and no way to tell which a customer is on.
* **What it pushes is the GROSS figure.** The catalogue's `amount_cents` is net
* and stays net it is frozen onto every contract and PlanChange prorates
* against it but the amount Stripe takes is that figure at the till, so the
* charge equals the price on the website and equals the total on the document.
* Pushing the net was the defect: a customer quoted 214,80 paid 179,00 and
* was then invoiced 179,00 plus 35,80 VAT that nobody had collected.
*
* Stripe's `automatic_tax` is deliberately NOT used. TaxTreatment is the single
* tax authority here; a second rate computed by Stripe would charge a German
* consumer 19 % while our document said 20 %.
*
* Idempotent by construction, and safe to re-run after a rate change: a row
* whose live Price already charges the right figure is skipped, and one that
* does not gets a NEW Price with the old one archived. That matters more here
* than usual, because a Stripe Price cannot be edited, so a run that minted
* duplicates would leave two live prices for one plan and no way to tell which a
* customer is on.
*
* It does not move anybody. Archiving a Price stops it being SOLD and leaves
* every subscription already on it exactly where it was moving those is
* stripe:reprice-subscriptions, which is a separate decision because it touches
* live contracts.
*
* Only PUBLISHED versions are synced. A draft has promised nothing, and a
* Product for it would be a price list entry for something that may never
@ -84,38 +104,7 @@ class SyncStripeCatalogue extends Command
foreach ($published as $version) {
foreach ($version->prices as $price) {
if ($price->stripe_price_id !== null) {
continue;
}
$this->line(sprintf(
' price %s v%d %s %d %s',
$family->key, $version->version, $price->term, $price->amount_cents, $price->currency,
));
$created++;
if ($dryRun || $productId === null) {
continue;
}
$priceId = $stripe->createPrice(
productId: $productId,
amountCents: $price->amount_cents,
currency: $price->currency,
interval: $price->term === 'yearly' ? 'year' : 'month',
metadata: [
'plan_family' => $family->key,
'plan_version' => (string) $version->version,
'plan_version_id' => (string) $version->id,
'plan_price_id' => (string) $price->id,
],
idempotencyKey: "clupilot-price-{$price->id}",
);
// Written straight through the query builder: stripe_price_id
// is not part of what publication froze, and the model would
// otherwise have to be re-read first.
PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
$created += $this->syncPrice($stripe, $family, $version, $price, $productId, $dryRun);
}
}
}
@ -137,6 +126,103 @@ class SyncStripeCatalogue extends Command
return self::SUCCESS;
}
/**
* The Stripe Price one priced catalogue row is sold on, brought into step.
*
* Three outcomes, and which one happens is decided by the CHARGED figure
* the row's net at today's rate and never by whether an id happens to be
* stored:
*
* - the live Price already charges it, and the row points at that Price:
* nothing to do, which is what makes a second run free;
* - a Price for that figure exists but is archived or is not the one the row
* points at a rate moved and moved back, or a run died between Stripe
* creating the Price and us storing its id: it is brought back rather than
* minted a second time;
* - nothing charges it: a new Price is created, the row is repointed, and
* whatever it was pointing at is archived in Stripe and here.
*
* Archiving stops a Price being offered and leaves every subscription on it
* untouched, which is exactly what is wanted: those are moved deliberately,
* by stripe:reprice-subscriptions.
*
* @return int how many objects this row changed, for the run's own count
*/
private function syncPrice(
StripeClient $stripe,
PlanFamily $family,
PlanVersion $version,
PlanPrice $price,
?string $productId,
bool $dryRun,
): int {
$charged = TaxTreatment::chargedCents((int) $price->amount_cents);
$existing = StripePlanPrice::query()
->where('plan_price_id', $price->id)
->where('charged_cents', $charged)
->first();
if ($existing !== null && $existing->isLive() && $price->stripe_price_id === $existing->stripe_price_id) {
return 0;
}
$this->line(sprintf(
' price %s v%d %s %d %s net → %d %s charged%s',
$family->key, $version->version, $price->term,
$price->amount_cents, $price->currency,
$charged, $price->currency,
$existing === null ? '' : ' (Price already exists)',
));
if ($dryRun || $productId === null) {
return 1;
}
$priceId = $existing?->stripe_price_id ?? $stripe->createPrice(
productId: $productId,
amountCents: $charged,
currency: $price->currency,
interval: $price->term === 'yearly' ? 'year' : 'month',
metadata: [
'plan_family' => $family->key,
'plan_version' => (string) $version->version,
'plan_version_id' => (string) $version->id,
'plan_price_id' => (string) $price->id,
],
// The CHARGED amount is part of the key. Without it a run after a
// rate change would replay the Price minted at the old figure, and
// Stripe would hand back the very object this is replacing.
idempotencyKey: "clupilot-price-{$price->id}-{$charged}",
);
StripePlanPrice::query()->updateOrCreate(
['plan_price_id' => $price->id, 'charged_cents' => $charged],
['stripe_price_id' => $priceId, 'archived_at' => null],
);
// Everything else this row was ever sold on stops being offered — after
// the replacement exists, never before, so a failure in between leaves
// the old Price selling rather than nothing at all.
$superseded = StripePlanPrice::query()
->where('plan_price_id', $price->id)
->where('charged_cents', '!=', $charged)
->whereNull('archived_at')
->get();
foreach ($superseded as $old) {
$stripe->archivePrice((string) $old->stripe_price_id);
$old->update(['archived_at' => now()]);
}
// Written straight through the query builder: stripe_price_id is not
// part of what publication froze, and the model would otherwise have to
// be re-read first.
PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
return 1;
}
/**
* A Product and two Prices monthly and yearly for every module on sale.
*
@ -166,14 +252,20 @@ class SyncStripeCatalogue extends Command
continue;
}
foreach ([Subscription::TERM_MONTHLY => $monthly, Subscription::TERM_YEARLY => $monthly * 12] as $term => $amount) {
$interval = $term === Subscription::TERM_YEARLY ? 'year' : 'month';
if ($prices->find($key, $amount, $currency, $interval) !== null) {
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
// Asked of the CHARGED figure, so a rate change is seen as a
// Price that has to be replaced rather than as one that is
// already there.
if ($prices->liveFor($key, $monthly, $currency, $term) !== null) {
continue;
}
$this->line(sprintf(' module %s %s %d %s', $key, $term, $amount, $currency));
$this->line(sprintf(
' module %s %s %d %s net → %d %s charged',
$key, $term,
AddonPrices::termNetCents($monthly, $term), $currency,
AddonPrices::chargedCents($monthly, $term), $currency,
));
$created++;
if ($dryRun) {

View File

@ -6,6 +6,7 @@ use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\HostCapacity;
use App\Support\CompanyProfile;
use App\Support\ProvisioningSettings;
@ -556,20 +557,23 @@ class LandingController extends Controller
* "179,00".
*/
/**
* A net figure with the domestic VAT put back on.
* A net figure as it is actually charged.
*
* The same rate an invoice would use, read from the same place
* (App\Services\Billing\TaxTreatment does too) a percentage written
* into the marketing page would be the second source that makes the sheet
* and the invoice disagree.
* Handed straight to TaxTreatment rather than worked out here. It is the same
* call the Stripe catalogue makes when it decides what a Price takes and the
* same one an invoice makes when it decides what a document totals to, so the
* figure on this page, the figure on the card statement and the figure on the
* document cannot drift apart. It used to be a multiplication of its own,
* which is a second source of the same number and therefore a second answer
* waiting to happen.
*
* Cross-border reverse charge is deliberately not modelled here: it depends
* on a VERIFIED VAT id, which a visitor to a public page does not have. The
* page quotes the domestic price and the invoice applies the treatment.
* Cross-border reverse charge is deliberately not modelled here, and it makes
* no difference to the price: a reverse-charge business is charged this
* figure like everybody else and their invoice states all of it as net.
*/
private function gross(int $netCents): int
{
return (int) round($netCents * (1 + CompanyProfile::taxRate() / 100));
return TaxTreatment::chargedCents($netCents);
}
private function money(int $cents, string $currency): string

View File

@ -89,11 +89,13 @@ class StripeWebhookController extends Controller
'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.
// fourteen-day withdrawal window. 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.
//
// Nothing decides anything on it now — a withdrawal refunds the
// whole amount either way — so it is carried as a record of what was
// agreed rather than as a gate. See App\Models\Order.
'immediate_start_consent' => ($meta['immediate_start'] ?? null) === '1',
'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created

View File

@ -22,10 +22,9 @@ use RuntimeException;
* 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.
* refund is the whole amount either way. 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
@ -98,7 +97,9 @@ class RecordWithdrawal extends ModalComponent
return view('livewire.admin.record-withdrawal', [
'right' => WithdrawalRight::for($contract),
'owedNetCents' => $contract === null ? 0 : WithdrawalRight::owedNetCents($contract),
// How long the cloud has been running. Nothing is charged for it —
// the refund is the whole amount — but it is the first thing an
// operator is asked on the telephone.
'deliveredDays' => $contract === null ? 0 : WithdrawalRight::deliveredDays($contract),
'termDays' => $contract === null ? 0 : WithdrawalRight::termDays($contract),
]);

View File

@ -35,18 +35,12 @@ class ConfirmWithdraw extends ModalComponent
public function render()
{
$contract = app(CustomDomainAccess::class)->contractOf($this->customer());
$right = WithdrawalRight::for($contract);
$right = WithdrawalRight::for(app(CustomDomainAccess::class)->contractOf($this->customer()));
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),
]);
// No figures to read out any more: the refund is the whole amount, so
// there is one sentence and nothing to work out from the contract. The
// right itself is still resolved here rather than handed in from the
// page, because markup is not evidence.
return view('livewire.confirm-withdraw', ['right' => $right]);
}
}

View File

@ -32,9 +32,14 @@ class Order extends Model implements ProvisioningSubject
'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.
// the withdrawal window.
//
// DEAD as a gate, kept as a record. It used to decide whether a
// withdrawing consumer owed the pro-rata value of the days already
// delivered; the owner has decided they owe nothing and get the whole
// amount back, so nothing reads this to make a decision any more. It
// stays because it is evidence of what the customer was shown and
// agreed to at the checkout.
'immediate_start_consent_at' => 'datetime',
];
}
@ -71,12 +76,39 @@ class Order extends Model implements ProvisioningSubject
/**
* Net is what is stored; gross is what gets charged and how much VAT that
* is depends on the customer, not on a global setting.
*
* Only ever asked of a PENDING cart line, whose `amount_cents` is a net
* catalogue figure. An order that has been through a Stripe checkout already
* holds what was taken; see chargedCents() below, which is what the invoice
* uses and which does not double the tax on such an order.
*/
public function grossCents(): int
{
return $this->taxTreatment()->grossCents($this->amount_cents);
}
/**
* What this order actually cost the customer, tax included.
*
* `amount_cents` means two things, and this is the one place that resolves
* which. An order created from a Stripe checkout carries the session's
* `amount_total` the gross that was taken from the card, and the catalogue
* is pushed to Stripe gross, so it already includes the VAT. An order that
* never went through a checkout (a grant, a line in the portal cart) carries
* the catalogue's NET figure, and what a checkout would have taken for it is
* that figure at the till.
*
* Keyed on `stripe_event_id` for exactly the reason OpenSubscription keys the
* register's charged amount on it, and not on the amount being non-zero: a
* fully discounted checkout charges nothing and is still a checkout.
*/
public function chargedCents(): int
{
return $this->stripe_event_id !== null
? (int) $this->amount_cents
: TaxTreatment::chargedCents((int) $this->amount_cents);
}
public function taxTreatment(): TaxTreatment
{
return TaxTreatment::for($this->customer);

View File

@ -12,6 +12,11 @@ use Illuminate\Database\Eloquent\Model;
* does for packages. See the migration for why a module needs more than one
* Price: a booking is frozen at the price it was booked at, and a Stripe Price
* cannot be edited.
*
* `amount_cents` is what the Price CHARGES, which is gross; `net_cents` is the
* catalogue figure it was formed from, which is what a booking is frozen at. A
* Price superseded by a rate change is archived rather than deleted, because a
* contract may still be billing on it.
*/
class StripeAddonPrice extends Model
{
@ -19,6 +24,16 @@ class StripeAddonPrice extends Model
protected function casts(): array
{
return ['amount_cents' => 'integer'];
return [
'amount_cents' => 'integer',
'net_cents' => 'integer',
'archived_at' => 'datetime',
];
}
/** Is this the Price a new booking of this module would be put on? */
public function isLive(): bool
{
return $this->archived_at === null;
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One Stripe Price a priced catalogue row has had, live or superseded.
*
* `plan_prices.stripe_price_id` points at the one that is on sale; this is the
* history behind it, and it exists because a Stripe Price cannot be edited. When
* the amount a row is charged at changes the move from net to gross, or a
* change to the VAT rate a new Price is minted, this row is archived, and the
* old Price stays live in Stripe for as long as a contract is still billing on
* it. See the migration for why that history has to be readable.
*/
class StripePlanPrice extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'charged_cents' => 'integer',
'archived_at' => 'datetime',
];
}
public function planPrice(): BelongsTo
{
return $this->belongsTo(PlanPrice::class);
}
/** Is this the Price the catalogue is selling on right now? */
public function isLive(): bool
{
return $this->archived_at === null;
}
}

View File

@ -67,6 +67,9 @@ class Subscription extends Model
// happened inside them. See App\Services\Billing\WithdrawalRight,
// which is the one place that decides whether the window is open.
'withdrawal_ends_at' => 'datetime',
// DEAD as a gate, kept as a record — see Order, which carries the
// same column for the same reason. A withdrawing consumer now gets
// the whole amount back whatever this says.
'immediate_start_consent_at' => 'datetime',
'withdrawn_at' => 'datetime',
'withdrawal_refund_cents' => 'integer',

View File

@ -28,11 +28,39 @@ use Illuminate\Database\UniqueConstraintViolationException;
* discounted grant, or a customer grandfathered on a figure the catalogue left
* behind years ago and refusing to bill those would be the same bug in a new
* place. Stripe's idempotency key makes the on-demand path safe to repeat.
*
* **What the Price charges is GROSS.** `amount_cents` on the row is what Stripe
* takes, and it is the catalogue's net figure with the domestic rate on it
* because the figure on the price sheet is the figure charged, for everybody.
* `net_cents` beside it is the catalogue figure it was formed from, which is what
* a booking is frozen at and what identifies the Price when the rate moves. A
* Price at the wrong figure is archived rather than edited: Stripe does not allow
* editing one, and a contract may still be billing on it.
*/
final class AddonPrices
{
public function __construct(private readonly StripeClient $stripe) {}
/**
* The net a whole term of this module costs.
*
* A module is priced per month; a yearly contract bills a year of it at
* once, in step with the package beside it. Twelve months exactly, and
* grossed ONCE afterwards rather than twelve times twelve rounded gross
* months can differ from a rounded gross year by a cent, and the year is the
* figure the customer actually pays.
*/
public static function termNetCents(int $monthlyNetCents, string $term): int
{
return $term === Subscription::TERM_YEARLY ? $monthlyNetCents * 12 : $monthlyNetCents;
}
/** What Stripe is asked to take for a term of this module. */
public static function chargedCents(int $monthlyNetCents, string $term): int
{
return TaxTreatment::chargedCents(self::termNetCents($monthlyNetCents, $term));
}
/**
* The Stripe Price for this module at this money on this term, creating it
* if Stripe has never been told about it.
@ -50,15 +78,24 @@ final class AddonPrices
$interval = $term === Subscription::TERM_YEARLY ? 'year' : 'month';
$currency = strtoupper($currency);
// A module is priced per month; a yearly contract bills a year of it at
// once, in step with the package beside it. Twelve months exactly, so
// the figure the customer was shown times twelve is the figure charged.
$amount = $interval === 'year' ? $unitNetCents * 12 : $unitNetCents;
$netCents = self::termNetCents($unitNetCents, $term);
$amount = TaxTreatment::chargedCents($netCents);
$existing = $this->find($addonKey, $amount, $currency, $interval);
$existing = StripeAddonPrice::query()
->where('addon_key', $addonKey)
->where('amount_cents', $amount)
->where('currency', $currency)
->where('interval', $interval)
->first();
if ($existing !== null) {
return $existing;
// Brought back rather than minted again. A rate that moves and then
// moves back — or a run interrupted between Stripe and us — must not
// leave a second Stripe Price for one figure.
$existing->update(['archived_at' => null, 'net_cents' => $netCents]);
$this->archiveSuperseded($addonKey, $netCents, $currency, $interval, $amount);
return (string) $existing->stripe_price_id;
}
$productId = $this->product($addonKey);
@ -75,16 +112,40 @@ final class AddonPrices
],
// Keyed on what the Price IS, so a crash between Stripe creating it
// and us storing its id gives back the same Price on the next
// attempt rather than a second one at the same money.
// attempt rather than a second one at the same money. The amount is
// the CHARGED one, so the move from net to gross does not replay the
// net Price the old key was minted under.
idempotencyKey: "clupilot-addon-price-{$addonKey}-{$interval}-{$amount}-{$currency}",
);
$this->remember($addonKey, $amount, $currency, $interval, $productId, $priceId);
$this->remember($addonKey, $amount, $netCents, $currency, $interval, $productId, $priceId);
$this->archiveSuperseded($addonKey, $netCents, $currency, $interval, $amount);
return $priceId;
}
/** The Price we already have for this combination, or null. */
/**
* The live Price for a module at a catalogue figure, or null.
*
* Asked with the MONTHLY net the booking is frozen at, because that is what
* a caller has: the charged figure is worked out here, from the one place
* that knows the rate.
*/
public function liveFor(string $addonKey, int $unitNetCents, string $currency, string $term): ?string
{
if ($unitNetCents <= 0) {
return null;
}
return $this->find(
$addonKey,
self::chargedCents($unitNetCents, $term),
$currency,
$term === Subscription::TERM_YEARLY ? 'year' : 'month',
);
}
/** The live Price we already have for this charged figure, or null. */
public function find(string $addonKey, int $amountCents, string $currency, string $interval): ?string
{
$id = StripeAddonPrice::query()
@ -92,11 +153,47 @@ final class AddonPrices
->where('amount_cents', $amountCents)
->where('currency', strtoupper($currency))
->where('interval', $interval)
->whereNull('archived_at')
->value('stripe_price_id');
return is_string($id) && $id !== '' ? $id : null;
}
/**
* Stop offering every other Price for this module at this catalogue figure.
*
* Only ones formed from the SAME net: a customer grandfathered at ten euros
* keeps their own Price, and archiving that would leave their subscription
* billing on something Stripe no longer sells. What is archived here is the
* Price for today's figure at yesterday's rate the net one this whole
* change replaces.
*
* Archived in Stripe as well as here. The Price object stays, as Stripe
* requires, because a contract may still be billing on it until
* stripe:reprice-subscriptions has moved it.
*/
private function archiveSuperseded(
string $addonKey,
int $netCents,
string $currency,
string $interval,
int $keepAmountCents,
): void {
$superseded = StripeAddonPrice::query()
->where('addon_key', $addonKey)
->where('net_cents', $netCents)
->where('currency', $currency)
->where('interval', $interval)
->where('amount_cents', '!=', $keepAmountCents)
->whereNull('archived_at')
->get();
foreach ($superseded as $price) {
$this->stripe->archivePrice((string) $price->stripe_price_id);
$price->update(['archived_at' => now()]);
}
}
/**
* The Stripe Product every Price for this module hangs off.
*
@ -124,6 +221,7 @@ final class AddonPrices
private function remember(
string $addonKey,
int $amountCents,
int $netCents,
string $currency,
string $interval,
string $productId,
@ -133,6 +231,7 @@ final class AddonPrices
StripeAddonPrice::create([
'addon_key' => $addonKey,
'amount_cents' => $amountCents,
'net_cents' => $netCents,
'currency' => $currency,
'interval' => $interval,
'stripe_product_id' => $productId,

View File

@ -62,6 +62,23 @@ final class InvoiceMath
return (int) round($netCents * $rateBasisPoints / 10000);
}
/**
* The VAT already contained in a figure that includes it.
*
* The exact inverse of tax() for every figure tax() can produce, and this
* is the part that matters it works for the figures it CANNOT produce as
* well. At 20 % one integer in six is not the gross of any whole-cent net
* (there is no net whose gross is 3 cents), and Stripe hands us exactly such
* figures whenever it prorates a term by days. Extracting the tax from the
* gross and taking the rest as net therefore always adds back up to the
* amount that was charged, which putting a rate on top of a rounded net
* would not.
*/
public static function taxInGross(int $grossCents, int $rateBasisPoints): int
{
return (int) round($grossCents * $rateBasisPoints / (10000 + $rateBasisPoints));
}
/**
* Every figure the document shows, from the lines and one adjustment.
*
@ -115,6 +132,115 @@ final class InvoiceMath
];
}
/**
* A document whose lines state what was CHARGED, turned into the net lines
* it prints and the totals it adds up to.
*
* Everything this platform sells is priced gross: the figure on the price
* sheet is the figure on the Stripe Price and the figure taken from the
* card, and the document's job is to say how that one amount divides into
* net and VAT not to rebuild it from a net figure and a rate. Rebuilding
* it is what produced an invoice for 214,80 against a charge of 179,00 ,
* and it cannot be made to land on the cent for a prorated amount.
*
* So the split happens ONCE, on the total, exactly as totals() rounds once:
* the VAT is extracted from the sum of the lines and the rest is the net. The
* per-line net figures are then shares of that net, with the odd cent given
* to the largest line, so the column on the page still adds up to the
* subtotal underneath it.
*
* A reverse-charge customer comes through here at a rate of zero, and the
* whole charged amount becomes net which is what their document has to
* say, since they paid the same total as everybody else.
*
* @param array<int, array<string, mixed>> $lines `unit_net_cents` holding the amount charged
* @return array{lines: array<int, array<string, mixed>>, totals: array<string, int>}
*/
public static function fromCharged(array $lines, int $rateBasisPoints): array
{
$lines = array_values($lines);
$charged = array_map(
fn (array $line) => self::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']),
$lines,
);
$chargedTotal = array_sum($charged);
$tax = self::taxInGross($chargedTotal, $rateBasisPoints);
$net = $chargedTotal - $tax;
$shares = self::shareOut($charged, $chargedTotal, $net);
foreach ($lines as $index => $line) {
$quantity = (int) $line['quantity_milli'];
$units = intdiv($quantity, 1000);
// The quantity survives only where the net share still divides by it
// exactly. Where it does not, the line collapses to one of itself at
// the whole amount — a unit price the reader could multiply into a
// different answer than the line beside it is worse than no unit
// price at all, and it is the same rule StripeInvoiceLines applies
// to Stripe's own amounts.
$divides = $units >= 1 && $units * 1000 === $quantity && $shares[$index] % $units === 0;
$lines[$index]['quantity_milli'] = $divides ? $quantity : 1000;
$lines[$index]['unit_net_cents'] = $divides ? intdiv($shares[$index], $units) : $shares[$index];
}
return [
'lines' => $lines,
'totals' => [
'subtotal' => $net,
'lines_net' => $net,
// No discount is expressible on a charged document: the amount
// was taken as it stands, and a discount here would describe a
// reduction that never reached the customer's card.
'adjustment' => 0,
'net' => $net,
'tax' => $tax,
'gross' => $chargedTotal,
],
];
}
/**
* `$total` divided between lines in proportion to `$parts`, to the cent.
*
* The odd cent goes to the biggest line, because it is the one where a cent
* is least visible and because it has to go somewhere: shares that are each
* rounded on their own do not add up to the sum they are shares of.
*
* @param array<int, int> $parts
* @return array<int, int>
*/
private static function shareOut(array $parts, int $partsTotal, int $total): array
{
if ($parts === []) {
return [];
}
if ($partsTotal === 0) {
// Nothing to divide in proportion to. Everything on the first line
// rather than a division by zero; a zero-total document has one line
// in practice anyway.
return array_map(fn (int $index) => $index === 0 ? $total : 0, array_keys($parts));
}
$shares = array_map(fn (int $part) => (int) round($part * $total / $partsTotal), $parts);
$largest = 0;
foreach ($parts as $index => $part) {
if (abs($part) > abs($parts[$largest])) {
$largest = $index;
}
}
$shares[$largest] += $total - array_sum($shares);
return $shares;
}
/** "1.234,56" — Austrian formatting, done here so no view invents its own. */
public static function money(int $cents): string
{

View File

@ -27,6 +27,22 @@ use RuntimeException;
*
* The number and the invoice commit together or not at all. A number taken and
* then lost to a failure is a gap in a series that must not have one.
*
* ## Two kinds of line, and why
*
* Everything sold from the catalogue is priced GROSS: the price sheet, the
* Stripe Price and the card charge are one figure. A document for such a sale
* therefore starts from what was CHARGED and divides it see
* InvoiceMath::fromCharged() because a document that rebuilds the total from a
* net figure and a rate states a sum nobody was ever charged. That was the whole
* defect: 179,00 taken and 214,80 invoiced.
*
* A service invoice an operator types is the other kind. Nothing has been
* charged yet, the figures are what was agreed on the telephone, and they are
* entered net with the VAT put on top exactly as before.
*
* The two never mix on one document, and which one a method produces is stated
* on the method.
*/
final class IssueInvoice
{
@ -41,6 +57,11 @@ final class IssueInvoice
* money. Each order becomes a line, which is also what was asked for
* every add-on listed on its own.
*
* Each line is what the order actually COST the customer Order::chargedCents(),
* which is Stripe's own total where the order went through a checkout. It
* used to be `amount_cents` read as a net figure, and once the catalogue
* started charging gross that put the VAT on the document twice.
*
* @param Collection<int, Order> $orders
*/
public function forOrders(Customer $customer, Collection $orders, ?InvoiceSeries $series = null): Invoice
@ -62,7 +83,7 @@ final class IssueInvoice
])),
'quantity_milli' => 1000,
'unit' => '',
'unit_net_cents' => (int) $order->amount_cents,
'unit_net_cents' => $order->chargedCents(),
])->all();
return $this->issue(
@ -71,6 +92,7 @@ final class IssueInvoice
currency: strtoupper((string) ($orders->first()->currency ?: 'EUR')),
series: $series,
attributes: ['order_id' => $orders->first()->id],
linesAreCharged: true,
);
}
@ -85,11 +107,12 @@ final class IssueInvoice
* actually ordered. The document points at the CONTRACT instead, which is
* what a renewal renews.
*
* The line is the contract's own frozen price `price_cents`, the
* catalogue's NET figure — and not Stripe's `amount_paid`, which is a gross
* total. What the customer is owed is what they agreed to; the register
* records beside it what was actually taken, and `stripe_invoice_id` on the
* row is how the two are reconciled afterwards.
* The line is what the cycle CHARGED. Where Stripe reported a total it is
* that total, to the cent; where it did not, it is the contract's own frozen
* net price at the till (TaxTreatment::chargedCents), which is the amount the
* Stripe Price for that contract carries. `price_cents` itself stays the
* catalogue's net figure and is not what a document totals to the customer
* was charged the gross of it.
*
* **The fallback, not the ordinary path.** Modules are items on the Stripe
* subscription now, so what a cycle actually charged for is on Stripe's own
@ -109,6 +132,7 @@ final class IssueInvoice
Carbon $to,
?string $stripeInvoiceId = null,
?InvoiceSeries $series = null,
?int $chargedCents = null,
): Invoice {
$customer = $subscription->customer;
@ -130,7 +154,7 @@ final class IssueInvoice
])],
'quantity_milli' => 1000,
'unit' => '',
'unit_net_cents' => (int) $subscription->price_cents,
'unit_net_cents' => $chargedCents ?? TaxTreatment::chargedCents((int) $subscription->price_cents),
]];
return $this->issue(
@ -138,6 +162,7 @@ final class IssueInvoice
lines: $lines,
currency: strtoupper((string) ($subscription->currency ?: 'EUR')),
series: $series,
linesAreCharged: true,
attributes: [
'subscription_id' => $subscription->id,
// Left null on purpose. The contract's opening order is the
@ -170,10 +195,12 @@ final class IssueInvoice
* one taken. Only the WORDING is ours see StripeInvoiceLines, which names
* each line from the catalogue instead of printing a Price id.
*
* **Net in, tax on top.** Our catalogue is pushed to Stripe as NET, so the
* line amounts that come back are net and the document adds VAT from the
* customer's own treatment. Nothing here reads `amount_paid`, which is a
* gross total and belongs to the proof register.
* **Charged in, split here.** Our catalogue is pushed to Stripe as GROSS, so
* the line amounts that come back are what the customer actually paid, and
* the document divides that sum into net and VAT rather than adding a rate
* on top of it. That distinction is not cosmetic: Stripe prorates a term by
* days and hands back figures that are not the gross of any whole-cent net,
* and a document rebuilt from a net figure would miss those by a cent.
*
* @param array<int, array<string, mixed>> $lines Stripe's `lines.data`
* @param array<int, string> $unnamed filled with the lines the catalogue could not name
@ -204,6 +231,7 @@ final class IssueInvoice
lines: $built['lines'],
currency: strtoupper((string) ($currency ?: $subscription->currency ?: 'EUR')),
series: $series,
linesAreCharged: true,
attributes: [
'subscription_id' => $subscription->id,
// Left null for the same reason as on a renewal: the contract's
@ -230,9 +258,12 @@ final class IssueInvoice
* invoices would be a second numbering and a series with a document
* missing from it is worth nothing at an audit.
*
* Lines arrive as a person typed them. Nothing here invents a price: the
* caller is the operator, and what they wrote is what the customer agreed
* to on the phone.
* Lines arrive as a person typed them, NET, with the VAT put on top the
* one door into this class that still works that way, and deliberately. A
* catalogue sale has already been charged a gross figure that the document
* has to divide; a service invoice charges nothing yet, and the console page
* asks the operator for net amounts because that is what was agreed on the
* telephone.
*
* @param array<int, array{description: string, details?: array<int, string>, quantity_milli: int, unit?: string, unit_net_cents: int}> $lines
*/
@ -324,7 +355,17 @@ final class IssueInvoice
return $line;
}, (array) ($snapshot['lines'] ?? []));
$totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate];
// The original's own figures, negated — not recomputed from the lines.
// A document built from a charged amount holds a split that its own
// lines cannot be made to reproduce (the odd cent lives on one of them),
// so recomputing would take back a cent more or less than was invoiced,
// and a cancellation that does not cancel leaves that cent standing for
// ever.
$totals = array_map(
fn ($value) => -(int) $value,
array_diff_key((array) ($snapshot['totals'] ?? []), ['rate_basis_points' => null]),
) + ['subtotal' => 0, 'lines_net' => 0, 'adjustment' => 0, 'net' => 0, 'tax' => 0, 'gross' => 0]
+ ['rate_basis_points' => $rate];
$snapshot['lines'] = $lines;
$snapshot['totals'] = $totals;
@ -356,19 +397,35 @@ final class IssueInvoice
);
}
/**
* @param bool $linesAreCharged true when `unit_net_cents` holds what the
* customer was actually charged, tax included
* every catalogue sale. False for the service
* invoice an operator types net.
*/
private function issue(
Customer $customer,
array $lines,
string $currency,
?InvoiceSeries $series,
array $attributes,
bool $linesAreCharged = false,
): Invoice {
$series ??= InvoiceSeries::query()->where('kind', 'invoice')->where('active', true)->firstOrFail();
$treatment = TaxTreatment::for($customer);
$rate = (int) round($treatment->rate * 10000);
$rate = $treatment->basisPoints();
$totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate];
if ($linesAreCharged) {
// The charged figure is the fixed point: a reverse-charge customer
// paid the same total as everybody else, so at a rate of zero the
// whole of it becomes net rather than the document shrinking by the
// VAT that was in the price.
['lines' => $lines, 'totals' => $totals] = InvoiceMath::fromCharged($lines, $rate);
$totals += ['rate_basis_points' => $rate];
} else {
$totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate];
}
$snapshot = [
'issuer' => CompanyProfile::all(),

View File

@ -5,6 +5,7 @@ namespace App\Services\Billing;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use Illuminate\Support\Carbon;
/**
@ -28,10 +29,11 @@ use Illuminate\Support\Carbon;
* line that was charged is worse than an ugly one: the total would still be
* right and the reader would have no way to see what they had paid for.
*
* Amounts are NET throughout, which is what our catalogue pushes to Stripe. Tax
* is added by IssueInvoice from the customer's own treatment see TaxTreatment,
* and note that a reverse-charge customer pays no VAT at all, which no figure
* from Stripe would know.
* Amounts are what was CHARGED our catalogue is pushed to Stripe gross, so
* every figure here already contains the VAT. IssueInvoice divides the sum
* according to the customer's own treatment rather than adding a rate to it; a
* reverse-charge customer paid the same total as everybody else and their
* document states the whole of it as net at 0 %. See TaxTreatment.
*/
final class StripeInvoiceLines
{
@ -59,7 +61,9 @@ final class StripeInvoiceLines
// is what was charged, and it is the figure the document has to
// total to — so a quantity that does not divide it exactly is shown
// as one line at the full amount rather than as a unit price the
// reader could multiply and get a different answer.
// reader could multiply and get a different answer. InvoiceMath
// applies the same rule again once the amount has been split into
// net, which is the figure actually printed.
$unit = intdiv($amount, $quantity);
$divides = $unit * $quantity === $amount;
@ -99,9 +103,12 @@ final class StripeInvoiceLines
{
$metadata = (array) ($line['price']['metadata'] ?? []);
// A module, by the Price we minted for it. The metadata is the same
// answer from the other direction and covers a Price created against
// this account before the table existed.
// A module, by the Price we minted for it. Archived Prices are in that
// table too and are looked up exactly like live ones — a contract keeps
// billing on the Price it was put on until it is deliberately moved, and
// a line nobody can name is the one failure this class exists to avoid.
// The metadata is the same answer from the other direction and covers a
// Price created against this account before the table existed.
$addonKey = $priceId === null
? null
: StripeAddonPrice::query()->where('stripe_price_id', $priceId)->value('addon_key');
@ -115,10 +122,23 @@ final class StripeInvoiceLines
// The package, by the priced row stripe:sync-catalogue pushed. Named by
// FAMILY rather than by the version number: "Paket Team" is what the
// customer bought, and the version is a fact about our catalogue.
$family = $priceId === null ? null : PlanPrice::query()
//
// `plan_prices.stripe_price_id` only ever holds the Price on sale right
// now, so a contract still billing on a superseded one is found through
// `stripe_plan_prices`, which keeps every Price a row has ever had.
$planPrice = $priceId === null ? null : PlanPrice::query()
->where('stripe_price_id', $priceId)
->with('version.family')
->first()?->version?->family;
->first();
if ($planPrice === null && $priceId !== null) {
$planPrice = StripePlanPrice::query()
->where('stripe_price_id', $priceId)
->with('planPrice.version.family')
->first()?->planPrice;
}
$family = $planPrice?->version?->family;
$family ??= is_string($metadata['plan_family'] ?? null)
? PlanFamily::query()->where('key', $metadata['plan_family'])->first()

View File

@ -38,11 +38,29 @@ use App\Support\CompanyProfile;
* that needs a maintained rate table per member state and a tax adviser's sign
* off, not a guess in a config file so those customers fall back to the
* domestic rate, which over-collects rather than under-collects.
*
* ## The price at the till is the domestic gross, for everybody
*
* The treatment above decides how a DOCUMENT is split, and it decides nothing
* about the amount taken from the card. That amount is chargedCents(): the net
* catalogue figure with the domestic rate on it, and it is the same number for a
* consumer in Vienna and for a reverse-charge business in Rotterdam. The owner's
* rule is that the figure on the website is the figure charged, and a Stripe
* Price cannot ask who is buying it carries one amount for everyone.
*
* So a reverse-charge customer pays that same total and their invoice states the
* whole of it as net at 0 %. That is a real commercial consequence and it is
* deliberate: see App\Services\Billing\IssueInvoice.
*
* Stripe's own `automatic_tax` is deliberately NOT enabled anywhere. This class
* is the single tax authority here, knowingly over-collecting on cross-border
* B2C rather than implementing OSS; letting Stripe compute a second rate would
* have it charge a German consumer 19 % while our document said 20 %.
*/
final readonly class TaxTreatment
{
/** Reverse charge applies inside the EU only. */
private const EU_MEMBER_STATES = [
public const EU_MEMBER_STATES = [
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR',
'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO',
'SE', 'SI', 'SK',
@ -53,20 +71,47 @@ final readonly class TaxTreatment
public bool $reverseCharge,
) {}
/**
* The seller's own rate, with nobody in particular in mind.
*
* From the Finance page, not from config: an operator can change the rate
* there and cannot change a config value, and two sources for one number is
* how an invoice ends up disagreeing with the checkout that produced it.
* CompanyProfile falls back to the config value until a rate has been saved.
*
* This is the ONE place the rate is read. Everything that has to form a
* gross figure the price sheet, the Stripe catalogue, the module prices
* goes through chargedCents() below rather than multiplying by a percentage
* of its own.
*/
public static function domestic(): self
{
return new self(CompanyProfile::taxRate() / 100, false);
}
/**
* What a net catalogue figure is actually charged as.
*
* The number on the website, the amount on the Stripe Price and the total on
* the invoice are all this one, so it is computed once and here. It takes no
* customer on purpose: a Stripe Price carries a single amount and cannot ask
* who is at the checkout, and the owner's rule is that everybody pays the
* figure they were shown.
*/
public static function chargedCents(int $netCents): int
{
return self::domestic()->grossCents($netCents);
}
public static function for(?Customer $customer): self
{
// From the Finance page, not from config: an operator can change the
// rate there and cannot change a config value, and two sources for one
// number is how an invoice ends up disagreeing with the checkout that
// produced it. CompanyProfile falls back to the config value until a
// rate has been saved.
$domestic = CompanyProfile::taxRate() / 100;
$domestic = self::domestic();
// Unverified is the normal state, and it must cost the customer nothing
// more than the domestic rate they would pay anyway — but it must not
// zero the VAT either. Otherwise typing "XX123" is a discount.
if ($customer === null || ! $customer->hasVerifiedVatId()) {
return new self($domestic, false);
return $domestic;
}
// Asked of the recorded type, not of the number. Somebody who has said
@ -76,7 +121,7 @@ final readonly class TaxTreatment
// 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);
return $domestic;
}
$vatId = $customer->normalisedVatId();
@ -89,7 +134,7 @@ final readonly class TaxTreatment
&& $country !== $seller
&& preg_match('/^[A-Z]{2}[0-9A-Z]{8,12}$/', $vatId) === 1;
return $eligible ? new self(0.0, true) : new self($domestic, false);
return $eligible ? new self(0.0, true) : $domestic;
}
public function grossCents(int $netCents): int
@ -97,6 +142,19 @@ final readonly class TaxTreatment
return (int) round($netCents * (1 + $this->rate));
}
/**
* The rate in the form the invoice arithmetic works in 2000 for 20 %.
*
* Here rather than at each call site, because the conversion was written out
* by hand where an invoice was issued and nowhere else, and a second reader
* of `rate` doing its own multiplication is how two documents end up at two
* rates.
*/
public function basisPoints(): int
{
return (int) round($this->rate * 10000);
}
public function percentLabel(): string
{
return rtrim(rtrim(number_format($this->rate * 100, 1, ',', '.'), '0'), ',');

View File

@ -16,29 +16,26 @@ use Illuminate\Support\Carbon;
* 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"
* ## It IS 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.
* FAGG §16 would let us keep the pro-rata value of the days the cloud actually
* ran, where the consumer expressly asked for the service to begin inside the
* window and was told what that would cost them. The owner has decided not to:
* a consumer who withdraws gets the WHOLE amount back, and a cancellation
* invoice is issued for the whole of it.
*
* 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.
* That is a business decision and not a legal one, and it is more generous than
* the statute requires which is why nothing turns on the express-start consent
* any more. It used to be the hinge the refund swung on; a full refund makes it
* irrelevant, and `immediate_start_consent_at` on the order and on the contract
* is now a record of what the customer ticked rather than a gate anything reads.
*
* ## How the share is measured
* ## Days are still counted
*
* 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.
* counts as delivered. Nothing is charged for them, but how long the service ran
* before a withdrawal is a fact about the contract, it goes into the proof
* register, and an operator recording a withdrawal by telephone is shown it.
*/
final readonly class WithdrawalRight
{
@ -56,14 +53,12 @@ final readonly class WithdrawalRight
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);
return new self(false, null, null, false, __('withdrawal.refusal_no_contract'));
}
$opensAt = $subscription->withdrawalOpenedAt();
@ -72,29 +67,28 @@ final readonly class WithdrawalRight
// 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);
return new self(false, $opensAt, $endsAt, false, __('withdrawal.refusal_business'));
}
if ($subscription->isWithdrawn()) {
return new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_already'), $consented);
return new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_already'));
}
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 new self(true, $opensAt, null, false, __('withdrawal.refusal_no_contract'));
}
return $endsAt->isFuture()
? new self(true, $opensAt, $endsAt, true, null, $consented)
: new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_expired'), $consented);
? new self(true, $opensAt, $endsAt, true, null)
: new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_expired'));
}
/**
@ -117,8 +111,9 @@ final readonly class WithdrawalRight
* 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.
* year is 365 or 366. Nothing is charged against it any more it is what
* `deliveredDays()` is a share OF, in the register and on the operator's
* screen.
*/
public static function termDays(Subscription $subscription): int
{
@ -142,8 +137,8 @@ final readonly class WithdrawalRight
*
* 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.
* Capped at the term, because no more of a term can be delivered than it
* holds.
*/
public static function deliveredDays(Subscription $subscription, ?Carbon $at = null): int
{
@ -158,30 +153,4 @@ final readonly class WithdrawalRight
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));
}
}

View File

@ -154,10 +154,11 @@ interface StripeClient
*
* 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.
* App\Actions\WithdrawContract. `$amountCents` is the whole of what was
* taken: the owner has decided a withdrawing consumer gets everything back,
* not the amount less the days the cloud ran. It stays an explicit figure
* rather than "refund the payment" because a Storno states an amount and the
* money has to be the amount the Storno states.
*
* `$paymentReference` is a PaymentIntent (`pi_…`) or a Charge (`ch_…`)
* whichever Stripe reported for the payment. Both are accepted rather than

View File

@ -0,0 +1,117 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* What it takes to charge the figure the website shows.
*
* The catalogue was pushed to Stripe at its NET amount and no tax was configured
* on the checkout, so a customer who was quoted 214,80 paid 179,00 and
* then received an invoice stating 179,00 net plus 35,80 VAT. Under § 11
* Abs. 12 UStG the VAT written on an invoice is owed to the tax office whether
* or not it was collected, so every document issued created a liability against
* money that never contained it.
*
* The fix is that the Stripe Price carries the GROSS figure. `plan_prices.amount_cents`
* and `subscription_addons.price_cents` stay NET they are frozen onto contracts
* and PlanChange prorates against them, and reinterpreting them would corrupt
* every contract and every pro-rata sum ever computed. What changes is only the
* amount Stripe is asked to take.
*
* A Stripe Price is immutable, so that is a NEW Price per priced row, the old one
* archived, and every live subscription moved onto it. Both tables below exist so
* that can be done idempotently and so a contract billing on a superseded Price
* can still be told what it is actually paying.
*/
return new class extends Migration
{
public function up(): void
{
/**
* Every Stripe Price a priced catalogue row has ever had.
*
* `plan_prices.stripe_price_id` still points at the one that is on sale
* the checkout reads it there and must keep working and this is the
* full history behind it. Two reasons it is a table rather than a second
* column:
*
* - a contract opened last year is still billed on the Price it was
* opened with, and the invoice we issue for that cycle has to be able
* to NAME the line. Overwriting the id in place would leave those
* lines anonymous;
* - the sync has to be able to answer "does a Price for this figure
* already exist" without asking Stripe, or a second run mints a
* duplicate that nothing can tell apart afterwards.
*/
Schema::create('stripe_plan_prices', function (Blueprint $table) {
$table->id();
$table->foreignId('plan_price_id')->constrained()->cascadeOnDelete();
// What the Price charges — GROSS. Deliberately not called
// `amount_cents` like the catalogue column beside it: the two are
// different numbers now, and a shared name is how they get confused.
$table->unsignedInteger('charged_cents');
$table->string('stripe_price_id')->unique();
// Set when the Price has been superseded and archived in Stripe.
// The row stays for ever: a contract may still be billing on it.
$table->timestamp('archived_at')->nullable();
$table->timestamps();
// One Price per row per figure. A second would leave two live Stripe
// Prices for one plan at one amount and no way to tell which a
// customer is on.
$table->unique(['plan_price_id', 'charged_cents'], 'stripe_plan_prices_unique');
$table->index(['plan_price_id', 'archived_at']);
});
/*
* The Prices that already exist charge the NET figure, because that is
* what they were created from. Recorded as such rather than left
* unexplained, so the sync can see they are the wrong amount and replace
* them and so a cycle invoice for a contract still on one of them can
* still name its line.
*/
$rows = DB::table('plan_prices')
->whereNotNull('stripe_price_id')
->select('id', 'amount_cents', 'stripe_price_id')
->get();
foreach ($rows as $row) {
DB::table('stripe_plan_prices')->insert([
'plan_price_id' => $row->id,
'charged_cents' => $row->amount_cents,
'stripe_price_id' => $row->stripe_price_id,
'archived_at' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
Schema::table('stripe_addon_prices', function (Blueprint $table) {
// The catalogue figure this Price was formed from. `amount_cents`
// has always meant "what the Price charges" and still does — it is
// the gross now — so the net it came from needs somewhere of its
// own, or nothing could tell a repriced module from a differently
// priced one.
$table->unsignedInteger('net_cents')->nullable()->after('amount_cents');
$table->timestamp('archived_at')->nullable()->after('stripe_price_id');
$table->index(['addon_key', 'net_cents', 'currency', 'interval'], 'stripe_addon_prices_net');
});
// Same story as the plan side: what is there charges the net figure.
DB::table('stripe_addon_prices')->update(['net_cents' => DB::raw('amount_cents')]);
}
public function down(): void
{
Schema::table('stripe_addon_prices', function (Blueprint $table) {
$table->dropIndex('stripe_addon_prices_net');
$table->dropColumn(['net_cents', 'archived_at']);
});
Schema::dropIfExists('stripe_plan_prices');
}
};

View File

@ -7,12 +7,15 @@ return [
'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.',
// Der ausdrückliche Wunsch, dass die Leistung sofort beginnt.
//
// Der zweite Satz nannte früher eine anteilige Zahlungspflicht (FAGG §16).
// Die gibt es hier nicht mehr: ein widerrufender Verbraucher bekommt den
// gesamten Betrag zurück (siehe App\Actions\WithdrawContract), und ein
// Häkchen, das dem Kunden etwas Nachteiligeres zusagt als das, was wir
// tatsächlich tun, ist eine falsche Angabe — auch wenn sie zu unseren
// Lasten ginge. Von der Zustimmung hängt jetzt nichts mehr ab; sie bleibt
// als Beleg dafür, dass der Kunde den sofortigen Beginn verlangt hat.
'immediate_start' => 'Ich verlange ausdrücklich, dass Sie mit der Leistung schon vor Ablauf der 14-tägigen Widerrufsfrist beginnen. Mein Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist erhalte ich den gesamten gezahlten Betrag zurück.',
'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.',
];

View File

@ -10,36 +10,30 @@
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.',
'card_sub' => 'Sie können diesen Vertrag noch bis zum :date widerrufen — :days Tag(e). Der Dienst endet dann sofort und Sie erhalten den gesamten gezahlten Betrag zurück.',
'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.',
'done' => 'Ihr Widerruf ist erfasst. Der Dienst wurde beendet, die Stornorechnung 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.',
'confirm_point_documents' => 'Die Rechnung wird storniert; Sie erhalten dazu eine Stornorechnung.',
'confirm_point_full' => 'Sie erhalten den gesamten gezahlten Betrag zurück — ohne Abzug für die bereits genutzten Tage.',
'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',
// Nur zur Information: einbehalten wird nichts mehr.
'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_effect' => 'Der Dienst wird sofort beendet, die Rechnung storniert und der gesamte Betrag zurückgezahlt.',
'admin_confirm' => 'Widerruf erfassen',
'recorded' => 'Widerruf für :name erfasst.',
];

View File

@ -7,11 +7,14 @@ return [
'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.',
// The express request that the service begin at once.
//
// The second sentence used to state a pro-rata liability (FAGG §16). There
// is none here any more: a withdrawing consumer gets the whole amount back
// (see App\Actions\WithdrawContract), and a tick-box that promises the
// customer something worse than what we actually do is a false statement,
// even when it is against our own interest. Nothing turns on the consent
// now; it stays as a record that the customer asked us to start at once.
'immediate_start' => 'I expressly request that you begin providing the service before the 14-day withdrawal period has ended. My right of withdrawal is unaffected: if I withdraw within the period I get the full amount paid back.',
'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.',
];

View File

@ -10,7 +10,7 @@
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.',
'card_sub' => 'You may withdraw from this contract until :date — :days day(s) left. The service ends immediately and you get the full amount paid back.',
'cta' => 'Declare withdrawal',
'keep' => 'Cancel',
'confirm' => 'Declare withdrawal',
@ -19,27 +19,21 @@ return [
'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.',
'confirm_point_documents' => 'The invoice is cancelled; you receive a cancellation document for it.',
'confirm_point_full' => 'You get the full amount paid back — with nothing deducted for the days already used.',
'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',
// Information only: nothing is retained any more.
'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_effect' => 'The service ends immediately, the invoice is cancelled and the full amount is refunded.',
'admin_confirm' => 'Record withdrawal',
'recorded' => 'Withdrawal recorded for :name.',
];

View File

@ -1,6 +1,3 @@
@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">
@ -25,14 +22,13 @@
<dt class="text-muted">{{ __('withdrawal.admin_window') }}</dt>
<dd class="text-ink">{{ $right->endsAt?->local()->isoFormat('LL') }}</dd>
</div>
{{-- Nothing is charged for them any more. Still shown, because an
operator taking a withdrawal on the telephone is asked how long
the cloud has been running and should not have to work it out. --}}
<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>

View File

@ -1,6 +1,3 @@
@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">
@ -19,21 +16,12 @@
<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. --}}
{{-- What comes back, stated before the button and not after it. FAGG §16
would let us keep the value of the days already delivered; the owner
has decided not to, so there is one sentence here and no arithmetic
to show. --}}
<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') }}
<x-ui.icon name="receipt" class="mt-0.5 size-4 shrink-0 text-muted" />{{ __('withdrawal.confirm_point_full') }}
</li>
</ul>

View File

@ -9,6 +9,7 @@ use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\CompanyProfile;
@ -90,7 +91,21 @@ function addonPlanPrice(Subscription $contract): string
/** The Stripe Price a module is sold at, monthly, at today's catalogue figure. */
function addonModulePrice(string $key, int $monthlyCents): string
{
return (string) app(AddonPrices::class)->find($key, $monthlyCents, 'EUR', 'month');
return (string) app(AddonPrices::class)->liveFor($key, $monthlyCents, 'EUR', Subscription::TERM_MONTHLY);
}
/**
* What Stripe actually takes for a net catalogue figure.
*
* The catalogue is pushed to Stripe GROSS, so every amount on a Stripe invoice
* line already contains the VAT. The fixtures below state the net figure a
* reader recognises from the price sheet and put it through the same call the
* sync does, rather than writing 21480 and leaving the next reader to work out
* where it came from.
*/
function atTheTill(int $netCents): int
{
return TaxTreatment::chargedCents($netCents);
}
/**
@ -119,7 +134,10 @@ function stripeLine(string $priceId, int $amount, Carbon $from, Carbon $to, int
*/
function paidInvoice(string $id, array $lines, string $reason, Carbon $from, Carbon $to): array
{
$net = array_sum(array_column($lines, 'amount'));
// What Stripe took IS the sum of its lines. The catalogue is pushed gross,
// so there is nothing to add on top — and the document has to total to this
// figure exactly, which is what these tests are checking.
$charged = array_sum(array_column($lines, 'amount'));
return [
'id' => 'evt_'.$id, 'type' => 'invoice.paid',
@ -128,10 +146,8 @@ function paidInvoice(string $id, array $lines, string $reason, Carbon $from, Car
'subscription' => 'sub_mod',
'billing_reason' => $reason,
'currency' => 'eur',
// Our catalogue is pushed to Stripe as net, so what is taken is that
// plus the tax we work out — the document must total to it.
'amount_paid' => (int) round($net * 1.2),
'total' => (int) round($net * 1.2),
'amount_paid' => $charged,
'total' => $charged,
'period_start' => $from->timestamp,
'period_end' => $to->timestamp,
'lines' => ['data' => $lines, 'has_more' => false],
@ -154,12 +170,12 @@ it('gives the owners example four invoices, and puts package and module on th
// Month one and month two: the package on its own, one document each.
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_m1', [stripeLine($planPrice, 17900, $month1, $month2)], 'subscription_cycle', $month1, $month2,
'in_m1', [stripeLine($planPrice, atTheTill(17900), $month1, $month2)], 'subscription_cycle', $month1, $month2,
))->assertOk();
Carbon::setTestNow($month2);
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_m2', [stripeLine($planPrice, 17900, $month2, $month3)], 'subscription_cycle', $month2, $month3,
'in_m2', [stripeLine($planPrice, atTheTill(17900), $month2, $month3)], 'subscription_cycle', $month2, $month3,
))->assertOk();
expect(Invoice::query()->count())->toBe(2);
@ -185,7 +201,7 @@ it('gives the owners example four invoices, and puts package and module on th
$storagePrice = addonModulePrice('storage', 1000);
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_m3_storage',
[stripeLine($storagePrice, 484, now(), $month4, proration: true)],
[stripeLine($storagePrice, atTheTill(484), now(), $month4, proration: true)],
'subscription_update',
now(),
$month4,
@ -198,8 +214,8 @@ it('gives the owners example four invoices, and puts package and module on th
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_m4',
[
stripeLine($planPrice, 17900, $month4, $month4->copy()->addMonth()),
stripeLine($storagePrice, 1000, $month4, $month4->copy()->addMonth()),
stripeLine($planPrice, atTheTill(17900), $month4, $month4->copy()->addMonth()),
stripeLine($storagePrice, atTheTill(1000), $month4, $month4->copy()->addMonth()),
],
'subscription_cycle',
$month4,
@ -230,11 +246,14 @@ it('gives the owners example four invoices, and puts package and module on th
expect($fourth->snapshot['lines'])->toHaveCount(2)
->and(array_column($fourth->snapshot['lines'], 'description'))
->toBe([__('billing.cart.plan', ['plan' => 'Team']), __('billing.storage_title')])
// Net from Stripe's own lines, tax added by us: the catalogue is pushed
// as net and the document is what adds VAT.
// Stripe took 226,80 € and the document says 226,80 €, split into the
// net and the VAT that were inside it. The catalogue is pushed gross, so
// the total is not something the document adds up to — it is the figure
// it starts from and divides.
->and($fourth->gross_cents)->toBe(atTheTill(17900) + atTheTill(1000))
->and($fourth->gross_cents)->toBe(22680)
->and($fourth->net_cents)->toBe(18900)
->and($fourth->tax_cents)->toBe(3780)
->and($fourth->gross_cents)->toBe(22680);
->and($fourth->tax_cents)->toBe(3780);
// One mail per document, never two for one.
Mail::assertQueuedCount(4);
@ -249,7 +268,7 @@ it('issues one document and consumes one number however often Stripe redelivers
$event = paidInvoice(
'in_twice',
[stripeLine(addonPlanPrice($contract), 17900, now(), now()->addMonth())],
[stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth())],
'subscription_cycle',
now(),
now()->addMonth(),
@ -304,7 +323,7 @@ it('keeps a cancelled module until the period end and leaves it off the next cyc
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_after_cancel',
[stripeLine(addonPlanPrice($contract), 17900, now(), now()->addMonth())],
[stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth())],
'subscription_cycle',
now(),
now()->addMonth(),
@ -344,8 +363,8 @@ it('books a second storage pack as a quantity rather than a second item', functi
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_packs',
[
stripeLine(addonPlanPrice($contract), 17900, now(), now()->addMonth()),
stripeLine(addonModulePrice('storage', 1000), 2000, now(), now()->addMonth(), quantity: 2),
stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth()),
stripeLine(addonModulePrice('storage', 1000), atTheTill(2000), now(), now()->addMonth(), quantity: 2),
],
'subscription_cycle',
now(),
@ -436,8 +455,8 @@ it('gives an upgrades proration a document for the amount Stripe actually cha
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_upgrade',
[
stripeLine(addonPlanPrice($contract), -8950, now(), now()->addDays(15), proration: true),
stripeLine($business, 17450, now(), now()->addDays(15), proration: true),
stripeLine(addonPlanPrice($contract), atTheTill(-8950), now(), now()->addDays(15), proration: true),
stripeLine($business, atTheTill(17450), now(), now()->addDays(15), proration: true),
],
'subscription_update',
now(),
@ -471,8 +490,8 @@ it('keeps a line it cannot name rather than dropping it from the document', func
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_unknown',
[
stripeLine(addonPlanPrice($contract), 17900, now(), now()->addMonth()),
stripeLine('price_typed_by_hand', 5000, now(), now()->addMonth()),
stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth()),
stripeLine('price_typed_by_hand', atTheTill(5000), now(), now()->addMonth()),
],
'subscription_cycle',
now(),

View File

@ -8,6 +8,7 @@ use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Collection;
@ -70,8 +71,12 @@ it('mirrors the catalogue into Stripe, once', function () {
// which is why it was charged in the month it was booked and never again.
$modules = array_merge(array_keys((array) config('provisioning.addons')), ['storage']);
// A yearly module Price is twelve months of the NET figure, grossed once —
// which is what a yearly customer is actually charged for it.
expect(modulePrices($stripe))->toHaveCount(count($modules) * 2)
->and(StripeAddonPrice::query()->where('addon_key', 'storage')->where('interval', 'year')->value('amount_cents'))
->toBe(TaxTreatment::chargedCents(12 * (int) config('provisioning.storage_addon.price_cents')))
->and(StripeAddonPrice::query()->where('addon_key', 'storage')->where('interval', 'year')->value('net_cents'))
->toBe(12 * (int) config('provisioning.storage_addon.price_cents'));
// A Stripe Price cannot be edited, so a second run that minted duplicates
@ -94,8 +99,11 @@ it('gives each price its own recurring interval', function () {
expect($intervals['month'])->toBe(4)
->and($intervals['year'])->toBe(4);
// The GROSS figure, which is the one on the website and the one taken from
// the card. The catalogue row stays net.
$team = planPrices($stripe)->first(fn ($p) => $p['metadata']['plan_family'] === 'team' && $p['interval'] === 'month');
expect($team['amount'])->toBe(17900)
expect($team['amount'])->toBe(21480)
->and($team['amount'])->toBe(TaxTreatment::chargedCents(17900))
->and($team['metadata']['plan_version'])->toBe('1');
});

View File

@ -24,20 +24,22 @@ 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 fourteen-day right of withdrawal who has it, what it gives back, 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
* price 49,00 net; Stripe charges the gross, 58,80
* 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 )
* refund = 5880, the whole of it (58,80 )
*
* FAGG §16 would let us keep the pro-rata value of those seven days 13,27
* gross. The owner has decided not to, so there is no arithmetic left: whatever
* the customer paid comes back, and the Storno is for the whole document.
*/
beforeEach(function () {
Carbon::setTestNow('2026-03-01 09:00:00');
@ -65,7 +67,7 @@ afterEach(function () {
*
* @return array{0: Subscription, 1: Invoice, 2: Instance}
*/
function withdrawableContract(array $customerState = [], bool $consented = true): array
function withdrawableContract(array $customerState = []): array
{
$customer = Customer::factory()->create($customerState + [
'name' => 'Frau Berger',
@ -75,21 +77,17 @@ function withdrawableContract(array $customerState = [], bool $consented = true)
$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,
// What the payment provider took, and what the document is written from:
// the GROSS of the 49,00 € catalogue figure, because that is what the
// Stripe Price charges.
'amount_cents' => 5880,
'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([
$contract = Subscription::factory()->create([
'customer_id' => $customer->id,
'order_id' => $order->id,
'price_cents' => 4900,
@ -217,59 +215,53 @@ it('refuses a withdrawal once the fourteen days are up', function () {
->and($stripe->refunds)->toBeEmpty();
});
it('refunds the amount paid less the pro-rata value of the service delivered', function () {
it('refunds a consumer withdrawing on day thirteen the whole of what they paid', 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);
// The document the customer actually has: 58,80 € was charged, and the
// document divides it — 49,00 € net and 9,80 € VAT.
expect($invoice->gross_cents)->toBe(5880)
->and($invoice->net_cents)->toBe(4900)
->and($invoice->tax_cents)->toBe(980);
Carbon::setTestNow('2026-03-08 09:00:00');
// Day thirteen: deep inside the window, and thirteen days of cloud really
// did run. None of it is charged for.
Carbon::setTestNow('2026-03-13 09:00:00');
expect(WithdrawalRight::deliveredDays($contract->refresh()))->toBe(7)
->and(WithdrawalRight::termDays($contract))->toBe(31)
->and(WithdrawalRight::owedNetCents($contract))->toBe(1106);
$right = WithdrawalRight::for($contract->refresh());
expect($right->open)->toBeTrue()
->and(WithdrawalRight::deliveredDays($contract))->toBe(12)
->and(WithdrawalRight::termDays($contract))->toBe(31);
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();
// Nothing is invoiced for the days that were delivered — there is the
// original and its Storno, and no third document.
expect(Invoice::query()->where('customer_id', $contract->customer_id)->count())->toBe(2);
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.
// And the money is the whole of what the cancelled document said.
expect($stripe->refunds)->toHaveCount(1)
->and($stripe->refunds[0]['amount'])->toBe(4553)
->and($stripe->refunds[0]['amount'])->toBe(5880)
->and($stripe->refunds[0]['payment'])->toBe('pi_withdraw')
->and($contract->refresh()->withdrawal_refund_cents)->toBe(4553);
expect(5880 - 1327)->toBe(4553);
->and($contract->refresh()->withdrawal_refund_cents)->toBe(5880);
});
it('refunds everything where no express request to start at once was recorded', function () {
it('refunds in full on day seven as well, whatever the express-start consent said', 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,
);
// FAGG §16 would let us keep the pro-rata share where the consumer asked for
// the service to begin inside the window. The owner has decided not to, so
// the consent decides nothing: the contract carries it and the refund is
// still everything.
[$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
$contract->update(['immediate_start_consent_at' => now()]);
Carbon::setTestNow('2026-03-08 09:00:00');
expect(WithdrawalRight::owedNetCents($contract->refresh()))->toBe(0);
app(WithdrawContract::class)($contract);
app(WithdrawContract::class)($contract->refresh());
expect($stripe->refunds[0]['amount'])->toBe($invoice->gross_cents)
->and($stripe->refunds[0]['amount'])->toBe(5880);
@ -354,13 +346,13 @@ it('records the withdrawal in the proof register', function () {
->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)
// Revenue leaving, with what actually went back — and the days the service
// ran beside it, which nothing is charged for but which is still a fact
// about this contract.
expect($entry->gross_cents)->toBe(-5880)
->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);
->and($entry->snapshot['withdrawal']['refunded_gross_cents'])->toBe(5880);
});
it('withdraws once however many times it is asked', function () {
@ -419,7 +411,7 @@ it('lets an operator record a withdrawal that came in by telephone', function ()
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);
->and($stripe->refunds[0]['amount'])->toBe(5880);
});
it('will not let an operator record a withdrawal a business customer does not have', function () {