fix(engine-b): async stripe payments, capacity honesty, durable credential mail

- 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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 12:29:47 +02:00
parent e0e4c4e18d
commit 64e3ca421d
5 changed files with 49 additions and 16 deletions

View File

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

View File

@ -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. */

View File

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

View File

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

View File

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