Zweiter Sicherungsort ebenfalls aus dem Verkauf nehmen, eigener Schalter
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
parent
d3ac839b1e
commit
bf95f9da8a
|
|
@ -62,6 +62,12 @@ class LandingController extends Controller
|
|||
// und __invoke() reicht sie der View als `officeOnSale` weiter, die
|
||||
// damit dieselbe Kachel „Office im Browser" aus der Aufzählung „Was
|
||||
// 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.
|
||||
|
|
@ -446,6 +452,16 @@ class LandingController extends Controller
|
|||
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);
|
||||
|
||||
// Nothing is sold for nothing. An entry without a price reads
|
||||
|
|
|
|||
|
|
@ -98,10 +98,12 @@ class Billing extends Component
|
|||
// saleRefusal() zuerst: ob es das Modul überhaupt zu kaufen gibt,
|
||||
// ist die grundsätzlichere Frage — ein Kunde soll nicht hören,
|
||||
// er habe es bereits, wenn er es in Wahrheit nie hätte kaufen
|
||||
// können. Heute betrifft das nur `collabora_pro` (Office liefert
|
||||
// nicht, siehe AddonCatalogue::OFFICE_ON_SALE), aber der Aufruf
|
||||
// ist derselbe, der auch die Preistafel filtert — ein Schalter
|
||||
// für beide Seiten.
|
||||
// können. Heute betrifft das `collabora_pro` (Office liefert
|
||||
// nicht, siehe AddonCatalogue::OFFICE_ON_SALE) und
|
||||
// `extra_backups` (der zweite Sicherungsort fehlt, siehe
|
||||
// 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)
|
||||
?? app(AddonCatalogue::class)->duplicateRefusal($contract, $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.
|
||||
*
|
||||
* Dieselbe Regel gilt für ein Modul, das AddonCatalogue::saleRefusal()
|
||||
* ganz grundsätzlich ablehnt (heute nur `collabora_pro`, solange Office
|
||||
* nicht liefert): die Karte verschwindet, statt einen Kauf anzubieten,
|
||||
* den purchase() ohnehin abweisen würde — aber auch hier nur, solange
|
||||
* niemand das Modul schon gebucht hat oder es im Warenkorb liegt.
|
||||
* ganz grundsätzlich ablehnt (heute `collabora_pro`, solange Office nicht
|
||||
* liefert, und `extra_backups`, solange der zweite Sicherungsort fehlt):
|
||||
* die Karte verschwindet, statt einen Kauf anzubieten, den purchase()
|
||||
* 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 Collection<int, Order> $pending
|
||||
|
|
|
|||
|
|
@ -80,6 +80,46 @@ final class AddonCatalogue
|
|||
*/
|
||||
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. */
|
||||
public function priceCents(string $key): ?int
|
||||
{
|
||||
|
|
@ -172,8 +212,20 @@ final class AddonCatalogue
|
|||
* Anders als availabilityRefusal() unten, das fragt, ob EIN Paket ein
|
||||
* Modul haben darf: die Antwort hier ist für jeden Vertrag und jedes
|
||||
* Paket gleich, weil das, was verkauft würde, schlicht nicht existiert.
|
||||
* Heute betrifft das nur `collabora_pro` — siehe OFFICE_ON_SALE oben für
|
||||
* die zwei Handgriffe, mit denen der Verkauf wieder aufgenommen wird.
|
||||
* Heute betrifft das `collabora_pro` und `extra_backups` — siehe
|
||||
* 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:
|
||||
* App\Http\Controllers\LandingController für die Preistafel und
|
||||
|
|
@ -187,11 +239,15 @@ final class AddonCatalogue
|
|||
*/
|
||||
public function saleRefusal(string $key): ?string
|
||||
{
|
||||
if ($key !== 'collabora_pro' || self::OFFICE_ON_SALE) {
|
||||
return null;
|
||||
if ($key === 'collabora_pro' && ! self::OFFICE_ON_SALE) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ final class OpenWork
|
|||
{
|
||||
return [
|
||||
[
|
||||
'title' => 'Zweiter Sicherungsort wird verkauft, aber nicht geliefert',
|
||||
'state' => self::SOLD,
|
||||
'where' => 'config/provisioning.php → addons.extra_backups',
|
||||
'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.',
|
||||
'risk' => 'Wer es bucht, zahlt monatlich für eine Leistung, die nicht erbracht wird.',
|
||||
'title' => 'Zweiter Sicherungsort ist aus dem Verkauf genommen',
|
||||
'state' => self::PLANNED,
|
||||
'where' => 'App\\Services\\Billing\\AddonCatalogue::EXTRA_BACKUPS_ON_SALE',
|
||||
'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' => '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',
|
||||
|
|
|
|||
|
|
@ -392,6 +392,11 @@ return [
|
|||
'unavailable_on' => ['enterprise'],
|
||||
],
|
||||
'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'],
|
||||
'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'],
|
||||
// Die PRO-Stufe eines Office, das im Paket schon drin ist — „mehr
|
||||
|
|
|
|||
|
|
@ -161,6 +161,13 @@ return [
|
|||
// Paket — nicht weil der Vertrag es nicht hergibt, sondern weil wir es
|
||||
// (noch) nicht liefern. Siehe App\Services\Billing\AddonCatalogue::saleRefusal().
|
||||
'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
|
||||
// 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.',
|
||||
|
|
|
|||
|
|
@ -160,6 +160,13 @@ return [
|
|||
// package — not because the contract rules it out, but because we do
|
||||
// not deliver it (yet). See App\Services\Billing\AddonCatalogue::saleRefusal().
|
||||
'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
|
||||
// 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.',
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
// Der Anlass für die ganze Seite. Verschwindet es hier stillschweigend,
|
||||
// ist die Liste wertlos — es kostet Geld, solange es offen ist.
|
||||
//
|
||||
// Bis zur Office-Umstellung standen hier zwei Module (Sicherungsort UND
|
||||
// Office) — Office ist jetzt kein SOLD-Punkt mehr, siehe die eigene
|
||||
// Prüfung unten, die genau diesen Wechsel festhält.
|
||||
$sold = collect(OpenWork::all())->where('state', OpenWork::SOLD)->pluck('title')->implode(' ');
|
||||
|
||||
expect($sold)->toContain('Sicherungsort');
|
||||
it('führt inzwischen kein Modul mehr als verkauft und nicht geliefert', function () {
|
||||
// Der Anlass für die ganze Seite war ein SOLD-Punkt — ein Modul, das
|
||||
// Geld kostet, während es offen ist. Hier standen einmal zwei
|
||||
// (Sicherungsort UND Office); beide sind inzwischen aus dem Verkauf
|
||||
// genommen statt weiter verkauft und nicht geliefert zu werden, siehe
|
||||
// die zwei eigenen Prüfungen unten, die je einen dieser Wechsel
|
||||
// festhalten. Verschwände SOLD hier künftig wieder unbemerkt aus der
|
||||
// Liste, wäre die Seite wertlos — dieser Test bleibt deshalb stehen,
|
||||
// auch wenn er heute nichts mehr findet.
|
||||
expect(collect(OpenWork::all())->where('state', OpenWork::SOLD))->toBeEmpty();
|
||||
});
|
||||
|
||||
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
|
||||
// von der Preistafel genommen, statt weiter verkauft und nicht geliefert
|
||||
// zu werden — der Unterschied zwischen SOLD (kostet gerade Geld) und
|
||||
// PLANNED (tut nichts, bis jemand anfängt) ist genau das, was die
|
||||
// Reihenfolge auf der Seite bestimmt (siehe ORDER unten).
|
||||
// Der Unterschied zwischen SOLD (kostet gerade Geld) und 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'));
|
||||
|
||||
expect($office)->not->toBeNull()
|
||||
->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 () {
|
||||
// Kein eigenes Recht: wer hier arbeitet, soll wissen, worauf er sich
|
||||
// verlassen kann und worauf nicht.
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
// 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();
|
||||
|
||||
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);
|
||||
|
||||
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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 () {
|
||||
// Der Riegel gilt nur `collabora_pro` — für alles andere muss
|
||||
// saleRefusal() weiterhin null bleiben, sonst hätte diese Änderung den
|
||||
// ganzen Shop zugesperrt statt eines einzelnen Moduls.
|
||||
// Der Riegel gilt nur `collabora_pro` (und, seit
|
||||
// tests/Feature/Billing/ExtraBackupsNotSoldTest.php, `extra_backups`) —
|
||||
// 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);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
use App\Models\PlanFamily;
|
||||
use App\Models\PlanVersion;
|
||||
use App\Support\CompanyProfile;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -37,7 +39,7 @@ use Illuminate\Support\Str;
|
|||
*/
|
||||
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
|
||||
? 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
|
||||
// ProvisioningSettings::dnsZone() reads first, and since instances moved to
|
||||
// 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('/')
|
||||
->assertOk()
|
||||
|
|
@ -292,12 +294,14 @@ it('names the modules it sells, with the prices the catalogue charges for them',
|
|||
|
||||
$page->assertSee('Optional dazubuchbar');
|
||||
|
||||
// `collabora_pro` fehlt hier absichtlich: es ist konfiguriert (BookAddon
|
||||
// kennt den Preis weiterhin — für Bestandsfälle und ein Geschenk des
|
||||
// Betreibers, siehe App\Actions\BookAddon), aber vom Markt genommen —
|
||||
// siehe die eigene Prüfung unten, AddonCatalogue::OFFICE_ON_SALE und
|
||||
// tests/Feature/Billing/OfficeNotSoldTest.php für die Abrechnungsseite.
|
||||
foreach (['extra_backups', 'priority_support', 'custom_domain'] as $key) {
|
||||
// `collabora_pro` und `extra_backups` fehlen hier absichtlich: beide sind
|
||||
// konfiguriert (BookAddon kennt ihren Preis weiterhin — für Bestandsfälle
|
||||
// und ein Geschenk des Betreibers, siehe App\Actions\BookAddon), aber vom
|
||||
// Markt genommen — siehe die eigenen Prüfungen unten,
|
||||
// AddonCatalogue::OFFICE_ON_SALE/EXTRA_BACKUPS_ON_SALE und
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 () {
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue