Merge branch 'claude/angry-golick-187172' into feat/neue-pakete
commit
f8d0c2c353
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue