diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php index 7c1412d..e394fca 100644 --- a/app/Actions/BookAddon.php +++ b/app/Actions/BookAddon.php @@ -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 diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index ebb17d0..de950c2 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -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. diff --git a/app/Services/Billing/AddonCatalogue.php b/app/Services/Billing/AddonCatalogue.php index f9b3b37..b5fa1f7 100644 --- a/app/Services/Billing/AddonCatalogue.php +++ b/app/Services/Billing/AddonCatalogue.php @@ -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 { diff --git a/config/provisioning.php b/config/provisioning.php index 6fc45bc..be295db 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -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'], diff --git a/lang/de/billing.php b/lang/de/billing.php index b4e42ee..b7750cb 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -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.', diff --git a/lang/en/billing.php b/lang/en/billing.php index da4d068..ee56682 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -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.', diff --git a/tests/Feature/Billing/CustomDomainAccessTest.php b/tests/Feature/Billing/CustomDomainAccessTest.php index af14c35..283ffca 100644 --- a/tests/Feature/Billing/CustomDomainAccessTest.php +++ b/tests/Feature/Billing/CustomDomainAccessTest.php @@ -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', + ]); }); diff --git a/tests/Feature/Billing/ProofRegisterTest.php b/tests/Feature/Billing/ProofRegisterTest.php index 5085bae..d4343b8 100644 --- a/tests/Feature/Billing/ProofRegisterTest.php +++ b/tests/Feature/Billing/ProofRegisterTest.php @@ -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); diff --git a/tests/Feature/Billing/StorageAllowanceTest.php b/tests/Feature/Billing/StorageAllowanceTest.php index f833444..71cb2c8 100644 --- a/tests/Feature/Billing/StorageAllowanceTest.php +++ b/tests/Feature/Billing/StorageAllowanceTest.php @@ -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); }); diff --git a/tests/Feature/Billing/StoragePackPricingTest.php b/tests/Feature/Billing/StoragePackPricingTest.php new file mode 100644 index 0000000..69f5155 --- /dev/null +++ b/tests/Feature/Billing/StoragePackPricingTest.php @@ -0,0 +1,99 @@ +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); +}); diff --git a/tests/Feature/Billing/StripeAddonBillingTest.php b/tests/Feature/Billing/StripeAddonBillingTest.php index 0b55846..3fa4bff 100644 --- a/tests/Feature/Billing/StripeAddonBillingTest.php +++ b/tests/Feature/Billing/StripeAddonBillingTest.php @@ -209,11 +209,12 @@ it('gives the owner’s 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 owner’s 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 owner’s 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 () { diff --git a/tests/Feature/CartTest.php b/tests/Feature/CartTest.php index 32f9479..eaee2b9 100644 --- a/tests/Feature/CartTest.php +++ b/tests/Feature/CartTest.php @@ -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);