billing: Nebenläufigkeit & Idempotenz härten (H4, Stripe, Boost-Slots)
- CreditTopup-Gutschrift, MAN-Rechnung, Proof-PDF und Stripe-Webhook- Spiegelung sperren die betroffene Zeile innerhalb der Transaktion und prüfen die Idempotenz erst danach (verhindert Doppelgutschrift bei at-least-once-Webhooks, H4). - Gemeinsamer LocksModelForUpdate-Trait für die häufige Lock-by-Key-Form (CreditTopup, ManualInvoice). - Unique-Index auf invoices.stripe_invoice_id; Boost-Inventar über boost_slot_locks serialisiert, verwaiste Kategorie-Locks werden beim Löschen der Rubrik entfernt. Deployment: vor migrate prüfen, ob invoices.stripe_invoice_id bereits Duplikate enthält (die Unique-Migration bricht sonst bewusst ab). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b3fdd6f522
commit
7043ea8ad5
14 changed files with 418 additions and 106 deletions
|
|
@ -227,7 +227,7 @@ test('the paid webhook depublishes the release and the public page shows a tombs
|
|||
// „Eingelöste Käufe" auf der Buchungs-Seite.
|
||||
expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed);
|
||||
expect($purchase->fresh()->consumed_at)->not->toBeNull();
|
||||
expect($release->statusLogs()->count())->toBeGreaterThan(0);
|
||||
expect($release->statusLogs()->where('source', 'depublication')->count())->toBe(1);
|
||||
|
||||
// Der Käufer erhält genau eine Bestätigung (Replay löst keine zweite aus).
|
||||
Mail::assertQueued(PressReleaseDepublished::class, function (PressReleaseDepublished $mail) use ($user, $release) {
|
||||
|
|
|
|||
|
|
@ -156,6 +156,22 @@ test('agreements with a future period end are not due', function () {
|
|||
expect(app(ManualInvoiceService::class)->duePaymentOptions())->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a stale due agreement instance cannot be invoiced twice', function () {
|
||||
Carbon::setTestNow('2026-06-12 08:00:00');
|
||||
$agreement = manualAgreement(['current_period_end' => '2026-06-11']);
|
||||
$service = app(ManualInvoiceService::class);
|
||||
|
||||
$firstInvoice = $service->invoiceFor($agreement);
|
||||
$replayedInvoice = $service->invoiceFor($agreement);
|
||||
|
||||
expect($firstInvoice)->not->toBeNull();
|
||||
expect($replayedInvoice)->toBeNull();
|
||||
expect(Invoice::count())->toBe(1);
|
||||
expect($agreement->fresh()->current_period_end->toDateString())->toBe('2026-07-11');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('the command invoices all due agreements', function () {
|
||||
manualAgreement();
|
||||
manualAgreement();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\BillingAddress;
|
|||
use App\Models\Invoice;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
|
|
@ -81,6 +82,17 @@ test('duplicate webhook deliveries do not create a second invoice', function ()
|
|||
expect(Invoice::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('stripe invoice ids are unique in the database while null remains repeatable', function () {
|
||||
Invoice::factory()->create(['stripe_invoice_id' => 'in_database_unique']);
|
||||
|
||||
expect(fn () => Invoice::factory()->create(['stripe_invoice_id' => 'in_database_unique']))
|
||||
->toThrow(QueryException::class);
|
||||
|
||||
Invoice::factory()->count(2)->create(['stripe_invoice_id' => null]);
|
||||
|
||||
expect(Invoice::query()->whereNull('stripe_invoice_id')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('the snapshot falls back to the local billing address including vat id', function () {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
BillingAddress::factory()->create([
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use App\Models\PressRelease;
|
|||
use App\Models\User;
|
||||
use App\Services\Billing\CreditWalletService;
|
||||
use App\Services\PressRelease\BoostService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->service = app(BoostService::class);
|
||||
|
|
@ -77,6 +79,10 @@ test('booking a boost debits the wallet and activates the placement', function (
|
|||
expect($this->wallet->balance($user))->toBe(0);
|
||||
expect($this->service->isBoosted($pr))->toBeTrue();
|
||||
expect($pr->fresh()->isBoosted())->toBeTrue();
|
||||
expect(DB::table('boost_slot_locks')->where(
|
||||
'lock_key',
|
||||
'home:'.$pr->portal->value.':'.$pr->language.':all',
|
||||
)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('boosting a non-green release is rejected at the gate', function () {
|
||||
|
|
@ -135,6 +141,10 @@ test('a category boost costs half the staffel and only activates the category pl
|
|||
expect($this->wallet->balance($user))->toBe(4);
|
||||
expect($this->service->isBoosted($pr, BoostPlacement::Category))->toBeTrue();
|
||||
expect($this->service->isBoosted($pr, BoostPlacement::Home))->toBeFalse();
|
||||
expect(DB::table('boost_slot_locks')->where(
|
||||
'lock_key',
|
||||
'category:'.$pr->portal->value.':'.$pr->language.':'.$pr->category_id,
|
||||
)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the placements extend independently of each other', function () {
|
||||
|
|
@ -248,3 +258,23 @@ test('home boosts do not consume category slots and vice versa', function () {
|
|||
|
||||
expect($this->service->boost($user, $pr, 7, BoostPlacement::Category))->toBeInstanceOf(Boost::class);
|
||||
});
|
||||
|
||||
test('deleting a category prunes its orphaned boost slot locks', function () {
|
||||
/** @var TestCase $this */
|
||||
$categoryId = 4711;
|
||||
|
||||
// Simuliert vorhandene Lock-Zeilen: eine Kategorie-Lock plus eine
|
||||
// Home-Lock (die nicht betroffen sein darf).
|
||||
DB::table('boost_slot_locks')->insert([
|
||||
['lock_key' => 'category:presseecho:de:'.$categoryId],
|
||||
['lock_key' => 'category:businessportal24:de:'.$categoryId],
|
||||
['lock_key' => 'home:presseecho:de:all'],
|
||||
['lock_key' => 'category:presseecho:de:99'],
|
||||
]);
|
||||
|
||||
$this->service->forgetSlotLocksForCategory($categoryId);
|
||||
|
||||
expect(DB::table('boost_slot_locks')->where('lock_key', 'like', 'category:%:'.$categoryId)->count())->toBe(0)
|
||||
->and(DB::table('boost_slot_locks')->where('lock_key', 'home:presseecho:de:all')->exists())->toBeTrue()
|
||||
->and(DB::table('boost_slot_locks')->where('lock_key', 'category:presseecho:de:99')->exists())->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,6 +59,27 @@ test('fulfilling a topup credits the wallet exactly once', function () {
|
|||
expect($user->creditTransactions()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('two concurrent webhook deliveries credit the wallet only once', function () {
|
||||
/**
|
||||
* Regression zu Befund H4 (Audit 14.07.2026): Stripe stellt Webhooks
|
||||
* at-least-once zu. Beide Zustellungen laden die Topup-Zeile, BEVOR eine
|
||||
* committet (credited_at je noch null). Nur eine darf gutschreiben.
|
||||
*/
|
||||
$user = User::factory()->create();
|
||||
$topup = $this->topups->startTopup($user, 'p50');
|
||||
|
||||
// Zwei separate Instanzen derselben Zeile – wie zwei parallele Deliveries,
|
||||
// die die Zeile vor der ersten Gutschrift geladen haben.
|
||||
$deliveryA = $topup->fresh();
|
||||
$deliveryB = $topup->fresh();
|
||||
|
||||
$this->topups->fulfill($deliveryA, 'cs_test_1', 'pi_test_1');
|
||||
$this->topups->fulfill($deliveryB, 'cs_test_1', 'pi_test_1');
|
||||
|
||||
expect($this->wallet->balance($user))->toBe(55);
|
||||
expect($user->creditTransactions()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('the checkout webhook credits the wallet from session metadata', function () {
|
||||
$user = User::factory()->create();
|
||||
$topup = $this->topups->startTopup($user, 'p100');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue