Zusatzspeicher: 20 GB fuer 15 Euro, hoechstens drei, nicht bei Enterprise

100 GB fuer 10 Euro waren 0,10 Euro/GB — unter Einstandspreis und billiger als
jeder Aufstieg. Wer stapelt, belegt dann den knappsten Rohstoff zum niedrigsten
Preis. 0,75 Euro/GB liegen ueber beiden Aufstiegen (0,73 und 0,67), und der
Deckel bei drei liegt dort, wo Aufsteigen billiger UND besser wird.

BookAddon haelt den Ausschluss durch AddonCatalogue::availabilityRefusal()
(gleiches Muster wie CustomDomainAccess), damit greift er auch beim
Verschenken durch den Betreiber. Billing::purchase() und storageLimitNote
fragen dieselbe Regel VOR der Zahlung, sonst haette ein Enterprise-Kunde einen
zahlbaren Auftrag anlegen koennen, den BookAddon erst danach abgelehnt haette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 15:09:02 +02:00
parent 4cd848eead
commit afa6183903
12 changed files with 246 additions and 51 deletions

View File

@ -80,6 +80,16 @@ class BookAddon
throw new RuntimeException($refusal);
}
// Nicht jedes Paket kennt jedes Modul. Enterprise verkauft eine eigene
// Maschine, und ein 20-GB-Block ist darauf keine Antwort — dieselbe
// Prüfung, die GrantAddon über diese Aktion ebenfalls durchläuft, denn
// ein Geschenk ist eine Buchung wie jede andere.
$closed = app(AddonCatalogue::class)->availabilityRefusal($subscription, $addonKey);
if ($closed !== null) {
throw new RuntimeException($closed);
}
// Der Deckel, an der Stelle, die ihn halten muss. Gezählt wird über
// ALLE laufenden Buchungen dieses Moduls, nicht je Buchung: drei
// Bestellungen à einem Block sind drei Blöcke, und eine Prüfung je

View File

@ -105,18 +105,21 @@ class Billing extends Component
}
}
// Speicher an der kaufmännischen Grenze: derselbe Grund wie oben bei
// einem Entitlement, aus demselben Satz (AddonCatalogue::quantityRefusal(),
// die auch BookAddon spricht). Der Knopf, der hierher führt, ist
// bedingungslos gerendert — ohne diese Prüfung entstünde eine
// Bestellzeile ($lines unten floort sonst auf mindestens 1), die nach
// der Zahlung nur noch BookAddon ablehnen könnte, während der Auftrag
// selbst `paid` stehen bliebe (OrderObserver fängt den Fehlschlag nur
// ab und loggt ihn). Ein Teil-Kontingent wird weiter befüllt (unten,
// $lines) — hier wird nur der Fall abgefangen, in dem gar nichts mehr
// geht.
// Speicher, an der Verfügbarkeit UND an der kaufmännischen Grenze:
// derselbe Grund wie oben bei einem Entitlement, aus demselben Satz
// (AddonCatalogue::availabilityRefusal()/quantityRefusal(), die auch
// BookAddon spricht). Der Knopf, der hierher führt, ist bedingungslos
// gerendert — ohne diese Prüfung entstünde eine Bestellzeile ($lines
// unten floort sonst auf mindestens 1), die nach der Zahlung nur noch
// BookAddon ablehnen könnte, während der Auftrag selbst `paid` stehen
// bliebe (OrderObserver fängt den Fehlschlag nur ab und loggt ihn).
// Verfügbarkeit zuerst geprüft, weil sie die schärfere Aussage ist:
// "gibt es für Ihr Paket nicht" statt "ist gerade voll". Ein
// Teil-Kontingent wird weiter befüllt (unten, $lines) — hier wird nur
// der Fall abgefangen, in dem gar nichts mehr geht.
if ($type === 'storage') {
$refusal = app(AddonCatalogue::class)->quantityRefusal($contract, AddonCatalogue::STORAGE, 1);
$refusal = app(AddonCatalogue::class)->availabilityRefusal($contract, AddonCatalogue::STORAGE)
?? app(AddonCatalogue::class)->quantityRefusal($contract, AddonCatalogue::STORAGE, 1);
if ($refusal !== null) {
$this->dispatch('notify', message: $refusal);
@ -650,10 +653,14 @@ class Billing extends Component
'downgrades' => $downgrades,
'storage' => (array) config('provisioning.storage_addon'),
// Null while the button may still sell a pack, the sentence
// BookAddon would refuse with once it may not — the same call
// BookAddon would refuse with once it may not — the same two calls
// purchase() makes before writing an order, so the card and the
// action never disagree about whether one more block is possible.
'storageLimitNote' => app(AddonCatalogue::class)->quantityRefusal($instance?->subscription ?? $contract, AddonCatalogue::STORAGE, 1),
// Availability first: Enterprise never had a cap to reach, so
// quantityRefusal alone stayed null and the button would have kept
// offering a module the package does not carry at all.
'storageLimitNote' => app(AddonCatalogue::class)->availabilityRefusal($instance?->subscription ?? $contract, AddonCatalogue::STORAGE)
?? app(AddonCatalogue::class)->quantityRefusal($instance?->subscription ?? $contract, AddonCatalogue::STORAGE, 1),
'trafficAddon' => (array) config('provisioning.traffic.addon'),
// Resolved once: the cart and the plan cards must not state
// different tax treatments on the same page.

View File

@ -122,6 +122,26 @@ final class AddonCatalogue
return __('billing.addon_already_booked', ['module' => $this->name($key)]);
}
/**
* Warum es dieses Modul für dieses Paket gar nicht gibt. Null, wenn es
* das gibt.
*
* Nicht dasselbe wie "schon gebucht" oder "Deckel erreicht": hier ist das
* Modul auf diesem Paket nicht vorgesehen. Genannt wird das Paket beim
* Schlüssel, wie bei der eigenen Domain der Katalog beschreibt, was ein
* Paket HAT, nicht was es kaufen darf.
*/
public function availabilityRefusal(?Subscription $subscription, string $key): ?string
{
$closed = (array) ($this->definition($key)['unavailable_on'] ?? []);
if ($subscription === null || ! in_array((string) $subscription->plan, $closed, true)) {
return null;
}
return __('billing.addon_not_available', ['module' => $this->name($key)]);
}
/** Wie viele Einheiten ein Vertrag von diesem Modul halten darf, oder null. */
public function maxQuantity(string $key): ?int
{

View File

@ -324,11 +324,32 @@ return [
| refuses to let one ship undeclared. Read by
| App\Services\Billing\AddonCatalogue, enforced by App\Actions\BookAddon.
*/
// Der Deckel ist kaufmännisch, nicht technisch: er liegt dort, wo Aufsteigen
// billiger wird als Stapeln. Ohne ihn belegt der günstigste Tarif den
// knappsten Rohstoff. Gelesen von AddonCatalogue::maxQuantity(), geprüft in
// BookAddon — im Formular steht er nur zusätzlich.
'storage_addon' => ['gb' => 100, 'price_cents' => 1000, 'sold_as' => 'quantity', 'max' => 3],
/*
| Ein Block Zusatzspeicher: 20 GB für den Kunden, 22 GB auf der Platte.
|
| Die zwei Gigabyte Unterschied sind Kopfraum. Ein Paket bringt seinen
| eigenen mit (max(10 GB, 12 %) seiner Platte); ohne denselben Aufschlag am
| Block hätte ein gestapeltes Paket weniger Luft als ein gekauftes gleicher
| Größe, und eine volle Platte legt die Instanz still, nicht nur den Upload.
|
| Der Preis folgt einer Regel, nicht einem Gefühl: ein Block muss teurer je
| GB sein als der Aufstieg in die nächste Stufe (0,75 gegen 0,73 und 0,67),
| sonst stapeln Kunden Zusätze, statt aufzusteigen, und belegen den
| knappsten Rohstoff zum niedrigsten Preis. `max` deckelt das zusätzlich
| dort, wo Aufsteigen billiger UND besser wird.
|
| `unavailable_on` nennt die Pakete, für die es das Modul nicht gibt bei
| Enterprise, weil dort eine eigene Maschine verkauft wird und ein 20-GB-
| Block darauf keine Antwort ist.
*/
'storage_addon' => [
'gb' => 20,
'disk_gb' => 22,
'price_cents' => 1500,
'sold_as' => 'quantity',
'max' => 3,
'unavailable_on' => ['enterprise'],
],
'addons' => [
'extra_backups' => ['price_cents' => 500, 'sold_as' => 'entitlement'],
'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'],

View File

@ -146,6 +146,10 @@ return [
'addon_in_cart' => '„:module“ liegt bereits im Warenkorb und wird nach der Zahlung freigeschaltet.',
'addon_in_cart_badge' => 'Im Warenkorb',
'addon_limit_reached' => ':module ist auf :max Einheiten begrenzt. Ein größeres Paket bietet mehr Speicher für weniger Geld.',
// Nicht "schon gebucht" und nicht "Deckel erreicht", sondern: für dieses
// Paket gibt es das Modul gar nicht — z. B. Zusatzspeicher auf Enterprise,
// wo eine eigene Maschine verkauft wird.
'addon_not_available' => ':module ist für Ihr Paket nicht vorgesehen.',
// Ein Block ist Platz auf einer bestimmten Maschine — eine laufende
// Instanz zieht nicht um. Gemeint ist der Host, nicht der Vertrag.
'storage_no_room' => 'Auf Ihrem Server ist derzeit kein Platz für weiteren Speicher. Bitte wenden Sie sich an den Support — wir sorgen für Raum.',

View File

@ -146,6 +146,10 @@ return [
'addon_in_cart' => 'The :module module is already in your cart and will be activated after payment.',
'addon_in_cart_badge' => 'In cart',
'addon_limit_reached' => ':module is limited to :max units. A larger package gives you more storage for less money.',
// Not "already booked" and not "limit reached" — this package simply does
// not carry the module at all, e.g. extra storage on Enterprise, where a
// dedicated machine is sold instead.
'addon_not_available' => ':module is not offered for your package.',
// A pack is room on a particular machine — a running instance does not
// move. This is about the host, not the contract.
'storage_no_room' => 'Your server currently has no room for more storage. Please contact support — we will make room.',

View File

@ -232,6 +232,14 @@ it('keeps the rule in one place, where it can only be changed once', function ()
// in the downgrade warning — is the one that ships a customer three
// different answers, so the config key that says where a domain is
// impossible is read by exactly one class.
//
// Since Task 5 (neue Pakete) `unavailable_on` is a NAMING CONVENTION, not
// a single rule: `storage_addon.unavailable_on` excludes Enterprise from
// the storage module, on its own key beside `addons.custom_domain`'s, and
// AddonCatalogue::availabilityRefusal() is the one class that reads it —
// the same "exactly one reader" property this test protects, for a second,
// independent commercial fact. The own-domain rule itself still lives
// nowhere but CustomDomainAccess.
$readers = [];
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(app_path()));
@ -243,5 +251,10 @@ it('keeps the rule in one place, where it can only be changed once', function ()
}
}
expect($readers)->toBe(['app/Services/Billing/CustomDomainAccess.php']);
sort($readers);
expect($readers)->toBe([
'app/Services/Billing/AddonCatalogue.php',
'app/Services/Billing/CustomDomainAccess.php',
]);
});

View File

@ -205,6 +205,10 @@ it('sums several bookings of one module rather than showing an arbitrary one', f
$subscription = Subscription::factory()->plan('team')->create();
// Two storage packs, bought at different times and different prices.
// Beide Preise ausdrücklich gesetzt (Task 5 änderte den Katalogpreis):
// der Test prüft, dass zwei UNTERSCHIEDLICHE Preise summiert werden, nicht
// was der Katalog gerade zufällig als Standard mitbringt.
config()->set('provisioning.storage_addon.price_cents', 1000);
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);
config()->set('provisioning.storage_addon.price_cents', 1500);
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);

View File

@ -123,14 +123,15 @@ it('raises the allowance with a booked pack and tells Nextcloud the larger figur
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(600);
// 500 + 20 (ein Block, Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(520);
expect(app(ApplyStorageQuota::class)->execute(storageRun($fixture))->type)->toBe('advance');
expect($pve->guestRan("files default_quota --value='600 GB'"))->toBeTrue()
expect($pve->guestRan("files default_quota --value='520 GB'"))->toBeTrue()
// Recorded as applied, so the sweep can tell an enforced machine from
// one where the figure has only ever been a row in our database.
->and($fixture['instance']->fresh()->quota_applied_gb)->toBe(600)
->and($fixture['instance']->fresh()->quota_applied_gb)->toBe(520)
->and($fixture['instance']->fresh()->quotaIsEnforced())->toBeTrue();
});
@ -141,11 +142,12 @@ it('stacks two packs and lowers the allowance again when one is cancelled', func
$first = app(BookAddon::class)($contract, AddonCatalogue::STORAGE);
app(BookAddon::class)($contract, AddonCatalogue::STORAGE);
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(700);
// 500 + 2 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(540);
app(BookAddon::class)->cancel($first);
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(600)
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(520)
// Cancelled, never deleted: what somebody was paying, and until when, is
// evidence.
->and(SubscriptionAddon::query()->count())->toBe(2);
@ -156,7 +158,8 @@ it('counts a pack booked with a quantity of more than one', function () {
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 3);
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(800);
// 500 + 3 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(560);
});
it('shows the whole allowance in the portal, not the package alone', function () {
@ -165,8 +168,8 @@ it('shows the whole allowance in the portal, not the package alone', function ()
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->assertSee('700 GB')
->assertSee(__('billing.storage_includes_packs', ['count' => 2, 'gb' => 200]));
->assertSee('540 GB')
->assertSee(__('billing.storage_includes_packs', ['count' => 2, 'gb' => 40]));
});
it('starts one delivery run when a pack is booked, and none while another is in flight', function () {
@ -199,7 +202,7 @@ it('starts a delivery run when a pack is cancelled, so the quota comes back down
it('grows the disk to the allowance plus the package overhead, and the guest with it', function () {
// Team is 500 GB sold on a 540 GB disk: 40 GB of overhead the package was
// sized with, and the pack is a hundred gigabytes ON TOP of it.
// sized with, and the pack (Task 5: 22 GB on disk) comes ON TOP of it.
$fixture = storageFixture('team');
$pve = app(ProxmoxClient::class);
scriptGuestFilesystem($pve);
@ -208,8 +211,8 @@ it('grows the disk to the allowance plus the package overhead, and the guest wit
$run = storageRun($fixture);
expect(app(ResizeVirtualMachine::class)->execute($run)->type)->toBe('advance')
->and($pve->resizeCalls)->toContain('101:scsi0:640G')
->and($fixture['instance']->fresh()->disk_gb)->toBe(640);
->and($pve->resizeCalls)->toContain('101:scsi0:562G')
->and($fixture['instance']->fresh()->disk_gb)->toBe(562);
expect(app(GrowGuestFilesystem::class)->execute($run)->type)->toBe('advance')
->and($pve->guestRan('/sys/class/block/sda/device/rescan'))->toBeTrue()
@ -237,7 +240,7 @@ it('does nothing the second time round', function () {
expect(app(ResizeVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance')
->and($pve->resizeCalls)->toBe([])
->and($fixture['instance']->fresh()->disk_gb)->toBe(640);
->and($fixture['instance']->fresh()->disk_gb)->toBe(562);
expect(app(GrowGuestFilesystem::class)->execute($run->fresh())->type)->toBe('advance');
});
@ -302,18 +305,25 @@ it('still blocks a downgrade the stored data does not fit, and says what would f
->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage'])
->and($check->storageRemedy)->not->toBeNull()
->and($check->storageRemedy['limit'])->toBe('500 GB')
// One pack covers the hundred gigabytes that are over.
->and($check->storageRemedy['packs'])->toBe(1)
->and($check->storageRemedy['pack_gb'])->toBe(100);
// Fünf Blöcke à 20 GB (Task 5) decken die hundert Gigabyte, die zu viel
// sind.
->and($check->storageRemedy['packs'])->toBe(5)
->and($check->storageRemedy['pack_gb'])->toBe(20);
});
it('lets exactly that downgrade through once the offered packs are booked', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 600);
// Absichtlich NICHT die 600 GB von oben: fünf Blöcke passen nicht mehr
// unter den Deckel (Task 2: höchstens drei), seit ein Block auf 20 GB
// geschrumpft ist (Task 5). Vierzig Gigabyte zu viel sind zwei Blöcke —
// innerhalb des Deckels, und genug, um denselben Ablauf zu belegen.
storedBytes($fixture['instance'], 540);
$team = app(PlanCatalogue::class)->sellable()['team'];
$packs = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->storageRemedy['packs'];
expect($packs)->toBe(2);
foreach (range(1, $packs) as $ignored) {
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
}
@ -412,8 +422,9 @@ it('keeps booked packs through a plan change and delivers them on the new packag
$instance = $fixture['instance']->fresh();
expect(SubscriptionAddon::query()->where('addon_key', AddonCatalogue::STORAGE)->active()->sum('quantity'))->toBe(2)
// Business is 1000 GB; the two packs are still theirs on top of it.
->and(StorageAllowance::for($instance)->totalGb())->toBe(1200);
// Business is 1000 GB; the two packs (Task 5: 20 GB each) are still
// theirs on top of it.
->and(StorageAllowance::for($instance)->totalGb())->toBe(1040);
$run = ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->firstOrFail();
@ -421,8 +432,9 @@ it('keeps booked packs through a plan change and delivers them on the new packag
app(GrowGuestFilesystem::class)->execute($run->fresh());
app(ApplyStorageQuota::class)->execute($run->fresh());
// 1200 GB for the customer, on Business's own 50 GB of overhead.
expect($pve->resizeCalls)->toContain('101:scsi0:1250G')
->and($pve->guestRan("files default_quota --value='1200 GB'"))->toBeTrue()
->and($instance->fresh()->quota_applied_gb)->toBe(1200);
// 1040 GB for the customer, on Business's own 50 GB of overhead plus the
// two packs' own 22 GB disk headroom each (44 GB).
expect($pve->resizeCalls)->toContain('101:scsi0:1094G')
->and($pve->guestRan("files default_quota --value='1040 GB'"))->toBeTrue()
->and($instance->fresh()->quota_applied_gb)->toBe(1040);
});

View File

@ -0,0 +1,99 @@
<?php
use App\Actions\BookAddon;
use App\Actions\GrantAddon;
use App\Actions\OpenSubscription;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Operator;
use App\Models\Order;
use App\Models\User;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\PlanCatalogue;
/**
* Was ein Block kostet, und wer ihn nicht kaufen kann.
*
* Die Preisregel: ein Block muss teurer je GB sein als der Aufstieg in die
* nächste Stufe. 20 GB für 15 sind 0,75 /GB gegen 0,73 (Start Team) und
* 0,67 (Team Business). Bei 12 wäre er mit 0,60 billiger als jeder
* Aufstieg gewesen, und Stapeln hätte sich gelohnt.
*/
it('verkauft 20 GB für 15 Euro netto', function () {
$catalogue = app(AddonCatalogue::class);
expect($catalogue->packGb())->toBe(20)
->and($catalogue->packDiskGb())->toBe(22)
->and($catalogue->priceCents(AddonCatalogue::STORAGE))->toBe(1500)
->and($catalogue->maxQuantity(AddonCatalogue::STORAGE))->toBe(3);
});
it('bleibt teurer je Gigabyte als der Aufstieg', function () {
// Gerechnet wird mit dem, was Katalog und Konfiguration WIRKLICH sagen —
// mit festen Zahlen prüfte dieser Test nur, ob PHP dividieren kann. So
// schlägt er an, wenn ein Preis später einmal so gesetzt wird, dass
// Stapeln sich wieder lohnt.
$catalogue = app(AddonCatalogue::class);
$plans = app(PlanCatalogue::class)->sellable();
$blockPerGb = $catalogue->priceCents(AddonCatalogue::STORAGE) / $catalogue->packGb();
foreach ([['start', 'team'], ['team', 'business']] as [$von, $nach]) {
$mehrGb = $plans[$nach]['quota_gb'] - $plans[$von]['quota_gb'];
$mehrCent = $plans[$nach]['price_cents'] - $plans[$von]['price_cents'];
expect($blockPerGb)->toBeGreaterThan($mehrCent / $mehrGb);
}
});
it('bietet Enterprise keinen Zusatzspeicher an', function () {
$order = Order::factory()->create(['plan' => 'enterprise', 'datacenter' => 'fsn', 'status' => 'paid']);
$subscription = app(OpenSubscription::class)($order);
expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1))
->toThrow(RuntimeException::class);
});
/**
* Nachtrag: derselbe Ausschluss, wenn der Betreiber ein Modul verschenkt.
*
* GrantAddon bucht durch BookAddon (siehe dessen Kopfkommentar), also reicht
* die Prüfung an einer Stelle dieser Test belegt nur, dass der Weg
* tatsächlich derselbe ist und nicht heimlich daran vorbeiführt.
*/
it('lässt auch den Betreiber keinen Zusatzspeicher an Enterprise verschenken', function () {
$order = Order::factory()->create(['plan' => 'enterprise', 'datacenter' => 'fsn', 'status' => 'paid']);
$subscription = app(OpenSubscription::class)($order);
$operator = Operator::factory()->create();
expect(fn () => app(GrantAddon::class)($subscription, $operator, AddonCatalogue::STORAGE, 0, 1))
->toThrow(RuntimeException::class);
});
/**
* Nachtrag: dieselbe Sperre, bevor Geld fließt, nicht erst danach.
*
* `storageLimitNote` ist das, was den Knopf im Portal verschwinden lässt (statt
* ihn anzubieten und dann an der Zahlung zu scheitern) derselbe Grund, aus
* dem die kaufmännische Grenze dort schon geprüft wurde. Ohne diese Prüfung
* würde ein Enterprise-Kunde einen zahlbaren Auftrag in den Warenkorb legen
* können, den BookAddon nach der Zahlung nur noch ablehnen könnte, während der
* Auftrag selbst „paid" stehen bliebe.
*/
it('bietet Enterprise auch im Warenkorb keinen Zusatzspeicher an', function () {
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email]);
$order = Order::factory()->withSubscription()->for($customer)->create(['plan' => 'enterprise']);
Instance::factory()->for($customer)->create([
'order_id' => $order->id, 'plan' => 'enterprise', 'status' => 'active',
]);
withStripeSecret();
Livewire\Livewire::actingAs($user)
->test(Billing::class)
->call('purchase', 'storage');
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'storage')->count())->toBe(0);
});

View File

@ -209,11 +209,12 @@ it('gives the owners example four invoices, and puts package and module on th
expect($stripe->itemCalls)->toHaveCount(1)
->and($stripe->itemCalls[0]['call'])->toBe('add')
->and($stripe->itemCalls[0]['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY)
->and($stripe->itemCalls[0]['price'])->toBe(addonModulePrice('storage', 1000));
// 1500, nicht 1000: der Katalogpreis eines Blocks seit Task 5.
->and($stripe->itemCalls[0]['price'])->toBe(addonModulePrice('storage', 1500));
// Which is the invoice Stripe then raises: half a month of the module, on
// its own, in the middle of a term. The owner's fourth document.
$storagePrice = addonModulePrice('storage', 1000);
$storagePrice = addonModulePrice('storage', 1500);
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_m3_storage',
[stripeLine($storagePrice, atTheTill(484), now(), $month4, proration: true)],
@ -230,7 +231,7 @@ it('gives the owners example four invoices, and puts package and module on th
'in_m4',
[
stripeLine($planPrice, atTheTill(17900), $month4, $month4->copy()->addMonth()),
stripeLine($storagePrice, atTheTill(1000), $month4, $month4->copy()->addMonth()),
stripeLine($storagePrice, atTheTill(1500), $month4, $month4->copy()->addMonth()),
],
'subscription_cycle',
$month4,
@ -265,10 +266,10 @@ it('gives the owners example four invoices, and puts package and module on th
// net and the VAT that were inside it. The catalogue is pushed gross, so
// the total is not something the document adds up to — it is the figure
// it starts from and divides.
->and($fourth->gross_cents)->toBe(atTheTill(17900) + atTheTill(1000))
->and($fourth->gross_cents)->toBe(22680)
->and($fourth->net_cents)->toBe(18900)
->and($fourth->tax_cents)->toBe(3780);
->and($fourth->gross_cents)->toBe(atTheTill(17900) + atTheTill(1500))
->and($fourth->gross_cents)->toBe(23280)
->and($fourth->net_cents)->toBe(19400)
->and($fourth->tax_cents)->toBe(3880);
// One mail per document, never two for one.
Mail::assertQueuedCount(4);
@ -379,7 +380,7 @@ it('books a second storage pack as a quantity rather than a second item', functi
'in_packs',
[
stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth()),
stripeLine(addonModulePrice('storage', 1000), atTheTill(2000), now(), now()->addMonth(), quantity: 2),
stripeLine(addonModulePrice('storage', 1500), atTheTill(3000), now(), now()->addMonth(), quantity: 2),
],
'subscription_cycle',
now(),
@ -390,7 +391,7 @@ it('books a second storage pack as a quantity rather than a second item', functi
->firstWhere('description', __('billing.storage_title'));
expect($storage['quantity_milli'])->toBe(2000)
->and($storage['unit_net_cents'])->toBe(1000);
->and($storage['unit_net_cents'])->toBe(1500);
});
it('books a module onto a granted contract without asking Stripe anything', function () {

View File

@ -48,7 +48,7 @@ it('removes an entry the customer changed their mind about', function () {
Livewire\Livewire::actingAs($user)
->test(ConfirmRemoveOrder::class, ['uuid' => $order->uuid])
->assertSee(__('billing.cart.storage', ['gb' => 100]))
->assertSee(__('billing.cart.storage', ['gb' => 20]))
->call('remove');
expect(Order::query()->count())->toBe(0);