Zweiter Sicherungsort ebenfalls aus dem Verkauf nehmen, eigener Schalter
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details

extra_backups verspricht einen zweiten, getrennten Sicherungsort, den es
nicht gibt (nur die erste, tägliche Sicherung läuft). Gesperrt nach dem
Muster von collabora_pro, aber mit eigener Konstante
(EXTRA_BACKUPS_ON_SALE statt OFFICE_ON_SALE) und eigenem Abweisungssatz,
weil beide Module aus unterschiedlichen Gründen fehlen und zu
unterschiedlichen Zeitpunkten zurückkommen. Keine Bestandsbuchung
betroffen (subscription_addons enthielt auf dieser Maschine keine Zeile
dazu). OpenWork-Eintrag von SOLD auf PLANNED umgestellt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 17:28:03 +02:00
parent d3ac839b1e
commit bf95f9da8a
12 changed files with 271 additions and 47 deletions

View File

@ -62,6 +62,12 @@ class LandingController extends Controller
// und __invoke() reicht sie der View als `officeOnSale` weiter, die // und __invoke() reicht sie der View als `officeOnSale` weiter, die
// damit dieselbe Kachel „Office im Browser" aus der Aufzählung „Was // damit dieselbe Kachel „Office im Browser" aus der Aufzählung „Was
// enthalten ist" filtert. // enthalten ist" filtert.
//
// Derselbe Riegel sperrt seit derselben Stelle auch `extra_backups`
// (App\Services\Billing\AddonCatalogue::EXTRA_BACKUPS_ON_SALE) — ohne
// eigenen View-Schalter wie `officeOnSale`, weil der zweite
// Sicherungsort in keiner Aufzählung „Was enthalten ist" steht, sondern
// nur in der Modulliste. modulePrices() unten reicht dafür allein.
/** /**
* Catalogue feature keys, in the words a customer uses. * Catalogue feature keys, in the words a customer uses.
@ -446,6 +452,16 @@ class LandingController extends Controller
continue; continue;
} }
// Der zweite Sicherungsort ist vom Markt genommen (siehe
// AddonCatalogue::EXTRA_BACKUPS_ON_SALE), solange der
// Speicherserver für das zweite Ziel fehlt — ein eigener
// Schalter, weil dieses Modul aus einem anderen Grund als
// Office nicht verkauft wird und zu einem anderen Zeitpunkt
// zurückkommt.
if ($key === 'extra_backups' && ! AddonCatalogue::EXTRA_BACKUPS_ON_SALE) {
continue;
}
$cents = $catalogue->priceCents((string) $key); $cents = $catalogue->priceCents((string) $key);
// Nothing is sold for nothing. An entry without a price reads // Nothing is sold for nothing. An entry without a price reads

View File

@ -98,10 +98,12 @@ class Billing extends Component
// saleRefusal() zuerst: ob es das Modul überhaupt zu kaufen gibt, // saleRefusal() zuerst: ob es das Modul überhaupt zu kaufen gibt,
// ist die grundsätzlichere Frage — ein Kunde soll nicht hören, // ist die grundsätzlichere Frage — ein Kunde soll nicht hören,
// er habe es bereits, wenn er es in Wahrheit nie hätte kaufen // er habe es bereits, wenn er es in Wahrheit nie hätte kaufen
// können. Heute betrifft das nur `collabora_pro` (Office liefert // können. Heute betrifft das `collabora_pro` (Office liefert
// nicht, siehe AddonCatalogue::OFFICE_ON_SALE), aber der Aufruf // nicht, siehe AddonCatalogue::OFFICE_ON_SALE) und
// ist derselbe, der auch die Preistafel filtert — ein Schalter // `extra_backups` (der zweite Sicherungsort fehlt, siehe
// für beide Seiten. // AddonCatalogue::EXTRA_BACKUPS_ON_SALE), aber der Aufruf ist
// derselbe, der auch die Preistafel filtert — ein Schalter für
// beide Seiten, je Modul einer.
$refusal = app(AddonCatalogue::class)->saleRefusal($key) $refusal = app(AddonCatalogue::class)->saleRefusal($key)
?? app(AddonCatalogue::class)->duplicateRefusal($contract, $key) ?? app(AddonCatalogue::class)->duplicateRefusal($contract, $key)
?? $this->cartRefusal($customer, $key); ?? $this->cartRefusal($customer, $key);
@ -531,10 +533,11 @@ class Billing extends Component
* is how one double click turns into two charges for one thing. * is how one double click turns into two charges for one thing.
* *
* Dieselbe Regel gilt für ein Modul, das AddonCatalogue::saleRefusal() * Dieselbe Regel gilt für ein Modul, das AddonCatalogue::saleRefusal()
* ganz grundsätzlich ablehnt (heute nur `collabora_pro`, solange Office * ganz grundsätzlich ablehnt (heute `collabora_pro`, solange Office nicht
* nicht liefert): die Karte verschwindet, statt einen Kauf anzubieten, * liefert, und `extra_backups`, solange der zweite Sicherungsort fehlt):
* den purchase() ohnehin abweisen würde aber auch hier nur, solange * die Karte verschwindet, statt einen Kauf anzubieten, den purchase()
* niemand das Modul schon gebucht hat oder es im Warenkorb liegt. * ohnehin abweisen würde aber auch hier nur, solange niemand das Modul
* schon gebucht hat oder es im Warenkorb liegt.
* *
* @param array<string, array<string, mixed>> $rows * @param array<string, array<string, mixed>> $rows
* @param Collection<int, Order> $pending * @param Collection<int, Order> $pending

View File

@ -80,6 +80,46 @@ final class AddonCatalogue
*/ */
public const OFFICE_ON_SALE = false; public const OFFICE_ON_SALE = false;
/**
* Ob der zweite Sicherungsort schon verkauft wird.
*
* Auf false, bis der Speicherserver für das zweite, getrennte Ziel steht
* siehe App\Support\OpenWork::all(). Anders als bei Office fehlt hier
* nicht die ganze Leistung: die nächtliche Sicherung selbst läuft längst,
* über App\Provisioning\Steps\Customer\RegisterBackup auf die lokale
* Ablage des Hosts. Was fehlt, ist ausschließlich der ZWEITE, davon
* getrennte Ort, den das Modul verspricht.
*
* Eigene Konstante statt OFFICE_ON_SALE mitzubenutzen: die beiden Module
* hängen an verschiedenen Voraussetzungen (Office am Dokumentenserver,
* dieses hier am Speicherserver) und werden nicht am selben Tag fertig
* ein gemeinsamer Schalter würde beim Fertigstellen des einen versehentlich
* auch das andere freigeben.
*
* Derselbe Mechanismus wie bei OFFICE_ON_SALE im Übrigen: saleRefusal()
* unten weist jede Buchung von `extra_backups` ab, solange diese Konstante
* false ist, und LandingController::modulePrices() liest dieselbe
* Konstante, um die Zeile aus der Preistafel zu nehmen. Der Werbetext
* (ADDONS im LandingController und lang/*\/billing.php) bleibt stehen,
* damit er beim Wiederanschalten nicht neu geschrieben werden muss.
*
* Die Begründung, die der Kunde liest, ist bewusst eine ANDERE als bei
* Office (siehe saleRefusal()): wer nach dem zweiten Ort fragt, sorgt sich
* um seine Daten und soll hören, dass sie sehr wohl täglich gesichert
* werden nur eben noch an einem einzigen Ort.
*
* Bewusst NICHT gespiegelt in priceCents()/knows(): App\Actions\BookAddon
* kennt den Preis unverändert weiter, aus demselben Grund wie bei Office
* ein Bestandsfall oder ein Geschenk des Betreibers (GrantAddon) sind
* keine Verkäufe an einen Kunden, der die Preistafel liest.
*
* Wieder anschalten, sobald der Speicherserver liefert: hier auf true
* stellen, sobald der Bereitstellungslauf tatsächlich ein zweites
* Sicherungsziel anlegt kein zweiter Handgriff nötig wie bei Office,
* weil kein Paketmerkmal von diesem Modul abhängt.
*/
public const EXTRA_BACKUPS_ON_SALE = false;
/** Today's monthly net price for one unit, or null if we do not sell it. */ /** Today's monthly net price for one unit, or null if we do not sell it. */
public function priceCents(string $key): ?int public function priceCents(string $key): ?int
{ {
@ -172,8 +212,20 @@ final class AddonCatalogue
* Anders als availabilityRefusal() unten, das fragt, ob EIN Paket ein * Anders als availabilityRefusal() unten, das fragt, ob EIN Paket ein
* Modul haben darf: die Antwort hier ist für jeden Vertrag und jedes * Modul haben darf: die Antwort hier ist für jeden Vertrag und jedes
* Paket gleich, weil das, was verkauft würde, schlicht nicht existiert. * Paket gleich, weil das, was verkauft würde, schlicht nicht existiert.
* Heute betrifft das nur `collabora_pro` siehe OFFICE_ON_SALE oben für * Heute betrifft das `collabora_pro` und `extra_backups` siehe
* die zwei Handgriffe, mit denen der Verkauf wieder aufgenommen wird. * OFFICE_ON_SALE bzw. EXTRA_BACKUPS_ON_SALE oben für die Handgriffe, mit
* denen der jeweilige Verkauf wieder aufgenommen wird. Zwei eigene
* Konstanten mit Absicht: die Module hängen an verschiedenen
* Voraussetzungen und werden nicht am selben Tag fertig, also braucht
* jedes seinen eigenen Schalter sonst schaltete das Fertigstellen des
* einen das andere versehentlich mit frei.
*
* Auch der SATZ, den der Kunde liest, ist bewusst nicht derselbe: bei
* Office fehlt das ganze Modul, beim zweiten Sicherungsort nur der
* zweite ORT die erste, tägliche Sicherung läuft unverändert weiter.
* Ein Kunde, der nach dem zweiten Ort fragt, soll das lesen können,
* statt denselben allgemeinen Satz wie für ein Modul zu bekommen, das es
* gar nicht gibt.
* *
* Gefragt an den zwei Stellen, an denen ein Kunde ein Modul NEU bucht: * Gefragt an den zwei Stellen, an denen ein Kunde ein Modul NEU bucht:
* App\Http\Controllers\LandingController für die Preistafel und * App\Http\Controllers\LandingController für die Preistafel und
@ -187,11 +239,15 @@ final class AddonCatalogue
*/ */
public function saleRefusal(string $key): ?string public function saleRefusal(string $key): ?string
{ {
if ($key !== 'collabora_pro' || self::OFFICE_ON_SALE) { if ($key === 'collabora_pro' && ! self::OFFICE_ON_SALE) {
return null; return __('billing.addon_not_on_sale', ['module' => $this->name($key)]);
} }
return __('billing.addon_not_on_sale', ['module' => $this->name($key)]); if ($key === 'extra_backups' && ! self::EXTRA_BACKUPS_ON_SALE) {
return __('billing.addon_second_location_not_on_sale', ['module' => $this->name($key)]);
}
return null;
} }
/** /**

View File

@ -36,11 +36,11 @@ final class OpenWork
{ {
return [ return [
[ [
'title' => 'Zweiter Sicherungsort wird verkauft, aber nicht geliefert', 'title' => 'Zweiter Sicherungsort ist aus dem Verkauf genommen',
'state' => self::SOLD, 'state' => self::PLANNED,
'where' => 'config/provisioning.php → addons.extra_backups', 'where' => 'App\\Services\\Billing\\AddonCatalogue::EXTRA_BACKUPS_ON_SALE',
'missing' => 'Das Modul kostet 6 € im Monat und verspricht „eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert". Im ganzen Repo kommt der Schlüssel nur im LandingController und in der Konfiguration vor — es gibt keinen Provisionierungsschritt, der ein zweites Sicherungsziel anlegt.', 'missing' => 'Das Modul kostete 6 € im Monat und versprach „eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert" — es gab dafür aber keinen Provisionierungsschritt, der ein zweites Sicherungsziel anlegt, nur die nächtliche vzdump-Sicherung auf die lokale Ablage des Hosts (App\\Provisioning\\Steps\\Customer\\RegisterBackup). Preistafel und Abrechnungsseite bieten das Modul jetzt nicht mehr an — der Code-Schalter ist die Konstante `EXTRA_BACKUPS_ON_SALE`, eigenständig von `OFFICE_ON_SALE`, weil dieses Modul an einem anderen Server hängt (dem künftigen Speicherserver, nicht dem Dokumentenserver) und darum zu einem anderen Zeitpunkt zurückkommt. Auf der Entwicklungsmaschine enthielt `subscription_addons` zum Zeitpunkt dieser Änderung keine einzige Zeile mit `extra_backups` — ob im Live-Betrieb schon gebucht wurde, ist von hier aus nicht sichtbar; sollte doch, entscheidet der Betreiber, was mit diesen Verträgen geschieht, diese Sperre betrifft nur NEUE Buchungen.',
'risk' => 'Wer es bucht, zahlt monatlich für eine Leistung, die nicht erbracht wird.', 'risk' => 'Kein akuter Schaden mehr — weder die Preistafel noch die Abrechnungsseite versprechen etwas, das nicht eingelöst wird. Das zweite Sicherungsziel selbst ist an keiner Stelle im Repository angefangen; das bleibt die Arbeit, die vor dem Wiederanschalten noch aussteht.',
], ],
[ [
'title' => 'Office (ONLYOFFICE über einen gemeinsamen Dokumentenserver) ist aus dem Verkauf genommen', 'title' => 'Office (ONLYOFFICE über einen gemeinsamen Dokumentenserver) ist aus dem Verkauf genommen',

View File

@ -392,6 +392,11 @@ return [
'unavailable_on' => ['enterprise'], 'unavailable_on' => ['enterprise'],
], ],
'addons' => [ 'addons' => [
// Der Preis bleibt hier stehen, auch während des Verkaufs — siehe
// App\Services\Billing\AddonCatalogue::EXTRA_BACKUPS_ON_SALE. Der
// Preis wird für Bestandsverträge und ein Geschenk des Betreibers
// weiter gebraucht (App\Actions\BookAddon); NEUE Buchungen weist
// AddonCatalogue::saleRefusal() ab, solange die Konstante false ist.
'extra_backups' => ['price_cents' => 500, 'sold_as' => 'entitlement'], 'extra_backups' => ['price_cents' => 500, 'sold_as' => 'entitlement'],
'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'], 'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'],
// Die PRO-Stufe eines Office, das im Paket schon drin ist — „mehr // Die PRO-Stufe eines Office, das im Paket schon drin ist — „mehr

View File

@ -161,6 +161,13 @@ return [
// Paket — nicht weil der Vertrag es nicht hergibt, sondern weil wir es // Paket — nicht weil der Vertrag es nicht hergibt, sondern weil wir es
// (noch) nicht liefern. Siehe App\Services\Billing\AddonCatalogue::saleRefusal(). // (noch) nicht liefern. Siehe App\Services\Billing\AddonCatalogue::saleRefusal().
'addon_not_on_sale' => ':module wird derzeit nicht verkauft.', 'addon_not_on_sale' => ':module wird derzeit nicht verkauft.',
// Eigener Text statt "addon_not_on_sale": bei Office fehlt das ganze
// Modul, beim zweiten Sicherungsort nur der zweite ORT — die erste,
// tägliche Sicherung läuft unverändert weiter. Wer nach diesem Modul
// fragt, sorgt sich um seine Daten, und "wird derzeit nicht verkauft"
// ließe offen, ob überhaupt gesichert wird. Siehe
// App\Services\Billing\AddonCatalogue::EXTRA_BACKUPS_ON_SALE.
'addon_second_location_not_on_sale' => '„:module“ ist noch nicht verfügbar — es fehlt der zweite, getrennte Ort für Ihre Daten. Die tägliche verschlüsselte Sicherung an Ihrem ersten Ort läuft davon unberührt weiter.',
// Ein Block ist Platz auf einer bestimmten Maschine — eine laufende // Ein Block ist Platz auf einer bestimmten Maschine — eine laufende
// Instanz zieht nicht um. Gemeint ist der Host, nicht der Vertrag. // 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.', '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

@ -160,6 +160,13 @@ return [
// package — not because the contract rules it out, but because we do // package — not because the contract rules it out, but because we do
// not deliver it (yet). See App\Services\Billing\AddonCatalogue::saleRefusal(). // not deliver it (yet). See App\Services\Billing\AddonCatalogue::saleRefusal().
'addon_not_on_sale' => ':module is not for sale right now.', 'addon_not_on_sale' => ':module is not for sale right now.',
// Eigener Text statt "addon_not_on_sale": bei Office fehlt das ganze
// Modul, beim zweiten Sicherungsort nur der zweite ORT — die erste,
// tägliche Sicherung läuft unverändert weiter. Wer nach diesem Modul
// fragt, sorgt sich um seine Daten, und "is not for sale" ließe offen,
// ob überhaupt gesichert wird. Siehe
// App\Services\Billing\AddonCatalogue::EXTRA_BACKUPS_ON_SALE.
'addon_second_location_not_on_sale' => '":module" is not available yet — the second, separate location for your data is missing. Your first, daily encrypted backup keeps running as usual.',
// A pack is room on a particular machine — a running instance does not // A pack is room on a particular machine — a running instance does not
// move. This is about the host, not the contract. // 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.', 'storage_no_room' => 'Your server currently has no room for more storage. Please contact support — we will make room.',

View File

@ -38,30 +38,37 @@ it('nennt zu jedem Punkt wo, was fehlt und was passiert, wenn es so bleibt', fun
} }
}); });
it('führt das bezahlte Modul, das nichts tut, als solches', function () { it('führt inzwischen kein Modul mehr als verkauft und nicht geliefert', function () {
// Der Anlass für die ganze Seite. Verschwindet es hier stillschweigend, // Der Anlass für die ganze Seite war ein SOLD-Punkt — ein Modul, das
// ist die Liste wertlos — es kostet Geld, solange es offen ist. // Geld kostet, während es offen ist. Hier standen einmal zwei
// // (Sicherungsort UND Office); beide sind inzwischen aus dem Verkauf
// Bis zur Office-Umstellung standen hier zwei Module (Sicherungsort UND // genommen statt weiter verkauft und nicht geliefert zu werden, siehe
// Office) — Office ist jetzt kein SOLD-Punkt mehr, siehe die eigene // die zwei eigenen Prüfungen unten, die je einen dieser Wechsel
// Prüfung unten, die genau diesen Wechsel festhält. // festhalten. Verschwände SOLD hier künftig wieder unbemerkt aus der
$sold = collect(OpenWork::all())->where('state', OpenWork::SOLD)->pluck('title')->implode(' '); // Liste, wäre die Seite wertlos — dieser Test bleibt deshalb stehen,
// auch wenn er heute nichts mehr findet.
expect($sold)->toContain('Sicherungsort'); expect(collect(OpenWork::all())->where('state', OpenWork::SOLD))->toBeEmpty();
}); });
it('führt Office nicht mehr als verkauft, sondern als aus dem Verkauf genommen', function () { it('führt Office nicht mehr als verkauft, sondern als aus dem Verkauf genommen', function () {
// Der Grund, warum der Test oben nur noch ein Modul findet: Office wurde // Der Unterschied zwischen SOLD (kostet gerade Geld) und PLANNED (tut
// von der Preistafel genommen, statt weiter verkauft und nicht geliefert // nichts, bis jemand anfängt) ist genau das, was die Reihenfolge auf der
// zu werden — der Unterschied zwischen SOLD (kostet gerade Geld) und // Seite bestimmt (siehe ORDER unten).
// PLANNED (tut nichts, bis jemand anfängt) ist genau das, was die
// Reihenfolge auf der Seite bestimmt (siehe ORDER unten).
$office = collect(OpenWork::all())->first(fn (array $item) => str_contains($item['title'], 'Office')); $office = collect(OpenWork::all())->first(fn (array $item) => str_contains($item['title'], 'Office'));
expect($office)->not->toBeNull() expect($office)->not->toBeNull()
->and($office['state'])->toBe(OpenWork::PLANNED); ->and($office['state'])->toBe(OpenWork::PLANNED);
}); });
it('führt den zweiten Sicherungsort nicht mehr als verkauft, sondern als aus dem Verkauf genommen', function () {
// Derselbe Wechsel wie bei Office, nur später und aus einem anderen
// Grund: hier fehlt nicht das ganze Modul, nur der zweite Ort.
$backups = collect(OpenWork::all())->first(fn (array $item) => str_contains($item['title'], 'Sicherungsort'));
expect($backups)->not->toBeNull()
->and($backups['state'])->toBe(OpenWork::PLANNED);
});
it('steht jedem offen, der die Konsole öffnen darf', function () { it('steht jedem offen, der die Konsole öffnen darf', function () {
// Kein eigenes Recht: wer hier arbeitet, soll wissen, worauf er sich // Kein eigenes Recht: wer hier arbeitet, soll wissen, worauf er sich
// verlassen kann und worauf nicht. // verlassen kann und worauf nicht.

View File

@ -190,13 +190,18 @@ it('stops offering an entitlement the customer already has, and keeps offering p
}); });
it('marks a module waiting in the cart so the card stops offering it', function () { it('marks a module waiting in the cart so the card stops offering it', function () {
// priority_support, not extra_backups: since the second backup location
// was closed to every customer (see
// tests/Feature/Billing/ExtraBackupsNotSoldTest.php), purchase() would
// refuse it before it ever reaches the cart, and this test is about what
// happens once a purchase DOES reach it.
[, $user] = entitlementShopper(); [, $user] = entitlementShopper();
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'extra_backups'); Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'priority_support');
$component = Livewire::actingAs($user)->test(Billing::class); $component = Livewire::actingAs($user)->test(Billing::class);
expect($component->viewData('addons')['extra_backups']['in_cart'])->toBeTrue(); expect($component->viewData('addons')['priority_support']['in_cart'])->toBeTrue();
$component->assertSee(__('billing.addon_in_cart_badge')); $component->assertSee(__('billing.addon_in_cart_badge'));
}); });

View File

@ -0,0 +1,88 @@
<?php
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\User;
use Livewire\Livewire;
/**
* Der zweite Sicherungsort liefert nicht siehe App\Support\OpenWork::all()
* aus demselben Riegel wie Office (tests/Feature/Billing/OfficeNotSoldTest.php),
* an derselben Stelle erweitert: AddonCatalogue::saleRefusal() weist jetzt
* auch `extra_backups` ab, solange AddonCatalogue::EXTRA_BACKUPS_ON_SALE
* false ist, und wird hier an denselben zwei Enden geprüft der Kauf selbst
* (am Formular vorbei, nicht nur "der Knopf fehlt") und die Karte, die ihn
* anbieten würde.
*
* Eigene Datei statt eines Anhängsels an OfficeNotSoldTest.php: die beiden
* Module werden aus verschiedenen Gründen gesperrt (Office fehlt ganz, hier
* fehlt nur der zweite ORT) und mit verschiedenen Sätzen abgewiesen zwei
* Module, zwei Prüfungen. Nur die Prüfung, dass die ÜBRIGEN Module unberührt
* bleiben, sitzt weiterhin allein in OfficeNotSoldTest.php, um nicht
* zweimal geprüft zu werden.
*/
function backupsShopper(string $plan = 'team'): array
{
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email]);
$order = Order::factory()->withSubscription()->for($customer)->create(['plan' => $plan]);
$instance = Instance::factory()->for($customer)->create([
'order_id' => $order->id,
'plan' => $plan,
'status' => 'active',
]);
$order->subscription->update(['instance_id' => $instance->id]);
// purchase() refuses outright without a Stripe key for the active mode
// (Task 5) — the refusal this test is about must not be masked by that
// earlier one.
withStripeSecret();
return [$customer, $user];
}
it('weist eine Buchung von extra_backups am Formular vorbei ab, solange der zweite Ort fehlt', function () {
// Absichtlich NICHT über einen fehlenden Knopf geprüft: das würde nur
// zeigen, dass die Anzeige geschlossen ist. Dieser Aufruf geht am
// Formular vorbei — genau der Weg, den ein zweites Browser-Fenster, eine
// alte Sitzung oder ein direkter Aufruf des Komponentennamens auch nähme.
[$customer, $user] = backupsShopper('team');
Livewire::actingAs($user)->test(Billing::class)
->call('purchase', 'addon', 'extra_backups')
->assertDispatched('notify', message: __('billing.addon_second_location_not_on_sale', [
'module' => __('billing.addon.extra_backups.name'),
]));
// Kein Auftrag, also nichts, das später fälschlich zur Buchung würde.
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse();
});
it('zeigt die Sicherungsort-Karte im Portal nicht mehr an, solange sie ohnehin abgewiesen würde', function () {
[, $user] = backupsShopper('team');
Livewire::actingAs($user)->test(Billing::class)
->assertDontSee(__('billing.addon.extra_backups.name'));
});
it('weist eine andere Meldung ab als Office, weil hier nur der zweite Ort fehlt', function () {
// Der eigentliche Punkt der Erweiterung: nicht derselbe Satz mit
// ausgetauschtem Modulnamen, sondern ein anderer Grund. Wer nach dem
// zweiten Ort fragt, soll erfahren, dass seine Daten sehr wohl täglich
// gesichert werden — nur eben noch an einem Ort.
[, $user] = backupsShopper('team');
$officeMessage = __('billing.addon_not_on_sale', [
'module' => __('billing.addon.collabora_pro.name'),
]);
Livewire::actingAs($user)->test(Billing::class)
->call('purchase', 'addon', 'extra_backups')
->assertDispatched('notify', message: __('billing.addon_second_location_not_on_sale', [
'module' => __('billing.addon.extra_backups.name'),
]))
->assertNotDispatched('notify', message: $officeMessage);
});

View File

@ -72,12 +72,15 @@ it('zeigt die Office-Karte im Portal nicht mehr an, solange sie ohnehin abgewies
}); });
it('lässt die anderen Module unberührt, sowohl in der Anzeige als auch bei der Buchung', function () { it('lässt die anderen Module unberührt, sowohl in der Anzeige als auch bei der Buchung', function () {
// Der Riegel gilt nur `collabora_pro` — für alles andere muss // Der Riegel gilt nur `collabora_pro` (und, seit
// saleRefusal() weiterhin null bleiben, sonst hätte diese Änderung den // tests/Feature/Billing/ExtraBackupsNotSoldTest.php, `extra_backups`) —
// ganzen Shop zugesperrt statt eines einzelnen Moduls. // für alles andere muss saleRefusal() weiterhin null bleiben, sonst hätte
// diese Änderung den ganzen Shop zugesperrt statt zweier einzelner
// Module. `extra_backups` steht deshalb absichtlich NICHT mehr in dieser
// Liste.
$catalogue = app(AddonCatalogue::class); $catalogue = app(AddonCatalogue::class);
foreach (['extra_backups', 'priority_support', 'custom_domain', AddonCatalogue::STORAGE] as $key) { foreach (['priority_support', 'custom_domain', AddonCatalogue::STORAGE] as $key) {
expect($catalogue->saleRefusal($key))->toBeNull(); expect($catalogue->saleRefusal($key))->toBeNull();
} }

View File

@ -2,6 +2,8 @@
use App\Models\PlanFamily; use App\Models\PlanFamily;
use App\Models\PlanVersion; use App\Models\PlanVersion;
use App\Support\CompanyProfile;
use App\Support\Settings;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -37,7 +39,7 @@ use Illuminate\Support\Str;
*/ */
function priceSheetGross(int $netCents): string function priceSheetGross(int $netCents): string
{ {
$cents = (int) round($netCents * (1 + App\Support\CompanyProfile::taxRate() / 100)); $cents = (int) round($netCents * (1 + CompanyProfile::taxRate() / 100));
$amount = $cents % 100 === 0 $amount = $cents % 100 === 0
? number_format($cents / 100, 0, ',', '.') ? number_format($cents / 100, 0, ',', '.')
@ -272,7 +274,7 @@ it('shows the platform address in the shape a customer will type it, under the c
// Through the setting, not the config: the setting is what // Through the setting, not the config: the setting is what
// ProvisioningSettings::dnsZone() reads first, and since instances moved to // ProvisioningSettings::dnsZone() reads first, and since instances moved to
// their own zone there is a row in it on every installation. // their own zone there is a row in it on every installation.
App\Support\Settings::set('provisioning.dns_zone', 'wolke.test'); Settings::set('provisioning.dns_zone', 'wolke.test');
$this->get('/') $this->get('/')
->assertOk() ->assertOk()
@ -292,12 +294,14 @@ it('names the modules it sells, with the prices the catalogue charges for them',
$page->assertSee('Optional dazubuchbar'); $page->assertSee('Optional dazubuchbar');
// `collabora_pro` fehlt hier absichtlich: es ist konfiguriert (BookAddon // `collabora_pro` und `extra_backups` fehlen hier absichtlich: beide sind
// kennt den Preis weiterhin — für Bestandsfälle und ein Geschenk des // konfiguriert (BookAddon kennt ihren Preis weiterhin — für Bestandsfälle
// Betreibers, siehe App\Actions\BookAddon), aber vom Markt genommen — // und ein Geschenk des Betreibers, siehe App\Actions\BookAddon), aber vom
// siehe die eigene Prüfung unten, AddonCatalogue::OFFICE_ON_SALE und // Markt genommen — siehe die eigenen Prüfungen unten,
// tests/Feature/Billing/OfficeNotSoldTest.php für die Abrechnungsseite. // AddonCatalogue::OFFICE_ON_SALE/EXTRA_BACKUPS_ON_SALE und
foreach (['extra_backups', 'priority_support', 'custom_domain'] as $key) { // tests/Feature/Billing/OfficeNotSoldTest.php bzw.
// ExtraBackupsNotSoldTest.php für die Abrechnungsseite.
foreach (['priority_support', 'custom_domain'] as $key) {
$page->assertSee($money((int) config("provisioning.addons.{$key}.price_cents")), false); $page->assertSee($money((int) config("provisioning.addons.{$key}.price_cents")), false);
} }
@ -333,6 +337,29 @@ it('bewirbt Office nicht, solange der Dokumentenserver nicht steht', function ()
->assertDontSee(priceSheetGross((int) config('provisioning.addons.collabora_pro.price_cents')), false); ->assertDontSee(priceSheetGross((int) config('provisioning.addons.collabora_pro.price_cents')), false);
}); });
/**
* Der zweite Sicherungsort liefert nicht siehe App\Support\OpenWork::all()
* und darf deshalb nicht mehr in der Liste „Optional dazubuchbar" stehen.
* Anders als bei Office betrifft das nur diese eine Zeile: das Modul kommt
* in keiner Aufzählung „Was enthalten ist" und keiner Vergleichstabelle vor,
* die tägliche Sicherung selbst (`daily_backups`) bleibt unverändert stehen.
*
* Absichtlich rot gedreht während der Entwicklung
* `EXTRA_BACKUPS_ON_SALE` auf `true` gestellt zeigte die Zeile wieder,
* dieser Test schlug wie erwartet fehl. Siehe den Bericht für das Ergebnis.
*/
it('bewirbt den zweiten Sicherungsort nicht, solange es keinen zweiten Ort gibt', function () {
$page = $this->get('/')->assertOk();
// Die tägliche Sicherung selbst bleibt beworben — nur der ZWEITE Ort
// fehlt, nicht die Sicherung.
$page->assertSee('Tägliche verschlüsselte Sicherung');
// Das Zusatzmodul und sein Preis in der Liste „Optional dazubuchbar".
$page->assertDontSee('Zweiter Sicherungsort')
->assertDontSee(priceSheetGross((int) config('provisioning.addons.extra_backups.price_cents')), false);
});
it('keeps the sheet up when nothing is on sale on top of a package', function () { it('keeps the sheet up when nothing is on sale on top of a package', function () {
// An installation that sells no modules is a configuration, not a failure. // An installation that sells no modules is a configuration, not a failure.
// The block disappears; the cells that could have been an offer go back to // The block disappears; the cells that could have been an offer go back to