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(); +});