Interne Pakete verschwinden aus dem Laden und bleiben in der Konsole

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 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 15:31:46 +02:00
parent afa6183903
commit c3c05ff9f8
11 changed files with 203 additions and 2 deletions

View File

@ -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) {

View File

@ -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(),

View File

@ -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

View File

@ -58,6 +58,7 @@ class PlanFamily extends Model
'tier' => 'integer',
'sales_enabled' => 'boolean',
'is_recommended' => 'boolean',
'internal' => 'boolean',
];
}

View File

@ -34,12 +34,41 @@ final class PlanCatalogue
* @return array<string, array<string, mixed>>
*/
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<string, array<string, mixed>>
*/
public function grantable(?Carbon $at = null): array
{
return $this->catalogue($at, includeInternal: true);
}
/** @return array<string, array<string, mixed>> */
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()

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Pakete, die es gibt, aber nicht zu kaufen.
*
* `sales_enabled` ist dafür der falsche Hebel: es schaltet auch das Verschenken
* ab, weil currentVersion() bei einer nicht verkäuflichen Familie wirft. Ein
* internes Paket soll aus dem Laden verschwinden und in der Konsole bleiben
* das sind zwei Fragen, und sie brauchen zwei Felder.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('plan_families', function (Blueprint $table) {
$table->boolean('internal')->default(false)->after('sales_enabled');
});
}
public function down(): void
{
Schema::table('plan_families', function (Blueprint $table) {
$table->dropColumn('internal');
});
}
};

View File

@ -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',

View File

@ -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',

View File

@ -32,6 +32,14 @@
{{ __('plans.recommended_badge') }}
</span>
@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. --}}
<span class="ml-2 rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs text-muted">
{{ __('plans.internal_badge') }}
</span>
@endif
@if ($family['drafts'] > 0)
<span class="ml-2 rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs text-muted">
{{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }}

View File

@ -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']);

View File

@ -0,0 +1,79 @@
<?php
use App\Models\PlanFamily;
use App\Models\User;
use App\Services\Billing\PlanCatalogue;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
/**
* Ein Paket, das nur der Betrieb sieht.
*
* Das Testpaket ist für Abnahmeläufe da. Es aus dem Verkauf zu nehmen, indem
* man `sales_enabled` abschaltet, nähme es auch aus dem Verschenken
* currentVersion() wirft dann bei einer nicht verkäuflichen Familie , und
* genau dafür existiert `internal` als eigenes Feld.
*
* Das Testpaket selbst legt heute der Betreiber von Hand über die Konsole an
* (Aufgabe 9 räumt es per Migration in den frisch installierten Katalog ein);
* es steckt in keiner Migration, die diese Suite mitbringt. Für die Tests
* bauen wir es hier deshalb selbst auf, denselben Weg, den die Konsole nimmt:
* Familie anlegen, Entwurf schreiben, veröffentlichen.
*/
beforeEach(function () {
$this->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();
});