diff --git a/app/Actions/IssueStripeInvoice.php b/app/Actions/IssueStripeInvoice.php index b8ae547..b46dc3d 100644 --- a/app/Actions/IssueStripeInvoice.php +++ b/app/Actions/IssueStripeInvoice.php @@ -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, diff --git a/app/Actions/OpenSubscription.php b/app/Actions/OpenSubscription.php index c7ab6ed..ca1a5f4 100644 --- a/app/Actions/OpenSubscription.php +++ b/app/Actions/OpenSubscription.php @@ -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', ], diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php index cbea15a..2cd2b10 100644 --- a/app/Actions/StartCustomerProvisioning.php +++ b/app/Actions/StartCustomerProvisioning.php @@ -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', ]); diff --git a/app/Actions/SyncStripeAddonItems.php b/app/Actions/SyncStripeAddonItems.php index 1bc8616..f696c85 100644 --- a/app/Actions/SyncStripeAddonItems.php +++ b/app/Actions/SyncStripeAddonItems.php @@ -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 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. * diff --git a/app/Actions/WithdrawContract.php b/app/Actions/WithdrawContract.php index 59061a6..7a5f17e 100644 --- a/app/Actions/WithdrawContract.php +++ b/app/Actions/WithdrawContract.php @@ -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, diff --git a/app/Console/Commands/RepriceStripeSubscriptions.php b/app/Console/Commands/RepriceStripeSubscriptions.php new file mode 100644 index 0000000..2031963 --- /dev/null +++ b/app/Console/Commands/RepriceStripeSubscriptions.php @@ -0,0 +1,149 @@ +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; + } +} diff --git a/app/Console/Commands/SyncStripeCatalogue.php b/app/Console/Commands/SyncStripeCatalogue.php index 3d4f8de..09e5e8d 100644 --- a/app/Console/Commands/SyncStripeCatalogue.php +++ b/app/Console/Commands/SyncStripeCatalogue.php @@ -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) { diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 8d9f78c..059fb48 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -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 diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index e5fd3c5..5f4f87c 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -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 diff --git a/app/Livewire/Admin/RecordWithdrawal.php b/app/Livewire/Admin/RecordWithdrawal.php index 5268b09..f92c1d6 100644 --- a/app/Livewire/Admin/RecordWithdrawal.php +++ b/app/Livewire/Admin/RecordWithdrawal.php @@ -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), ]); diff --git a/app/Livewire/ConfirmWithdraw.php b/app/Livewire/ConfirmWithdraw.php index 7292c13..0e75397 100644 --- a/app/Livewire/ConfirmWithdraw.php +++ b/app/Livewire/ConfirmWithdraw.php @@ -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]); } } diff --git a/app/Models/Order.php b/app/Models/Order.php index 40b870c..0c78911 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -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); diff --git a/app/Models/StripeAddonPrice.php b/app/Models/StripeAddonPrice.php index 40eaaa8..242ceb0 100644 --- a/app/Models/StripeAddonPrice.php +++ b/app/Models/StripeAddonPrice.php @@ -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; } } diff --git a/app/Models/StripePlanPrice.php b/app/Models/StripePlanPrice.php new file mode 100644 index 0000000..6a5854f --- /dev/null +++ b/app/Models/StripePlanPrice.php @@ -0,0 +1,40 @@ + '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; + } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 5a3f7b6..1ba5cec 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -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', diff --git a/app/Services/Billing/AddonPrices.php b/app/Services/Billing/AddonPrices.php index c397151..62f1312 100644 --- a/app/Services/Billing/AddonPrices.php +++ b/app/Services/Billing/AddonPrices.php @@ -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, diff --git a/app/Services/Billing/InvoiceMath.php b/app/Services/Billing/InvoiceMath.php index e2d279e..d7dcc3d 100644 --- a/app/Services/Billing/InvoiceMath.php +++ b/app/Services/Billing/InvoiceMath.php @@ -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> $lines `unit_net_cents` holding the amount charged + * @return array{lines: array>, totals: array} + */ + 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 $parts + * @return array + */ + 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 { diff --git a/app/Services/Billing/IssueInvoice.php b/app/Services/Billing/IssueInvoice.php index c59a107..dcb5bee 100644 --- a/app/Services/Billing/IssueInvoice.php +++ b/app/Services/Billing/IssueInvoice.php @@ -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 $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> $lines Stripe's `lines.data` * @param array $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, 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(), diff --git a/app/Services/Billing/StripeInvoiceLines.php b/app/Services/Billing/StripeInvoiceLines.php index a20b4da..47fb6da 100644 --- a/app/Services/Billing/StripeInvoiceLines.php +++ b/app/Services/Billing/StripeInvoiceLines.php @@ -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() diff --git a/app/Services/Billing/TaxTreatment.php b/app/Services/Billing/TaxTreatment.php index a6341aa..20d5073 100644 --- a/app/Services/Billing/TaxTreatment.php +++ b/app/Services/Billing/TaxTreatment.php @@ -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'), ','); diff --git a/app/Services/Billing/WithdrawalRight.php b/app/Services/Billing/WithdrawalRight.php index 8e1260d..464f897 100644 --- a/app/Services/Billing/WithdrawalRight.php +++ b/app/Services/Billing/WithdrawalRight.php @@ -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)); - } } diff --git a/app/Services/Stripe/StripeClient.php b/app/Services/Stripe/StripeClient.php index ee61fb3..4aa461a 100644 --- a/app/Services/Stripe/StripeClient.php +++ b/app/Services/Stripe/StripeClient.php @@ -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 diff --git a/database/migrations/2026_07_30_110000_charge_the_price_that_is_advertised.php b/database/migrations/2026_07_30_110000_charge_the_price_that_is_advertised.php new file mode 100644 index 0000000..e516412 --- /dev/null +++ b/database/migrations/2026_07_30_110000_charge_the_price_that_is_advertised.php @@ -0,0 +1,117 @@ +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'); + } +}; diff --git a/lang/de/checkout.php b/lang/de/checkout.php index f7ba793..d90e0f6 100644 --- a/lang/de/checkout.php +++ b/lang/de/checkout.php @@ -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.', ]; diff --git a/lang/de/withdrawal.php b/lang/de/withdrawal.php index a32fe0e..d0daa58 100644 --- a/lang/de/withdrawal.php +++ b/lang/de/withdrawal.php @@ -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.', ]; diff --git a/lang/en/checkout.php b/lang/en/checkout.php index 2cb28d3..58e9bcf 100644 --- a/lang/en/checkout.php +++ b/lang/en/checkout.php @@ -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.', ]; diff --git a/lang/en/withdrawal.php b/lang/en/withdrawal.php index 7f228ea..a73b82a 100644 --- a/lang/en/withdrawal.php +++ b/lang/en/withdrawal.php @@ -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.', ]; diff --git a/resources/views/livewire/admin/record-withdrawal.blade.php b/resources/views/livewire/admin/record-withdrawal.blade.php index 6439466..579aefe 100644 --- a/resources/views/livewire/admin/record-withdrawal.blade.php +++ b/resources/views/livewire/admin/record-withdrawal.blade.php @@ -1,6 +1,3 @@ -@php - $eur = fn (int $cents) => \Illuminate\Support\Number::currency($cents / 100, in: 'EUR', locale: app()->getLocale()); -@endphp
@@ -25,14 +22,13 @@
{{ __('withdrawal.admin_window') }}
{{ $right->endsAt?->local()->isoFormat('LL') }}
+ {{-- 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. --}}
{{ __('withdrawal.admin_delivered') }}
{{ $deliveredDays }} / {{ $termDays }}
-
-
{{ __('withdrawal.admin_owed') }}
-
{{ $eur($owedNetCents) }}
-

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

diff --git a/resources/views/livewire/confirm-withdraw.blade.php b/resources/views/livewire/confirm-withdraw.blade.php index 711d731..68e8d73 100644 --- a/resources/views/livewire/confirm-withdraw.blade.php +++ b/resources/views/livewire/confirm-withdraw.blade.php @@ -1,6 +1,3 @@ -@php - $eur = fn (int $cents) => \Illuminate\Support\Number::currency($cents / 100, in: 'EUR', locale: app()->getLocale()); -@endphp
@@ -19,21 +16,12 @@
  • {{ __('withdrawal.confirm_point_documents') }}
  • - {{-- The pro-rata share, stated before the button and not after it. - FAGG §16 lets us keep it only because the customer asked for the - service to start at once and was told this would follow — so this - is where they are told it again, with the actual figure. Where no - such request was recorded the amount is zero and the sentence says - the whole amount comes back. --}} + {{-- 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. --}}
  • - - {{ $owedNetCents > 0 - ? __('withdrawal.confirm_point_prorata', [ - 'amount' => $eur($owedNetCents), - 'days' => $deliveredDays, - 'term' => $termDays, - ]) - : __('withdrawal.confirm_point_full') }} + {{ __('withdrawal.confirm_point_full') }}
  • diff --git a/tests/Feature/Billing/StripeAddonBillingTest.php b/tests/Feature/Billing/StripeAddonBillingTest.php index 87eec92..646e74f 100644 --- a/tests/Feature/Billing/StripeAddonBillingTest.php +++ b/tests/Feature/Billing/StripeAddonBillingTest.php @@ -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 owner’s 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 owner’s 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 owner’s 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 owner’s 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 upgrade’s 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(), diff --git a/tests/Feature/Billing/StripeBillingTest.php b/tests/Feature/Billing/StripeBillingTest.php index 995a4ad..7783d70 100644 --- a/tests/Feature/Billing/StripeBillingTest.php +++ b/tests/Feature/Billing/StripeBillingTest.php @@ -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'); }); diff --git a/tests/Feature/Billing/WithdrawalTest.php b/tests/Feature/Billing/WithdrawalTest.php index 30aef42..833236d 100644 --- a/tests/Feature/Billing/WithdrawalTest.php +++ b/tests/Feature/Billing/WithdrawalTest.php @@ -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 () {