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
parent
4cd848eead
commit
afa6183903
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue