Skip the invoice for a full gift, keep it out of revenue, hide its price
IssueInvoice now rejects free-grant orders before it will consume an invoice number for them — a full gift produces no invoice at all, while a discounted grant still charges something and is invoiced exactly like an ordinary sale. Keyed on the linked subscription/add-on's own provenance, not on the amount being zero, so a genuinely fully-discounted Stripe checkout is unaffected. Revenue excludes granted contracts from MRR/ARR/ARPU and the contracts count — a gift at 0 must not drag the average down — and shows their number as its own KPI instead of folding it in, since the owner measures himself on it. The portal (Cloud, Billing) shows a granted package or module without a price at all: not "free", not struck through, just the service, so a later conversion to paid does not read as a negotiation.feat/granted-plans
parent
602602864a
commit
c71b2c362b
|
|
@ -59,6 +59,15 @@ class Revenue extends Component
|
|||
'value' => (string) array_sum(array_column($totals, 'contracts')),
|
||||
'sub' => __('admin.rev.contracts_sub'),
|
||||
],
|
||||
[
|
||||
// Shown separately rather than folded into the figures
|
||||
// above: a granted contract at 0 must not drag ARPU down
|
||||
// or read as revenue, and the owner measures himself on
|
||||
// this number too.
|
||||
'label' => __('admin.rev.granted'),
|
||||
'value' => (string) $this->grantedCount(),
|
||||
'sub' => __('admin.rev.granted_sub'),
|
||||
],
|
||||
],
|
||||
'planCharts' => $this->planCharts(),
|
||||
'payments' => $this->recentPayments($locale),
|
||||
|
|
@ -66,13 +75,18 @@ class Revenue extends Component
|
|||
}
|
||||
|
||||
/**
|
||||
* Recurring revenue per currency, off the contracts.
|
||||
* Recurring revenue per currency, off the PAYING contracts.
|
||||
*
|
||||
* Never summed across currencies. Frozen contract prices, not the
|
||||
* catalogue, so a price rise does not retroactively inflate what
|
||||
* grandfathered customers are reported to pay. Add-ons included and yearly
|
||||
* terms divided — totalMonthlyCents() owns both of those rules.
|
||||
*
|
||||
* Granted contracts are excluded here, not merely zeroed: a contract with
|
||||
* `granted_at` set is a gift or a discount an operator handed out, not a
|
||||
* sale, and counting it — even at 0 — would inflate the contracts count
|
||||
* this average is divided by and understate ARPU for everyone else.
|
||||
*
|
||||
* @return array<string, array{cents:int, customers:array<int,bool>, contracts:int}>
|
||||
*/
|
||||
private function recurringTotals(): array
|
||||
|
|
@ -81,6 +95,7 @@ class Revenue extends Component
|
|||
|
||||
Subscription::query()
|
||||
->where('status', 'active')
|
||||
->whereNull('granted_at')
|
||||
->with('addons')
|
||||
->chunkById(200, function ($subscriptions) use (&$totals) {
|
||||
foreach ($subscriptions as $subscription) {
|
||||
|
|
@ -117,7 +132,7 @@ class Revenue extends Component
|
|||
*
|
||||
* By what customers are ON, not by what is on sale: a withdrawn plan still
|
||||
* bills, and leaving it out would understate the total the chart sits next
|
||||
* to.
|
||||
* to. Granted contracts are excluded — see recurringTotals().
|
||||
*
|
||||
* Per currency, because a doughnut adds its slices together. Two currencies
|
||||
* in one ring produces a total that is not an amount of anything, and it
|
||||
|
|
@ -131,6 +146,7 @@ class Revenue extends Component
|
|||
|
||||
Subscription::query()
|
||||
->where('status', 'active')
|
||||
->whereNull('granted_at')
|
||||
->with('addons')
|
||||
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
|
||||
foreach ($subscriptions as $subscription) {
|
||||
|
|
@ -169,6 +185,12 @@ class Revenue extends Component
|
|||
return $charts;
|
||||
}
|
||||
|
||||
/** How many active contracts are gifts or discounts, not sales. */
|
||||
private function grantedCount(): int
|
||||
{
|
||||
return Subscription::query()->where('status', 'active')->whereNotNull('granted_at')->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* The last payments actually recorded.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ use App\Models\Subscription;
|
|||
use App\Services\Billing\AddonCatalogue;
|
||||
use App\Services\Billing\DowngradeCheck;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Billing\TaxTreatment;
|
||||
use App\Services\Traffic\TrafficMeter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.portal-app')]
|
||||
|
|
@ -112,7 +115,7 @@ class Billing extends Component
|
|||
$this->dispatch('notify', message: __('billing.purchased'));
|
||||
}
|
||||
|
||||
#[\Livewire\Attributes\On('order-removed')]
|
||||
#[On('order-removed')]
|
||||
public function orderRemoved(): void
|
||||
{
|
||||
$this->dispatch('notify', message: __('billing.cart.removed'));
|
||||
|
|
@ -136,6 +139,10 @@ class Billing extends Component
|
|||
// Per month: the card says "/ month", and a yearly contract stores
|
||||
// the whole year.
|
||||
'price_cents' => $subscription->monthlyPriceCents(),
|
||||
// The portal shows a granted package without a price at all — not
|
||||
// "free", not struck through — so a later conversion to paid does
|
||||
// not read as a negotiation.
|
||||
'granted' => $subscription->isGranted(),
|
||||
'currency' => $subscription->currency,
|
||||
'quota_gb' => $subscription->quota_gb,
|
||||
'traffic_gb' => $subscription->traffic_gb,
|
||||
|
|
@ -187,8 +194,8 @@ class Billing extends Component
|
|||
'trafficAddon' => (array) config('provisioning.traffic.addon'),
|
||||
// Resolved once: the cart and the plan cards must not state
|
||||
// different tax treatments on the same page.
|
||||
'tax' => \App\Services\Billing\TaxTreatment::for($customer),
|
||||
'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null,
|
||||
'tax' => TaxTreatment::for($customer),
|
||||
'trafficMeter' => $instance !== null ? TrafficMeter::for($instance) : null,
|
||||
// Booked modules at the price they were booked at; everything else
|
||||
// at today's. Reading them all off the catalogue would re-price
|
||||
// half a customer's bill behind their back.
|
||||
|
|
|
|||
|
|
@ -47,12 +47,17 @@ class Cloud extends Component
|
|||
'name' => $customer ? __('cloud.instance_name', ['name' => $customer->name]) : __('cloud.title'),
|
||||
'domain' => $domain,
|
||||
'status' => $shown->status ?? 'active',
|
||||
'plan' => __('cloud.plan_line', [
|
||||
'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey),
|
||||
// The line ends in "/mo", so a yearly contract has to be
|
||||
// divided down before it goes in.
|
||||
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
|
||||
]),
|
||||
// A granted package shows without a price at all — not "free",
|
||||
// not struck through — so a later conversion to paid does not
|
||||
// read as a negotiation.
|
||||
'plan' => $contract?->isGranted()
|
||||
? __('cloud.plan_line_granted', ['plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey)])
|
||||
: __('cloud.plan_line', [
|
||||
'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey),
|
||||
// The line ends in "/mo", so a yearly contract has to be
|
||||
// divided down before it goes in.
|
||||
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
|
||||
]),
|
||||
'location' => __('cloud.datacenter'),
|
||||
'storageUsed' => $used,
|
||||
'storageQuota' => $quota,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ final class AddonCatalogue
|
|||
},
|
||||
'monthly_cents' => (int) $own->sum(fn ($addon) => $addon->monthlyCents()),
|
||||
'booked' => $own->isNotEmpty(),
|
||||
// A granted module shows without a price too, the same rule as
|
||||
// a granted plan — "ein Plugin schenken" should not read as
|
||||
// "kostenlos" on the very page that sells it to everyone else.
|
||||
'granted' => $own->isNotEmpty() && $own->every(fn ($addon) => $addon->isGranted()),
|
||||
'quantity' => (int) $own->sum('quantity'),
|
||||
'bookings' => $own->map(fn ($addon) => [
|
||||
'price_cents' => $addon->price_cents,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ final class IssueInvoice
|
|||
*/
|
||||
public function forOrders(Customer $customer, Collection $orders, ?InvoiceSeries $series = null): Invoice
|
||||
{
|
||||
// A full gift produces no invoice — no amount, no number drawn from
|
||||
// the series. A zero invoice with a sequential number is a bookkeeping
|
||||
// foreign body. A discounted grant is unaffected: it still charges
|
||||
// something and is invoiced exactly like an ordinary sale below.
|
||||
$orders = $orders->reject(fn (Order $order) => $order->isFreeGrant());
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ return [
|
|||
|
||||
'net_reverse_charge' => 'netto — Reverse Charge',
|
||||
'net_per_month' => 'netto pro Monat',
|
||||
'granted_plan' => 'Bereitgestellt',
|
||||
'net_once' => 'netto, einmalig',
|
||||
'net_hint' => 'netto, zzgl. :percent % USt.',
|
||||
'cart' => [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ return [
|
|||
'title' => 'Meine Cloud',
|
||||
'subtitle' => 'Details und Ressourcen Ihrer Instanz.',
|
||||
'plan_line' => ':plan · :price €/Monat',
|
||||
// A granted package: not "kostenlos", not a struck-through amount — just
|
||||
// the service. A later conversion to paid should not read as a negotiation.
|
||||
'plan_line_granted' => ':plan',
|
||||
'datacenter' => 'EU',
|
||||
'now' => 'jetzt',
|
||||
'instance_label' => 'Instanz',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ return [
|
|||
|
||||
'net_reverse_charge' => 'net — reverse charge',
|
||||
'net_per_month' => 'net per month',
|
||||
'granted_plan' => 'Provided',
|
||||
'net_once' => 'net, one-off',
|
||||
'net_hint' => 'net, plus :percent % VAT',
|
||||
'cart' => [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ return [
|
|||
'title' => 'My cloud',
|
||||
'subtitle' => 'Details and resources of your instance.',
|
||||
'plan_line' => ':plan · €:price/mo',
|
||||
// A granted package: not "free", not a struck-through amount — just the
|
||||
// service. A later conversion to paid should not read as a negotiation.
|
||||
'plan_line_granted' => ':plan',
|
||||
'datacenter' => 'EU',
|
||||
'now' => 'now',
|
||||
'instance_label' => 'Instance',
|
||||
|
|
|
|||
|
|
@ -15,14 +15,20 @@
|
|||
<p class="mt-0.5 text-lg font-semibold text-ink">{{ __('billing.plan.'.$currentKey) }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-semibold text-ink">{{ $eur($current['price_cents'] ?? 0) }}</p>
|
||||
<p class="text-xs text-muted">{{ __('billing.net_per_month') }}</p>
|
||||
@if ($totalMonthlyCents !== null && $totalMonthlyCents !== ($current['price_cents'] ?? 0))
|
||||
{{-- The plan alone is not the bill once modules are booked,
|
||||
and every part of it is frozen at what was agreed. --}}
|
||||
<p class="mt-1 text-sm font-medium text-body">
|
||||
{{ __('billing.total_with_addons', ['total' => $eur($totalMonthlyCents)]) }}
|
||||
</p>
|
||||
@if ($current['granted'] ?? false)
|
||||
{{-- Not "free", not struck through — just the service. A
|
||||
later conversion to paid must not read as a negotiation. --}}
|
||||
<p class="text-xs text-muted">{{ __('billing.granted_plan') }}</p>
|
||||
@else
|
||||
<p class="text-2xl font-semibold text-ink">{{ $eur($current['price_cents'] ?? 0) }}</p>
|
||||
<p class="text-xs text-muted">{{ __('billing.net_per_month') }}</p>
|
||||
@if ($totalMonthlyCents !== null && $totalMonthlyCents !== ($current['price_cents'] ?? 0))
|
||||
{{-- The plan alone is not the bill once modules are booked,
|
||||
and every part of it is frozen at what was agreed. --}}
|
||||
<p class="mt-1 text-sm font-medium text-body">
|
||||
{{ __('billing.total_with_addons', ['total' => $eur($totalMonthlyCents)]) }}
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -245,19 +251,24 @@
|
|||
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
|
||||
{{-- What they actually pay for it once booked (packs and all),
|
||||
and today's price while it is still a sale to be made. --}}
|
||||
<p class="mt-3 text-lg font-semibold text-ink">
|
||||
{{ $eur($addon['booked'] ? $addon['monthly_cents'] : (int) $addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span>
|
||||
@if ($addon['booked'] && $addon['quantity'] > 1)
|
||||
<span class="text-sm font-normal text-muted">· {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }}</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ $tax->reverseCharge
|
||||
? __('billing.net_reverse_charge')
|
||||
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
|
||||
</p>
|
||||
@if ($addon['granted'] ?? false)
|
||||
{{-- Not "free", not struck through — just the service. --}}
|
||||
<p class="mt-3 text-sm font-medium text-muted">{{ __('billing.granted_plan') }}</p>
|
||||
@else
|
||||
{{-- What they actually pay for it once booked (packs and all),
|
||||
and today's price while it is still a sale to be made. --}}
|
||||
<p class="mt-3 text-lg font-semibold text-ink">
|
||||
{{ $eur($addon['booked'] ? $addon['monthly_cents'] : (int) $addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span>
|
||||
@if ($addon['booked'] && $addon['quantity'] > 1)
|
||||
<span class="text-sm font-normal text-muted">· {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }}</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ $tax->reverseCharge
|
||||
? __('billing.net_reverse_charge')
|
||||
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
|
||||
</p>
|
||||
@endif
|
||||
@if ($addon['booked'])
|
||||
{{-- Already theirs, at the price they booked it for — which
|
||||
is why this card does not show today's. --}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Revenue;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Subscription;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* A granted contract at 0 must not drag ARPU down or count as revenue-bearing
|
||||
* — and the owner measures himself on the number of granted contracts too, so
|
||||
* it is shown separately rather than folded into MRR/ARR/ARPU/contracts.
|
||||
*/
|
||||
it('excludes a granted contract from MRR, ARR and the contracts count', function () {
|
||||
$customer = Customer::factory()->create();
|
||||
|
||||
Subscription::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'status' => 'active', 'term' => 'monthly', 'price_cents' => 17900, 'currency' => 'EUR',
|
||||
]);
|
||||
|
||||
Subscription::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'status' => 'active', 'term' => 'monthly', 'price_cents' => 0, 'currency' => 'EUR',
|
||||
'granted_at' => now(), 'catalogue_price_cents' => 17900,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs(operator('Owner'), 'operator')->test(Revenue::class);
|
||||
|
||||
// Only the paying contract's 179,00 shows in MRR — a granted contract
|
||||
// adding its 0 would still inflate the contracts count underneath ARPU.
|
||||
$component->assertViewHas('kpis', function (array $kpis) {
|
||||
[$mrr, , $arpu, $contracts, $granted] = $kpis;
|
||||
|
||||
return str_contains($mrr['value'], '179,00')
|
||||
&& str_contains($arpu['value'], '179,00')
|
||||
&& $contracts['value'] === '1'
|
||||
&& $granted['value'] === '1';
|
||||
});
|
||||
});
|
||||
|
||||
it('folds a discount into revenue at what the customer actually pays, but still counts it as granted', function () {
|
||||
$customer = Customer::factory()->create();
|
||||
|
||||
Subscription::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'status' => 'active', 'term' => 'monthly', 'price_cents' => 5000, 'currency' => 'EUR',
|
||||
'granted_at' => now(), 'catalogue_price_cents' => 17900,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs(operator('Owner'), 'operator')->test(Revenue::class);
|
||||
|
||||
// A discount is still an operator's grant, so — like a full gift — it is
|
||||
// excluded from what the owner counts as a sale.
|
||||
$component->assertViewHas('kpis', function (array $kpis) {
|
||||
[$mrr, , , $contracts, $granted] = $kpis;
|
||||
|
||||
return str_contains($mrr['value'], '0,00')
|
||||
&& $contracts['value'] === '0'
|
||||
&& $granted['value'] === '1';
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\GrantAddon;
|
||||
use App\Actions\GrantSubscription;
|
||||
use App\Actions\OpenSubscription;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceSeries;
|
||||
use App\Models\Operator;
|
||||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\IssueInvoice;
|
||||
use App\Support\CompanyProfile;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* The other half of the owner's second decision: a full gift produces no
|
||||
* invoice at all, but a discounted grant is invoiced normally, at what the
|
||||
* customer actually pays. This is the one place IssueInvoice had to change —
|
||||
* a single reject() of free-grant orders — and this file is what proves it.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
CompanyProfile::put([
|
||||
'name' => 'CluPilot Cloud e.U.',
|
||||
'address' => 'Dreherstraße 66/1/8',
|
||||
'postcode' => '1110',
|
||||
'city' => 'Wien',
|
||||
'vat_id' => 'ATU00000000',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips a fully granted subscription: no invoice, no number consumed', function () {
|
||||
Queue::fake();
|
||||
|
||||
$customer = Customer::factory()->create();
|
||||
$owner = Operator::factory()->create();
|
||||
|
||||
app(GrantSubscription::class)(
|
||||
customer: $customer, grantedBy: $owner, plan: 'team',
|
||||
term: Subscription::TERM_MONTHLY, datacenter: 'fsn', priceCents: 0,
|
||||
);
|
||||
|
||||
$order = Order::query()->where('customer_id', $customer->id)->sole();
|
||||
|
||||
expect(fn () => app(IssueInvoice::class)->forOrders($customer, collect([$order])))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
expect(Invoice::query()->count())->toBe(0)
|
||||
->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(1);
|
||||
});
|
||||
|
||||
it('invoices a discounted grant normally, at what the customer actually pays', function () {
|
||||
Queue::fake();
|
||||
|
||||
$customer = Customer::factory()->create();
|
||||
$owner = Operator::factory()->create();
|
||||
|
||||
app(GrantSubscription::class)(
|
||||
customer: $customer, grantedBy: $owner, plan: 'team',
|
||||
term: Subscription::TERM_MONTHLY, datacenter: 'fsn', priceCents: 5000,
|
||||
);
|
||||
|
||||
$order = Order::query()->where('customer_id', $customer->id)->sole();
|
||||
$invoice = app(IssueInvoice::class)->forOrders($customer, collect([$order]));
|
||||
|
||||
expect($invoice->net_cents)->toBe(5000)
|
||||
->and($invoice->number)->toStartWith('RE-');
|
||||
});
|
||||
|
||||
it('skips a granted, free module the same way', function () {
|
||||
$order = Order::factory()->create(['plan' => 'team', 'datacenter' => 'fsn', 'status' => 'paid']);
|
||||
$subscription = app(OpenSubscription::class)($order);
|
||||
$owner = Operator::factory()->create();
|
||||
$customer = $subscription->customer;
|
||||
|
||||
app(GrantAddon::class)($subscription, $owner, 'priority_support', 0);
|
||||
|
||||
$addonOrder = Order::query()->where('type', 'addon')->sole();
|
||||
|
||||
expect(fn () => app(IssueInvoice::class)->forOrders($customer, collect([$addonOrder])))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
expect(Invoice::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('does not mistake an ordinary fully-discounted Stripe checkout for a grant', function () {
|
||||
// The exact case OpenSubscription's own docblock warns about: a real
|
||||
// Stripe checkout can also land at amount_cents = 0 (a 100% coupon). That
|
||||
// subscription was never granted (granted_at stays null) and is still an
|
||||
// owed invoice — isFreeGrant() must key off provenance, not off the
|
||||
// amount alone.
|
||||
$order = Order::factory()->create([
|
||||
'plan' => 'team', 'datacenter' => 'fsn', 'amount_cents' => 0,
|
||||
'currency' => 'EUR', 'status' => 'paid', 'stripe_event_id' => 'evt_full_coupon',
|
||||
]);
|
||||
$subscription = app(OpenSubscription::class)($order, Subscription::TERM_MONTHLY, ['price_cents' => 0]);
|
||||
|
||||
expect($subscription->isGranted())->toBeFalse();
|
||||
|
||||
$invoice = app(IssueInvoice::class)->forOrders($order->customer, collect([$order]));
|
||||
|
||||
expect($invoice->net_cents)->toBe(0)
|
||||
->and($invoice->number)->toStartWith('RE-');
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Billing;
|
||||
use App\Livewire\Cloud;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Operator;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionAddon;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The owner's third decision: the portal shows a granted package without a
|
||||
* price — not "free", not struck through — so a later conversion to paid
|
||||
* does not read as a negotiation.
|
||||
*/
|
||||
function customerWithGrantedPlan(): array
|
||||
{
|
||||
$customer = Customer::factory()->create();
|
||||
$user = User::factory()->create(['email' => $customer->email]);
|
||||
$customer->update(['user_id' => $user->id]);
|
||||
|
||||
$instance = Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'host_id' => Host::factory()->active()->create()->id,
|
||||
'plan' => 'team',
|
||||
'status' => 'active',
|
||||
]);
|
||||
$subscription = Subscription::factory()->plan('team')->create([
|
||||
'customer_id' => $customer->id,
|
||||
'instance_id' => $instance->id,
|
||||
'price_cents' => 0,
|
||||
'granted_by' => Operator::factory()->create()->id,
|
||||
'granted_at' => now(),
|
||||
'catalogue_price_cents' => 17900,
|
||||
]);
|
||||
|
||||
return [$customer, $user, $subscription];
|
||||
}
|
||||
|
||||
it('shows the granted plan on the cloud dashboard without a price', function () {
|
||||
[, $user] = customerWithGrantedPlan();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Cloud::class)
|
||||
->assertDontSee('179')
|
||||
->assertDontSee('0 €')
|
||||
->assertSee(__('billing.plan.team'));
|
||||
});
|
||||
|
||||
it('shows the granted plan on the billing page without a price', function () {
|
||||
[, $user] = customerWithGrantedPlan();
|
||||
|
||||
$card = Livewire::actingAs($user)->test(Billing::class)->viewData('current');
|
||||
|
||||
expect($card['granted'])->toBeTrue();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Billing::class)
|
||||
->assertSee(__('billing.granted_plan'))
|
||||
->assertDontSee('179,00');
|
||||
});
|
||||
|
||||
it('shows a granted module without a price, but a booked one still with its price', function () {
|
||||
[$customer, $user, $subscription] = customerWithGrantedPlan();
|
||||
|
||||
SubscriptionAddon::query()->create([
|
||||
'subscription_id' => $subscription->id,
|
||||
'addon_key' => 'priority_support',
|
||||
'price_cents' => 0,
|
||||
'currency' => 'EUR',
|
||||
'quantity' => 1,
|
||||
'booked_at' => now(),
|
||||
'granted_by' => Operator::factory()->create()->id,
|
||||
'granted_at' => now(),
|
||||
'catalogue_price_cents' => 2900,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Billing::class)
|
||||
->assertSee(__('billing.addon.priority_support.name'))
|
||||
->assertSee(__('billing.granted_plan'));
|
||||
});
|
||||
Loading…
Reference in New Issue