Say the same thing to the customer, the register and the bank
Five places where the product told somebody a figure that nothing behind it produced. **The invoice page showed five invoices nobody was ever issued.** A mock that outlived its purpose: CP-2026-0003 to CP-2026-0007, a prefix belonging to no configured series, all "paid", a spend curve from 179 to 198 € and a next charge of 198 € on 01.08.2026 — behind ['auth','verified','customer.active'], so every signed-in customer saw it. Their real invoices existed in `invoices`, were rendered as PDFs and mailed to them, and appeared nowhere in the portal at all. The page now lists their own documents, newest first, with the numbers, dates and totals off the frozen snapshot, a Storno marked as one, and a download. The PDF route resolves the document against the signed-in customer in the query — a URL anybody can type is not made private by not printing it. No next charge and no chart: nothing here computes what Stripe will take next cycle, and a figure nobody has worked out is worse than a blank space. **The setup fee was quoted everywhere and charged nowhere.** On the price sheet, on the booking page, set by the operator in the console — and CompanyProfile::setupFeeCents() had no reader outside the two sentences that quoted it. It is charged now, as a second non-recurring line on the same checkout session, which Stripe puts on the initial invoice only. `orders.setup_fee_cents` records how much of the charge it was, so the whole total still goes back on a withdrawal while the register holds the PACKAGE's charge against the contract price and the invoice prints the fee as a line of its own. The booking page was also quoting the fee NET under the same sentence the website quotes GROSS — 99,00 against 118,80 — and now quotes what will be taken. **An upgrade click cancelled a booked downgrade and delivered nothing.** Nothing in this application marks a cart order paid, so the customer traded a downgrade they had genuinely booked for a cart line that would never be fulfilled. The booking now stands until the upgrade actually lands on the contract, where ApplyPlanChange clears it. The fulfilment seam is reachable for every type that can be paid — a module and a storage pack book through BookAddon, which was the missing path from a paid order to a SubscriptionAddon and left AddonPrices, SyncStripeAddonItems and clupilot:end-cancelled-addons with no caller behind a payment. A paid traffic pack logs an error rather than pretending: the metered allowance is a standing count of packs while a top-up is sold for one month, and which of the two is meant is not a guess to make here. That no path to `paid` exists yet is now stated in both places rather than in one. **The proof register called correct charges wrong.** expected_gross_cents used the CUSTOMER's rate, which is zero under reverse charge, against the domestic gross Stripe actually took — so matches_catalogue was false for every EU business sale charged exactly the advertised amount, and for every renewal on a contract with a module, which was held against `price_cents` alone. It compares against TaxTreatment::chargedCents() and against the whole term now. Where no money moved, charged and the flag are null instead of the expected figure agreeing with itself, and a plan change records the terms with the pro-rata figures in the snapshot rather than money that arrives again on Stripe's proration invoice. **The seller's own VAT id was on the invoice, unlabelled**, between the register number and the court. Labelled — and the footer's composition moved out of the TCPDF method, because inside one the only way to read what it says is to render a PDF and un-subset its fonts. One test enshrined a figure the code does not produce: a reverse-charge purchase recorded with no charged amount, asserting 17900/0/17900, where OpenSubscription always passes the charged figure and a real one records 21480/0/21480. Another asserted that an upgrade click cancels a booked downgrade. Full suite: 1676 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus
parent
336ca9d88c
commit
8ca0d4d257
|
|
@ -10,6 +10,7 @@ use App\Models\SubscriptionRecord;
|
||||||
use App\Provisioning\Jobs\AdvanceRunJob;
|
use App\Provisioning\Jobs\AdvanceRunJob;
|
||||||
use App\Services\Billing\CustomDomainAccess;
|
use App\Services\Billing\CustomDomainAccess;
|
||||||
use App\Services\Billing\PlanChange;
|
use App\Services\Billing\PlanChange;
|
||||||
|
use App\Services\Billing\TaxTreatment;
|
||||||
use App\Services\Stripe\StripeClient;
|
use App\Services\Stripe\StripeClient;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -131,6 +132,20 @@ class ApplyPlanChange
|
||||||
$run = DB::transaction(function () use ($subscription, $target, $targetPlan, $change, $order, $instance, $before, $at) {
|
$run = DB::transaction(function () use ($subscription, $target, $targetPlan, $change, $order, $instance, $before, $at) {
|
||||||
$subscription->applyPlanSnapshot($target);
|
$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
|
// The register row is written from the contract that has just been
|
||||||
// moved, so the evidence and the contract cannot describe different
|
// moved, so the evidence and the contract cannot describe different
|
||||||
// packages — the same order OpenSubscription writes a purchase in.
|
// packages — the same order OpenSubscription writes a purchase in.
|
||||||
|
|
@ -139,21 +154,36 @@ class ApplyPlanChange
|
||||||
? SubscriptionRecord::EVENT_UPGRADE
|
? SubscriptionRecord::EVENT_UPGRADE
|
||||||
: SubscriptionRecord::EVENT_DOWNGRADE,
|
: SubscriptionRecord::EVENT_DOWNGRADE,
|
||||||
subscription: $subscription,
|
subscription: $subscription,
|
||||||
// What this move is worth: the pro-rata difference for an
|
// Zero, because a plan change moves TERMS and no money at all.
|
||||||
// upgrade, nothing for a downgrade. Deliberately NOT the order's
|
// Stripe settles the difference on a proration invoice of its
|
||||||
// amount_cents, which is the shop's headline price for the whole
|
// own (PRORATE_IMMEDIATELY below), and that invoice arrives here
|
||||||
// package — charging or recording that for a move made on the
|
// as its own EVENT_INVOICE_PAID row carrying the real figure. An
|
||||||
// sixteenth would state a sum nobody agreed to.
|
// amount on this row as well counted one upgrade's money twice in
|
||||||
netCents: $change->chargeCents,
|
// 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,
|
at: $at,
|
||||||
extra: ['plan_change' => [
|
extra: ['plan_change' => [
|
||||||
'from_plan' => $before['plan'],
|
'from_plan' => $before['plan'],
|
||||||
'to_plan' => $targetPlan,
|
'to_plan' => $targetPlan,
|
||||||
'remaining_days' => $change->remainingDays,
|
'remaining_days' => $change->remainingDays,
|
||||||
'term_days' => $change->termDays,
|
'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
|
// Nothing has been charged AT THIS EVENT. Left null rather than
|
||||||
// invoice exists for this move yet. Left null rather than
|
|
||||||
// reported as zero, which the register would read as a free sale.
|
// reported as zero, which the register would read as a free sale.
|
||||||
chargedGrossCents: null,
|
chargedGrossCents: null,
|
||||||
order: $order,
|
order: $order,
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,15 @@ class ApplyStripeBillingEvent
|
||||||
fn () => ($this->record)(
|
fn () => ($this->record)(
|
||||||
event: $event,
|
event: $event,
|
||||||
subscription: $subscription->refresh(),
|
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: [
|
stripe: [
|
||||||
'invoice' => $invoiceId ?: null,
|
'invoice' => $invoiceId ?: null,
|
||||||
'subscription' => $subscription->stripe_subscription_id,
|
'subscription' => $subscription->stripe_subscription_id,
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,13 @@ class OpenSubscription
|
||||||
// recorded" would file a free sale as though it had been paid for
|
// 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
|
// in full. An order without a Stripe id was never charged through
|
||||||
// a checkout at all, and has nothing to report.
|
// 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;
|
return $subscription;
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,19 @@ class RecordCommercialEvent
|
||||||
$tax = TaxTreatment::for($customer);
|
$tax = TaxTreatment::for($customer);
|
||||||
|
|
||||||
$agreedNet = $netCents;
|
$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
|
// The flat columns describe the TRANSACTION, not the contract: gross is
|
||||||
// what was taken from the customer, and net and tax are that gross
|
// what was taken from the customer, and net and tax are that gross
|
||||||
|
|
@ -57,8 +69,12 @@ class RecordCommercialEvent
|
||||||
$gross = $chargedGrossCents;
|
$gross = $chargedGrossCents;
|
||||||
$net = (int) round($gross / (1 + $tax->rate));
|
$net = (int) round($gross / (1 + $tax->rate));
|
||||||
} else {
|
} 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;
|
$net = $agreedNet;
|
||||||
$gross = $expectedGross;
|
$gross = $tax->grossCents($agreedNet);
|
||||||
}
|
}
|
||||||
|
|
||||||
$version = $subscription->planVersion;
|
$version = $subscription->planVersion;
|
||||||
|
|
@ -137,8 +153,17 @@ class RecordCommercialEvent
|
||||||
// someone asks a year later.
|
// someone asks a year later.
|
||||||
'agreed_net_cents' => $agreedNet,
|
'agreed_net_cents' => $agreedNet,
|
||||||
'expected_gross_cents' => $expectedGross,
|
'expected_gross_cents' => $expectedGross,
|
||||||
'charged_gross_cents' => $gross,
|
// Null where no money moved at this event, and the flag with
|
||||||
'matches_catalogue' => $gross === $expectedGross,
|
// 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),
|
], $extra),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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
|
public function fromStripeEvent(array $event): ?Order
|
||||||
{
|
{
|
||||||
|
|
@ -71,6 +71,13 @@ class StartCustomerProvisioning
|
||||||
// what this did before the column existed.
|
// what this did before the column existed.
|
||||||
'plan_version_id' => $event['plan_version_id'] ?? null,
|
'plan_version_id' => $event['plan_version_id'] ?? null,
|
||||||
'amount_cents' => $event['amount_cents'],
|
'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'],
|
'currency' => $event['currency'],
|
||||||
'datacenter' => $event['datacenter'],
|
'datacenter' => $event['datacenter'],
|
||||||
'stripe_event_id' => $event['id'],
|
'stripe_event_id' => $event['id'],
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
use App\Services\Billing\PlanCatalogue;
|
use App\Services\Billing\PlanCatalogue;
|
||||||
|
use App\Services\Billing\SetupFee;
|
||||||
use App\Services\Provisioning\HostCapacity;
|
use App\Services\Provisioning\HostCapacity;
|
||||||
use App\Services\Stripe\StripeClient;
|
use App\Services\Stripe\StripeClient;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
@ -99,6 +100,12 @@ class CheckoutController extends Controller
|
||||||
return back()->withErrors(['plan' => __('checkout.plan_gone')]);
|
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 {
|
try {
|
||||||
$url = $stripe->createCheckoutSession(
|
$url = $stripe->createCheckoutSession(
|
||||||
priceId: (string) $price->stripe_price_id,
|
priceId: (string) $price->stripe_price_id,
|
||||||
|
|
@ -119,8 +126,14 @@ class CheckoutController extends Controller
|
||||||
// the payment does, and a consent recorded for a checkout
|
// the payment does, and a consent recorded for a checkout
|
||||||
// somebody abandoned would be a consent to nothing.
|
// somebody abandoned would be a consent to nothing.
|
||||||
'immediate_start' => '1',
|
'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,
|
customerEmail: $user?->email,
|
||||||
|
oneOff: $setup,
|
||||||
);
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('Stripe would not open a checkout session', ['exception' => $e]);
|
Log::error('Stripe would not open a checkout session', ['exception' => $e]);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use App\Models\Subscription;
|
||||||
use App\Services\Billing\AddonCatalogue;
|
use App\Services\Billing\AddonCatalogue;
|
||||||
use App\Services\Billing\CustomDomainAccess;
|
use App\Services\Billing\CustomDomainAccess;
|
||||||
use App\Services\Billing\PlanCatalogue;
|
use App\Services\Billing\PlanCatalogue;
|
||||||
|
use App\Services\Billing\SetupFee;
|
||||||
use App\Services\Billing\TaxTreatment;
|
use App\Services\Billing\TaxTreatment;
|
||||||
use App\Services\Provisioning\HostCapacity;
|
use App\Services\Provisioning\HostCapacity;
|
||||||
use App\Support\CompanyProfile;
|
use App\Support\CompanyProfile;
|
||||||
|
|
@ -198,7 +199,11 @@ class LandingController extends Controller
|
||||||
$modules = $this->modulePrices();
|
$modules = $this->modulePrices();
|
||||||
|
|
||||||
$tax = CompanyProfile::taxRate();
|
$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', [
|
return view('landing', [
|
||||||
'plans' => $plans,
|
'plans' => $plans,
|
||||||
|
|
@ -212,7 +217,7 @@ class LandingController extends Controller
|
||||||
'label' => rtrim(rtrim(number_format($tax, 1, ',', '.'), '0'), ','),
|
'label' => rtrim(rtrim(number_format($tax, 1, ',', '.'), '0'), ','),
|
||||||
],
|
],
|
||||||
'setup' => $setupNet === 0 ? null : [
|
'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()),
|
'net' => $this->money($setupNet, Subscription::catalogueCurrency()),
|
||||||
],
|
],
|
||||||
'baseline' => $this->baseline($plans),
|
'baseline' => $this->baseline($plans),
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,20 @@ class StripeWebhookController extends Controller
|
||||||
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
|
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
|
||||||
'datacenter' => $meta['datacenter'] ?? 'fsn',
|
'datacenter' => $meta['datacenter'] ?? 'fsn',
|
||||||
'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
|
'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'),
|
'currency' => strtoupper($object['currency'] ?? 'eur'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,21 @@ class Billing extends Component
|
||||||
private const MAX_STORAGE_PACKS = 50;
|
private const MAX_STORAGE_PACKS = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is
|
* Record a purchase intent (order), at status `pending`.
|
||||||
* mocked for now — the order is created with 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
|
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
|
// right now. Reading that date again later would be reading a
|
||||||
// moving target: Stripe pushes it forward on every renewal.
|
// moving target: Stripe pushes it forward on every renewal.
|
||||||
//
|
//
|
||||||
// An upgrade clears a booked downgrade instead of standing beside
|
// An upgrade deliberately does NOT unbook a booked downgrade. It used
|
||||||
// it, for the same reason the order above was just deleted: the two
|
// to, and that was the whole of what an upgrade click did: nothing in
|
||||||
// contradict each other, and a downgrade left booked would come
|
// this application marks a cart order paid, so the customer traded a
|
||||||
// due months later and quietly undo the bigger package the customer
|
// downgrade they had genuinely booked for a cart line that was never
|
||||||
// had moved to in the meantime.
|
// fulfilled. The two really do contradict each other — a downgrade
|
||||||
if ($contract !== null) {
|
// left booked would come due months later and undo the bigger package
|
||||||
match ($type) {
|
// — and that contradiction is settled where the money is: ApplyPlanChange
|
||||||
'downgrade' => $contract->bookPendingPlanChange((string) $plan),
|
// clears the booking at the moment the upgrade actually lands on the
|
||||||
'upgrade' => $contract->clearPendingPlanChange(),
|
// contract. Until then the booking stands, and the card that states the
|
||||||
default => null,
|
// customer's terms goes on saying so.
|
||||||
};
|
if ($contract !== null && $type === 'downgrade') {
|
||||||
|
$contract->bookPendingPlanChange((string) $plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $replaced;
|
return $replaced;
|
||||||
|
|
|
||||||
|
|
@ -2,57 +2,75 @@
|
||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Illuminate\Support\Carbon;
|
use App\Livewire\Concerns\ResolvesCustomer;
|
||||||
use Illuminate\Support\Number;
|
use App\Models\Invoice;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator as Paginator;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Component;
|
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')]
|
#[Layout('layouts.portal-app')]
|
||||||
class Invoices extends Component
|
class Invoices extends Component
|
||||||
{
|
{
|
||||||
|
use ResolvesCustomer;
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$locale = app()->getLocale();
|
$customer = $this->customer();
|
||||||
$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'],
|
|
||||||
];
|
|
||||||
|
|
||||||
return view('livewire.invoices', [
|
return view('livewire.invoices', [
|
||||||
'rows' => $rows,
|
'invoices' => $this->invoices($customer?->id),
|
||||||
'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]],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<int, Invoice>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Livewire;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
use App\Services\Billing\PlanCatalogue;
|
use App\Services\Billing\PlanCatalogue;
|
||||||
|
use App\Services\Billing\SetupFee;
|
||||||
use App\Services\Provisioning\HostCapacity;
|
use App\Services\Provisioning\HostCapacity;
|
||||||
use App\Support\CompanyProfile;
|
use App\Support\CompanyProfile;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
@ -49,7 +50,13 @@ class Order extends Component
|
||||||
'plans' => $this->plans(),
|
'plans' => $this->plans(),
|
||||||
'contract' => $contract,
|
'contract' => $contract,
|
||||||
'vat' => rtrim(rtrim(number_format(CompanyProfile::taxRate(), 1, ',', '.'), '0'), ','),
|
'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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ class Order extends Model implements ProvisioningSubject
|
||||||
use HasFactory, HasUuid;
|
use HasFactory, HasUuid;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency',
|
'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'setup_fee_cents',
|
||||||
'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'stripe_payment_intent_id',
|
'currency', 'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'stripe_payment_intent_id',
|
||||||
'stripe_invoice_id', 'status', 'immediate_start_consent_at',
|
'stripe_invoice_id', 'status', 'immediate_start_consent_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -30,6 +30,7 @@ class Order extends Model implements ProvisioningSubject
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'amount_cents' => 'integer',
|
'amount_cents' => 'integer',
|
||||||
|
'setup_fee_cents' => 'integer',
|
||||||
'plan_version_id' => 'integer',
|
'plan_version_id' => 'integer',
|
||||||
// When the consumer expressly asked for the service to begin inside
|
// When the consumer expressly asked for the service to begin inside
|
||||||
// the withdrawal window.
|
// the withdrawal window.
|
||||||
|
|
@ -109,6 +110,26 @@ class Order extends Model implements ProvisioningSubject
|
||||||
: TaxTreatment::chargedCents((int) $this->amount_cents);
|
: 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
|
public function taxTreatment(): TaxTreatment
|
||||||
{
|
{
|
||||||
return TaxTreatment::for($this->customer);
|
return TaxTreatment::for($this->customer);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\Billing\AddonPrices;
|
||||||
use App\Services\Billing\PlanCatalogue;
|
use App\Services\Billing\PlanCatalogue;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -370,4 +371,27 @@ class Subscription extends Model
|
||||||
->where('cancelled_at', null)
|
->where('cancelled_at', null)
|
||||||
->sum(fn (SubscriptionAddon $addon) => $addon->monthlyCents());
|
->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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,41 @@
|
||||||
namespace App\Observers;
|
namespace App\Observers;
|
||||||
|
|
||||||
use App\Actions\ApplyPlanChange;
|
use App\Actions\ApplyPlanChange;
|
||||||
|
use App\Actions\BookAddon;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Services\Billing\AddonCatalogue;
|
||||||
|
use App\Services\Billing\CustomDomainAccess;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
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
|
* StartCustomerProvisioning turns a paid FIRST purchase into work from the
|
||||||
* announced the payment. An upgrade has no such webhook: there is no checkout in
|
* webhook that announced the payment. Nothing sold from the portal's shop has
|
||||||
* this application yet, payment is mocked repo-wide, and the order sits in the
|
* such a webhook: **no code path in this application marks a cart order paid.**
|
||||||
* cart at `pending` until something marks it paid. So the trigger is hung on the
|
* That is stated here rather than implied, because it is the one fact a reader of
|
||||||
* fact itself — the order becoming paid — rather than on whichever call site
|
* this file needs and cannot see from it — the shop writes the order at
|
||||||
* happens to exist today. A real Stripe checkout, an operator settling a
|
* `pending`, tells the customer "wir schalten ihn nach der Zahlung frei", and
|
||||||
* purchase by hand and a seeder all end at the same `status = paid`, and all
|
* there it stops until a second checkout, an operator or a seeder moves it on.
|
||||||
* three have to result in the customer getting what they bought.
|
*
|
||||||
|
* 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
|
* 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
|
* already-paid by provisioning and by a grant — both of which do their own
|
||||||
* than changing one) or pending by the shop; an upgrade that goes straight in as
|
* fulfilment, because a grant is not a payment — or pending by the shop. Firing
|
||||||
* paid is not a state this application produces, and firing on create would make
|
* on create would make every fixture that builds a paid order provision, book and
|
||||||
* every fixture that builds one start a provisioning run.
|
* bill.
|
||||||
*
|
*
|
||||||
* A downgrade is deliberately not here. It costs nothing, is never paid, and
|
* 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
|
class OrderObserver
|
||||||
{
|
{
|
||||||
|
|
@ -36,22 +47,85 @@ class OrderObserver
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($order->type !== 'upgrade') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (Throwable $e) {
|
||||||
// Never allowed to fail the write that marked the order paid. That
|
// Never allowed to fail the write that marked the order paid. That
|
||||||
// write is the record that money changed hands, and undoing it over
|
// write is the record that money changed hands, and undoing it over a
|
||||||
// a plan change would erase the payment and leave nothing to retry
|
// delivery would erase the payment and leave nothing to retry from. A
|
||||||
// from. A change that did not land is loud in its own right: the
|
// delivery that did not land is loud in its own right: the order stays
|
||||||
// order stays unconsumed and the contract still says the old
|
// unconsumed and the contract still says what it said, both of which
|
||||||
// package, both of which somebody can see.
|
// somebody can see.
|
||||||
Log::error('Failed to apply the plan change a paid order bought.', [
|
Log::error('Failed to deliver what a paid order bought.', [
|
||||||
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
|
'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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,25 @@ final class InvoiceDocument extends TCPDF
|
||||||
|
|
||||||
private string $pageLabel = 'Seite';
|
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<string, mixed> $issuer */
|
/** @param array<string, mixed> $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->issuer = $issuer;
|
||||||
$this->logoFile = $logoFile;
|
$this->logoFile = $logoFile;
|
||||||
$this->pageLabel = $pageLabel;
|
$this->pageLabel = $pageLabel;
|
||||||
|
$this->vatLabel = $vatLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function Header(): void // phpcs:ignore
|
public function Header(): void // phpcs:ignore
|
||||||
|
|
@ -50,28 +63,7 @@ final class InvoiceDocument extends TCPDF
|
||||||
$this->SetFont('dejavusans', '', 6.5);
|
$this->SetFont('dejavusans', '', 6.5);
|
||||||
$this->SetTextColor(110, 110, 122);
|
$this->SetTextColor(110, 110, 122);
|
||||||
|
|
||||||
$columns = [
|
$columns = self::footerColumns($this->issuer, $this->vatLabel);
|
||||||
[
|
|
||||||
$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'] ?? '',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
// A hairline above it, the same one the totals block uses, so the page
|
// A hairline above it, the same one the totals block uses, so the page
|
||||||
// reads as one document rather than two halves.
|
// reads as one document rather than two halves.
|
||||||
|
|
@ -82,14 +74,77 @@ final class InvoiceDocument extends TCPDF
|
||||||
|
|
||||||
foreach ($columns as $index => $lines) {
|
foreach ($columns as $index => $lines) {
|
||||||
$this->SetXY(20 + ($index * $width), -24);
|
$this->SetXY(20 + ($index * $width), -24);
|
||||||
// Empty entries are dropped rather than printed as blank lines: a
|
$this->MultiCell($width - 2, 3, implode("\n", $lines), 0, 'L', false, 0, '', '', true, 0, false, true, 0, 'T');
|
||||||
// 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->SetXY(150, -30);
|
$this->SetXY(150, -30);
|
||||||
$this->SetFont('dejavusans', '', 6.5);
|
$this->SetFont('dejavusans', '', 6.5);
|
||||||
$this->Cell(40, 3, $this->pageLabel.' '.$this->getAliasNumPage().'/'.$this->getAliasNbPages(), 0, 0, 'R');
|
$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<string, mixed> $issuer
|
||||||
|
* @return array<int, array<int, string>>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ final class InvoiceRenderer
|
||||||
$issuer = (array) ($snapshot['issuer'] ?? []);
|
$issuer = (array) ($snapshot['issuer'] ?? []);
|
||||||
|
|
||||||
$pdf = new InvoiceDocument('P', 'mm', 'A4', true, 'UTF-8');
|
$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->SetCreator('CluPilot');
|
||||||
$pdf->SetAuthor((string) ($issuer['name'] ?? ''));
|
$pdf->SetAuthor((string) ($issuer['name'] ?? ''));
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,15 @@ final class IssueInvoice
|
||||||
* money. Each order becomes a line, which is also what was asked for —
|
* money. Each order becomes a line, which is also what was asked for —
|
||||||
* every add-on listed on its own.
|
* every add-on listed on its own.
|
||||||
*
|
*
|
||||||
* Each line is what the order actually COST the customer — Order::chargedCents(),
|
* Each line is what the order actually COST the customer —
|
||||||
* which is Stripe's own total where the order went through a checkout. It
|
* Order::packageChargedCents(), which is Stripe's own total for the package
|
||||||
* used to be `amount_cents` read as a net figure, and once the catalogue
|
* where the order went through a checkout. It used to be `amount_cents` read
|
||||||
* started charging gross that put the VAT on the document twice.
|
* 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<int, Order> $orders
|
* @param Collection<int, Order> $orders
|
||||||
*/
|
*/
|
||||||
|
|
@ -83,9 +88,30 @@ final class IssueInvoice
|
||||||
])),
|
])),
|
||||||
'quantity_milli' => 1000,
|
'quantity_milli' => 1000,
|
||||||
'unit' => '',
|
'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();
|
])->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(
|
return $this->issue(
|
||||||
customer: $customer,
|
customer: $customer,
|
||||||
lines: $lines,
|
lines: $lines,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Support\CompanyProfile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-off charge for setting a customer up — and the one place it is turned
|
||||||
|
* into money.
|
||||||
|
*
|
||||||
|
* The fee had been advertised for months and charged to nobody. It was on the
|
||||||
|
* public price sheet ("Einrichtung kostet einmalig 118,80 €"), on the booking page
|
||||||
|
* ("zzgl. Einrichtung einmalig"), and the operator set the figure in the console —
|
||||||
|
* and CompanyProfile::setupFeeCents() had no reader outside those two sentences
|
||||||
|
* and the form that wrote it. Nothing put it on a checkout, on an invoice item or
|
||||||
|
* on any bill at all.
|
||||||
|
*
|
||||||
|
* It is charged now. The two sentences and the money read the same figure through
|
||||||
|
* this class, so a change of rate or of fee cannot move one without moving the
|
||||||
|
* others.
|
||||||
|
*
|
||||||
|
* **The figure is GROSS, like every other price this platform charges.** The
|
||||||
|
* console asks the operator for the net amount, because that is the commercial
|
||||||
|
* number and the field says so; what a customer pays is that with the domestic
|
||||||
|
* rate on it, formed by TaxTreatment::chargedCents(), which is the single place a
|
||||||
|
* gross is formed. That is also why the fee is the same for a consumer in Vienna
|
||||||
|
* and for a reverse-charge business in Rotterdam: the price on the page is the
|
||||||
|
* price at the till, for everybody, and the invoice is where the split differs.
|
||||||
|
*
|
||||||
|
* Zero means there is no such fee. Every reader has to treat that as "no line at
|
||||||
|
* all" rather than "a line of nought" — a checkout that itemises nothing owed is
|
||||||
|
* worse than one that says nothing.
|
||||||
|
*/
|
||||||
|
final class SetupFee
|
||||||
|
{
|
||||||
|
/** The net figure an operator typed, in cents. Zero when there is no fee. */
|
||||||
|
public static function netCents(): int
|
||||||
|
{
|
||||||
|
return CompanyProfile::setupFeeCents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a customer is actually charged for it, tax included. */
|
||||||
|
public static function chargedCents(): int
|
||||||
|
{
|
||||||
|
$net = self::netCents();
|
||||||
|
|
||||||
|
return $net === 0 ? 0 : TaxTreatment::chargedCents($net);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fee as the extra, non-recurring line of a checkout session — or null
|
||||||
|
* when the operator has set none.
|
||||||
|
*
|
||||||
|
* `$currency` is the recurring Price's own, not the catalogue default: Stripe
|
||||||
|
* requires every line of one session to share a currency, and reading two
|
||||||
|
* sources for it is how a checkout ends up refused for a mismatch nobody can
|
||||||
|
* see from either page.
|
||||||
|
*
|
||||||
|
* @return array{amount_cents: int, label: string, currency: string}|null
|
||||||
|
*/
|
||||||
|
public static function checkoutLine(string $currency): ?array
|
||||||
|
{
|
||||||
|
$charged = self::chargedCents();
|
||||||
|
|
||||||
|
if ($charged === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'amount_cents' => $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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -103,7 +103,11 @@ class FakeStripeClient implements StripeClient
|
||||||
/**
|
/**
|
||||||
* Every checkout that was opened, in order.
|
* Every checkout that was opened, in order.
|
||||||
*
|
*
|
||||||
* @var array<int, array{price: string, success: string, cancel: string, metadata: array, email: ?string}>
|
* `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<int, array{price: string, success: string, cancel: string, metadata: array, email: ?string, one_off: ?array<string, mixed>}>
|
||||||
*/
|
*/
|
||||||
public array $checkouts = [];
|
public array $checkouts = [];
|
||||||
|
|
||||||
|
|
@ -119,6 +123,7 @@ class FakeStripeClient implements StripeClient
|
||||||
array $metadata = [],
|
array $metadata = [],
|
||||||
?string $customerEmail = null,
|
?string $customerEmail = null,
|
||||||
?string $idempotencyKey = null,
|
?string $idempotencyKey = null,
|
||||||
|
?array $oneOff = null,
|
||||||
): string {
|
): string {
|
||||||
$this->failIfAsked();
|
$this->failIfAsked();
|
||||||
|
|
||||||
|
|
@ -128,6 +133,7 @@ class FakeStripeClient implements StripeClient
|
||||||
'cancel' => $cancelUrl,
|
'cancel' => $cancelUrl,
|
||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
'email' => $customerEmail,
|
'email' => $customerEmail,
|
||||||
|
'one_off' => $oneOff,
|
||||||
];
|
];
|
||||||
|
|
||||||
return 'https://checkout.stripe.test/session/'.count($this->checkouts);
|
return 'https://checkout.stripe.test/session/'.count($this->checkouts);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class HttpStripeClient implements StripeClient
|
||||||
array $metadata = [],
|
array $metadata = [],
|
||||||
?string $customerEmail = null,
|
?string $customerEmail = null,
|
||||||
?string $idempotencyKey = null,
|
?string $idempotencyKey = null,
|
||||||
|
?array $oneOff = null,
|
||||||
): string {
|
): string {
|
||||||
return (string) $this->request($idempotencyKey)
|
return (string) $this->request($idempotencyKey)
|
||||||
->asForm()
|
->asForm()
|
||||||
|
|
@ -33,6 +34,7 @@ class HttpStripeClient implements StripeClient
|
||||||
'mode' => 'subscription',
|
'mode' => 'subscription',
|
||||||
'line_items[0][price]' => $priceId,
|
'line_items[0][price]' => $priceId,
|
||||||
'line_items[0][quantity]' => 1,
|
'line_items[0][quantity]' => 1,
|
||||||
|
...$this->oneOffLine($oneOff),
|
||||||
'success_url' => $successUrl,
|
'success_url' => $successUrl,
|
||||||
'cancel_url' => $cancelUrl,
|
'cancel_url' => $cancelUrl,
|
||||||
// Prefilled, not fixed: Stripe still lets the customer correct
|
// Prefilled, not fixed: Stripe still lets the customer correct
|
||||||
|
|
@ -49,6 +51,39 @@ class HttpStripeClient implements StripeClient
|
||||||
->json('url');
|
->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<string, string|int>
|
||||||
|
*/
|
||||||
|
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
|
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
|
||||||
{
|
{
|
||||||
return (string) $this->request($idempotencyKey)
|
return (string) $this->request($idempotencyKey)
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,22 @@ interface StripeClient
|
||||||
* plan, plan version and datacenter. It is set on the SESSION and on the
|
* plan, plan version and datacenter. It is set on the SESSION and on the
|
||||||
* subscription: the session's copy is what StripeWebhookController reads,
|
* subscription: the session's copy is what StripeWebhookController reads,
|
||||||
* the subscription's is what any later billing event carries.
|
* 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(
|
public function createCheckoutSession(
|
||||||
string $priceId,
|
string $priceId,
|
||||||
|
|
@ -73,6 +89,7 @@ interface StripeClient
|
||||||
array $metadata = [],
|
array $metadata = [],
|
||||||
?string $customerEmail = null,
|
?string $customerEmail = null,
|
||||||
?string $idempotencyKey = null,
|
?string $idempotencyKey = null,
|
||||||
|
?array $oneOff = null,
|
||||||
): string;
|
): string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of what was charged at the checkout was the one-off setup fee.
|
||||||
|
*
|
||||||
|
* The fee has been on the public price sheet and on the booking page for months
|
||||||
|
* — "zzgl. Einrichtung einmalig", and since the gross-pricing work with the
|
||||||
|
* figure named — and it was never charged to anybody. The operator sets it in
|
||||||
|
* the console, two pages read it out to quote it, and nothing put it on a bill.
|
||||||
|
* A price advertised and not taken is a smaller problem than one taken and not
|
||||||
|
* advertised, but it is the same class of problem: the page and the money have
|
||||||
|
* to say the same thing.
|
||||||
|
*
|
||||||
|
* It is charged now, as a one-off line on the Stripe checkout session beside the
|
||||||
|
* recurring one — which Stripe puts on the initial invoice only, which is exactly
|
||||||
|
* what a setup fee is. That makes the session's `amount_total`, and therefore the
|
||||||
|
* order's `amount_cents`, the package's gross PLUS the fee, and this column is
|
||||||
|
* what lets the two be told apart again afterwards.
|
||||||
|
*
|
||||||
|
* Why it matters that they can: `amount_cents` is what a withdrawal refunds and
|
||||||
|
* what the confirmation mail states, and both of those want the whole of what was
|
||||||
|
* taken. But the proof register holds the charge against the CONTRACT's agreed
|
||||||
|
* price, and the invoice needs the fee as a line of its own rather than folded
|
||||||
|
* silently into the package's — so everything that reasons about the package
|
||||||
|
* subtracts this, through Order::packageChargedCents().
|
||||||
|
*
|
||||||
|
* Zero, not null, and no backfill: every order that already exists was charged no
|
||||||
|
* setup fee, and zero is the truth about them rather than a placeholder.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('orders', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('setup_fee_cents')->default(0)->after('amount_cents');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('orders', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('setup_fee_cents');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,12 @@ return [
|
||||||
'plan_gone' => 'Dieses Paket ist derzeit nicht buchbar. Bitte wählen Sie ein anderes.',
|
'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.',
|
'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 ausdrückliche Wunsch, dass die Leistung sofort beginnt.
|
||||||
//
|
//
|
||||||
// Der zweite Satz nannte früher eine anteilige Zahlungspflicht (FAGG §16).
|
// Der zweite Satz nannte früher eine anteilige Zahlungspflicht (FAGG §16).
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ return [
|
||||||
'due' => 'Fällig am',
|
'due' => 'Fällig am',
|
||||||
'customer_number' => 'Kunde',
|
'customer_number' => 'Kunde',
|
||||||
'customer_vat' => 'UID',
|
'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',
|
'page' => 'Seite',
|
||||||
|
|
||||||
'col_pos' => 'Pos',
|
'col_pos' => 'Pos',
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,14 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => 'Rechnungen',
|
'title' => 'Rechnungen',
|
||||||
'subtitle' => 'Ihre Abrechnungen und kommende Zahlungen.',
|
'subtitle' => 'Ihre ausgestellten Rechnungen, jederzeit als PDF.',
|
||||||
'col_no' => 'Nummer',
|
'col_no' => 'Nummer',
|
||||||
'col_date' => 'Datum',
|
'col_date' => 'Datum',
|
||||||
'col_amount' => 'Betrag',
|
'col_net' => 'Netto',
|
||||||
'col_status' => 'Status',
|
'col_gross' => 'Brutto',
|
||||||
'paid' => 'Bezahlt',
|
|
||||||
'pdf' => 'PDF',
|
'pdf' => 'PDF',
|
||||||
'next_charge' => 'Nächste Abbuchung',
|
// Eine Stornorechnung. Eigenes Dokument mit eigener Nummer, das die
|
||||||
'on_date' => 'am :date',
|
// ursprüngliche vollständig zurücknimmt — deshalb die negativen Beträge.
|
||||||
'spend' => 'Ausgaben (5 Monate)',
|
'storno' => 'Storno',
|
||||||
'download_toast' => 'Rechnungs-PDF im Prototyp nur angedeutet.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ return [
|
||||||
'plan_gone' => 'That package cannot be booked at the moment. Please choose another one.',
|
'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.',
|
'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 express request that the service begin at once.
|
||||||
//
|
//
|
||||||
// The second sentence used to state a pro-rata liability (FAGG §16). There
|
// The second sentence used to state a pro-rata liability (FAGG §16). There
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ return [
|
||||||
'due' => 'Due',
|
'due' => 'Due',
|
||||||
'customer_number' => 'Customer',
|
'customer_number' => 'Customer',
|
||||||
'customer_vat' => 'VAT ID',
|
'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',
|
'page' => 'Page',
|
||||||
|
|
||||||
'col_pos' => 'Pos',
|
'col_pos' => 'Pos',
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,14 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => 'Invoices',
|
'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_no' => 'Number',
|
||||||
'col_date' => 'Date',
|
'col_date' => 'Date',
|
||||||
'col_amount' => 'Amount',
|
'col_net' => 'Net',
|
||||||
'col_status' => 'Status',
|
'col_gross' => 'Gross',
|
||||||
'paid' => 'Paid',
|
|
||||||
'pdf' => 'PDF',
|
'pdf' => 'PDF',
|
||||||
'next_charge' => 'Next charge',
|
// A cancellation invoice: its own document with its own number, taking the
|
||||||
'on_date' => 'on :date',
|
// original back in full — which is why its amounts are negative.
|
||||||
'spend' => 'Spend (5 months)',
|
'storno' => 'Cancellation',
|
||||||
'download_toast' => 'Invoice PDF is only indicated in this prototype.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,62 @@
|
||||||
<div class="space-y-6" x-data="{ msgs: @js(['download' => __('invoices.download_toast')]) }">
|
<div class="space-y-6">
|
||||||
<div class="animate-rise">
|
<div class="animate-rise">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('invoices.title') }}</h1>
|
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('invoices.title') }}</h1>
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('invoices.subtitle') }}</p>
|
<p class="mt-1 text-sm text-muted">{{ __('invoices.subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px] lg:items-start">
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
@if ($invoices->isEmpty())
|
||||||
|
<p class="p-8 text-center text-sm text-muted">{{ __('invoices.empty') }}</p>
|
||||||
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||||
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_no') }}</th>
|
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_no') }}</th>
|
||||||
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_date') }}</th>
|
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_date') }}</th>
|
||||||
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_amount') }}</th>
|
<th class="px-4 py-3 text-right font-semibold">{{ __('invoices.col_net') }}</th>
|
||||||
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_status') }}</th>
|
<th class="px-4 py-3 text-right font-semibold">{{ __('invoices.col_gross') }}</th>
|
||||||
<th class="px-4 py-3"></th>
|
<th class="px-4 py-3"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach ($rows as $r)
|
@foreach ($invoices as $invoice)
|
||||||
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
|
<tr wire:key="inv-{{ $invoice->uuid }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
|
||||||
<td class="px-4 py-3 font-mono text-xs text-body">{{ $r['no'] }}</td>
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3 text-body">{{ $r['date'] }}</td>
|
<span class="font-mono text-xs font-semibold text-ink">{{ $invoice->number }}</span>
|
||||||
<td class="px-4 py-3 font-mono text-body">{{ $r['amount'] }}</td>
|
{{-- A cancellation says so on the row. Two documents
|
||||||
<td class="px-4 py-3"><x-ui.badge status="active">{{ __('invoices.paid') }}</x-ui.badge></td>
|
with opposite figures and nothing to tell them
|
||||||
<td class="px-4 py-3 text-right"><x-ui.button variant="ghost" size="sm" @click="$dispatch('notify', { message: msgs.download })"><x-ui.icon name="download" class="size-4" />{{ __('invoices.pdf') }}</x-ui.button></td>
|
apart reads as a double charge. --}}
|
||||||
|
@if ($invoice->cancels_invoice_id !== null)
|
||||||
|
<x-ui.badge status="warning" class="ml-2">{{ __('invoices.storno') }}</x-ui.badge>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
{{-- ->local() even for a date (R19): stored in UTC, and
|
||||||
|
around midnight the wall clock is already tomorrow. --}}
|
||||||
|
<td class="px-4 py-3 text-muted">{{ $invoice->issued_on?->local()->format('d.m.Y') }}</td>
|
||||||
|
<td class="px-4 py-3 text-right font-mono tabular-nums text-muted">{{ \App\Services\Billing\InvoiceMath::money($invoice->net_cents) }}</td>
|
||||||
|
<td class="px-4 py-3 text-right font-mono font-semibold tabular-nums text-ink">{{ \App\Services\Billing\InvoiceMath::money($invoice->gross_cents) }} {{ $invoice->currency }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
{{-- 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. --}}
|
||||||
|
<a href="{{ route('invoices.pdf', $invoice->uuid) }}"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1.5 text-xs font-medium text-body hover:border-accent-border hover:text-accent-text">
|
||||||
|
<x-ui.icon name="download" class="size-4" />{{ __('invoices.pdf') }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
@if ($invoices->hasPages())
|
||||||
<div class="rounded-lg border border-accent-border bg-accent-subtle p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
<div class="border-t border-line p-4">{{ $invoices->links() }}</div>
|
||||||
<p class="text-xs font-semibold text-muted">{{ __('invoices.next_charge') }}</p>
|
@endif
|
||||||
<p class="mt-1 font-mono text-2xl font-semibold text-ink">{{ $nextAmount }}</p>
|
@endif
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('invoices.on_date', ['date' => $nextCharge]) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('invoices.spend') }}</h2>
|
|
||||||
<div class="mt-3 h-48" wire:ignore>
|
|
||||||
<x-ui.chart :config="$spendChart" class="h-48" :label="__('invoices.spend')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,35 @@ $portal = function () {
|
||||||
Route::get('/users', Users::class)->name('users');
|
Route::get('/users', Users::class)->name('users');
|
||||||
Route::get('/backups', Backups::class)->name('backups');
|
Route::get('/backups', Backups::class)->name('backups');
|
||||||
Route::get('/invoices', Invoices::class)->name('invoices');
|
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('/billing', Billing::class)->name('billing');
|
||||||
Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
|
Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
|
||||||
Route::get('/support', Support::class)->name('support');
|
Route::get('/support', Support::class)->name('support');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Billing;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Instance;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\SubscriptionAddon;
|
||||||
|
use App\Models\SubscriptionRecord;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Billing\AddonCatalogue;
|
||||||
|
use App\Services\Stripe\FakeStripeClient;
|
||||||
|
use App\Services\Stripe\StripeClient;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whatever marks an order paid delivers what the order bought.
|
||||||
|
*
|
||||||
|
* The seam existed for exactly one type. OrderObserver fired only for `upgrade`,
|
||||||
|
* so a module, a storage pack or a traffic pack marked paid — by an operator, by
|
||||||
|
* a seeder, by the second checkout when it is built — reached nothing at all:
|
||||||
|
* there was no path from a paid module order to a SubscriptionAddon anywhere in
|
||||||
|
* the application, which left AddonPrices, SyncStripeAddonItems and
|
||||||
|
* clupilot:end-cancelled-addons with no reachable caller behind a payment.
|
||||||
|
*
|
||||||
|
* **Nothing in this application marks a cart order paid yet.** That is why the
|
||||||
|
* trigger hangs on the fact rather than on a call site: these tests do by hand
|
||||||
|
* what the missing checkout will do, and the delivery is written once, where it
|
||||||
|
* cannot be forgotten by whichever thing eventually pays.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->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);
|
||||||
|
});
|
||||||
|
|
@ -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');
|
->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();
|
Queue::fake();
|
||||||
[, $user, $subscription] = pendingChangeCustomer('team');
|
[, $user, $subscription] = pendingChangeCustomer('team');
|
||||||
|
|
||||||
|
|
@ -119,15 +120,47 @@ it('cancels a booked downgrade when the customer upgrades instead', function ()
|
||||||
|
|
||||||
$component->call('purchase', 'upgrade', 'business');
|
$component->call('purchase', 'upgrade', 'business');
|
||||||
|
|
||||||
// Left booked, it would have come due months later and undone the bigger
|
// The click used to unbook it there and then, and nothing in this
|
||||||
// package the customer had just paid for.
|
// application marks a cart order paid — so the customer gave up a downgrade
|
||||||
expect($subscription->fresh()->pending_plan)->toBeNull()
|
// 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();
|
->and($subscription->fresh()->pending_effective_at)->toBeNull();
|
||||||
|
|
||||||
Carbon::setTestNow($bookedFor->copy()->addHour());
|
Carbon::setTestNow($bookedFor->copy()->addHour());
|
||||||
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
|
$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();
|
Carbon::setTestNow();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Invoices;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Billing\IssueInvoice;
|
||||||
|
use App\Support\CompanyProfile;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer sees their own invoices, and only those.
|
||||||
|
*
|
||||||
|
* The page used to be a mock nobody took back out: five invented documents
|
||||||
|
* numbered CP-2026-0003 to CP-2026-0007 — a prefix belonging to no series this
|
||||||
|
* installation has ever configured — all marked paid, a rising spend curve and a
|
||||||
|
* next charge of 198 € on a date somebody typed. Every signed-in customer saw it,
|
||||||
|
* while the invoices we had really issued them, and really mailed them, appeared
|
||||||
|
* nowhere in the portal at all.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
CompanyProfile::put([
|
||||||
|
'name' => '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'));
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\ApplyStripeBillingEvent;
|
||||||
use App\Actions\BookAddon;
|
use App\Actions\BookAddon;
|
||||||
use App\Actions\RecordCommercialEvent;
|
use App\Actions\RecordCommercialEvent;
|
||||||
|
use App\Livewire\Billing;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
|
use App\Models\Host;
|
||||||
|
use App\Models\Instance;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
use App\Models\SubscriptionAddon;
|
use App\Models\SubscriptionAddon;
|
||||||
use App\Models\SubscriptionRecord;
|
use App\Models\SubscriptionRecord;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Billing\AddonCatalogue;
|
use App\Services\Billing\AddonCatalogue;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
|
@ -15,6 +20,16 @@ use Illuminate\Support\Facades\Queue;
|
||||||
* — without reconstructing it from tables that have moved on since.
|
* — without reconstructing it from tables that have moved on since.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** A business in another member state, its VAT ID verified — reverse charge. */
|
||||||
|
function reverseChargeCustomer(): Customer
|
||||||
|
{
|
||||||
|
return Customer::factory()->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 () {
|
it('records the sale the moment a contract is opened', function () {
|
||||||
Queue::fake();
|
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 () {
|
it('shows the customer their whole monthly bill, modules included', function () {
|
||||||
$subscription = Subscription::factory()->plan('team')->create();
|
$subscription = Subscription::factory()->plan('team')->create();
|
||||||
$instance = App\Models\Instance::factory()->create([
|
$instance = Instance::factory()->create([
|
||||||
'customer_id' => $subscription->customer_id,
|
'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',
|
'plan' => 'team', 'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$subscription->update(['instance_id' => $instance->id]);
|
$subscription->update(['instance_id' => $instance->id]);
|
||||||
|
|
||||||
app(BookAddon::class)($subscription, 'priority_support');
|
app(BookAddon::class)($subscription, 'priority_support');
|
||||||
|
|
||||||
$user = App\Models\User::factory()->create(['email' => $subscription->customer->email]);
|
$user = User::factory()->create(['email' => $subscription->customer->email]);
|
||||||
$page = Livewire\Livewire::actingAs($user)->test(App\Livewire\Billing::class);
|
$page = Livewire\Livewire::actingAs($user)->test(Billing::class);
|
||||||
|
|
||||||
// The plan alone is not what they pay once a module is booked.
|
// The plan alone is not what they pay once a module is booked.
|
||||||
expect($page->viewData('totalMonthlyCents'))->toBe(17900 + 2900);
|
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 () {
|
it('states net, tax and gross rather than leaving them to be recomputed', function () {
|
||||||
$customer = Customer::factory()->create([
|
$subscription = Subscription::factory()->plan('team')->create(['customer_id' => reverseChargeCustomer()->id]);
|
||||||
'vat_id' => 'DE123456789',
|
|
||||||
'vat_id_verified_at' => now(),
|
|
||||||
'vat_id_verified_value' => 'DE123456789',
|
|
||||||
]);
|
|
||||||
$subscription = Subscription::factory()->plan('team')->create(['customer_id' => $customer->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
|
// The whole of it is net at 0 %. Recorded as it stood on the day — a rate
|
||||||
// later must not change the answer for this sale.
|
// that changes later must not change the answer for this sale.
|
||||||
expect($record->net_cents)->toBe(17900)
|
expect($record->net_cents)->toBe(21480)
|
||||||
->and($record->tax_cents)->toBe(0)
|
->and($record->tax_cents)->toBe(0)
|
||||||
->and($record->gross_cents)->toBe(17900)
|
->and($record->gross_cents)->toBe(21480)
|
||||||
->and($record->reverse_charge)->toBeTrue();
|
->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();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Order as OrderPage;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\SubscriptionRecord;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Stripe\FakeStripeClient;
|
||||||
|
use App\Services\Stripe\StripeClient;
|
||||||
|
use App\Support\CompanyProfile;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The setup fee is charged, because it is advertised.
|
||||||
|
*
|
||||||
|
* It sat on the public price sheet ("Einrichtung kostet einmalig 118,80 €") and on
|
||||||
|
* the booking page ("zzgl. Einrichtung einmalig") for months, the operator set the
|
||||||
|
* figure in the console, and nothing anywhere ever charged it: the checkout posted
|
||||||
|
* one recurring line item and there was no invoice item, no one-off line and no
|
||||||
|
* reader of the figure outside the two sentences that quoted it.
|
||||||
|
*
|
||||||
|
* It rides along as a second, non-recurring line on the same Stripe session, which
|
||||||
|
* Stripe puts on the initial invoice only — charged once, with the first month, and
|
||||||
|
* never again.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->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);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Billing\InvoiceDocument;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The issuer's own VAT number is labelled.
|
||||||
|
*
|
||||||
|
* It was printed bare, between the register number and the court, on every page of
|
||||||
|
* every invoice. The document was valid — the reverse-charge note is printed and
|
||||||
|
* the RECIPIENT's number is labelled — but an auditor scanning for "UID" found the
|
||||||
|
* recipient's and not ours, so the one number an Austrian invoice is legally
|
||||||
|
* worthless without read as a reference of some kind.
|
||||||
|
*/
|
||||||
|
$issuer = [
|
||||||
|
'name' => '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([]);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue