Office (collabora_pro) auch über die Abrechnungsseite sperren
Der Vortag nahm Office nur von der Preistafel — ein eingeloggter Team- oder Business-Kunde hätte collabora_pro technisch weiterhin über Billing::purchase() buchen können, weil AddonCatalogue den Preis unverändert kannte und die Abrechnungsseite nie danach fragte. Der Schalter (OFFICE_ON_SALE) wandert deshalb vom LandingController in AddonCatalogue, der einzigen Stelle, die Preistafel UND Abrechnungsseite kennen; eine neue Methode saleRefusal() weist die Buchung an beiden Enden ab (Kauf-Aufruf und Karten-Anzeige), ohne BookAddon/GrantAddon anzufassen — Bestandsfälle und Geschenke des Betreibers bleiben möglich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/versandtakt
parent
42fd059fdd
commit
d3ac839b1e
|
|
@ -47,6 +47,16 @@ use RuntimeException;
|
|||
* App\Actions\SyncStripeAddonItems, which is asked to bring the two into step
|
||||
* after every change here. Like the storage delivery beside it, it never fails a
|
||||
* booking: what it could not tell Stripe is parked on the contract and retried.
|
||||
*
|
||||
* Was hier bewusst NICHT gefragt wird: AddonCatalogue::saleRefusal() — ob ein
|
||||
* Modul überhaupt im Verkauf ist, unabhängig vom Paket. Das ist eine Frage an
|
||||
* einen KUNDEN, der die Preistafel oder die Abrechnungsseite liest, und die
|
||||
* wird dort gestellt (App\Http\Controllers\LandingController,
|
||||
* App\Livewire\Billing::purchase()) — nicht hier. Ein Bestandsfall bleibt
|
||||
* buchbar, und ein Geschenk des Betreibers (App\Actions\GrantAddon, das über
|
||||
* diese Aktion bucht) bleibt möglich, auch für ein Modul, das gerade nicht
|
||||
* verkauft wird — beides ist kein Verkauf an einen Kunden, der die Preistafel
|
||||
* liest.
|
||||
*/
|
||||
class BookAddon
|
||||
{
|
||||
|
|
|
|||
|
|
@ -49,35 +49,19 @@ use Throwable;
|
|||
*/
|
||||
class LandingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Ob Office (ONLYOFFICE über den geplanten gemeinsamen Dokumentenserver)
|
||||
* schon verkauft wird.
|
||||
*
|
||||
* Auf false, bis der Dokumentenserver steht — siehe App\Support\OpenWork::all().
|
||||
* Der einzige Schalter für dieses Versprechen HIER im Controller: nimmt
|
||||
* unten in modulePrices() die Zeile `collabora_pro` aus der Modulliste,
|
||||
* und __invoke() reicht ihn der View als `officeOnSale` weiter, die damit
|
||||
* dieselbe Kachel „Office im Browser" aus der Aufzählung „Was enthalten
|
||||
* ist" filtert. Beide Werbetexte (hier in ADDONS und in landing.blade.php)
|
||||
* bleiben dabei stehen, statt gelöscht zu werden — sie müssen beim
|
||||
* Wiederanschalten nicht neu geschrieben werden.
|
||||
*
|
||||
* Das Paketmerkmal `office` selbst steht NICHT hier: die aktuell verkaufte
|
||||
* Version von Team und Business führt es seit
|
||||
* database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php
|
||||
* nicht mehr, darum verschwindet die zugehörige Zeile in der
|
||||
* Vergleichstabelle schon dadurch, dass kein verkauftes Paket den
|
||||
* Schlüssel mehr trägt (siehe comparison()) — bestehende Verträge tragen
|
||||
* ihn unverändert weiter, und ihre eigene Ansicht in der Konsole/im Portal
|
||||
* zeigt Office deshalb weiterhin als enthalten.
|
||||
*
|
||||
* Wieder anschalten, sobald der Dokumentenserver liefert: hier auf true
|
||||
* stellen UND in der Konsole eine neue Planversion für Team und Business
|
||||
* veröffentlichen, die `office` erneut führt — derselbe Weg, mit dem jede
|
||||
* andere Änderung an einem Paket gemacht wird (PlanCatalogue::draft() /
|
||||
* publish()), kein Sonderfall für dieses Merkmal.
|
||||
*/
|
||||
private const OFFICE_ON_SALE = false;
|
||||
// Ob Office schon verkauft wird — die Konstante selbst und die
|
||||
// vollständige Begründung stehen in
|
||||
// App\Services\Billing\AddonCatalogue::OFFICE_ON_SALE, nicht mehr hier.
|
||||
// Sie stand ursprünglich in diesem Controller, bis auffiel, dass ein
|
||||
// eingeloggter Kunde `collabora_pro` trotzdem über die Abrechnungsseite
|
||||
// hätte buchen können (App\Livewire\Billing::purchase() kannte den
|
||||
// Schalter nicht) — verschoben an die Stelle, die Preistafel UND
|
||||
// Abrechnungsseite kennen, damit ein Schalter beide Wege schließt. Hier
|
||||
// reicht die Kurzfassung: modulePrices() unten nimmt die Zeile
|
||||
// `collabora_pro` aus der Modulliste, solange die Konstante false ist,
|
||||
// und __invoke() reicht sie der View als `officeOnSale` weiter, die
|
||||
// damit dieselbe Kachel „Office im Browser" aus der Aufzählung „Was
|
||||
// enthalten ist" filtert.
|
||||
|
||||
/**
|
||||
* Catalogue feature keys, in the words a customer uses.
|
||||
|
|
@ -210,10 +194,11 @@ class LandingController extends Controller
|
|||
'name' => 'Bevorzugter Support',
|
||||
'body' => 'Ihre Anfragen werden vorgezogen — Reaktion innerhalb einer Stunde statt am selben Werktag.',
|
||||
],
|
||||
// Aus dem Verkauf genommen, solange OFFICE_ON_SALE oben false ist —
|
||||
// der Text bleibt stehen, modulePrices() liefert für diesen Schlüssel
|
||||
// aber keinen Preis mehr, solange die Konstante steht, und ohne Preis
|
||||
// taucht der Eintrag in addons() gar nicht erst auf.
|
||||
// Aus dem Verkauf genommen, solange AddonCatalogue::OFFICE_ON_SALE
|
||||
// false ist — der Text bleibt stehen, modulePrices() liefert für
|
||||
// diesen Schlüssel aber keinen Preis mehr, solange die Konstante
|
||||
// steht, und ohne Preis taucht der Eintrag in addons() gar nicht
|
||||
// erst auf.
|
||||
'collabora_pro' => [
|
||||
'name' => 'Office mit vollem Funktionsumfang',
|
||||
'body' => 'Mehr gleichzeitige Bearbeiter und die erweiterten Funktionen der Office-Integration.',
|
||||
|
|
@ -264,8 +249,8 @@ class LandingController extends Controller
|
|||
'addons' => $this->addons($modules),
|
||||
'enterprise' => $this->enterprise($plans),
|
||||
// Der einzige Schalter für die Office-Kachel in der Aufzählung
|
||||
// „Was enthalten ist" — siehe OFFICE_ON_SALE oben.
|
||||
'officeOnSale' => self::OFFICE_ON_SALE,
|
||||
// „Was enthalten ist" — siehe AddonCatalogue::OFFICE_ON_SALE.
|
||||
'officeOnSale' => AddonCatalogue::OFFICE_ON_SALE,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -453,10 +438,11 @@ class LandingController extends Controller
|
|||
$prices = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
// Office ist vom Markt genommen (siehe OFFICE_ON_SALE) — die
|
||||
// Pro-Stufe einer Grundfunktion, die es nicht gibt, gehört so
|
||||
// wenig auf die Preistafel wie das Grundmodul selbst.
|
||||
if ($key === 'collabora_pro' && ! self::OFFICE_ON_SALE) {
|
||||
// Office ist vom Markt genommen (siehe
|
||||
// AddonCatalogue::OFFICE_ON_SALE) — die Pro-Stufe einer
|
||||
// Grundfunktion, die es nicht gibt, gehört so wenig auf die
|
||||
// Preistafel wie das Grundmodul selbst.
|
||||
if ($key === 'collabora_pro' && ! AddonCatalogue::OFFICE_ON_SALE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,15 @@ class Billing extends Component
|
|||
// told from a broken button, so the customer clicks it again, which is
|
||||
// how the second order gets placed in the first place.
|
||||
if ($type === 'addon' && is_string($key)) {
|
||||
$refusal = app(AddonCatalogue::class)->duplicateRefusal($contract, $key)
|
||||
// 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.
|
||||
$refusal = app(AddonCatalogue::class)->saleRefusal($key)
|
||||
?? app(AddonCatalogue::class)->duplicateRefusal($contract, $key)
|
||||
?? $this->cartRefusal($customer, $key);
|
||||
|
||||
if ($refusal !== null) {
|
||||
|
|
@ -522,6 +530,12 @@ class Billing extends Component
|
|||
* the purchase is placed and waiting to be paid for, and offering it again
|
||||
* 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.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $rows
|
||||
* @param Collection<int, Order> $pending
|
||||
* @return array<string, array<string, mixed>>
|
||||
|
|
@ -529,9 +543,14 @@ class Billing extends Component
|
|||
private function offerable(array $rows, ?Customer $customer, Collection $pending): array
|
||||
{
|
||||
$inCart = $pending->where('type', 'addon')->pluck('addon_key')->filter()->all();
|
||||
$catalogue = app(AddonCatalogue::class);
|
||||
|
||||
foreach ($rows as $key => $row) {
|
||||
$rows[$key]['in_cart'] = in_array($key, $inCart, true);
|
||||
|
||||
if (! $row['booked'] && ! $rows[$key]['in_cart'] && $catalogue->saleRefusal($key) !== null) {
|
||||
unset($rows[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$access = app(CustomDomainAccess::class);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,49 @@ final class AddonCatalogue
|
|||
/** Had or not had: booking another one gives the customer nothing. */
|
||||
public const ENTITLEMENT = 'entitlement';
|
||||
|
||||
/**
|
||||
* Ob Office (ONLYOFFICE über den geplanten gemeinsamen Dokumentenserver)
|
||||
* schon verkauft wird.
|
||||
*
|
||||
* Auf false, bis der Dokumentenserver steht — siehe App\Support\OpenWork::all().
|
||||
* Der einzige Schalter für dieses Versprechen: saleRefusal() unten weist
|
||||
* jede Buchung von `collabora_pro` ab, solange die Konstante false ist,
|
||||
* und App\Http\Controllers\LandingController::modulePrices() liest
|
||||
* dieselbe Konstante, um die Zeile aus der Preistafel zu nehmen. Beide
|
||||
* Werbetexte (dort in ADDONS und in landing.blade.php, hier in
|
||||
* lang/*\/billing.php) bleiben dabei stehen, statt gelöscht zu werden —
|
||||
* sie müssen beim Wiederanschalten nicht neu geschrieben werden.
|
||||
*
|
||||
* Stand ursprünglich im LandingController, der als einziger Leser
|
||||
* reichte — bis auffiel, dass ein eingeloggter Kunde das Modul über die
|
||||
* Abrechnungsseite trotzdem hätte buchen können: die Preistafel zeigt
|
||||
* nur, App\Livewire\Billing bucht. Hierher gezogen, an die Stelle, die
|
||||
* beide kennen, damit ein Schalter beide Wege schließt statt nur den,
|
||||
* der zuerst auffiel.
|
||||
*
|
||||
* Bewusst NICHT gespiegelt in priceCents()/knows(): App\Actions\BookAddon
|
||||
* kennt den Preis unverändert weiter, denn ein Bestandsfall oder ein
|
||||
* Geschenk des Betreibers (App\Actions\GrantAddon, das über BookAddon
|
||||
* bucht) sind keine Verkäufe an einen Kunden, der die Preistafel liest —
|
||||
* siehe saleRefusal() für die genaue Abgrenzung.
|
||||
*
|
||||
* Das Paketmerkmal `office` selbst steht NICHT hier: die aktuell
|
||||
* verkaufte Version von Team und Business führt es seit
|
||||
* database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php
|
||||
* nicht mehr, darum verschwindet die zugehörige Zeile in der
|
||||
* Vergleichstabelle schon dadurch, dass kein verkauftes Paket den
|
||||
* Schlüssel mehr trägt — bestehende Verträge tragen ihn unverändert
|
||||
* weiter, und ihre eigene Ansicht in der Konsole/im Portal zeigt Office
|
||||
* deshalb weiterhin als enthalten.
|
||||
*
|
||||
* Wieder anschalten, sobald der Dokumentenserver liefert: hier auf true
|
||||
* stellen UND in der Konsole eine neue Planversion für Team und Business
|
||||
* veröffentlichen, die `office` erneut führt — derselbe Weg, mit dem
|
||||
* jede andere Änderung an einem Paket gemacht wird (PlanCatalogue::draft() /
|
||||
* publish()), kein Sonderfall für dieses Merkmal.
|
||||
*/
|
||||
public const OFFICE_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
|
||||
{
|
||||
|
|
@ -122,6 +165,35 @@ final class AddonCatalogue
|
|||
return __('billing.addon_already_booked', ['module' => $this->name($key)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warum dieses Modul überhaupt nicht verkauft wird — unabhängig vom
|
||||
* Vertrag und unabhängig vom Paket. Null, wenn es verkauft wird.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Gefragt an den zwei Stellen, an denen ein Kunde ein Modul NEU bucht:
|
||||
* App\Http\Controllers\LandingController für die Preistafel und
|
||||
* App\Livewire\Billing::purchase() für den Kauf-Knopf selbst — beide
|
||||
* lesen dieselbe Antwort, damit ein Modul, das die Seite nicht mehr
|
||||
* zeigt, nicht trotzdem über einen direkten Aufruf des Kauf-Knopfs
|
||||
* buchbar bleibt. App\Actions\BookAddon fragt bewusst NICHT hier nach:
|
||||
* ein Bestandsfall oder ein Geschenk des Betreibers (GrantAddon, das
|
||||
* über BookAddon bucht) sind keine Verkäufe an einen Kunden, der die
|
||||
* Preistafel liest, und dürfen deshalb weiterlaufen bzw. möglich bleiben.
|
||||
*/
|
||||
public function saleRefusal(string $key): ?string
|
||||
{
|
||||
if ($key !== 'collabora_pro' || self::OFFICE_ON_SALE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return __('billing.addon_not_on_sale', ['module' => $this->name($key)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warum es dieses Modul für dieses Paket gar nicht gibt. Null, wenn es
|
||||
* das gibt.
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ final class OpenWork
|
|||
[
|
||||
'title' => 'Office (ONLYOFFICE über einen gemeinsamen Dokumentenserver) ist aus dem Verkauf genommen',
|
||||
'state' => self::PLANNED,
|
||||
'where' => 'App\\Http\\Controllers\\LandingController::OFFICE_ON_SALE, database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php',
|
||||
'missing' => 'Die Preistafel versprach „Office im Browser" an drei Stellen — der Aufzählung „Was enthalten ist", dem Paketmerkmal `office` in Team und Business, und dem Zusatzmodul `collabora_pro` für 22,80 € im Monat —, obwohl weder die goldene Vorlage (deploy/bootstrap/assets/docker-compose.yml enthält nur Nextcloud und die Datenbank) noch ein Bereitstellungsschritt je ein Office installiert haben. Alle drei Stellen sind jetzt stillgelegt, aber nicht gelöscht: der Code-Schalter ist die Konstante `OFFICE_ON_SALE` (dokumentiert an ihrer eigenen Stelle mit den zwei Handgriffen, die ein Wiederanschalten braucht — die Konstante auf true, und in der Konsole eine neue Planversion für Team und Business veröffentlichen, die `office` wieder führt). Wer die beiden Pakete vor dieser Umschaltung gekauft hat, behält `office` in seinem eingefrorenen Vertrag unverändert — dieser Punkt betrifft ausschließlich, was NEU beworben wird. `collabora_pro` hat bislang niemand gebucht (subscription_addons enthielt zum Zeitpunkt dieser Änderung keine einzige Zeile dazu), es gibt also keinen laufenden Vertrag, den eine spätere Entscheidung träfe.',
|
||||
'risk' => 'Kein akuter Schaden mehr — die Preistafel verspricht nichts, was sie nicht einlöst. Der eigentliche Dokumentenserver ist an keiner Stelle im Repository angefangen; das bleibt die Arbeit, die vor dem Wiederanschalten noch aussteht.',
|
||||
'where' => 'App\\Services\\Billing\\AddonCatalogue::OFFICE_ON_SALE, database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php',
|
||||
'missing' => 'Die Preistafel versprach „Office im Browser" an drei Stellen — der Aufzählung „Was enthalten ist", dem Paketmerkmal `office` in Team und Business, und dem Zusatzmodul `collabora_pro` für 22,80 € im Monat —, obwohl weder die goldene Vorlage (deploy/bootstrap/assets/docker-compose.yml enthält nur Nextcloud und die Datenbank) noch ein Bereitstellungsschritt je ein Office installiert haben. Alle drei Stellen sind jetzt stillgelegt, aber nicht gelöscht: der Code-Schalter ist die Konstante `OFFICE_ON_SALE` (dokumentiert an ihrer eigenen Stelle mit den zwei Handgriffen, die ein Wiederanschalten braucht — die Konstante auf true, und in der Konsole eine neue Planversion für Team und Business veröffentlichen, die `office` wieder führt). Wer die beiden Pakete vor dieser Umschaltung gekauft hat, behält `office` in seinem eingefrorenen Vertrag unverändert — dieser Punkt betrifft ausschließlich, was NEU beworben wird. `collabora_pro` hat bislang niemand gebucht (subscription_addons enthielt zum Zeitpunkt dieser Änderung keine einzige Zeile dazu), es gibt also keinen laufenden Vertrag, den eine spätere Entscheidung träfe. Am Tag nach der ersten Schliessung fiel auf, dass sie nur die Preistafel betraf: ein eingeloggter Team- oder Business-Kunde hätte `collabora_pro` weiterhin über die Abrechnungsseite buchen können, weil AddonCatalogue den Preis unverändert kannte und Billing::purchase() nie danach gefragt hat. Die Konstante wanderte deshalb vom LandingController in AddonCatalogue, die einzige Stelle, die beide Seiten lesen — Preistafel UND Kauf-Knopf weisen `collabora_pro` jetzt aus demselben Schalter ab.',
|
||||
'risk' => 'Kein akuter Schaden mehr — weder die Preistafel noch die Abrechnungsseite versprechen etwas, das nicht eingelöst wird. Der eigentliche Dokumentenserver ist an keiner Stelle im Repository angefangen; das bleibt die Arbeit, die vor dem Wiederanschalten noch aussteht.',
|
||||
],
|
||||
[
|
||||
'title' => 'Der Rettungssystem-Weg liegt still',
|
||||
|
|
|
|||
|
|
@ -43,8 +43,10 @@ use Illuminate\Support\Str;
|
|||
* wieder in den Merkmalen (`PlanCatalogue::draft()` / `publish()`) — dieselbe
|
||||
* kaufmännische Entscheidung, mit der jede andere Änderung an einem Paket
|
||||
* gemacht wird, und keine, die ein `migrate:rollback` nebenbei träfe. Der
|
||||
* zweite Teil des Schalters — App\Http\Controllers\LandingController::OFFICE_ON_SALE —
|
||||
* steht in diesem Controller, nicht hier.
|
||||
* zweite Teil des Schalters — App\Services\Billing\AddonCatalogue::OFFICE_ON_SALE
|
||||
* (ursprünglich im LandingController, seit der Schliessung der
|
||||
* Abrechnungsseite dorthin verschoben, weil beide Seiten ihn kennen müssen)
|
||||
* — steht dort, nicht hier.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
|
|
|
|||
|
|
@ -157,6 +157,10 @@ return [
|
|||
// Paket gibt es das Modul gar nicht — z. B. Zusatzspeicher auf Enterprise,
|
||||
// wo eine eigene Maschine verkauft wird.
|
||||
'addon_not_available' => ':module ist für Ihr Paket nicht vorgesehen.',
|
||||
// Anders als "addon_not_available": hier gibt es das Modul für KEIN
|
||||
// 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.',
|
||||
// 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.',
|
||||
|
|
|
|||
|
|
@ -156,6 +156,10 @@ return [
|
|||
// not carry the module at all, e.g. extra storage on Enterprise, where a
|
||||
// dedicated machine is sold instead.
|
||||
'addon_not_available' => ':module is not offered for your package.',
|
||||
// Unlike "addon_not_available": here the module is not offered on ANY
|
||||
// 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.',
|
||||
// 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.',
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@
|
|||
</div>
|
||||
<div class="mt-8 grid gap-2 sm:grid-cols-2">
|
||||
{{-- „Office im Browser" bleibt in der Liste stehen und wird nur
|
||||
herausgefiltert (App\Http\Controllers\LandingController::OFFICE_ON_SALE),
|
||||
herausgefiltert (App\Services\Billing\AddonCatalogue::OFFICE_ON_SALE),
|
||||
damit die Zeile beim Wiederanschalten nicht neu getippt
|
||||
werden muss. --}}
|
||||
@foreach (collect([
|
||||
|
|
|
|||
|
|
@ -140,14 +140,18 @@ it('stacks a module that is sold in packs, and totals it as the sum of its booki
|
|||
});
|
||||
|
||||
it('refuses a second purchase of an entitlement through the shop, in words', function () {
|
||||
// priority_support, not collabora_pro: since Office was closed to every
|
||||
// customer (see tests/Feature/Billing/OfficeNotSoldTest.php),
|
||||
// collabora_pro would refuse here for the wrong reason — "not on sale"
|
||||
// rather than "already booked" — and this test is about the latter.
|
||||
[$customer, $user, $subscription] = entitlementShopper();
|
||||
|
||||
app(BookAddon::class)($subscription, 'collabora_pro');
|
||||
app(BookAddon::class)($subscription, 'priority_support');
|
||||
|
||||
Livewire::actingAs($user)->test(Billing::class)
|
||||
->call('purchase', 'addon', 'collabora_pro')
|
||||
->call('purchase', 'addon', 'priority_support')
|
||||
->assertDispatched('notify', message: __('billing.addon_already_booked', [
|
||||
'module' => __('billing.addon.collabora_pro.name'),
|
||||
'module' => __('billing.addon.priority_support.name'),
|
||||
]));
|
||||
|
||||
// No order, so nothing to charge for: the refusal is the point, the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Billing;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Office liefert nicht — siehe App\Support\OpenWork::all() — und die
|
||||
* Preistafel verspricht `collabora_pro` deshalb seit der Umstellung nicht
|
||||
* mehr (LandingPriceSheetTest). Offen blieb der zweite Weg, den der Umsetzer
|
||||
* selbst gemeldet hat: ein eingeloggter Team- oder Business-Kunde konnte das
|
||||
* Modul weiterhin über die Abrechnungsseite buchen, weil AddonCatalogue den
|
||||
* Preis unverändert kannte (BookAddon braucht ihn für Bestandsfälle und ein
|
||||
* Geschenk des Betreibers) und App\Livewire\Billing::purchase() nie danach
|
||||
* gefragt hat.
|
||||
*
|
||||
* Der Riegel sitzt jetzt in AddonCatalogue::saleRefusal() — derselben Stelle,
|
||||
* die auch LandingController::modulePrices() liest — und wird hier an BEIDEN
|
||||
* Enden geprüft: der Kauf selbst (am Formular vorbei, nicht nur "der Knopf
|
||||
* fehlt") und die Karte, die ihn anbieten würde.
|
||||
*/
|
||||
function officeShopper(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 collabora_pro am Formular vorbei ab, solange Office nicht verkauft wird', 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] = officeShopper('team');
|
||||
|
||||
Livewire::actingAs($user)->test(Billing::class)
|
||||
->call('purchase', 'addon', 'collabora_pro')
|
||||
->assertDispatched('notify', message: __('billing.addon_not_on_sale', [
|
||||
'module' => __('billing.addon.collabora_pro.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 Office-Karte im Portal nicht mehr an, solange sie ohnehin abgewiesen würde', function () {
|
||||
[, $user] = officeShopper('team');
|
||||
|
||||
// Team trägt `collabora_pro` nicht in `unavailable_on` — ohne den neuen
|
||||
// Riegel stünde die Karte hier also, mit einem Kauf-Knopf, der beim
|
||||
// Anklicken nur die Meldung aus dem Test oben gezeigt hätte.
|
||||
Livewire::actingAs($user)->test(Billing::class)
|
||||
->assertDontSee(__('billing.addon.collabora_pro.name'));
|
||||
});
|
||||
|
||||
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.
|
||||
$catalogue = app(AddonCatalogue::class);
|
||||
|
||||
foreach (['extra_backups', 'priority_support', 'custom_domain', AddonCatalogue::STORAGE] as $key) {
|
||||
expect($catalogue->saleRefusal($key))->toBeNull();
|
||||
}
|
||||
|
||||
[, $user] = officeShopper('team');
|
||||
|
||||
Livewire::actingAs($user)->test(Billing::class)
|
||||
->call('purchase', 'addon', 'priority_support')
|
||||
->assertDispatched('notify', message: __('billing.purchased'));
|
||||
|
||||
expect(Order::query()->where('type', 'addon')->where('addon_key', 'priority_support')->exists())->toBeTrue();
|
||||
});
|
||||
|
|
@ -62,7 +62,12 @@ it('books the module a paid order bought', function () {
|
|||
Queue::fake();
|
||||
[, $user, $subscription] = paidOrderCustomer();
|
||||
|
||||
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'collabora_pro');
|
||||
// priority_support, not collabora_pro: Office is closed to every
|
||||
// customer (AddonCatalogue::OFFICE_ON_SALE, see
|
||||
// tests/Feature/Billing/OfficeNotSoldTest.php), so purchase() would
|
||||
// refuse it before an order is even written — the wrong refusal for
|
||||
// what this test is about.
|
||||
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'priority_support');
|
||||
|
||||
$order = Order::query()->where('type', 'addon')->sole();
|
||||
|
||||
|
|
@ -72,10 +77,10 @@ it('books the module a paid order bought', function () {
|
|||
|
||||
$addon = SubscriptionAddon::query()->sole();
|
||||
|
||||
expect($addon->addon_key)->toBe('collabora_pro')
|
||||
expect($addon->addon_key)->toBe('priority_support')
|
||||
->and($addon->order_id)->toBe($order->id)
|
||||
// Frozen at the price the customer was shown, and entered in the register.
|
||||
->and($addon->price_cents)->toBe(1900)
|
||||
->and($addon->price_cents)->toBe(2900)
|
||||
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_ADDON_BOOKED)->count())->toBe(1)
|
||||
// And it is on the Stripe subscription, which is what makes it recur.
|
||||
->and($this->stripe->subscriptionItems)->toHaveCount(1);
|
||||
|
|
@ -139,7 +144,8 @@ it('never lets a failed delivery undo the record that money changed hands', func
|
|||
Queue::fake();
|
||||
[$customer, $user] = paidOrderCustomer();
|
||||
|
||||
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'collabora_pro');
|
||||
// priority_support, not collabora_pro — see the note above.
|
||||
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'priority_support');
|
||||
|
||||
$order = Order::query()->where('type', 'addon')->sole();
|
||||
|
||||
|
|
|
|||
|
|
@ -293,9 +293,10 @@ 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 ein Portal, das niemand hier prüft), aber
|
||||
// vom Markt genommen — siehe die eigene Prüfung unten und
|
||||
// LandingController::OFFICE_ON_SALE.
|
||||
// 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) {
|
||||
$page->assertSee($money((int) config("provisioning.addons.{$key}.price_cents")), false);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue