From c3c05ff9f8cad433405fc4ea62c66b4d486027df Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 15:31:46 +0200 Subject: [PATCH] Interne Pakete verschwinden aus dem Laden und bleiben in der Konsole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sales_enabled waere der falsche Hebel gewesen: es schaltet auch das Verschenken ab. Das Testpaket ist fuer Abnahmelaeufe da und muss verschenkbar bleiben, also zwei Felder fuer zwei Fragen. Der Checkout lehnt einen internen Schluessel ausdruecklich ab, ueber denselben Fang wie einen unbekannten — eine URL ist keine Liste, und die Antwort darf keinen Unterschied verraten. GrantPlan las bislang dieselbe sellable()-Liste wie Preisblatt und Warenkorb, sowohl fuer sein Dropdown als auch fuer die Validierung des Formularfelds — ein interner Schluessel waere dort ebenso abgelehnt worden wie im Checkout, und das Verschenken haette sein einziges Tor verloren. PlanCatalogue bekommt deshalb grantable() als zweiten Leser derselben Abfrage. Co-Authored-By: Claude Opus 5 --- app/Http/Controllers/CheckoutController.php | 13 +++ app/Livewire/Admin/GrantPlan.php | 7 +- app/Livewire/Admin/Plans.php | 4 + app/Models/PlanFamily.php | 1 + app/Services/Billing/PlanCatalogue.php | 29 +++++++ ...1_000002_add_internal_to_plan_families.php | 30 +++++++ lang/de/plans.php | 1 + lang/en/plans.php | 1 + .../views/livewire/admin/plans.blade.php | 8 ++ tests/Feature/Admin/GrantPlanTest.php | 32 ++++++++ tests/Feature/Billing/InternalPlanTest.php | 79 +++++++++++++++++++ 11 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_08_01_000002_add_internal_to_plan_families.php create mode 100644 tests/Feature/Billing/InternalPlanTest.php diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 3193012..abf4870 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Customer; +use App\Models\PlanFamily; use App\Models\Subscription; use App\Services\Billing\PlanCatalogue; use App\Services\Billing\PlanPrices; @@ -13,6 +14,7 @@ use App\Services\Stripe\StripeClient; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use RuntimeException; use Throwable; /** @@ -96,6 +98,17 @@ class CheckoutController extends Controller } try { + // Die einzige Stelle, an der ein Paketschlüssel VON AUSSEN in + // currentVersion() läuft. Dass ein internes Paket in keiner Liste + // steht, ist kein Schutz — eine URL ist keine Liste. Geworfen statt + // sofort abgelehnt, damit es genau in den Fang darunter fällt und + // dieselbe Antwort bekommt wie ein Schlüssel, den es gar nicht + // gibt: wer die Preisliste nicht kennt, soll aus der Antwort nicht + // lernen können, dass es dieses Paket gibt. + if (PlanFamily::query()->where('key', $data['plan'])->value('internal')) { + throw new RuntimeException("Plan '{$data['plan']}' is internal and not sold at checkout."); + } + $version = $catalogue->currentVersion($data['plan']); $price = $version->prices->firstWhere('term', $term); } catch (Throwable $e) { diff --git a/app/Livewire/Admin/GrantPlan.php b/app/Livewire/Admin/GrantPlan.php index 23e1d0c..346389e 100644 --- a/app/Livewire/Admin/GrantPlan.php +++ b/app/Livewire/Admin/GrantPlan.php @@ -130,7 +130,10 @@ class GrantPlan extends ModalComponent }; $data = $this->validate([ - 'plan' => ['required', Rule::in(array_keys(app(PlanCatalogue::class)->sellable()))], + // grantable(), nicht sellable(): das interne Testpaket ist aus dem + // Laden, aber genau dieses Formular ist der Weg, auf dem es einen + // Kunden trotzdem erreicht. + 'plan' => ['required', Rule::in(array_keys(app(PlanCatalogue::class)->grantable()))], 'term' => ['required', Rule::in([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY])], 'datacenter' => ['required', Rule::exists('datacenters', 'code')->where('active', true)], 'priceEuros' => ['required', 'string', $priceRule], @@ -283,7 +286,7 @@ class GrantPlan extends ModalComponent $customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail(); return view('livewire.admin.grant-plan', [ - 'plans' => app(PlanCatalogue::class)->sellable(), + 'plans' => app(PlanCatalogue::class)->grantable(), 'addons' => (array) config('provisioning.addons'), 'datacenters' => Datacenter::query()->active()->orderBy('code')->pluck('name', 'code'), 'cataloguePriceCents' => $this->cataloguePriceCents(), diff --git a/app/Livewire/Admin/Plans.php b/app/Livewire/Admin/Plans.php index 321fdb2..0641a0e 100644 --- a/app/Livewire/Admin/Plans.php +++ b/app/Livewire/Admin/Plans.php @@ -96,6 +96,10 @@ class Plans extends Component 'tier' => $family->tier, 'sales_enabled' => $family->sales_enabled, 'is_recommended' => $family->is_recommended, + // Ob die Familie überhaupt ein Angebot ist, unabhängig vom + // Verkaufsschalter — der bleibt an, damit currentVersion() + // sie fürs Verschenken weiterhin liefert. + 'internal' => $family->internal, 'versions_count' => $family->versions_count, 'live' => $family->versions->first(fn ($v) => $v->isAvailableAt($now)), // What is queued up next, so a scheduled launch is visible diff --git a/app/Models/PlanFamily.php b/app/Models/PlanFamily.php index 2a360de..3139407 100644 --- a/app/Models/PlanFamily.php +++ b/app/Models/PlanFamily.php @@ -58,6 +58,7 @@ class PlanFamily extends Model 'tier' => 'integer', 'sales_enabled' => 'boolean', 'is_recommended' => 'boolean', + 'internal' => 'boolean', ]; } diff --git a/app/Services/Billing/PlanCatalogue.php b/app/Services/Billing/PlanCatalogue.php index 2d2080e..938ea53 100644 --- a/app/Services/Billing/PlanCatalogue.php +++ b/app/Services/Billing/PlanCatalogue.php @@ -34,12 +34,41 @@ final class PlanCatalogue * @return array> */ public function sellable(?Carbon $at = null): array + { + return $this->catalogue($at, includeInternal: false); + } + + /** + * Everything a package grant can choose from: sellable(), plus the + * families marked `internal`. + * + * A second reader rather than a flag left to sellable()'s many callers: + * GrantPlan is the one place outside currentVersion() that is meant to + * see an internal family (that is the entire point of the flag — the + * operator's test package must stay handable-out even though it left the + * shop), and naming that here keeps it from leaking into the price sheet, + * the order page or the cart by an accidental copy-pasted call site. + * + * @return array> + */ + public function grantable(?Carbon $at = null): array + { + return $this->catalogue($at, includeInternal: true); + } + + /** @return array> */ + private function catalogue(?Carbon $at, bool $includeInternal): array { $at ??= now(); $currency = Subscription::catalogueCurrency(); return PlanFamily::query() ->where('sales_enabled', true) + // Interne Pakete sind kein Angebot für den Laden — Preisblatt, + // Bestellung und Warenkorb lesen alle sellable() und lassen sie + // damit aus. grantable() lässt sie bewusst herein, denn genau + // dafür vergibt der Betreiber sie von Hand. + ->when(! $includeInternal, fn ($q) => $q->where('internal', false)) ->with(['versions' => fn ($q) => $q->available($at)->with('prices')]) ->orderBy('tier') ->get() diff --git a/database/migrations/2026_08_01_000002_add_internal_to_plan_families.php b/database/migrations/2026_08_01_000002_add_internal_to_plan_families.php new file mode 100644 index 0000000..0a3cf57 --- /dev/null +++ b/database/migrations/2026_08_01_000002_add_internal_to_plan_families.php @@ -0,0 +1,30 @@ +boolean('internal')->default(false)->after('sales_enabled'); + }); + } + + public function down(): void + { + Schema::table('plan_families', function (Blueprint $table) { + $table->dropColumn('internal'); + }); + } +}; diff --git a/lang/de/plans.php b/lang/de/plans.php index d594780..1fd5d15 100644 --- a/lang/de/plans.php +++ b/lang/de/plans.php @@ -34,6 +34,7 @@ return [ 'marketing_title' => 'Marketing', 'marketing_saved' => 'Marketing-Angaben gespeichert.', 'recommended_badge' => 'Empfohlen', + 'internal_badge' => 'Intern', 'recommended_label' => 'Als empfohlen markieren', 'recommended_hint' => 'Nur ein Paket kann empfohlen sein — die Markierung wandert von einem anderen Paket hierher.', 'audience' => 'Zielgruppe', diff --git a/lang/en/plans.php b/lang/en/plans.php index 6fc36bf..7069cf7 100644 --- a/lang/en/plans.php +++ b/lang/en/plans.php @@ -34,6 +34,7 @@ return [ 'marketing_title' => 'Marketing', 'marketing_saved' => 'Marketing details saved.', 'recommended_badge' => 'Recommended', + 'internal_badge' => 'Internal', 'recommended_label' => 'Mark as recommended', 'recommended_hint' => 'Only one plan can be recommended — the mark moves here from whichever plan carried it before.', 'audience' => 'Audience', diff --git a/resources/views/livewire/admin/plans.blade.php b/resources/views/livewire/admin/plans.blade.php index 477f230..87ea8a8 100644 --- a/resources/views/livewire/admin/plans.blade.php +++ b/resources/views/livewire/admin/plans.blade.php @@ -32,6 +32,14 @@ {{ __('plans.recommended_badge') }} @endif + @if ($family['internal']) + {{-- Dieselben Klassen wie der Schalter im Zustand "aus dem Verkauf" + (unten): ein internes Paket ist kein Angebot, also keine Farbe, + die nach Auszeichnung aussieht. --}} + + {{ __('plans.internal_badge') }} + + @endif @if ($family['drafts'] > 0) {{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }} diff --git a/tests/Feature/Admin/GrantPlanTest.php b/tests/Feature/Admin/GrantPlanTest.php index a34ffa5..3ba8798 100644 --- a/tests/Feature/Admin/GrantPlanTest.php +++ b/tests/Feature/Admin/GrantPlanTest.php @@ -3,9 +3,11 @@ use App\Livewire\Admin\Customers; use App\Livewire\Admin\GrantPlan; use App\Models\Customer; +use App\Models\PlanFamily; use App\Models\Subscription; use App\Models\SubscriptionAddon; use App\Provisioning\Jobs\AdvanceRunJob; +use App\Services\Billing\PlanCatalogue; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -69,6 +71,36 @@ it('grants a free package to a customer with no contract yet', function () { Queue::assertPushed(AdvanceRunJob::class); }); +it('still lets an internal package be granted — that is the one door it has', function () { + // Ein internes Paket verlässt sellable() und damit Preisblatt, Bestellung + // und Warenkorb (Aufgabe 6). Dieses Formular liest bewusst grantable() + // statt sellable() für sein Dropdown UND seine Validierung — sonst wäre + // das interne Paket auch hier draußen und nirgends mehr vergebbar. + $family = PlanFamily::create([ + 'key' => 'test', 'name' => 'Test', 'tier' => 0, + 'sales_enabled' => true, 'internal' => true, + ]); + $version = app(PlanCatalogue::class)->draft($family, [ + 'quota_gb' => 5, 'traffic_gb' => 1000, 'seats' => 3, 'free_months' => 0, + 'ram_mb' => 4096, 'cores' => 2, 'disk_gb' => 20, 'performance' => 'standard', + 'template_vmid' => 9000, 'features' => [], + ], ['monthly' => 100, 'yearly' => 1200]); + app(PlanCatalogue::class)->publish($version); + + $customer = Customer::factory()->create(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(GrantPlan::class, ['uuid' => $customer->uuid]) + ->set('plan', 'test') + ->set('term', 'monthly') + ->set('datacenter', 'fsn') + ->set('priceEuros', '0') + ->call('grant') + ->assertHasNoErrors(); + + expect(Subscription::query()->where('customer_id', $customer->id)->sole()->plan)->toBe('test'); +}); + it('grants a discounted module onto an existing contract', function () { $customer = Customer::factory()->create(); Subscription::factory()->create(['customer_id' => $customer->id, 'status' => 'active']); diff --git a/tests/Feature/Billing/InternalPlanTest.php b/tests/Feature/Billing/InternalPlanTest.php new file mode 100644 index 0000000..5679f83 --- /dev/null +++ b/tests/Feature/Billing/InternalPlanTest.php @@ -0,0 +1,79 @@ +stripe = new FakeStripeClient; + app()->instance(StripeClient::class, $this->stripe); + + $family = PlanFamily::create([ + 'key' => 'test', + 'name' => 'Test', + 'tier' => 0, + 'sales_enabled' => true, + 'internal' => true, + ]); + + $version = app(PlanCatalogue::class)->draft($family, [ + 'quota_gb' => 5, + 'traffic_gb' => 1000, + 'seats' => 3, + 'free_months' => 0, + 'ram_mb' => 4096, + 'cores' => 2, + 'disk_gb' => 20, + 'performance' => 'standard', + 'template_vmid' => 9000, + 'features' => [], + ], ['monthly' => 100, 'yearly' => 1200]); + + app(PlanCatalogue::class)->publish($version); +}); + +it('zeigt ein internes Paket nicht im Verkauf', function () { + expect(array_keys(app(PlanCatalogue::class)->sellable()))->not->toContain('test'); +}); + +it('lässt ein internes Paket weiterhin verschenken', function () { + // currentVersion() ist der Weg, den GrantPlan benutzt — wirft er hier, + // ist das Verschenken mit abgeschaltet, und genau das soll `internal` + // gerade NICHT tun. + expect(app(PlanCatalogue::class)->currentVersion('test')->quota_gb)->toBe(5); +}); + +it('lehnt einen Checkout auf ein internes Paket ab, nicht unterscheidbar von einem unbekannten', function () { + $buyer = User::factory()->create(['email_verified_at' => now()]); + + // Eine URL ist keine Liste: dass "test" in keinem Preisblatt steht, hält + // niemanden davon ab, den Schlüssel direkt zu posten. Erwartet wird + // dieselbe Antwort wie für einen Schlüssel, den es gar nicht gibt — wer + // die Preisliste nicht kennt, soll aus der Antwort nicht lernen können, + // dass es dieses Paket gibt. + $this->actingAs($buyer) + ->post(route('checkout.start'), ['plan' => 'test', 'terms_accepted' => '1']) + ->assertSessionHasErrors(['plan' => __('checkout.plan_gone')]); + + $this->actingAs($buyer) + ->post(route('checkout.start'), ['plan' => 'gibt-es-nicht', 'terms_accepted' => '1']) + ->assertSessionHasErrors(['plan' => __('checkout.plan_gone')]); + + expect($this->stripe->checkouts)->toBeEmpty(); +});