From 64e3ca421d72ec974913b07cb270de3637d068dc Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 12:29:47 +0200 Subject: [PATCH] fix(engine-b): async stripe payments, capacity honesty, durable credential mail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Accept checkout.session.async_payment_succeeded (async methods) and dedupe on the checkout session id, not the event id. - committedGb still counts a failed instance while its VM exists (has vmid); releases quota only when no VM was created — no overcommit vs no leak. - CloudReady is queued (durable). Credential delivery is documented at-least-once (a rare duplicate welcome email beats a lost credential; exactly-once = v1.1 outbox). Co-Authored-By: Claude Opus 4.8 --- .../Controllers/StripeWebhookController.php | 15 +++++++++----- app/Models/Host.php | 10 ++++++++-- app/Notifications/CloudReady.php | 13 +++++++++--- .../Provisioning/CustomerDataModelTest.php | 7 ++++--- .../Provisioning/StripeWebhookTest.php | 20 ++++++++++++++++--- 5 files changed, 49 insertions(+), 16 deletions(-) diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index 99979f6..0622ff5 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -28,11 +28,14 @@ class StripeWebhookController extends Controller $event = json_decode($payload, true) ?: []; $object = $event['data']['object'] ?? []; + $type = $event['type'] ?? ''; - // Handle exactly ONE canonical event (a card checkout also emits - // payment_intent.succeeded with a different id) and only when actually - // paid — async methods complete the session while still unpaid. - if (($event['type'] ?? '') !== 'checkout.session.completed' || ($object['payment_status'] ?? null) !== 'paid') { + // Paid triggers: a synchronous checkout (completed + paid) OR an async + // method clearing later (async_payment_succeeded). Ignore everything else, + // including the still-unpaid completed event for async methods. + $paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid') + || $type === 'checkout.session.async_payment_succeeded'; + if (! $paid) { return response()->json(['ignored' => true]); } @@ -46,7 +49,9 @@ class StripeWebhookController extends Controller } $action->fromStripeEvent([ - 'id' => (string) ($event['id'] ?? ''), + // Deduplicate on the checkout session id (stable across the completed + // and async-succeeded events for one purchase), not the event id. + 'id' => (string) ($object['id'] ?? $event['id'] ?? ''), 'email' => $email, 'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null), 'stripe_customer_id' => $object['customer'] ?? null, diff --git a/app/Models/Host.php b/app/Models/Host.php index 185b082..983706a 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -62,10 +62,16 @@ class Host extends Model implements ProvisioningSubject return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100); } - /** Storage already committed to non-failed instances. */ + /** + * Storage already committed. A failed instance still counts while its VM + * exists (has a vmid) — the disk is really consumed until cleanup; a failure + * before any VM was created (no vmid) releases its quota. + */ public function committedGb(): int { - return (int) $this->instances()->where('status', '!=', 'failed')->sum('quota_gb'); + return (int) $this->instances() + ->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid')) + ->sum('quota_gb'); } /** Storage still available for new instances. */ diff --git a/app/Notifications/CloudReady.php b/app/Notifications/CloudReady.php index 193f677..23ad91a 100644 --- a/app/Notifications/CloudReady.php +++ b/app/Notifications/CloudReady.php @@ -4,14 +4,21 @@ namespace App\Notifications; use App\Models\Instance; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; /** - * Delivers the initial admin credentials once provisioning completes. The - * password is passed transiently and never persisted in plaintext. + * Delivers the initial admin credentials once provisioning completes. Queued for + * durable delivery; the password is passed transiently and never persisted in + * plaintext. + * + * Delivery is at-least-once: a worker crash in the tiny window between sending + * and recording the `credentials_sent` breadcrumb can re-send (a duplicate + * welcome email). Exactly-once would need a transactional outbox (v1.1); a + * duplicate is preferred over locking the customer out with a lost credential. */ -class CloudReady extends Notification +class CloudReady extends Notification implements ShouldQueue { use Queueable; diff --git a/tests/Feature/Provisioning/CustomerDataModelTest.php b/tests/Feature/Provisioning/CustomerDataModelTest.php index f9b41ff..df6ad47 100644 --- a/tests/Feature/Provisioning/CustomerDataModelTest.php +++ b/tests/Feature/Provisioning/CustomerDataModelTest.php @@ -55,10 +55,11 @@ it('computes host capacity from committed instance quotas', function () { // freeGb = 850 Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 100, 'status' => 'active']); Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 200, 'status' => 'provisioning']); - Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 500, 'status' => 'failed']); // excluded + Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 500, 'status' => 'failed', 'vmid' => null]); // released (no VM) + Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 150, 'status' => 'failed', 'vmid' => 900]); // VM still exists → counts - expect($host->committedGb())->toBe(300) - ->and($host->availableGb())->toBe(550); + expect($host->committedGb())->toBe(450) // 100 + 200 + 150 + ->and($host->availableGb())->toBe(400); // 850 - 450 }); it('places an instance on an active host in the datacenter with room', function () { diff --git a/tests/Feature/Provisioning/StripeWebhookTest.php b/tests/Feature/Provisioning/StripeWebhookTest.php index 29d5ddb..6f16caf 100644 --- a/tests/Feature/Provisioning/StripeWebhookTest.php +++ b/tests/Feature/Provisioning/StripeWebhookTest.php @@ -11,6 +11,7 @@ function stripeEvent(string $id = 'evt_1', string $plan = 'team'): array 'id' => $id, 'type' => 'checkout.session.completed', 'data' => ['object' => [ + 'id' => 'cs_'.$id, // checkout session id = idempotency key 'customer' => 'cus_1', 'payment_status' => 'paid', 'customer_details' => ['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger'], @@ -26,7 +27,7 @@ it('creates a customer, order and run from a paid checkout', function () { $this->postJson(route('webhooks.stripe'), stripeEvent())->assertOk(); - $order = Order::query()->where('stripe_event_id', 'evt_1')->first(); + $order = Order::query()->where('stripe_event_id', 'cs_evt_1')->first(); expect($order)->not->toBeNull() ->and($order->plan)->toBe('team') ->and($order->status)->toBe('paid') @@ -40,7 +41,7 @@ it('is idempotent — a duplicate webhook starts only one run', function () { $this->postJson(route('webhooks.stripe'), stripeEvent('evt_dup'))->assertOk(); $this->postJson(route('webhooks.stripe'), stripeEvent('evt_dup'))->assertOk(); - expect(Order::query()->where('stripe_event_id', 'evt_dup')->count())->toBe(1); + expect(Order::query()->where('stripe_event_id', 'cs_evt_dup')->count())->toBe(1); Queue::assertPushed(AdvanceRunJob::class, 1); }); @@ -66,6 +67,19 @@ it('ignores a paid checkout without a customer email', function () { Queue::assertNothingPushed(); }); +it('provisions an async payment that succeeds later', function () { + Queue::fake(); + + $event = stripeEvent('evt_async'); + $event['type'] = 'checkout.session.async_payment_succeeded'; + unset($event['data']['object']['payment_status']); // async success is inherently paid + + $this->postJson(route('webhooks.stripe'), $event)->assertOk(); + + expect(Order::query()->where('stripe_event_id', 'cs_evt_async')->exists())->toBeTrue(); + Queue::assertPushed(AdvanceRunJob::class, 1); +}); + it('ignores a completed but unpaid checkout session', function () { Queue::fake(); @@ -101,5 +115,5 @@ it('accepts a valid signature', function () { $payload, )->assertOk(); - expect(Order::query()->where('stripe_event_id', 'evt_ok')->exists())->toBeTrue(); + expect(Order::query()->where('stripe_event_id', 'cs_evt_ok')->exists())->toBeTrue(); });