Ein Ausweg wird nur angeboten, wenn er auch buchbar ist

DowngradeCheck baute seine Empfehlung aus packsToCover(), und die rechnet
bloss. BookAddon lehnt aus ZWEI Gruenden ab - der Deckel bei drei Bloecken
und der Ausschluss von Enterprise -, und die Empfehlung kannte keinen davon.
Solange ein Block 100 GB brachte, war das unerreichbar; seit er 20 GB bringt,
landet jede Ueberschreitung ueber 60 GB dort.

Business -> Team mit 600 GB belegt bot "5 x Zusatzspeicher buchen (+100 GB)"
an. Das Modal klemmte still auf drei, den vierten haette BookAddon abgelehnt,
und der Kunde stand nach 45 Euro im Monat genau dort, wo er vorher stand - auf
der Karte, die sein Abo billiger machen sollte. Enterprise -> Business empfahl
Bloecke, die es fuer dieses Paket ueberhaupt nicht gibt.

AddonCatalogue::bookableQuantity() antwortet jetzt auf beide Gruende. Es gab
einem Enterprise-Vertrag "3" auf die Frage, die sein eigener Docblock stellt.

DowngradeCheck stellt diese eine Frage und klemmt daran: `packs` ist die
gebrauchte Zahl nur, wenn der Vertrag sie auch buchen darf, sonst null - und
dann traegt `short`, was nach allen buchbaren Bloecken uebrig bliebe, gemessen
an DEREN Restmenge statt am Deckel, damit ein Kunde mit einem Block nicht mehr
zu loeschen bekommt als noetig. Kein halbes Angebot: eine Dauerbuchung, die
den Wechsel trotzdem nicht freigibt, ist kein Ausweg, sondern der ausgegraute
Knopf mit Preisschild.

packsToCover() bleibt reine Arithmetik, mit einem Kommentar, der sagt warum:
die kaufmaennische Grenze steht in AddonCatalogue, und ein Klemmen an dieser
Stelle zoege eine Vertragsabfrage in jeden Kontingent-Schritt und ins Portal,
die beide keine Verkaufsfrage stellen.

Der Knopf haengt schon an `packs > 0` und verschwindet von selbst; der Satz
wechselt auf downgrade_escape.capped, der die verbleibende Luecke nennt und
nicht den Grund - "hoechstens drei Bloecke" waere im Enterprise-Fall falsch,
wo es gar keine gibt.

Zwei bestehende Tests hingen an 600 und 800 GB aus der 100-GB-Zeit, beide
inzwischen nicht mehr deckbar; einer haette das Modal gesucht, das die Karte
zu Recht nicht mehr oeffnet. Auf 550 und 560 GB umgestellt, wo sie das pruefen,
wofuer sie geschrieben wurden.

Dazu ein Test, der belegt statt annimmt, warum das max(1, min(...)) in
ConfirmBookStorage stehen bleiben darf: ein Modal ist per openModal direkt
erreichbar, aber bookStoragePacks() legt weder am Deckel noch bei Enterprise
eine Bestellzeile an. Die bestehenden Tests deckten purchase() ab, nicht diese
Weiterleitung.

Voller Testlauf: 2405 bestanden. Pint sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 15:40:26 +02:00
parent afa6183903
commit 3b5dee8f0d
7 changed files with 216 additions and 11 deletions

View File

@ -150,9 +150,22 @@ final class AddonCatalogue
return $max === null ? null : max(1, (int) $max);
}
/** Wie viele davon dieser Vertrag jetzt noch buchen könnte. */
/**
* Wie viele davon dieser Vertrag jetzt noch buchen könnte.
*
* BEIDE Gründe, aus denen BookAddon eine Buchung abweist, nicht nur der
* Deckel: ein Modul, das es für dieses Paket gar nicht gibt, ist null Mal
* buchbar und nicht drei Mal. Vorher antwortete diese Methode einem
* Enterprise-Vertrag „3" auf die Frage, die ihr eigener Docblock stellt —
* eine Zahl, die die Aktion dahinter jedes Mal abgelehnt hätte, und ein
* Aufrufer, der sie einem Kunden zeigt, verspricht etwas Unmögliches.
*/
public function bookableQuantity(?Subscription $subscription, string $key): int
{
if ($this->availabilityRefusal($subscription, $key) !== null) {
return 0;
}
$max = $this->maxQuantity($key);
if ($max === null) {

View File

@ -34,6 +34,17 @@ use App\Support\Bytes;
* packs covers what is stored the packs count towards the limit here, on the
* same authority everything else reads or delete data and ask again against a
* fresh reading rather than last night's.
*
* "Both are real" is a promise, and it has to be kept against the COMMERCIAL
* ceiling and not only against the arithmetic. Packs are capped per contract and
* are not sold on every package at all, so the number that closes the gap on
* paper is not always a number anyone may book and once a pack shrank from 100
* GB usable to 20, every overage above three packs' worth landed there. The
* remedy therefore offers packs only when the packs a contract may ACTUALLY book
* would clear the blocker, and otherwise offers none and says how much would
* still be left over. An offer that BookAddon would refuse, or that costs money
* every month and leaves the downgrade blocked anyway, is not a way out it is
* the greyed-out button again, wearing a price tag.
*/
final readonly class DowngradeCheck
{
@ -49,7 +60,14 @@ final readonly class DowngradeCheck
* target package plus the packs already booked would allow, how much has
* to go, and how many further packs would cover it instead.
*
* @var array{used: string, limit: string, over: string, packs: int, pack_gb: int}|null
* `packs` is what this contract may buy AND what would actually lift the
* blocker zero when no bookable number of packs does both, and then
* `short` carries what would still be over after every pack the contract
* could book. The two are exclusive on purpose: `short` being set is the
* signal that buying is not one of the ways out here, and the card reads
* it to say so instead of offering a button.
*
* @var array{used: string, limit: string, over: string, packs: int, pack_gb: int, short: ?string}|null
*/
public ?array $storageRemedy = null,
) {}
@ -106,12 +124,29 @@ final readonly class DowngradeCheck
'limit' => $allowance->totalGb().' GB',
];
// What closes the gap on paper, and what this contract may actually
// buy. The second figure is the commercial one and is not re-derived
// here: AddonCatalogue holds the ceiling and the packages a pack is
// sold on, and it is the same question BookAddon refuses by — two
// formulations of one limit would be two limits.
$wanted = $allowance->packsToCover((int) $metric->disk_used_bytes);
$bookable = app(AddonCatalogue::class)->bookableQuantity($contract, AddonCatalogue::STORAGE);
$enough = $wanted <= $bookable;
$storageRemedy = [
'used' => Bytes::human($metric->disk_used_bytes),
'limit' => $allowance->totalGb().' GB',
'over' => Bytes::human($metric->disk_used_bytes - $allowance->totalGb() * 1024 ** 3),
'packs' => $allowance->packsToCover((int) $metric->disk_used_bytes),
'packs' => $enough ? $wanted : 0,
'pack_gb' => $allowance->packSizeGb,
// Measured against every pack the contract could still book, not
// against the ceiling: a customer already holding one pack has a
// smaller remainder than one holding none, and quoting the
// ceiling's figure at them would overstate what is left to delete.
'short' => $enough ? null : Bytes::human(
(int) $metric->disk_used_bytes
- ($allowance->totalGb() + $bookable * $allowance->packSizeGb) * 1024 ** 3
),
];
}

View File

@ -107,6 +107,14 @@ final readonly class StorageAllowance
* Zero when the allowance already covers it. Rounded up, because half a pack
* is not for sale and an answer that leaves the customer one gigabyte short
* is an answer that sends them back a second time.
*
* ARITHMETIC ONLY, deliberately: this says how many packs the bytes need,
* never how many the customer may buy. The ceiling and the packages a pack
* is sold on are commercial rules, they live in AddonCatalogue, and the
* caller that holds the contract applies them see DowngradeCheck, which is
* where the answer is turned into an offer. Clamping here instead would put
* a contract lookup into every construction of this class, including the
* quota step and the portal, neither of which is asking a sales question.
*/
public function packsToCover(int $bytes): int
{

View File

@ -33,6 +33,12 @@ return [
// endete. Es gibt genau zwei Auswege, und beide stehen hier mit Zahlen.
'downgrade_escape' => [
'intro' => 'Sie belegen :used, mit diesem Paket wären :limit möglich — :over müssen weg. Sie können stattdessen Zusatzspeicher buchen; gebuchte Pakete zählen zu Ihrem Kontingent.',
// Wenn Kaufen keiner der beiden Auswege mehr ist. Der Satz nennt die
// Lücke, die bliebe, und nicht den Grund: „höchstens drei Blöcke“ wäre
// im Enterprise-Fall falsch, wo es gar keine gibt, und eine Begründung,
// die in der Hälfte der Fälle daneben liegt, ist schlechter als eine
// allgemeine, die immer stimmt.
'capped' => 'Sie belegen :used, mit diesem Paket wären :limit möglich — :over müssen weg. Zusatzspeicher schließt diese Lücke nicht: selbst mit allen Blöcken, die dieser Vertrag noch buchen kann, blieben :short zu viel. Löschen Sie Daten und messen Sie danach neu.',
'book' => ':count × Zusatzspeicher buchen (+:gb GB)',
'remeasure' => 'Speicher neu messen',
'measured_at' => 'Gemessen am :when. Nach dem Löschen von Daten hier neu messen, statt auf die nächtliche Messung zu warten.',

View File

@ -33,6 +33,11 @@ return [
// There are exactly two ways out, and both are stated here with numbers.
'downgrade_escape' => [
'intro' => 'You are using :used, this package would allow :limit — :over has to go. You can book extra storage instead; booked packs count towards your allowance.',
// When buying is no longer one of the two ways out. Names the gap that
// would remain rather than the reason: "at most three packs" would be
// wrong for Enterprise, which is sold none at all, and a reason that is
// false half the time is worse than a general one that always holds.
'capped' => 'You are using :used, this package would allow :limit — :over has to go. Extra storage cannot close that gap: even with every pack this contract could still book, :short would remain. Delete data and measure again afterwards.',
'book' => 'Add :count × extra storage (+:gb GB)',
'remeasure' => 'Measure storage again',
'measured_at' => 'Measured on :when. After deleting data, measure again here rather than waiting for the nightly reading.',

View File

@ -324,14 +324,22 @@
room, or delete the files and have us look
again and being told to wait for tonight's
reading after clearing 40 GB is the same
dead end as a greyed-out button. --}}
dead end as a greyed-out button.
Buying is only one of them while it actually
works. Where the packs this contract may book
cannot lift the blocker, `short` is set, and
then the sentence says so and no button
offers a charge that would leave the customer
exactly where they are. --}}
@if ($remedy = $plan['check']->storageRemedy)
<div class="mt-3 border-t border-warning-border pt-3">
<p class="text-xs leading-relaxed text-ink">
{{ __('billing.downgrade_escape.intro', [
{{ __('billing.downgrade_escape.'.($remedy['short'] === null ? 'intro' : 'capped'), [
'used' => $remedy['used'],
'limit' => $remedy['limit'],
'over' => $remedy['over'],
'short' => $remedy['short'] ?? '',
]) }}
</p>
<div class="mt-3 flex flex-wrap gap-2">

View File

@ -296,7 +296,11 @@ function storedBytes(Instance $instance, int $gib, ?string $day = null): Instanc
it('still blocks a downgrade the stored data does not fit, and says what would fix it', function () {
$fixture = storageFixture('business'); // 1000 GB package
storedBytes($fixture['instance'], 600);
// Fünfzig Gigabyte zu viel: drei Blöcke à 20 GB decken sie, und das ist
// genau der Deckel — der letzte Betrag, für den Kaufen überhaupt noch ein
// Ausweg ist. Vorher standen hier 600 GB, aus der Zeit, als ein Block noch
// 100 GB brachte; seit Task 5 wären das fünf Blöcke und damit keiner.
storedBytes($fixture['instance'], 550);
$team = app(PlanCatalogue::class)->sellable()['team']; // 500 GB
$check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team');
@ -305,10 +309,11 @@ 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')
// 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);
->and($check->storageRemedy['packs'])->toBe(3)
->and($check->storageRemedy['pack_gb'])->toBe(20)
// Gedeckt, also kein Restbetrag — und damit der Satz, der zum Knopf
// gehört, und nicht der, der ihn ersetzt.
->and($check->storageRemedy['short'])->toBeNull();
});
it('lets exactly that downgrade through once the offered packs are booked', function () {
@ -334,9 +339,134 @@ it('lets exactly that downgrade through once the offered packs are booked', func
->and($after->blockers)->toBe([]);
});
/**
* Der Ausweg muss auch kaufmännisch gehbar sein, nicht nur rechnerisch.
*
* `packsToCover()` rechnet, und die Rechnung kennt keinen Deckel: Hundert
* Gigabyte zu viel waren fünf Blöcke, und höchstens drei sind buchbar. Die
* Karte bot „5 × Zusatzspeicher buchen (+100 GB) an, das Modal klemmte still
* auf drei, BookAddon hätte den vierten ohnehin abgelehnt und der Kunde
* stand nach 45 im Monat genau dort, wo er vorher stand. Solange ein Block
* 100 GB brachte, war das unerreichbar; seit Task 5 sind es 20, und jede
* Überschreitung über 60 GB landet hier.
*/
it('verspricht keine Blöcke, die dieser Vertrag gar nicht buchen könnte', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 600); // 100 GB zu viel für Team = fünf Blöcke
$team = app(PlanCatalogue::class)->sellable()['team'];
$check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team');
expect($check->allowed)->toBeFalse()
// Kein Angebot statt eines unerfüllbaren: die Karte hängt ihren Knopf
// an diese Zahl.
->and($check->storageRemedy['packs'])->toBe(0)
// Und was nach allen drei buchbaren Blöcken übrig bliebe, in Ziffern —
// nicht die vollen 107 GB, die ohne Blöcke wegmüssten.
->and($check->storageRemedy['short'])->toBe('42,9 GB')
->and($check->storageRemedy['over'])->toBe('107 GB');
});
/**
* Der Beleg, dass das alte Versprechen wirklich keines war: Kunde folgt dem
* Rat, kauft das Höchstmaß und der Wechsel bleibt gesperrt.
*/
it('bleibt gesperrt, auch wenn der Kunde das Höchstmaß an Blöcken bucht', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 600);
foreach (range(1, 3) as $ignored) {
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
}
// Der vierte, dem die alte Empfehlung entsprochen hätte, geht nicht.
expect(fn () => app(BookAddon::class)($fixture['subscription']->refresh(), AddonCatalogue::STORAGE))
->toThrow(RuntimeException::class);
$team = app(PlanCatalogue::class)->sellable()['team'];
$after = DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team');
expect($after->allowed)->toBeFalse()
->and($after->storageRemedy['packs'])->toBe(0)
->and($after->storageRemedy['short'])->toBe('42,9 GB');
});
/** Dieselbe Grenze in der Ansicht: kein Knopf, und der Satz sagt warum. */
it('bietet auf der Karte keinen Kauf an, den der Vertrag nicht abschließen kann', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 600);
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->assertDontSee('confirm-book-storage')
->assertSee(__('billing.downgrade_escape.capped', [
'used' => '644 GB',
'limit' => '500 GB',
'over' => '107 GB',
'short' => '42,9 GB',
]))
// Der andere Ausweg bleibt: löschen und neu messen.
->assertSee(__('billing.downgrade_escape.remeasure'));
});
/**
* Der zweite Grund, aus dem BookAddon ablehnt, und derselbe Fehler wäre er
* gewesen: Enterprise wird gar kein Zusatzspeicher verkauft. Der Deckel allein
* hätte hier „3“ geantwortet und die Karte hätte Blöcke angeboten, die es für
* dieses Paket nicht gibt.
*/
it('bietet einem Enterprise-Vertrag keine Blöcke an, den es dort nicht gibt', function () {
$fixture = storageFixture('enterprise');
storedBytes($fixture['instance'], 1200); // 200 GB zu viel für Business
$business = app(PlanCatalogue::class)->sellable()['business'];
$check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $business, 'business');
expect($check->allowed)->toBeFalse()
->and($check->storageRemedy['packs'])->toBe(0)
// Nichts buchbar, also bleibt der volle Betrag stehen — der Satz nennt
// dieselbe Zahl zweimal, und das ist die ehrliche Auskunft.
->and($check->storageRemedy['short'])->toBe('215 GB')
->and($check->storageRemedy['over'])->toBe('215 GB');
});
/**
* Warum das Klemmen in ConfirmBookStorage (`max(1, min(…))`, das bei nichts
* mehr Buchbarem auf einen Block hochzieht) bewusst stehen bleibt: der Weg
* dahinter ist ohnehin zu. Ein Modal ist per `openModal` direkt erreichbar,
* also wird das nicht angenommen, sondern belegt beide Gründe, aus denen
* BookAddon ablehnt, halten schon in `purchase()`, bevor eine Bestellzeile
* entsteht.
*/
it('legt über den Bestätigungsweg keine Bestellzeile an, wenn nichts buchbar ist', function () {
$amDeckel = storageFixture('business');
app(BookAddon::class)($amDeckel['subscription'], AddonCatalogue::STORAGE, 3);
// Eigene Adresse: `storageFixture()` vergibt sonst zweimal dieselbe, und
// `instances.subdomain` ist eindeutig.
$enterprise = storageFixture('enterprise', ['subdomain' => 'huber']);
withStripeSecret();
foreach ([$amDeckel, $enterprise] as $fixture) {
expect(app(AddonCatalogue::class)->bookableQuantity($fixture['subscription']->refresh(), AddonCatalogue::STORAGE))->toBe(0);
Livewire::actingAs($fixture['user'])
->test(Billing::class)
// Genau das, was ein direkt geöffnetes Modal auslöst.
->call('bookStoragePacks', 1);
expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(0);
}
});
it('offers the packs through a modal, and books exactly the number that was offered', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 800); // three packs short of Team
// Sechzig Gigabyte zu viel, also genau drei Blöcke. Vorher 800 GB, was
// damals „drei Blöcke zu wenig“ hieß und seit Task 5 fünfzehn wären — die
// Karte böte dafür gar keinen Knopf mehr an, und der Test hätte das Modal
// gesucht, das sie zu Recht nicht mehr öffnet.
storedBytes($fixture['instance'], 560);
Livewire::actingAs($fixture['user'])
->test(Billing::class)