diff --git a/app/Actions/ApplyPlanChange.php b/app/Actions/ApplyPlanChange.php index c3af6e4..b8697af 100644 --- a/app/Actions/ApplyPlanChange.php +++ b/app/Actions/ApplyPlanChange.php @@ -10,6 +10,7 @@ use App\Models\SubscriptionRecord; use App\Provisioning\Jobs\AdvanceRunJob; use App\Services\Billing\CustomDomainAccess; use App\Services\Billing\PlanChange; +use App\Services\Billing\TaxTreatment; use App\Services\Stripe\StripeClient; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -131,6 +132,20 @@ class ApplyPlanChange $run = DB::transaction(function () use ($subscription, $target, $targetPlan, $change, $order, $instance, $before, $at) { $subscription->applyPlanSnapshot($target); + // A booked change is spent the moment the contract moves — whether it + // was this one arriving on its date, or a move in the other direction + // overtaking it. This is where the shop used to do it: an upgrade CLICK + // cleared a booked downgrade, in the cart, before a cent had been paid, + // and since nothing marks a cart order paid the customer lost the + // booking and got nothing in exchange. The contradiction is real — + // a downgrade left booked would come due months later and quietly undo + // the bigger package — but it is only real once the bigger package is + // actually theirs, which is here. + // + // Cleared AFTER PlanChange::evaluate() above, which reads the booked + // due date to decide whether a downgrade is allowed yet. + $subscription->clearPendingPlanChange(); + // The register row is written from the contract that has just been // moved, so the evidence and the contract cannot describe different // packages — the same order OpenSubscription writes a purchase in. @@ -139,21 +154,36 @@ class ApplyPlanChange ? SubscriptionRecord::EVENT_UPGRADE : SubscriptionRecord::EVENT_DOWNGRADE, subscription: $subscription, - // What this move is worth: the pro-rata difference for an - // upgrade, nothing for a downgrade. Deliberately NOT the order's - // amount_cents, which is the shop's headline price for the whole - // package — charging or recording that for a move made on the - // sixteenth would state a sum nobody agreed to. - netCents: $change->chargeCents, + // Zero, because a plan change moves TERMS and no money at all. + // Stripe settles the difference on a proration invoice of its + // own (PRORATE_IMMEDIATELY below), and that invoice arrives here + // as its own EVENT_INVOICE_PAID row carrying the real figure. An + // amount on this row as well counted one upgrade's money twice in + // any sum over the register. + // + // What the move is WORTH is not lost — it goes into the snapshot + // below, which is where the question "what were they quoted?" + // belongs. Deliberately NOT the order's amount_cents either: + // that is the shop's headline price for the whole package, and + // recording it for a move made on the sixteenth would state a sum + // nobody agreed to. + netCents: 0, at: $at, extra: ['plan_change' => [ 'from_plan' => $before['plan'], 'to_plan' => $targetPlan, 'remaining_days' => $change->remainingDays, 'term_days' => $change->termDays, + // The preview's pro-rata difference, and what the till would + // take for it. Both, because PlanChange prorates the NET + // catalogue figures while the Stripe Prices it prorates + // against are gross — so the net figure alone reads as a fifth + // less than the proration invoice that follows, and somebody + // reconciling the two needs the number to compare. + 'prorated_net_cents' => $change->chargeCents, + 'prorated_charge_cents' => TaxTreatment::chargedCents($change->chargeCents), ]], - // Nothing has been charged: payment is mocked repo-wide and no - // invoice exists for this move yet. Left null rather than + // Nothing has been charged AT THIS EVENT. Left null rather than // reported as zero, which the register would read as a free sale. chargedGrossCents: null, order: $order, diff --git a/app/Actions/ApplyStripeBillingEvent.php b/app/Actions/ApplyStripeBillingEvent.php index 19e44a6..519d466 100644 --- a/app/Actions/ApplyStripeBillingEvent.php +++ b/app/Actions/ApplyStripeBillingEvent.php @@ -117,7 +117,15 @@ class ApplyStripeBillingEvent fn () => ($this->record)( event: $event, subscription: $subscription->refresh(), - netCents: $isRenewal ? $subscription->price_cents : 0, + // The whole term, modules included. A cycle invoice bills the + // package item AND one item per booked module, so what Stripe + // took is the gross of all of them — held against `price_cents` + // alone, every renewal on a contract with a single module was + // filed as charged at the wrong amount. Nothing is agreed for a + // proration or a hand-raised charge: what those are worth is + // whatever Stripe worked out, and it arrives as the charged + // figure below. + netCents: $isRenewal ? $subscription->termNetCents() : 0, stripe: [ 'invoice' => $invoiceId ?: null, 'subscription' => $subscription->stripe_subscription_id, diff --git a/app/Actions/OpenSubscription.php b/app/Actions/OpenSubscription.php index ca1a5f4..57ae48c 100644 --- a/app/Actions/OpenSubscription.php +++ b/app/Actions/OpenSubscription.php @@ -117,7 +117,13 @@ class OpenSubscription // recorded" would file a free sale as though it had been paid for // in full. An order without a Stripe id was never charged through // a checkout at all, and has nothing to report. - chargedGrossCents: $order->stripe_event_id !== null ? (int) $order->amount_cents : null, + // packageChargedCents(), not the whole total: a first purchase can + // carry the one-off setup fee on the same Stripe session, and that fee + // buys the setting-up rather than the package. Held against the + // contract price it would report every sale with a fee on it as charged + // at the wrong amount, which is precisely what the register's flag is + // for and precisely why it must not fire on a correct sale. + chargedGrossCents: $order->stripe_event_id !== null ? $order->packageChargedCents() : null, ); return $subscription; diff --git a/app/Actions/RecordCommercialEvent.php b/app/Actions/RecordCommercialEvent.php index 2543cf7..2444c1f 100644 --- a/app/Actions/RecordCommercialEvent.php +++ b/app/Actions/RecordCommercialEvent.php @@ -43,7 +43,19 @@ class RecordCommercialEvent $tax = TaxTreatment::for($customer); $agreedNet = $netCents; - $expectedGross = $tax->grossCents($agreedNet); + + // What the till would take for this catalogue figure — the DOMESTIC + // gross, for everybody. Not $tax->grossCents(), which is how this + // customer's DOCUMENT is split: at a rate of zero that returns the net + // figure, and comparing it against the money Stripe actually took + // reported every reverse-charge sale charged at exactly the advertised + // amount as a mismatch. The one flag whose job is "somebody must look at + // this" was false for the whole healthy majority of EU business sales. + // + // TaxTreatment::chargedCents() is the single place a gross is formed, and + // it is what the Stripe Price carries — so this is the figure the charge + // can honestly be held against. + $expectedGross = TaxTreatment::chargedCents($agreedNet); // The flat columns describe the TRANSACTION, not the contract: gross is // what was taken from the customer, and net and tax are that gross @@ -57,8 +69,12 @@ class RecordCommercialEvent $gross = $chargedGrossCents; $net = (int) round($gross / (1 + $tax->rate)); } else { + // Nothing was charged at this event. The columns still state what the + // terms are worth, split by this customer's own rate — a + // reverse-charge contract owes no VAT and must not have any invented + // for it here. $net = $agreedNet; - $gross = $expectedGross; + $gross = $tax->grossCents($agreedNet); } $version = $subscription->planVersion; @@ -137,8 +153,17 @@ class RecordCommercialEvent // someone asks a year later. 'agreed_net_cents' => $agreedNet, 'expected_gross_cents' => $expectedGross, - 'charged_gross_cents' => $gross, - 'matches_catalogue' => $gross === $expectedGross, + // Null where no money moved at this event, and the flag with + // it. Stamping the expected figure into the charged column + // made a plan change — where the terms move and Stripe raises + // its proration invoice afterwards — claim it had been paid + // for, and then agree with itself. A row that answers "was + // the right amount taken?" with "yes" when nothing was taken + // is worse than one that says it does not know. + 'charged_gross_cents' => $chargedGrossCents, + 'matches_catalogue' => $chargedGrossCents === null + ? null + : $chargedGrossCents === $expectedGross, ], ], $extra), ]); diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php index 31ff9ee..dd2f2aa 100644 --- a/app/Actions/StartCustomerProvisioning.php +++ b/app/Actions/StartCustomerProvisioning.php @@ -32,7 +32,7 @@ class StartCustomerProvisioning ) {} /** - * @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string,immediate_start_consent?:bool} $event + * @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,setup_fee_cents?:int,currency:string,immediate_start_consent?:bool} $event */ public function fromStripeEvent(array $event): ?Order { @@ -71,6 +71,13 @@ class StartCustomerProvisioning // what this did before the column existed. 'plan_version_id' => $event['plan_version_id'] ?? null, 'amount_cents' => $event['amount_cents'], + // How much of `amount_cents` was the one-off setup fee. Kept + // beside the total rather than subtracted from it: a withdrawal + // sends back everything that was taken and the confirmation + // mail states everything that was taken, while the register and + // the invoice need the package and the fee apart — see + // Order::packageChargedCents(). + 'setup_fee_cents' => (int) ($event['setup_fee_cents'] ?? 0), 'currency' => $event['currency'], 'datacenter' => $event['datacenter'], 'stripe_event_id' => $event['id'], diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 159766f..bf97df6 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\Customer; use App\Models\Subscription; use App\Services\Billing\PlanCatalogue; +use App\Services\Billing\SetupFee; use App\Services\Provisioning\HostCapacity; use App\Services\Stripe\StripeClient; use Illuminate\Http\RedirectResponse; @@ -99,6 +100,12 @@ class CheckoutController extends Controller return back()->withErrors(['plan' => __('checkout.plan_gone')]); } + // The one-off setup fee, gross, or null where the operator has set none. + // Advertised on the price sheet and on the booking page for months and + // charged to nobody; it rides along as a second, non-recurring line on + // this session, which Stripe puts on the initial invoice only. + $setup = SetupFee::checkoutLine((string) $price->currency); + try { $url = $stripe->createCheckoutSession( priceId: (string) $price->stripe_price_id, @@ -119,8 +126,14 @@ class CheckoutController extends Controller // the payment does, and a consent recorded for a checkout // somebody abandoned would be a consent to nothing. 'immediate_start' => '1', + // What part of Stripe's amount_total is the setup fee, so the + // webhook can tell the two apart again: the package's charge + // is what the register holds against the contract price, and + // the fee is a line of its own on the first invoice. + 'setup_fee_cents' => (string) ($setup['amount_cents'] ?? 0), ], customerEmail: $user?->email, + oneOff: $setup, ); } catch (Throwable $e) { Log::error('Stripe would not open a checkout session', ['exception' => $e]); diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 059fb48..f4deab8 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\SetupFee; use App\Services\Billing\TaxTreatment; use App\Services\Provisioning\HostCapacity; use App\Support\CompanyProfile; @@ -198,7 +199,11 @@ class LandingController extends Controller $modules = $this->modulePrices(); $tax = CompanyProfile::taxRate(); - $setupNet = CompanyProfile::setupFeeCents(); + // Through SetupFee, which is also what the checkout charges. The sentence + // on this page named a figure that nothing collected for months; now the + // quote and the charge come out of one place, so a change to either the fee + // or the rate cannot move one without moving the other. + $setupNet = SetupFee::netCents(); return view('landing', [ 'plans' => $plans, @@ -212,7 +217,7 @@ class LandingController extends Controller 'label' => rtrim(rtrim(number_format($tax, 1, ',', '.'), '0'), ','), ], 'setup' => $setupNet === 0 ? null : [ - 'gross' => $this->money($this->gross($setupNet), Subscription::catalogueCurrency()), + 'gross' => $this->money(SetupFee::chargedCents(), Subscription::catalogueCurrency()), 'net' => $this->money($setupNet, Subscription::catalogueCurrency()), ], 'baseline' => $this->baseline($plans), diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index 5f4f87c..fabe785 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -103,6 +103,20 @@ class StripeWebhookController extends Controller 'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null, 'datacenter' => $meta['datacenter'] ?? 'fsn', 'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0), + // How much of that total was the one-off setup fee, which rode along + // as a second line item on the session. From OUR metadata rather than + // from Stripe's line items: this is the figure we asked for, we asked + // for it once, and reading it back from the amounts would mean + // guessing which line was which. + // + // Clamped to the total. A negative or oversized value could only come + // from a session that was not ours, and the package's charge is derived + // by subtracting this — a wild figure there would put a negative price + // on the contract's own line. + 'setup_fee_cents' => max(0, min( + (int) ($object['amount_total'] ?? $object['amount'] ?? 0), + (int) ($meta['setup_fee_cents'] ?? 0), + )), 'currency' => strtoupper($object['currency'] ?? 'eur'), ]); diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 536a8ba..99182a5 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -42,8 +42,21 @@ class Billing extends Component private const MAX_STORAGE_PACKS = 50; /** - * Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is - * mocked for now — the order is created with status 'pending'. + * Record a purchase intent (order), at status `pending`. + * + * **This does not take money, and nothing in this application yet does for a + * cart order.** Said plainly because it is the one thing about this page a + * reader would otherwise assume: the first purchase of a package goes through + * a real Stripe checkout (App\Http\Controllers\CheckoutController), and + * everything sold from HERE — a plan change, a module, a storage pack, a + * traffic top-up — is only written down. There is no second checkout, and no + * code path in this repository sets a cart order to `paid`. + * + * What DOES happen once something marks one paid — an operator, a seeder, the + * second checkout when it is built — is App\Observers\OrderObserver, which is + * the seam that completes every type of order. The customer is told the same + * thing this docblock says: "Kauf vorgemerkt — wir schalten ihn nach der + * Zahlung frei", beside a cart headed "Wird nach Zahlung aktiviert". */ public function purchase(string $type, ?string $key = null, int $quantity = 1): void { @@ -174,17 +187,18 @@ class Billing extends Component // right now. Reading that date again later would be reading a // moving target: Stripe pushes it forward on every renewal. // - // An upgrade clears a booked downgrade instead of standing beside - // it, for the same reason the order above was just deleted: the two - // contradict each other, and a downgrade left booked would come - // due months later and quietly undo the bigger package the customer - // had moved to in the meantime. - if ($contract !== null) { - match ($type) { - 'downgrade' => $contract->bookPendingPlanChange((string) $plan), - 'upgrade' => $contract->clearPendingPlanChange(), - default => null, - }; + // An upgrade deliberately does NOT unbook a booked downgrade. It used + // to, and that was the whole of what an upgrade click did: nothing in + // this application marks a cart order paid, so the customer traded a + // downgrade they had genuinely booked for a cart line that was never + // fulfilled. The two really do contradict each other — a downgrade + // left booked would come due months later and undo the bigger package + // — and that contradiction is settled where the money is: ApplyPlanChange + // clears the booking at the moment the upgrade actually lands on the + // contract. Until then the booking stands, and the card that states the + // customer's terms goes on saying so. + if ($contract !== null && $type === 'downgrade') { + $contract->bookPendingPlanChange((string) $plan); } return $replaced; diff --git a/app/Livewire/Invoices.php b/app/Livewire/Invoices.php index dbf232a..201a698 100644 --- a/app/Livewire/Invoices.php +++ b/app/Livewire/Invoices.php @@ -2,57 +2,75 @@ namespace App\Livewire; -use Illuminate\Support\Carbon; -use Illuminate\Support\Number; +use App\Livewire\Concerns\ResolvesCustomer; +use App\Models\Invoice; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Pagination\LengthAwarePaginator as Paginator; use Livewire\Attributes\Layout; use Livewire\Component; +use Livewire\WithPagination; +/** + * The customer's own documents, newest first. + * + * This page used to be a mock that outlived its purpose: five invented invoices + * numbered CP-2026-0003 to CP-2026-0007 — a prefix belonging to no configured + * series — all "paid", a spend curve rising from 179 to 198 € and a next charge + * of 198 € on 01.08.2026. Every signed-in customer saw it, and their REAL + * invoices, which exist in `invoices` and are mailed to them as PDFs, appeared + * nowhere in the portal at all. The paperwork was real and the page lied about + * it. + * + * What is here now is the same rows the operator console lists (App\Livewire\ + * Admin\Invoices), scoped to the one customer. Read-only, like the console's, + * and for the same reason: an issued invoice is never edited or deleted, so + * there is no button here whose absence needs explaining. + * + * **No next charge, and no spend chart.** Both were invented, and neither is a + * figure this application actually holds: what Stripe will take on the next + * cycle depends on prorations, credit on the account and discounts, and nothing + * computes it. A number nobody has worked out is worse than a blank space, so + * the space stays blank — what the customer pays per month is on the billing + * page, from their contract, where it is true. + */ #[Layout('layouts.portal-app')] class Invoices extends Component { + use ResolvesCustomer; + use WithPagination; + public function render() { - $locale = app()->getLocale(); - $eur = fn (float $v) => Number::currency($v, in: 'EUR', locale: $locale); - $date = fn (string $iso) => Carbon::parse($iso)->local()->locale($locale)->isoFormat('LL'); - - $months = collect(['2026-03-01', '2026-04-01', '2026-05-01', '2026-06-01', '2026-07-01']) - ->map(fn ($m) => Carbon::parse($m)->local()->locale($locale)->isoFormat('MMM'))->all(); - - $rows = [ - ['no' => 'CP-2026-0007', 'date' => $date('2026-07-01'), 'amount' => $eur(198), 'status' => 'paid'], - ['no' => 'CP-2026-0006', 'date' => $date('2026-06-01'), 'amount' => $eur(198), 'status' => 'paid'], - ['no' => 'CP-2026-0005', 'date' => $date('2026-05-01'), 'amount' => $eur(198), 'status' => 'paid'], - ['no' => 'CP-2026-0004', 'date' => $date('2026-04-01'), 'amount' => $eur(179), 'status' => 'paid'], - ['no' => 'CP-2026-0003', 'date' => $date('2026-03-15'), 'amount' => $eur(179), 'status' => 'paid'], - ]; + $customer = $this->customer(); return view('livewire.invoices', [ - 'rows' => $rows, - 'nextCharge' => $date('2026-08-01'), - 'nextAmount' => $eur(198), - 'spendChart' => [ - 'type' => 'line', - 'data' => [ - 'labels' => $months, - 'datasets' => [[ - 'label' => 'EUR', - 'data' => [179, 179, 198, 198, 198], - 'borderColor' => 'token:accent', - 'fill' => true, - 'tension' => 0.35, - 'pointRadius' => 0, - 'borderWidth' => 2, - ]], - ], - 'options' => [ - 'scales' => [ - 'x' => ['grid' => ['display' => false]], - 'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']], - ], - 'plugins' => ['legend' => ['display' => false]], - ], - ], + 'invoices' => $this->invoices($customer?->id), ]); } + + /** + * This customer's invoices, and nobody else's. + * + * Scoped by `customer_id` in the query rather than filtered afterwards, and + * an empty page for somebody with no customer record at all — an operator + * signed into the portal, an account whose customer has been deleted. The + * document itself is scoped again at the download route: a list is a list, + * and hiding a link has never stopped anybody fetching a URL. + * + * @return LengthAwarePaginator + */ + private function invoices(?int $customerId): LengthAwarePaginator + { + if ($customerId === null) { + return new Paginator([], 0, 12); + } + + return Invoice::query() + ->where('customer_id', $customerId) + ->orderByDesc('issued_on') + // Then by id, because a day can hold several documents and a list + // whose order changes between two page loads reads as a fault. + ->orderByDesc('id') + ->paginate(12); + } } diff --git a/app/Livewire/Order.php b/app/Livewire/Order.php index e70b46e..6a554ae 100644 --- a/app/Livewire/Order.php +++ b/app/Livewire/Order.php @@ -5,6 +5,7 @@ namespace App\Livewire; use App\Models\Customer; use App\Models\Subscription; use App\Services\Billing\PlanCatalogue; +use App\Services\Billing\SetupFee; use App\Services\Provisioning\HostCapacity; use App\Support\CompanyProfile; use Illuminate\Support\Facades\Auth; @@ -49,7 +50,13 @@ class Order extends Component 'plans' => $this->plans(), 'contract' => $contract, 'vat' => rtrim(rtrim(number_format(CompanyProfile::taxRate(), 1, ',', '.'), '0'), ','), - 'setup' => CompanyProfile::setupFeeCents(), + // Gross, like the package price above it and like the public sheet, + // which quotes "Einrichtung kostet einmalig 118,80 €". This page was + // quoting the NET figure under the same sentence, so the site said + // 118,80 and the page with the button on it said 99,00 — and the + // checkout now charges the gross of it, which is what a visitor was + // shown out there. + 'setup' => SetupFee::chargedCents(), ]); } diff --git a/app/Models/Order.php b/app/Models/Order.php index 0c78911..0224581 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -21,8 +21,8 @@ class Order extends Model implements ProvisioningSubject use HasFactory, HasUuid; protected $fillable = [ - 'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency', - 'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'stripe_payment_intent_id', + 'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'setup_fee_cents', + 'currency', 'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'stripe_payment_intent_id', 'stripe_invoice_id', 'status', 'immediate_start_consent_at', ]; @@ -30,6 +30,7 @@ class Order extends Model implements ProvisioningSubject { return [ 'amount_cents' => 'integer', + 'setup_fee_cents' => 'integer', 'plan_version_id' => 'integer', // When the consumer expressly asked for the service to begin inside // the withdrawal window. @@ -109,6 +110,26 @@ class Order extends Model implements ProvisioningSubject : TaxTreatment::chargedCents((int) $this->amount_cents); } + /** + * What was charged for the PACKAGE alone — the whole charge, less the one-off + * setup fee that rode along on the same checkout. + * + * The two have to be separable because they answer to different things. A + * withdrawal sends back everything that was taken and the confirmation mail + * states everything that was taken, so both read `amount_cents` whole. The + * proof register holds the charge against the contract's agreed price, and the + * invoice prints the fee as a line of its own — neither of which works if the + * fee is folded into the package's figure. + * + * Zero for every order that carries no fee, which is every order placed before + * the fee was charged at all and every order that is not a first purchase: a + * setup fee is charged once, when the cloud is set up. + */ + public function packageChargedCents(): int + { + return $this->chargedCents() - (int) $this->setup_fee_cents; + } + public function taxTreatment(): TaxTreatment { return TaxTreatment::for($this->customer); diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index aeefa36..0163a6c 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\Billing\AddonPrices; use App\Services\Billing\PlanCatalogue; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -370,4 +371,27 @@ class Subscription extends Model ->where('cancelled_at', null) ->sum(fn (SubscriptionAddon $addon) => $addon->monthlyCents()); } + + /** + * What a whole TERM of this contract is agreed at, net: the package plus + * every module still booked, each for the same term. + * + * The figure a renewal has to be held against. Stripe bills a cycle as the + * package item plus one item per module, so what it takes is the gross of + * ALL of them — and the proof register was comparing that against + * `price_cents` alone. Every renewal on a contract with a single module was + * therefore filed as charged at the wrong amount, which is the one thing the + * register's flag exists to point at. + * + * Per term rather than per month because that is what a cycle bills: a yearly + * contract renews a year of the package and a year of each module beside it — + * see App\Services\Billing\AddonPrices, which is where the Price those items + * bill on gets its amount. + */ + public function termNetCents(): int + { + return (int) $this->price_cents + $this->addons + ->where('cancelled_at', null) + ->sum(fn (SubscriptionAddon $addon) => AddonPrices::termNetCents($addon->monthlyCents(), (string) $this->term)); + } } diff --git a/app/Observers/OrderObserver.php b/app/Observers/OrderObserver.php index 9bd281d..a75bbc7 100644 --- a/app/Observers/OrderObserver.php +++ b/app/Observers/OrderObserver.php @@ -3,30 +3,41 @@ namespace App\Observers; use App\Actions\ApplyPlanChange; +use App\Actions\BookAddon; use App\Models\Order; +use App\Models\Subscription; +use App\Services\Billing\AddonCatalogue; +use App\Services\Billing\CustomDomainAccess; use Illuminate\Support\Facades\Log; use Throwable; /** - * The moment a bought upgrade becomes a bigger machine. + * The moment a paid order becomes the thing that was bought. * - * StartCustomerProvisioning turns a paid order into work from the webhook that - * announced the payment. An upgrade has no such webhook: there is no checkout in - * this application yet, payment is mocked repo-wide, and the order sits in the - * cart at `pending` until something marks it paid. So the trigger is hung on the - * fact itself — the order becoming paid — rather than on whichever call site - * happens to exist today. A real Stripe checkout, an operator settling a - * purchase by hand and a seeder all end at the same `status = paid`, and all - * three have to result in the customer getting what they bought. + * StartCustomerProvisioning turns a paid FIRST purchase into work from the + * webhook that announced the payment. Nothing sold from the portal's shop has + * such a webhook: **no code path in this application marks a cart order paid.** + * That is stated here rather than implied, because it is the one fact a reader of + * this file needs and cannot see from it — the shop writes the order at + * `pending`, tells the customer "wir schalten ihn nach der Zahlung frei", and + * there it stops until a second checkout, an operator or a seeder moves it on. + * + * So the trigger is hung on the FACT — the order becoming paid — rather than on + * whichever call site happens to exist today. Whatever eventually marks one paid + * finds the fulfilment already written, for every type of order that can be sold, + * and does not have to remember to call four different actions in the right + * order. * * Only an UPDATE, never a create. Every order in this codebase is created either - * already-paid by provisioning (a first purchase, which opens a contract rather - * than changing one) or pending by the shop; an upgrade that goes straight in as - * paid is not a state this application produces, and firing on create would make - * every fixture that builds one start a provisioning run. + * already-paid by provisioning and by a grant — both of which do their own + * fulfilment, because a grant is not a payment — or pending by the shop. Firing + * on create would make every fixture that builds a paid order provision, book and + * bill. * * A downgrade is deliberately not here. It costs nothing, is never paid, and - * lands at the end of the term — clupilot:apply-due-plan-changes carries those. + * lands at the end of the term the customer already paid for — + * clupilot:apply-due-plan-changes carries those from the date stamped on the + * contract. */ class OrderObserver { @@ -36,22 +47,85 @@ class OrderObserver return; } - if ($order->type !== 'upgrade') { - return; - } - try { - app(ApplyPlanChange::class)->forOrder($order); + match ($order->type) { + 'upgrade' => app(ApplyPlanChange::class)->forOrder($order), + // A module and a storage pack are the same purchase to the + // contract: one booking, at the price the customer was shown, + // charged onward as an item on the Stripe subscription. Storage + // is sold in packs and the shop writes one order per pack, so + // each order books exactly one — which is also what the unique + // index on (order_id, addon_key) enforces. + 'addon' => $this->bookAddon($order, (string) $order->addon_key), + 'storage' => $this->bookAddon($order, AddonCatalogue::STORAGE), + // A traffic pack is the one thing sold here that nothing can + // deliver yet, and it is left loud rather than quietly dropped. + // The allowance TrafficMeter meters against is a standing count + // of packs on the instance (`traffic_addons`), while a top-up is + // sold as a one-off for the month that is running out — so + // incrementing it would hand the customer the extra thousand + // gigabytes every month for ever off one payment, and doing + // nothing leaves them paying for nothing. Which of the two the + // owner means is a commercial decision, not a guess to be made + // here, and until it is made a paid traffic order must be + // impossible to miss. + 'traffic' => Log::error('A paid traffic pack has no fulfilment path: nothing raises the metered allowance.', [ + 'order_id' => $order->id, 'customer_id' => $order->customer_id, + ]), + // The first purchase of a package ('new') is delivered by the + // webhook that announced its payment, not from here; a downgrade + // is never paid at all. Named rather than left to the default so + // that a type nobody has thought about still falls through it. + 'new', 'downgrade' => null, + default => null, + }; } catch (Throwable $e) { // Never allowed to fail the write that marked the order paid. That - // write is the record that money changed hands, and undoing it over - // a plan change would erase the payment and leave nothing to retry - // from. A change that did not land is loud in its own right: the - // order stays unconsumed and the contract still says the old - // package, both of which somebody can see. - Log::error('Failed to apply the plan change a paid order bought.', [ - 'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(), + // write is the record that money changed hands, and undoing it over a + // delivery would erase the payment and leave nothing to retry from. A + // delivery that did not land is loud in its own right: the order stays + // unconsumed and the contract still says what it said, both of which + // somebody can see. + Log::error('Failed to deliver what a paid order bought.', [ + 'order_id' => $order->id, 'type' => $order->type, + 'plan' => $order->plan, 'addon' => $order->addon_key, + 'error' => $e->getMessage(), ]); } } + + /** + * Book the module this order paid for onto the customer's contract. + * + * The contract is resolved through CustomDomainAccess::contractOf(), which is + * already the one place that answers "which contract is this customer on" — + * the same resolution ApplyPlanChange::forOrder() makes, and for the same + * reason: a second copy of that query is the one that would disagree the day + * the rule changes. + * + * BookAddon is idempotent per order, refuses a module the package may not + * have, freezes the price and adds the Stripe item; none of those rules are + * repeated here, because a rule enforced in two places is a rule that will + * disagree with itself. + */ + private function bookAddon(Order $order, string $addonKey): void + { + if ($addonKey === '') { + Log::error('A paid module order names no module.', ['order_id' => $order->id]); + + return; + } + + $subscription = app(CustomDomainAccess::class)->contractOf($order->customer); + + if (! $subscription instanceof Subscription) { + Log::warning('No contract to book a paid module onto.', [ + 'order_id' => $order->id, 'addon' => $addonKey, + ]); + + return; + } + + app(BookAddon::class)($subscription, $addonKey, 1, $order); + } } diff --git a/app/Services/Billing/InvoiceDocument.php b/app/Services/Billing/InvoiceDocument.php index fd2f22b..a1173db 100644 --- a/app/Services/Billing/InvoiceDocument.php +++ b/app/Services/Billing/InvoiceDocument.php @@ -25,12 +25,25 @@ final class InvoiceDocument extends TCPDF private string $pageLabel = 'Seite'; + /** + * What to call the issuer's own VAT number in the footer. + * + * It used to be printed bare, between the register number and the court, and + * a bare `ATU12345678` is only recognisable to somebody who already knows + * what they are looking at. An auditor scanning a document for "UID" found + * the RECIPIENT's, which is labelled, and not ours — so the one number an + * Austrian invoice is legally worthless without read as a reference of some + * kind. The document was valid; it was unreadable. + */ + private string $vatLabel = 'UID'; + /** @param array $issuer */ - public function setIssuer(array $issuer, string $logoFile = '', string $pageLabel = 'Seite'): void + public function setIssuer(array $issuer, string $logoFile = '', string $pageLabel = 'Seite', string $vatLabel = 'UID'): void { $this->issuer = $issuer; $this->logoFile = $logoFile; $this->pageLabel = $pageLabel; + $this->vatLabel = $vatLabel; } public function Header(): void // phpcs:ignore @@ -50,28 +63,7 @@ final class InvoiceDocument extends TCPDF $this->SetFont('dejavusans', '', 6.5); $this->SetTextColor(110, 110, 122); - $columns = [ - [ - $this->issuer['name'] ?? '', - $this->issuer['address'] ?? '', - trim(($this->issuer['postcode'] ?? '').' '.($this->issuer['city'] ?? '')), - ], - [ - $this->issuer['phone'] ?? '', - $this->issuer['email'] ?? '', - $this->issuer['website'] ?? '', - ], - [ - $this->issuer['register_number'] ?? '', - $this->issuer['vat_id'] ?? '', - $this->issuer['register_court'] ?? '', - ], - [ - $this->issuer['bank_name'] ?? '', - $this->issuer['iban'] ?? '', - $this->issuer['bic'] ?? '', - ], - ]; + $columns = self::footerColumns($this->issuer, $this->vatLabel); // A hairline above it, the same one the totals block uses, so the page // reads as one document rather than two halves. @@ -82,14 +74,77 @@ final class InvoiceDocument extends TCPDF foreach ($columns as $index => $lines) { $this->SetXY(20 + ($index * $width), -24); - // Empty entries are dropped rather than printed as blank lines: a - // company with no register number should not have a gap where one - // would be. - $this->MultiCell($width - 2, 3, implode("\n", array_filter($lines, fn ($l) => trim((string) $l) !== '')), 0, 'L', false, 0, '', '', true, 0, false, true, 0, 'T'); + $this->MultiCell($width - 2, 3, implode("\n", $lines), 0, 'L', false, 0, '', '', true, 0, false, true, 0, 'T'); } $this->SetXY(150, -30); $this->SetFont('dejavusans', '', 6.5); $this->Cell(40, 3, $this->pageLabel.' '.$this->getAliasNumPage().'/'.$this->getAliasNbPages(), 0, 0, 'R'); } + + /** + * What the four footer columns SAY, apart from where they are drawn. + * + * Separated from Footer() because the two are different kinds of decision and + * only one of them can be read: what belongs on the bottom of an invoice is a + * legal question with a right answer, while millimetre coordinates are a + * layout. Inside a TCPDF method the answer is only observable by rendering a + * PDF and un-subsetting its fonts, which is why the VAT number went unlabelled + * for months without anything noticing. + * + * Empty entries are dropped rather than printed as blank lines: a company with + * no register number should not have a gap where one would be. + * + * @param array $issuer + * @return array> + */ + public static function footerColumns(array $issuer, string $vatLabel = 'UID'): array + { + $columns = [ + [ + $issuer['name'] ?? '', + $issuer['address'] ?? '', + trim(($issuer['postcode'] ?? '').' '.($issuer['city'] ?? '')), + ], + [ + $issuer['phone'] ?? '', + $issuer['email'] ?? '', + $issuer['website'] ?? '', + ], + [ + $issuer['register_number'] ?? '', + // Labelled, unlike its neighbours, because this is the one the + // reader is looking FOR — and an unlabelled ATU number between a + // register number and a court reads as a reference of some kind. + self::labelled($vatLabel, $issuer['vat_id'] ?? ''), + $issuer['register_court'] ?? '', + ], + [ + $issuer['bank_name'] ?? '', + $issuer['iban'] ?? '', + $issuer['bic'] ?? '', + ], + ]; + + return array_map( + fn (array $lines) => array_values(array_filter( + array_map(fn ($line) => trim((string) $line), $lines), + fn (string $line) => $line !== '', + )), + $columns, + ); + } + + /** + * "UID: ATU12345678", or nothing at all when there is no number. + * + * An empty string rather than a label on its own, so a company with no VAT + * number recorded keeps the blank line it had before this label existed. + */ + private static function labelled(string $label, mixed $value): string + { + $value = trim((string) $value); + + return $value === '' ? '' : $label.': '.$value; + } } diff --git a/app/Services/Billing/InvoiceRenderer.php b/app/Services/Billing/InvoiceRenderer.php index 97f1a9d..689fc6c 100644 --- a/app/Services/Billing/InvoiceRenderer.php +++ b/app/Services/Billing/InvoiceRenderer.php @@ -34,7 +34,7 @@ final class InvoiceRenderer $issuer = (array) ($snapshot['issuer'] ?? []); $pdf = new InvoiceDocument('P', 'mm', 'A4', true, 'UTF-8'); - $pdf->setIssuer($issuer, $this->logoFile($issuer), __('invoice.page')); + $pdf->setIssuer($issuer, $this->logoFile($issuer), __('invoice.page'), __('invoice.issuer_vat')); $pdf->SetCreator('CluPilot'); $pdf->SetAuthor((string) ($issuer['name'] ?? '')); diff --git a/app/Services/Billing/IssueInvoice.php b/app/Services/Billing/IssueInvoice.php index dcb5bee..dd96702 100644 --- a/app/Services/Billing/IssueInvoice.php +++ b/app/Services/Billing/IssueInvoice.php @@ -57,10 +57,15 @@ 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. + * Each line is what the order actually COST the customer — + * Order::packageChargedCents(), which is Stripe's own total for the package + * 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. + * + * A one-off setup fee charged on the same session gets a line of its own, so + * the document adds up to exactly what the card was charged AND says what each + * part of it was for. * * @param Collection $orders */ @@ -83,9 +88,30 @@ final class IssueInvoice ])), 'quantity_milli' => 1000, 'unit' => '', - 'unit_net_cents' => $order->chargedCents(), + // The package alone. A first purchase can carry the one-off setup fee + // on the same Stripe session, and it gets a line of its own below — + // folded into this one it would be a charge the customer can see on + // their statement and cannot find on their invoice. + 'unit_net_cents' => $order->packageChargedCents(), ])->all(); + // The setup fee, once, after the packages. Its own line because it is its + // own thing: charged one time, not monthly, and named on the document with + // the same words the price sheet and the checkout used. + foreach ($orders as $order) { + if ((int) $order->setup_fee_cents <= 0) { + continue; + } + + $lines[] = [ + 'description' => __('checkout.setup_fee_line'), + 'details' => [__('invoice.line_once')], + 'quantity_milli' => 1000, + 'unit' => '', + 'unit_net_cents' => (int) $order->setup_fee_cents, + ]; + } + return $this->issue( customer: $customer, lines: $lines, diff --git a/app/Services/Billing/SetupFee.php b/app/Services/Billing/SetupFee.php new file mode 100644 index 0000000..a40f3d3 --- /dev/null +++ b/app/Services/Billing/SetupFee.php @@ -0,0 +1,79 @@ + $charged, + // What the customer reads on Stripe's page and on Stripe's receipt. + // The same words the price sheet uses, because meeting a different + // name for the same charge at the till is exactly the moment somebody + // abandons a checkout. + 'label' => __('checkout.setup_fee_line'), + 'currency' => strtoupper($currency), + ]; + } +} diff --git a/app/Services/Stripe/FakeStripeClient.php b/app/Services/Stripe/FakeStripeClient.php index 2c5e657..0beaee1 100644 --- a/app/Services/Stripe/FakeStripeClient.php +++ b/app/Services/Stripe/FakeStripeClient.php @@ -103,7 +103,11 @@ class FakeStripeClient implements StripeClient /** * Every checkout that was opened, in order. * - * @var array + * `one_off` is the setup-fee line, or null where none was sent — a test has to + * be able to assert both that it is charged and that it is absent when the + * operator has set no fee. + * + * @var array}> */ public array $checkouts = []; @@ -119,6 +123,7 @@ class FakeStripeClient implements StripeClient array $metadata = [], ?string $customerEmail = null, ?string $idempotencyKey = null, + ?array $oneOff = null, ): string { $this->failIfAsked(); @@ -128,6 +133,7 @@ class FakeStripeClient implements StripeClient 'cancel' => $cancelUrl, 'metadata' => $metadata, 'email' => $customerEmail, + 'one_off' => $oneOff, ]; return 'https://checkout.stripe.test/session/'.count($this->checkouts); diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php index 68afdf6..d96a879 100644 --- a/app/Services/Stripe/HttpStripeClient.php +++ b/app/Services/Stripe/HttpStripeClient.php @@ -26,6 +26,7 @@ class HttpStripeClient implements StripeClient array $metadata = [], ?string $customerEmail = null, ?string $idempotencyKey = null, + ?array $oneOff = null, ): string { return (string) $this->request($idempotencyKey) ->asForm() @@ -33,6 +34,7 @@ class HttpStripeClient implements StripeClient 'mode' => 'subscription', 'line_items[0][price]' => $priceId, 'line_items[0][quantity]' => 1, + ...$this->oneOffLine($oneOff), 'success_url' => $successUrl, 'cancel_url' => $cancelUrl, // Prefilled, not fixed: Stripe still lets the customer correct @@ -49,6 +51,39 @@ class HttpStripeClient implements StripeClient ->json('url'); } + /** + * The setup fee as a second, non-recurring line item. + * + * Priced inline (`price_data`) rather than against a stored Price id, and this + * is the one place in the file that does. A plan Price is minted once and + * billed against for years — grandfathering a customer needs the Price to + * outlive the catalogue row, which is why those are stored. A setup fee is + * taken once, at this checkout, and never referred to again: an immutable + * Stripe Price per figure the operator ever types would be a table of dead + * objects, and nothing would ever bill against yesterday's. + * + * No `recurring` key, which is what makes it one-off — Stripe charges it on + * the initial invoice and on no later one. + * + * @param array{amount_cents: int, label: string, currency: string}|null $oneOff + * @return array + */ + private function oneOffLine(?array $oneOff): array + { + $amount = (int) ($oneOff['amount_cents'] ?? 0); + + if ($oneOff === null || $amount <= 0) { + return []; + } + + return [ + 'line_items[1][quantity]' => 1, + 'line_items[1][price_data][currency]' => strtolower((string) $oneOff['currency']), + 'line_items[1][price_data][unit_amount]' => $amount, + 'line_items[1][price_data][product_data][name]' => (string) $oneOff['label'], + ]; + } + public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string { return (string) $this->request($idempotencyKey) diff --git a/app/Services/Stripe/StripeClient.php b/app/Services/Stripe/StripeClient.php index 5a1c8d7..92dc03c 100644 --- a/app/Services/Stripe/StripeClient.php +++ b/app/Services/Stripe/StripeClient.php @@ -65,6 +65,22 @@ interface StripeClient * plan, plan version and datacenter. It is set on the SESSION and on the * subscription: the session's copy is what StripeWebhookController reads, * the subscription's is what any later billing event carries. + * + * `$oneOff` is the setup fee: a second line item with no recurrence, whose + * `amount_cents` is GROSS like every other figure this platform charges. + * Stripe allows one-time prices in a subscription-mode session and puts them + * on the INITIAL invoice only, which is precisely what a setup fee is — so it + * is charged once, with the first month, and never again. Null means there is + * no such fee and no second line is sent at all: a zero-amount line on a + * checkout is a line telling the customer they owe nothing for something. + * + * `label` is what that line is CALLED on Stripe's own page and on the receipt + * Stripe sends. It arrives from the caller rather than being written here + * because it is customer-facing wording, and that belongs in the language + * files with the rest of it. `currency` has to match the recurring Price's, so + * it comes from the same catalogue the Price was formed from. + * + * @param array{amount_cents: int, label: string, currency: string}|null $oneOff */ public function createCheckoutSession( string $priceId, @@ -73,6 +89,7 @@ interface StripeClient array $metadata = [], ?string $customerEmail = null, ?string $idempotencyKey = null, + ?array $oneOff = null, ): string; /** diff --git a/database/migrations/2026_07_31_180000_charge_the_setup_fee_that_is_advertised.php b/database/migrations/2026_07_31_180000_charge_the_setup_fee_that_is_advertised.php new file mode 100644 index 0000000..c3e9026 --- /dev/null +++ b/database/migrations/2026_07_31_180000_charge_the_setup_fee_that_is_advertised.php @@ -0,0 +1,49 @@ +unsignedInteger('setup_fee_cents')->default(0)->after('amount_cents'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('setup_fee_cents'); + }); + } +}; diff --git a/lang/de/checkout.php b/lang/de/checkout.php index d90e0f6..d5306cf 100644 --- a/lang/de/checkout.php +++ b/lang/de/checkout.php @@ -7,6 +7,12 @@ 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.', + // Die Position, die die einmalige Einrichtung auf der Zahlungsseite und auf + // der ersten Rechnung trägt. Wortgleich mit der Preisliste: einen anderen + // Namen für dieselbe Gebühr an der Kasse zu lesen ist genau der Moment, in + // dem jemand abbricht. + 'setup_fee_line' => 'Einrichtung Ihrer Cloud, einmalig', + // Der ausdrückliche Wunsch, dass die Leistung sofort beginnt. // // Der zweite Satz nannte früher eine anteilige Zahlungspflicht (FAGG §16). diff --git a/lang/de/invoice.php b/lang/de/invoice.php index 3ff7624..b15a53c 100644 --- a/lang/de/invoice.php +++ b/lang/de/invoice.php @@ -7,6 +7,10 @@ return [ 'due' => 'Fällig am', 'customer_number' => 'Kunde', 'customer_vat' => 'UID', + // Unsere eigene UID in der Fußzeile. Sie stand dort monatelang nackt + // zwischen Firmenbuchnummer und Gericht: wer ein Dokument nach „UID" + // absucht, fand die des Empfängers und nicht die des Ausstellers. + 'issuer_vat' => 'UID', 'page' => 'Seite', 'col_pos' => 'Pos', diff --git a/lang/de/invoices.php b/lang/de/invoices.php index 5d53573..1715eb1 100644 --- a/lang/de/invoices.php +++ b/lang/de/invoices.php @@ -2,15 +2,14 @@ return [ 'title' => 'Rechnungen', - 'subtitle' => 'Ihre Abrechnungen und kommende Zahlungen.', + 'subtitle' => 'Ihre ausgestellten Rechnungen, jederzeit als PDF.', 'col_no' => 'Nummer', 'col_date' => 'Datum', - 'col_amount' => 'Betrag', - 'col_status' => 'Status', - 'paid' => 'Bezahlt', + 'col_net' => 'Netto', + 'col_gross' => 'Brutto', 'pdf' => 'PDF', - 'next_charge' => 'Nächste Abbuchung', - 'on_date' => 'am :date', - 'spend' => 'Ausgaben (5 Monate)', - 'download_toast' => 'Rechnungs-PDF im Prototyp nur angedeutet.', + // Eine Stornorechnung. Eigenes Dokument mit eigener Nummer, das die + // ursprüngliche vollständig zurücknimmt — deshalb die negativen Beträge. + 'storno' => 'Storno', + 'empty' => 'Hier steht noch keine Rechnung. Die erste erhalten Sie per E-Mail, sobald Ihre Zahlung verbucht ist — und danach finden Sie sie auch hier.', ]; diff --git a/lang/en/checkout.php b/lang/en/checkout.php index 58e9bcf..146113e 100644 --- a/lang/en/checkout.php +++ b/lang/en/checkout.php @@ -7,6 +7,11 @@ 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 line the one-off setup carries on the payment page and on the first + // invoice. Worded exactly as the price sheet words it: meeting a different + // name for the same charge at the till is when somebody abandons a checkout. + 'setup_fee_line' => 'Setting up your cloud, one-off', + // The express request that the service begin at once. // // The second sentence used to state a pro-rata liability (FAGG §16). There diff --git a/lang/en/invoice.php b/lang/en/invoice.php index 5a37912..aeb0c9c 100644 --- a/lang/en/invoice.php +++ b/lang/en/invoice.php @@ -7,6 +7,10 @@ return [ 'due' => 'Due', 'customer_number' => 'Customer', 'customer_vat' => 'VAT ID', + // Our own VAT number in the footer. It sat there bare between the register + // number and the court: anybody scanning the document for "UID" found the + // recipient's and not the issuer's. + 'issuer_vat' => 'VAT ID', 'page' => 'Page', 'col_pos' => 'Pos', diff --git a/lang/en/invoices.php b/lang/en/invoices.php index 5b1056d..7d1bce3 100644 --- a/lang/en/invoices.php +++ b/lang/en/invoices.php @@ -2,15 +2,14 @@ return [ 'title' => 'Invoices', - 'subtitle' => 'Your billing history and upcoming charges.', + 'subtitle' => 'The invoices issued to you, as a PDF whenever you need one.', 'col_no' => 'Number', 'col_date' => 'Date', - 'col_amount' => 'Amount', - 'col_status' => 'Status', - 'paid' => 'Paid', + 'col_net' => 'Net', + 'col_gross' => 'Gross', 'pdf' => 'PDF', - 'next_charge' => 'Next charge', - 'on_date' => 'on :date', - 'spend' => 'Spend (5 months)', - 'download_toast' => 'Invoice PDF is only indicated in this prototype.', + // A cancellation invoice: its own document with its own number, taking the + // original back in full — which is why its amounts are negative. + 'storno' => 'Cancellation', + 'empty' => 'No invoices yet. You will get the first one by email as soon as your payment is recorded, and it will appear here too.', ]; diff --git a/resources/views/livewire/invoices.blade.php b/resources/views/livewire/invoices.blade.php index 555387c..c54de29 100644 --- a/resources/views/livewire/invoices.blade.php +++ b/resources/views/livewire/invoices.blade.php @@ -1,49 +1,62 @@ -
+

{{ __('invoices.title') }}

{{ __('invoices.subtitle') }}

-
-
+
+ @if ($invoices->isEmpty()) +

{{ __('invoices.empty') }}

+ @else
- - + + - @foreach ($rows as $r) - - - - - - + @foreach ($invoices as $invoice) + + + {{-- ->local() even for a date (R19): stored in UTC, and + around midnight the wall clock is already tomorrow. --}} + + + + @endforeach
{{ __('invoices.col_no') }} {{ __('invoices.col_date') }}{{ __('invoices.col_amount') }}{{ __('invoices.col_status') }}{{ __('invoices.col_net') }}{{ __('invoices.col_gross') }}
{{ $r['no'] }}{{ $r['date'] }}{{ $r['amount'] }}{{ __('invoices.paid') }}{{ __('invoices.pdf') }}
+ {{ $invoice->number }} + {{-- A cancellation says so on the row. Two documents + with opposite figures and nothing to tell them + apart reads as a double charge. --}} + @if ($invoice->cancels_invoice_id !== null) + {{ __('invoices.storno') }} + @endif + {{ $invoice->issued_on?->local()->format('d.m.Y') }}{{ \App\Services\Billing\InvoiceMath::money($invoice->net_cents) }}{{ \App\Services\Billing\InvoiceMath::money($invoice->gross_cents) }} {{ $invoice->currency }} + {{-- A link, not a Livewire action: a PDF is a file + download, and routing one through a component + round-trip only adds a way for it to fail. The + route resolves the document against the + signed-in customer itself — this link being + here is not what makes it theirs. --}} + + {{ __('invoices.pdf') }} + +
-
-
-
-

{{ __('invoices.next_charge') }}

-

{{ $nextAmount }}

-

{{ __('invoices.on_date', ['date' => $nextCharge]) }}

-
-
-

{{ __('invoices.spend') }}

-
- -
-
-
+ @if ($invoices->hasPages()) +
{{ $invoices->links() }}
+ @endif + @endif
diff --git a/routes/web.php b/routes/web.php index 51dedd8..d5218cb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -271,6 +271,35 @@ $portal = function () { Route::get('/users', Users::class)->name('users'); Route::get('/backups', Backups::class)->name('backups'); Route::get('/invoices', Invoices::class)->name('invoices'); + + // The customer's own invoice as a PDF, rendered on demand from the frozen + // document — the same renderer the console uses, because there is only one + // version of a document that has been issued. + // + // A plain route rather than a Livewire action: a download is a download, + // and routing one through a component round-trip only adds a way for it to + // fail. The ownership check is HERE, in the query, and not in the list that + // links to it — a URL anybody can type is not made private by not showing + // it. firstOrFail() on a query that already carries `customer_id` gives a + // 404 for somebody else's document, which is also the right answer: it + // tells a stranger nothing about whether the number exists. + Route::get('/invoices/{uuid}/pdf', function (string $uuid) { + $customer = \App\Models\Customer::forUser(auth()->user()); + + abort_if($customer === null, 404); + + $invoice = \App\Models\Invoice::query() + ->where('customer_id', $customer->id) + ->where('uuid', $uuid) + ->firstOrFail(); + + return response()->streamDownload( + fn () => print (app(\App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)), + $invoice->number.'.pdf', + ['Content-Type' => 'application/pdf'], + ); + })->name('invoices.pdf'); + Route::get('/billing', Billing::class)->name('billing'); Route::get('/settings', \App\Livewire\Settings::class)->name('settings'); Route::get('/support', Support::class)->name('support'); diff --git a/tests/Feature/Billing/PaidOrderFulfilmentTest.php b/tests/Feature/Billing/PaidOrderFulfilmentTest.php new file mode 100644 index 0000000..bdf4d48 --- /dev/null +++ b/tests/Feature/Billing/PaidOrderFulfilmentTest.php @@ -0,0 +1,148 @@ +stripe = new FakeStripeClient; + app()->instance(StripeClient::class, $this->stripe); +}); + +/** A customer on a live contract, with a machine. */ +function paidOrderCustomer(string $plan = 'business'): array +{ + $customer = Customer::factory()->create(); + $user = User::factory()->create(['email' => $customer->email]); + $order = Order::factory()->withSubscription()->for($customer)->create(['plan' => $plan]); + $instance = Instance::factory()->for($customer)->create([ + 'order_id' => $order->id, 'plan' => $plan, 'status' => 'active', + ]); + + $subscription = $order->subscription; + $subscription->update(['instance_id' => $instance->id, 'stripe_subscription_id' => 'sub_'.$customer->id]); + + return [$customer, $user, $subscription->fresh()]; +} + +it('books the module a paid order bought', function () { + Queue::fake(); + [, $user, $subscription] = paidOrderCustomer(); + + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'collabora_pro'); + + $order = Order::query()->where('type', 'addon')->sole(); + + expect(SubscriptionAddon::query()->count())->toBe(0); + + $order->update(['status' => 'paid']); + + $addon = SubscriptionAddon::query()->sole(); + + expect($addon->addon_key)->toBe('collabora_pro') + ->and($addon->order_id)->toBe($order->id) + // Frozen at the price the customer was shown, and entered in the register. + ->and($addon->price_cents)->toBe(1900) + ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_ADDON_BOOKED)->count())->toBe(1) + // And it is on the Stripe subscription, which is what makes it recur. + ->and($this->stripe->subscriptionItems)->toHaveCount(1); + + // Delivered once, however often the row is touched again. + $order->update(['status' => 'paid', 'updated_at' => now()->addMinute()]); + + expect(SubscriptionAddon::query()->count())->toBe(1); +}); + +it('books the storage pack a paid order bought, one per order', function () { + Queue::fake(); + [, $user, $subscription] = paidOrderCustomer(); + + // Two packs is two orders, because that is how the shop writes them. + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'storage', null, 2); + + Order::query()->where('type', 'storage')->get()->each->update(['status' => 'paid']); + + $packs = SubscriptionAddon::query()->where('addon_key', AddonCatalogue::STORAGE)->get(); + + expect($packs)->toHaveCount(2) + ->and($packs->pluck('order_id')->unique())->toHaveCount(2); +}); + +it('delivers nothing for a downgrade, which is never paid for', function () { + Queue::fake(); + [, $user, $subscription] = paidOrderCustomer(); + + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team'); + + // A downgrade lands at the end of the term from the date stamped on the + // contract. Marked paid by hand it must not move the package today. + Order::query()->where('type', 'downgrade')->sole()->update(['status' => 'paid']); + + expect($subscription->fresh()->plan)->toBe('business') + ->and($subscription->fresh()->pending_plan)->toBe('team'); +}); + +it('says out loud that a paid traffic pack has nowhere to go', function () { + Queue::fake(); + [, $user] = paidOrderCustomer(); + + Log::spy(); + + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'traffic'); + Order::query()->where('type', 'traffic')->sole()->update(['status' => 'paid']); + + // The metered allowance is a standing count of packs on the instance, while a + // top-up is sold as a one-off for the month running out — so raising it would + // hand over the extra thousand gigabytes every month for ever off one payment, + // and doing nothing leaves the customer paying for nothing. Which of the two + // is meant is a commercial decision, and until it is made the order has to be + // impossible to miss. + Log::shouldHaveReceived('error') + ->withArgs(fn (string $message) => str_contains($message, 'no fulfilment path')) + ->once(); +}); + +it('never lets a failed delivery undo the record that money changed hands', function () { + Queue::fake(); + [$customer, $user] = paidOrderCustomer(); + + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'collabora_pro'); + + $order = Order::query()->where('type', 'addon')->sole(); + + // The contract goes before the order is settled. Booking will refuse, and the + // order must still be paid: that write is the evidence of the payment, and + // rolling it back would erase the payment and leave nothing to retry from. + Subscription::query()->where('customer_id', $customer->id)->delete(); + + $order->update(['status' => 'paid']); + + expect($order->fresh()->status)->toBe('paid') + ->and(SubscriptionAddon::query()->count())->toBe(0); +}); diff --git a/tests/Feature/Billing/PendingPlanChangeTest.php b/tests/Feature/Billing/PendingPlanChangeTest.php index 90c459f..be5c640 100644 --- a/tests/Feature/Billing/PendingPlanChangeTest.php +++ b/tests/Feature/Billing/PendingPlanChangeTest.php @@ -106,7 +106,8 @@ it('replaces a booked downgrade when the customer picks a different package', fu ->and(Order::query()->where('type', 'downgrade')->value('plan'))->toBe('start'); }); -it('cancels a booked downgrade when the customer upgrades instead', function () { +it('keeps a booked downgrade until the upgrade the customer chose instead is actually paid', function () { + fakeServices(); Queue::fake(); [, $user, $subscription] = pendingChangeCustomer('team'); @@ -119,15 +120,47 @@ it('cancels a booked downgrade when the customer upgrades instead', function () $component->call('purchase', 'upgrade', 'business'); - // Left booked, it would have come due months later and undone the bigger - // package the customer had just paid for. - expect($subscription->fresh()->pending_plan)->toBeNull() + // The click used to unbook it there and then, and nothing in this + // application marks a cart order paid — so the customer gave up a downgrade + // they had genuinely booked in exchange for a cart line that was never + // fulfilled. The booking stands while the upgrade is only wanted. + expect($subscription->fresh()->pending_plan)->toBe('start') + ->and($subscription->fresh()->pending_effective_at->eq($bookedFor))->toBeTrue(); + + // Paid. Now the two genuinely contradict each other, and the booking goes: + // left standing it would come due months later and quietly undo the bigger + // package the customer has just paid for. + Order::query()->where('type', 'upgrade')->sole()->update(['status' => 'paid']); + + expect($subscription->fresh()->plan)->toBe('business') + ->and($subscription->fresh()->pending_plan)->toBeNull() ->and($subscription->fresh()->pending_effective_at)->toBeNull(); Carbon::setTestNow($bookedFor->copy()->addHour()); $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); - expect($subscription->fresh()->plan)->toBe('team'); + expect($subscription->fresh()->plan)->toBe('business'); + + Carbon::setTestNow(); +}); + +it('carries out the downgrade the customer kept when the upgrade is never paid', function () { + Queue::fake(); + [, $user, $subscription] = pendingChangeCustomer('team'); + + $bookedFor = $subscription->current_period_end; + + $component = Livewire::actingAs($user)->test(Billing::class); + $component->call('purchase', 'downgrade', 'start'); + $component->call('purchase', 'upgrade', 'business'); + + // Nothing pays the upgrade, which is the state every cart order is in today. + // What the customer booked is what happens. + Carbon::setTestNow($bookedFor->copy()->addHour()); + $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); + + expect($subscription->fresh()->plan)->toBe('start') + ->and($subscription->fresh()->pending_plan)->toBeNull(); Carbon::setTestNow(); }); diff --git a/tests/Feature/Billing/PortalInvoicesTest.php b/tests/Feature/Billing/PortalInvoicesTest.php new file mode 100644 index 0000000..393a865 --- /dev/null +++ b/tests/Feature/Billing/PortalInvoicesTest.php @@ -0,0 +1,128 @@ + 'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8', + 'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000', + ]); +}); + +/** A portal customer with a signed-in user behind them. */ +function invoiceCustomer(string $email): array +{ + $user = User::factory()->create(['email' => $email, 'email_verified_at' => now()]); + $customer = Customer::factory()->create(['email' => $email, 'user_id' => $user->id]); + + return [$user, $customer]; +} + +function issuedFor(Customer $customer, int $chargedCents): Invoice +{ + return app(IssueInvoice::class)->forOrders($customer, collect([ + Order::factory()->create([ + 'customer_id' => $customer->id, + 'amount_cents' => $chargedCents, + 'currency' => 'EUR', + 'status' => 'paid', + 'stripe_event_id' => 'cs_'.uniqid(), + ]), + ])); +} + +it('lists the invoices this customer was actually issued, with their real numbers and totals', function () { + [$user, $customer] = invoiceCustomer('own@invoices.test'); + + $older = issuedFor($customer, 21480); + $newer = issuedFor($customer, 23880); + + Livewire::actingAs($user)->test(Invoices::class) + ->assertSee($older->number) + ->assertSee($newer->number) + // The number the customer was charged, off the document itself. + ->assertSee('238,80') + ->assertSee('214,80') + // Newest first: the one somebody came here to fetch is the last one. + ->assertViewHas('invoices', fn ($invoices) => $invoices->first()->is($newer)) + // And nothing invented. These were the mock's, and its prefix belongs to + // no configured series. + ->assertDontSee('CP-2026-0007') + ->assertDontSee('01.08.2026'); +}); + +it('never shows one customer another customer\'s invoice', function () { + [$user] = invoiceCustomer('mine@invoices.test'); + [, $stranger] = invoiceCustomer('theirs@invoices.test'); + + $theirs = issuedFor($stranger, 21480); + + Livewire::actingAs($user)->test(Invoices::class) + ->assertDontSee($theirs->number) + ->assertSee(__('invoices.empty')); +}); + +it('hands over the customer\'s own PDF and refuses everybody else\'s', function () { + [$user, $customer] = invoiceCustomer('pdf@invoices.test'); + [$stranger] = invoiceCustomer('nosy@invoices.test'); + + $invoice = issuedFor($customer, 21480); + + $this->actingAs($user)->get(route('invoices.pdf', $invoice->uuid)) + ->assertOk() + ->assertHeader('content-type', 'application/pdf'); + + // Checked in the query, not by leaving the link off the page: a URL anybody + // can type is not made private by not printing it. + $this->actingAs($stranger)->get(route('invoices.pdf', $invoice->uuid))->assertNotFound(); +}); + +it('does not hand an invoice to somebody who is not signed in', function () { + [, $customer] = invoiceCustomer('guarded@invoices.test'); + + $invoice = issuedFor($customer, 21480); + + $this->get(route('invoices.pdf', $invoice->uuid))->assertRedirect('/login'); +}); + +it('says nothing at all about a next charge nobody has computed', function () { + [$user, $customer] = invoiceCustomer('nofuture@invoices.test'); + issuedFor($customer, 21480); + + // The mock announced "Nächste Abbuchung 198 € am 01.08.2026" to every + // customer on the platform. What Stripe will take next cycle depends on + // prorations, account credit and discounts, and nothing here works it out — + // so the page says nothing rather than guessing. + $html = Livewire::actingAs($user)->test(Invoices::class)->html(); + + expect($html)->not->toContain('Nächste Abbuchung') + ->and($html)->not->toContain('Ausgaben'); +}); + +it('marks a cancellation so it cannot be mistaken for a second charge', function () { + [$user, $customer] = invoiceCustomer('storno@invoices.test'); + + $original = issuedFor($customer, 21480); + $storno = app(IssueInvoice::class)->cancelling($original); + + Livewire::actingAs($user)->test(Invoices::class) + ->assertSee($storno->number) + ->assertSee(__('invoices.storno')); +}); diff --git a/tests/Feature/Billing/ProofRegisterTest.php b/tests/Feature/Billing/ProofRegisterTest.php index f47ef52..20326d6 100644 --- a/tests/Feature/Billing/ProofRegisterTest.php +++ b/tests/Feature/Billing/ProofRegisterTest.php @@ -1,12 +1,17 @@ create([ + 'vat_id' => 'DE123456789', + 'vat_id_verified_at' => now(), + 'vat_id_verified_value' => 'DE123456789', + ]); +} + it('records the sale the moment a contract is opened', function () { Queue::fake(); @@ -56,17 +71,17 @@ it('records the sale the moment a contract is opened', function () { it('shows the customer their whole monthly bill, modules included', function () { $subscription = Subscription::factory()->plan('team')->create(); - $instance = App\Models\Instance::factory()->create([ + $instance = Instance::factory()->create([ 'customer_id' => $subscription->customer_id, - 'host_id' => App\Models\Host::factory()->active()->create()->id, + 'host_id' => Host::factory()->active()->create()->id, 'plan' => 'team', 'status' => 'active', ]); $subscription->update(['instance_id' => $instance->id]); app(BookAddon::class)($subscription, 'priority_support'); - $user = App\Models\User::factory()->create(['email' => $subscription->customer->email]); - $page = Livewire\Livewire::actingAs($user)->test(App\Livewire\Billing::class); + $user = User::factory()->create(['email' => $subscription->customer->email]); + $page = Livewire\Livewire::actingAs($user)->test(Billing::class); // The plan alone is not what they pay once a module is booked. expect($page->viewData('totalMonthlyCents'))->toBe(17900 + 2900); @@ -289,19 +304,84 @@ it('records a customer name that later disappears', function () { }); it('states net, tax and gross rather than leaving them to be recomputed', function () { - $customer = Customer::factory()->create([ - 'vat_id' => 'DE123456789', - 'vat_id_verified_at' => now(), - 'vat_id_verified_value' => 'DE123456789', - ]); - $subscription = Subscription::factory()->plan('team')->create(['customer_id' => $customer->id]); + $subscription = Subscription::factory()->plan('team')->create(['customer_id' => reverseChargeCustomer()->id]); - $record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900); + // What a real reverse-charge purchase records: OpenSubscription always passes + // the charged figure, and the charged figure is the domestic gross, because a + // Stripe Price carries one amount and everybody pays the one they were shown. + $record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 21480); - // Reverse charge, recorded as it stood on the day — a rate that changes - // later must not change the answer for this sale. - expect($record->net_cents)->toBe(17900) + // The whole of it is net at 0 %. Recorded as it stood on the day — a rate + // that changes later must not change the answer for this sale. + expect($record->net_cents)->toBe(21480) ->and($record->tax_cents)->toBe(0) - ->and($record->gross_cents)->toBe(17900) + ->and($record->gross_cents)->toBe(21480) ->and($record->reverse_charge)->toBeTrue(); }); + +it('does not call a reverse-charge sale charged at the advertised amount a mismatch', function () { + $subscription = Subscription::factory()->plan('team')->create(['customer_id' => reverseChargeCustomer()->id]); + + // 179,00 net at the till is 214,80 for everybody — the figure on the website, + // the figure on the Stripe Price, the figure taken from the card. The flag was + // holding it against the customer's OWN rate, which is zero, and so reported + // every single EU business sale as charged at the wrong amount. A flag that + // means "somebody must look at this" cannot be false for the healthy majority. + $record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 21480); + + expect($record->snapshot['amounts']['expected_gross_cents'])->toBe(21480) + ->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(21480) + ->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue(); + + // And it still catches the thing it is for: a reverse-charge customer who was + // charged something other than the advertised amount. + $short = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 14900); + + expect($short->snapshot['amounts']['matches_catalogue'])->toBeFalse(); +}); + +it('says it does not know whether the right amount was taken when nothing was taken', function () { + $subscription = Subscription::factory()->plan('team')->create(); + + // A plan change moves terms; Stripe raises the proration invoice afterwards + // and that arrives as its own event with the real money on it. This row used + // to be stamped with the expected figure as though it had been charged, and + // then to agree with itself — "yes, the right amount was taken" about an + // event where nothing was. + $record = app(RecordCommercialEvent::class)('upgrade', $subscription, 5000); + + expect($record->snapshot['amounts']['charged_gross_cents'])->toBeNull() + ->and($record->snapshot['amounts']['matches_catalogue'])->toBeNull() + // What was agreed is still stated, which is the whole point of keeping it. + ->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(5000) + ->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(6000); +}); + +it('holds a renewal against the whole term, modules included', function () { + $subscription = Subscription::factory()->plan('team')->create(); + $subscription->update(['stripe_subscription_id' => 'sub_renewal_modules']); + + app(BookAddon::class)($subscription, 'priority_support'); // 29,00 net / month + + $start = now()->startOfDay(); + + // What Stripe bills for the cycle: the package item AND the module item, each + // at the gross of its net figure. Held against `price_cents` alone, every + // renewal on a contract with a single module was filed as charged at the + // wrong amount — the one thing this flag exists to point at, false for a + // customer whose bill was correct to the cent. + app(ApplyStripeBillingEvent::class)->invoicePaid([ + 'id' => 'in_renewal_modules', + 'subscription' => 'sub_renewal_modules', + 'billing_reason' => 'subscription_cycle', + 'period_start' => $start->getTimestamp(), + 'period_end' => $start->copy()->addMonth()->getTimestamp(), + 'amount_paid' => 24960, // (179,00 + 29,00) + 20 % + ]); + + $record = SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->sole(); + + expect($record->snapshot['amounts']['agreed_net_cents'])->toBe(17900 + 2900) + ->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(24960) + ->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue(); +}); diff --git a/tests/Feature/Billing/SetupFeeTest.php b/tests/Feature/Billing/SetupFeeTest.php new file mode 100644 index 0000000..d2d4b29 --- /dev/null +++ b/tests/Feature/Billing/SetupFeeTest.php @@ -0,0 +1,156 @@ +stripe = new FakeStripeClient; + app()->instance(StripeClient::class, $this->stripe); + + DB::table('plan_prices')->update(['stripe_price_id' => 'price_test']); + + CompanyProfile::put([ + 'name' => 'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8', + 'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000', + ]); + + // 99 € net, which is the 118,80 € the price sheet quotes at 20 %. + Settings::set('company.setup_fee_cents', 9900); +}); + +function setupFeeBuyer(): User +{ + return User::factory()->create(['email_verified_at' => now()]); +} + +it('charges the fee it advertises, as a one-off line beside the monthly one', function () { + $this->actingAs(setupFeeBuyer()) + ->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => '1']) + ->assertRedirect(); + + $checkout = $this->stripe->checkouts[0]; + + // Gross, like every other figure this platform charges: the price on the page + // is the price at the till, for everybody. + expect($checkout['one_off'])->not->toBeNull() + ->and($checkout['one_off']['amount_cents'])->toBe(11880) + ->and($checkout['one_off']['currency'])->toBe('EUR') + ->and($checkout['one_off']['label'])->toBe(__('checkout.setup_fee_line')) + // And the figure travels in the metadata, because the webhook has to be + // able to tell the fee back out of Stripe's amount_total. + ->and($checkout['metadata']['setup_fee_cents'])->toBe('11880'); +}); + +it('sends no line at all when the operator charges no setup fee', function () { + // Zero is not "a line for nought" on a checkout — it is a charge that does not + // exist, and itemising it would tell the customer they owe nothing for + // something. + Settings::set('company.setup_fee_cents', 0); + + $this->actingAs(setupFeeBuyer())->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => '1']); + + expect($this->stripe->checkouts[0]['one_off'])->toBeNull() + // Stated as zero rather than left out. The webhook reads it either way, + // and an explicit nought says this session was priced with no fee — as + // opposed to having been opened by something that knew nothing about fees. + ->and($this->stripe->checkouts[0]['metadata']['setup_fee_cents'])->toBe('0'); +}); + +it('quotes the same figure on the booking page as the website and the till', function () { + // The page was printing the NET figure under "zzgl. Einrichtung einmalig", + // so the website said 118,80 € and the page with the button on it said 99 €. + Livewire::actingAs(setupFeeBuyer())->test(OrderPage::class) + ->assertViewHas('setup', 11880) + ->assertSee('118,80'); +}); + +it('puts the fee on the first invoice as its own line, and nowhere else', function () { + Queue::fake(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_setup', + 'type' => 'checkout.session.completed', + 'data' => ['object' => [ + 'id' => 'cs_setup', + 'payment_status' => 'paid', + 'customer_details' => ['email' => 'einrichtung@example.com', 'name' => 'Kanzlei Berger'], + // What Stripe took: 179,00 + 20 % for the package, plus 99,00 + 20 % + // once for the setting-up. + 'amount_total' => 21480 + 11880, + 'currency' => 'eur', + 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn', 'setup_fee_cents' => '11880'], + ]], + ])->assertOk(); + + $order = Order::query()->where('stripe_event_id', 'cs_setup')->sole(); + + // The whole of what was taken stays on the order — that is what a withdrawal + // sends back — with the fee recorded beside it rather than subtracted from it. + expect($order->amount_cents)->toBe(33360) + ->and($order->setup_fee_cents)->toBe(11880) + ->and($order->packageChargedCents())->toBe(21480); + + $invoice = Invoice::query()->where('order_id', $order->id)->sole(); + $lines = $invoice->snapshot['lines']; + + // Two lines, adding up to exactly what the card was charged. A fee folded into + // the package's line would be a charge the customer can see on their bank + // statement and cannot find on their invoice. + expect($lines)->toHaveCount(2) + ->and($lines[1]['description'])->toBe(__('checkout.setup_fee_line')) + ->and($invoice->gross_cents)->toBe(33360); + + // And the register holds the PACKAGE's charge against the contract's price, so + // a sale with a setup fee on it is not reported as charged at the wrong amount. + $record = SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_PURCHASE)->sole(); + + expect($record->snapshot['amounts']['charged_gross_cents'])->toBe(21480) + ->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue(); +}); + +it('never lets a session claim more setup fee than it charged in total', function () { + Queue::fake(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_setup_wild', + 'type' => 'checkout.session.completed', + 'data' => ['object' => [ + 'id' => 'cs_setup_wild', + 'payment_status' => 'paid', + 'customer_details' => ['email' => 'wild@example.com'], + 'amount_total' => 21480, + 'currency' => 'eur', + // A figure no session of ours would carry. Unclamped it would put a + // negative price on the contract's own invoice line. + 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn', 'setup_fee_cents' => '99999'], + ]], + ])->assertOk(); + + $order = Order::query()->where('stripe_event_id', 'cs_setup_wild')->sole(); + + expect($order->setup_fee_cents)->toBe(21480) + ->and($order->packageChargedCents())->toBe(0); +}); diff --git a/tests/Unit/InvoiceFooterTest.php b/tests/Unit/InvoiceFooterTest.php new file mode 100644 index 0000000..f2e3a55 --- /dev/null +++ b/tests/Unit/InvoiceFooterTest.php @@ -0,0 +1,51 @@ + 'CluPilot Cloud e.U.', + 'address' => 'Dreherstraße 66/1/8', + 'postcode' => '1110', + 'city' => 'Wien', + 'register_number' => 'FN 123456a', + 'register_court' => 'Handelsgericht Wien', + 'vat_id' => 'ATU00000000', +]; + +it('labels the issuer VAT number, and only that one', function () use ($issuer) { + $columns = InvoiceDocument::footerColumns($issuer, 'UID'); + + expect($columns[2])->toBe([ + // Its neighbours stay as they are: a Firmenbuch number and a court name + // announce themselves, an ATU string does not. + 'FN 123456a', + 'UID: ATU00000000', + 'Handelsgericht Wien', + ]); +}); + +it('leaves out the line entirely when no VAT number is recorded', function () use ($issuer) { + // A label with nothing after it is worse than the bare number was. The company + // details are required before an invoice can be issued at all, so this is the + // half-configured installation rather than a document anybody receives. + $columns = InvoiceDocument::footerColumns(['vat_id' => ''] + $issuer, 'UID'); + + expect($columns[2])->toBe(['FN 123456a', 'Handelsgericht Wien']); +}); + +it('drops empty details rather than leaving gaps where they would be', function () { + $columns = InvoiceDocument::footerColumns(['name' => 'Nur ein Name']); + + expect($columns[0])->toBe(['Nur ein Name']) + ->and($columns[1])->toBe([]) + ->and($columns[3])->toBe([]); +});