Phase 9D: Tarif-Datenmodell, Cashier und hybride Rechnungskreise STR-/MAN-
Tarif-Datenmodell (Decision-Update): - plans: Starter/Business/Pro/Agency mit Monats-/Jahrespreis (Jahres = 10 x Monat), PM-Kontingent, Tageslimit, Stripe-IDs; idempotenter Seeder - single_purchases: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit Status-Lifecycle und Stripe-Checkout-Referenzen - laravel/cashier ^16.5 installiert (freigegeben); User ist Billable, Cashier-Migrationen published + ausgefuehrt; lokale invoices()-Relation ueberschreibt bewusst die Cashier-Methode Hybride Rechnungskreise (Entscheidung 12.06.2026): - invoice_number_sequences + InvoiceNumberGenerator: atomare fortlaufende Nummern pro Kreis (STR- fuer den neuen Stripe-Shop, MAN- fuer den manuellen Legacy-Kreis); Alt-Archiv legacy_invoices bleibt unveraendert - ManualInvoiceService + billing:generate-manual-invoices (Scheduler taeglich 04:30): prueft aktive/grandfathered user_payment_options ohne Stripe-Subscription auf erreichtes Periodenende, friert die Rechnungsadresse als Snapshot ein, stellt die MAN-Rechnung aus (Zahlungsziel billing.manual_due_days) und schaltet die Periode weiter; Konditions-Overrides via legacy_conditions, sonst Netto-Preis + billing.vat_rate; nicht abrechenbare Faelle werden geloggt und beim naechsten Lauf erneut geprueft Submit-Gate: - User::hasActiveBooking() prueft jetzt echt (hinter billing.enforce_booking): Cashier-Abo, bezahlter Einzel-/Extra-PM-Kauf oder laufende Legacy-Vereinbarung (MAN-Kreis) Suite: 468 passed, 4 skipped (17 neue Billing-Tests). Pint clean. Offen fuer 9E: Stripe-Checkout/Webhooks, STR-Spiegelung, Slot-Logik auf Plan-Kontingent, Migration der aktiven Legacy-Zahlungen in user_payment_options. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4419d9ff43
commit
d548f4b235
28 changed files with 1545 additions and 25 deletions
75
tests/Feature/Billing/HasActiveBookingTest.php
Normal file
75
tests/Feature/Billing/HasActiveBookingTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\PaymentOption;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
|
||||
beforeEach(function (): void {
|
||||
config()->set('billing.enforce_booking', true);
|
||||
});
|
||||
|
||||
test('without the enforce flag everyone counts as booked', function () {
|
||||
config()->set('billing.enforce_booking', false);
|
||||
|
||||
expect(User::factory()->create()->hasActiveBooking())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a user without any booking is rejected when the gate is enforced', function () {
|
||||
expect(User::factory()->create()->hasActiveBooking())->toBeFalse();
|
||||
});
|
||||
|
||||
test('an active cashier subscription counts as booking', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test_123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_test',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
expect($user->hasActiveBooking())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a paid unconsumed single purchase counts as booking', function () {
|
||||
$user = User::factory()->create();
|
||||
SinglePurchase::factory()->paid()->create(['user_id' => $user->id]);
|
||||
|
||||
expect($user->hasActiveBooking())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a consumed single purchase does not count as booking', function () {
|
||||
$user = User::factory()->create();
|
||||
SinglePurchase::factory()->consumed()->create(['user_id' => $user->id]);
|
||||
|
||||
expect($user->hasActiveBooking())->toBeFalse();
|
||||
});
|
||||
|
||||
test('an active legacy payment agreement counts as booking (manual circle)', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'payment_option_id' => PaymentOption::factory()->create()->id,
|
||||
'status' => UserPaymentOptionStatus::Active->value,
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
|
||||
expect($user->hasActiveBooking())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a cancelled legacy agreement does not count as booking', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'payment_option_id' => PaymentOption::factory()->create()->id,
|
||||
'status' => UserPaymentOptionStatus::Cancelled->value,
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
|
||||
expect($user->hasActiveBooking())->toBeFalse();
|
||||
});
|
||||
18
tests/Feature/Billing/InvoiceNumberGeneratorTest.php
Normal file
18
tests/Feature/Billing/InvoiceNumberGeneratorTest.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Billing\InvoiceNumberGenerator;
|
||||
|
||||
test('numbers are sequential per circle and circles are independent', function () {
|
||||
$generator = app(InvoiceNumberGenerator::class);
|
||||
|
||||
expect($generator->nextStripeNumber())->toBe('STR-00001');
|
||||
expect($generator->nextStripeNumber())->toBe('STR-00002');
|
||||
expect($generator->nextManualNumber())->toBe('MAN-00001');
|
||||
expect($generator->nextStripeNumber())->toBe('STR-00003');
|
||||
expect($generator->nextManualNumber())->toBe('MAN-00002');
|
||||
});
|
||||
|
||||
test('an unknown circle is rejected', function () {
|
||||
expect(fn () => app(InvoiceNumberGenerator::class)->next('XXX'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
137
tests/Feature/Billing/ManualInvoiceGenerationTest.php
Normal file
137
tests/Feature/Billing/ManualInvoiceGenerationTest.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\GenerateManualInvoices;
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\BillingAddress;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PaymentOption;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Billing\ManualInvoiceService;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
function manualAgreement(array $overrides = [], array $optionOverrides = []): UserPaymentOption
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
BillingAddress::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$paymentOption = PaymentOption::factory()->create([
|
||||
'price_cents' => 4900,
|
||||
'interval' => 'monthly',
|
||||
...$optionOverrides,
|
||||
]);
|
||||
|
||||
return UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'payment_option_id' => $paymentOption->id,
|
||||
'status' => UserPaymentOptionStatus::Active->value,
|
||||
'stripe_subscription_id' => null,
|
||||
'current_period_start' => today()->subMonth(),
|
||||
'current_period_end' => today()->subDay(),
|
||||
'legacy_conditions' => null,
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
test('a due manual agreement gets a MAN invoice and the period advances', function () {
|
||||
Carbon::setTestNow('2026-06-12 08:00:00');
|
||||
|
||||
$agreement = manualAgreement([
|
||||
'current_period_end' => '2026-06-11',
|
||||
]);
|
||||
|
||||
$invoice = app(ManualInvoiceService::class)->invoiceFor($agreement);
|
||||
|
||||
expect($invoice)->not->toBeNull();
|
||||
expect($invoice->number)->toBe('MAN-00001');
|
||||
expect($invoice->status)->toBe(InvoiceStatus::Open);
|
||||
expect($invoice->amount_cents)->toBe(4900);
|
||||
expect($invoice->tax_cents)->toBe(931);
|
||||
expect($invoice->total_cents)->toBe(5831);
|
||||
expect($invoice->due_date->toDateString())->toBe('2026-06-26');
|
||||
|
||||
$fresh = $agreement->fresh();
|
||||
expect($fresh->current_period_start->toDateString())->toBe('2026-06-11');
|
||||
expect($fresh->current_period_end->toDateString())->toBe('2026-07-11');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('legacy_conditions override amounts and interval', function () {
|
||||
Carbon::setTestNow('2026-06-12 08:00:00');
|
||||
|
||||
$agreement = manualAgreement([
|
||||
'current_period_end' => '2026-06-10',
|
||||
'legacy_conditions' => [
|
||||
'amount_cents' => 10000,
|
||||
'tax_cents' => 1900,
|
||||
'total_cents' => 11900,
|
||||
'interval' => 'yearly',
|
||||
],
|
||||
]);
|
||||
|
||||
$invoice = app(ManualInvoiceService::class)->invoiceFor($agreement);
|
||||
|
||||
expect($invoice->amount_cents)->toBe(10000);
|
||||
expect($invoice->tax_cents)->toBe(1900);
|
||||
expect($invoice->total_cents)->toBe(11900);
|
||||
expect($agreement->fresh()->current_period_end->toDateString())->toBe('2027-06-10');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('the invoice freezes the billing address as a snapshot', function () {
|
||||
$agreement = manualAgreement();
|
||||
$agreement->user->billingAddress->update(['name' => 'Alpha GmbH', 'city' => 'Berlin']);
|
||||
|
||||
$invoice = app(ManualInvoiceService::class)->invoiceFor($agreement);
|
||||
|
||||
$agreement->user->billingAddress->update(['name' => 'Beta GmbH', 'city' => 'Hamburg']);
|
||||
|
||||
expect($invoice->fresh()->invoiceBillingAddress->name)->toBe('Alpha GmbH');
|
||||
expect($invoice->fresh()->invoiceBillingAddress->city)->toBe('Berlin');
|
||||
});
|
||||
|
||||
test('agreements without a billing address are skipped and keep their period', function () {
|
||||
$agreement = manualAgreement();
|
||||
$agreement->user->billingAddress->delete();
|
||||
|
||||
$periodEnd = $agreement->current_period_end->toDateString();
|
||||
|
||||
$invoice = app(ManualInvoiceService::class)->invoiceFor($agreement->fresh());
|
||||
|
||||
expect($invoice)->toBeNull();
|
||||
expect(Invoice::count())->toBe(0);
|
||||
expect($agreement->fresh()->current_period_end->toDateString())->toBe($periodEnd);
|
||||
});
|
||||
|
||||
test('stripe-managed agreements are never picked up by the manual circle', function () {
|
||||
manualAgreement(['stripe_subscription_id' => 'sub_123']);
|
||||
|
||||
expect(app(ManualInvoiceService::class)->duePaymentOptions())->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('agreements with a future period end are not due', function () {
|
||||
manualAgreement(['current_period_end' => today()->addWeek()]);
|
||||
|
||||
expect(app(ManualInvoiceService::class)->duePaymentOptions())->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the command invoices all due agreements', function () {
|
||||
manualAgreement();
|
||||
manualAgreement();
|
||||
|
||||
$this->artisan(GenerateManualInvoices::class)->assertSuccessful();
|
||||
|
||||
expect(Invoice::count())->toBe(2);
|
||||
expect(Invoice::pluck('number')->sort()->values()->all())->toBe(['MAN-00001', 'MAN-00002']);
|
||||
});
|
||||
|
||||
test('the command dry-run does not create invoices', function () {
|
||||
manualAgreement();
|
||||
|
||||
$this->artisan(GenerateManualInvoices::class, ['--dry-run' => true])->assertSuccessful();
|
||||
|
||||
expect(Invoice::count())->toBe(0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue