252 lines
9.8 KiB
PHP
252 lines
9.8 KiB
PHP
<?php
|
|
|
|
use App\Mail\InvoiceMail;
|
|
use App\Mail\OrderConfirmationMail;
|
|
use App\Models\Customer;
|
|
use App\Models\Invoice;
|
|
use App\Models\Operator;
|
|
use App\Models\Order;
|
|
use App\Models\ProvisioningRun;
|
|
use App\Models\User;
|
|
use App\Provisioning\Jobs\AdvanceRunJob;
|
|
use App\Support\CompanyProfile;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
function stripeEvent(string $id = 'evt_1', string $plan = 'team'): array
|
|
{
|
|
return [
|
|
'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'],
|
|
'amount_total' => 4900,
|
|
'currency' => 'eur',
|
|
'metadata' => ['plan' => $plan, 'datacenter' => 'fsn'],
|
|
]],
|
|
];
|
|
}
|
|
|
|
it('creates a customer, order and run from a paid checkout', function () {
|
|
Queue::fake();
|
|
|
|
$this->postJson(route('webhooks.stripe'), stripeEvent())->assertOk();
|
|
|
|
$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')
|
|
->and(ProvisioningRun::query()->where('subject_id', $order->id)->where('pipeline', 'customer')->exists())->toBeTrue();
|
|
Queue::assertPushed(AdvanceRunJob::class, 1);
|
|
});
|
|
|
|
it('is idempotent — a duplicate webhook starts only one run', function () {
|
|
Queue::fake();
|
|
|
|
$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', 'cs_evt_dup')->count())->toBe(1);
|
|
Queue::assertPushed(AdvanceRunJob::class, 1);
|
|
});
|
|
|
|
it('ignores unrelated event types', function () {
|
|
Queue::fake();
|
|
|
|
$this->postJson(route('webhooks.stripe'), ['id' => 'evt_x', 'type' => 'payout.paid', 'data' => ['object' => []]])
|
|
->assertOk()
|
|
->assertJson(['ignored' => true]);
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
it('answers a billing event for a subscription it does not have, without retrying forever', function () {
|
|
Queue::fake();
|
|
|
|
// Stripe delivers events for objects created by hand in the dashboard, and
|
|
// for other environments pointed at the same endpoint. A non-2xx here would
|
|
// have them retry something that can never succeed.
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_orphan', 'type' => 'invoice.paid',
|
|
'data' => ['object' => ['id' => 'in_1', 'subscription' => 'sub_unknown', 'amount_paid' => 21480]],
|
|
])->assertOk()->assertJson(['handled' => 'invoice.paid', 'applied' => false]);
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
it('ignores a paid checkout without a customer email', function () {
|
|
Queue::fake();
|
|
|
|
$event = stripeEvent('evt_noemail');
|
|
unset($event['data']['object']['customer_details'], $event['data']['object']['customer_email'], $event['data']['object']['metadata']['email']);
|
|
|
|
$this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['ignored' => 'no_customer_email']);
|
|
|
|
expect(Order::query()->where('stripe_event_id', 'evt_noemail')->exists())->toBeFalse();
|
|
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();
|
|
|
|
$event = stripeEvent('evt_unpaid');
|
|
$event['data']['object']['payment_status'] = 'unpaid';
|
|
|
|
$this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['ignored' => true]);
|
|
|
|
expect(Order::query()->where('stripe_event_id', 'evt_unpaid')->exists())->toBeFalse();
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
it('still provisions a paid order when the customer email already belongs to an operator, but withholds the portal login', function () {
|
|
// R21: one address must not represent both an operator and a customer.
|
|
// The payment already happened, so — same principle as openContract()'s
|
|
// own "the order is the record that money changed hands" — the order and
|
|
// run are still created; only the colliding portal LOGIN is withheld.
|
|
Queue::fake();
|
|
|
|
Operator::factory()->create(['email' => 'kunde@example.com']);
|
|
|
|
$this->postJson(route('webhooks.stripe'), stripeEvent('evt_opcollide'))->assertOk();
|
|
|
|
$order = Order::query()->where('stripe_event_id', 'cs_evt_opcollide')->first();
|
|
expect($order)->not->toBeNull()
|
|
->and(ProvisioningRun::query()->where('subject_id', $order->id)->where('pipeline', 'customer')->exists())->toBeTrue()
|
|
->and(User::query()->where('email', 'kunde@example.com')->exists())->toBeFalse();
|
|
|
|
Queue::assertPushed(AdvanceRunJob::class, 1);
|
|
});
|
|
|
|
/**
|
|
* A purchase whose worker was killed after the order committed.
|
|
*
|
|
* The order commits before the contract is opened and long before the invoice is
|
|
* issued, so that a failure in either can never erase the record of a payment. The
|
|
* window that leaves is real: a paid order, and nothing else. Built here through
|
|
* the same OpenSubscription the webhook itself calls, so what these tests start
|
|
* from is the interrupted state and not an approximation of it.
|
|
*/
|
|
function interruptedPurchase(string $sessionId, bool $withContract = true): Order
|
|
{
|
|
CompanyProfile::put([
|
|
'name' => 'CluPilot Cloud e.U.',
|
|
'address' => 'Dreherstraße 66/1/8',
|
|
'postcode' => '1110',
|
|
'city' => 'Wien',
|
|
'vat_id' => 'ATU00000000',
|
|
]);
|
|
|
|
$customer = Customer::factory()->create(['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger']);
|
|
|
|
$factory = Order::factory();
|
|
|
|
return ($withContract ? $factory->withSubscription() : $factory)->create([
|
|
'customer_id' => $customer->id,
|
|
'plan' => 'team',
|
|
'amount_cents' => 21480,
|
|
'currency' => 'EUR',
|
|
'status' => 'paid',
|
|
'stripe_event_id' => $sessionId,
|
|
]);
|
|
}
|
|
|
|
it('finishes the invoice a crash interrupted, once, however often Stripe asks', function () {
|
|
Mail::fake();
|
|
Queue::fake();
|
|
|
|
// A paid order with its contract open and NO invoice: the state a worker
|
|
// killed between the order committing and confirmByMail() leaves behind.
|
|
// Nothing anywhere sweeps for that, so Stripe's own retry is the only chance
|
|
// this customer has of ever getting the invoice they paid for — and resume()
|
|
// used to see the contract, decide there was nothing to do, and return.
|
|
$order = interruptedPurchase('cs_evt_crash');
|
|
|
|
expect(Invoice::query()->count())->toBe(0);
|
|
|
|
$this->postJson(route('webhooks.stripe'), stripeEvent('evt_crash'))->assertOk();
|
|
|
|
$invoice = Invoice::query()->sole();
|
|
|
|
expect($invoice->order_id)->toBe($order->id)
|
|
->and($invoice->gross_cents)->toBe(21480)
|
|
->and($invoice->number)->toBe('RE-'.now()->year.'-0001')
|
|
// Stamped, which is what makes the mail idempotent as well as the number.
|
|
->and($invoice->sent_at)->not->toBeNull();
|
|
|
|
Mail::assertQueued(InvoiceMail::class, 1);
|
|
// The invoice mail says everything the confirmation said and carries the
|
|
// document, so it replaces it. Two mails for one purchase is the defect this
|
|
// whole path was written around.
|
|
Mail::assertNotQueued(OrderConfirmationMail::class);
|
|
|
|
// Two more redeliveries change nothing. An invoice number can never be handed
|
|
// out twice, and nobody may receive the same invoice three times.
|
|
$this->postJson(route('webhooks.stripe'), stripeEvent('evt_crash'))->assertOk();
|
|
$this->postJson(route('webhooks.stripe'), stripeEvent('evt_crash'))->assertOk();
|
|
|
|
expect(Invoice::query()->count())->toBe(1)
|
|
->and(Invoice::query()->sole()->number)->toBe($invoice->number);
|
|
|
|
Mail::assertQueued(InvoiceMail::class, 1);
|
|
});
|
|
|
|
it('opens the contract and invoices when the crash came even earlier', function () {
|
|
Mail::fake();
|
|
Queue::fake();
|
|
|
|
// Killed before openContract() this time: a paid order with no contract at
|
|
// all. Both halves of the repair have to happen on the redelivery.
|
|
$order = interruptedPurchase('cs_evt_crash_early', withContract: false);
|
|
|
|
expect($order->subscription()->exists())->toBeFalse();
|
|
|
|
$this->postJson(route('webhooks.stripe'), stripeEvent('evt_crash_early'))->assertOk();
|
|
|
|
expect($order->fresh()->subscription()->exists())->toBeTrue()
|
|
->and(Invoice::query()->sole()->order_id)->toBe($order->id);
|
|
|
|
Mail::assertQueued(InvoiceMail::class, 1);
|
|
});
|
|
|
|
it('rejects an invalid signature when a secret is configured', function () {
|
|
config()->set('services.stripe.webhook_secret', 'whsec_test');
|
|
|
|
$this->postJson(route('webhooks.stripe'), stripeEvent('evt_sig'), ['Stripe-Signature' => 't=1,v1=deadbeef'])
|
|
->assertStatus(400);
|
|
});
|
|
|
|
it('accepts a valid signature', function () {
|
|
Queue::fake();
|
|
config()->set('services.stripe.webhook_secret', 'whsec_test');
|
|
|
|
$payload = json_encode(stripeEvent('evt_ok'));
|
|
$timestamp = time(); // within Stripe's replay tolerance
|
|
$signature = hash_hmac('sha256', $timestamp.'.'.$payload, 'whsec_test');
|
|
|
|
$this->call(
|
|
'POST',
|
|
route('webhooks.stripe'),
|
|
[], [], [],
|
|
['CONTENT_TYPE' => 'application/json', 'HTTP_STRIPE_SIGNATURE' => "t={$timestamp},v1={$signature}"],
|
|
$payload,
|
|
)->assertOk();
|
|
|
|
expect(Order::query()->where('stripe_event_id', 'cs_evt_ok')->exists())->toBeTrue();
|
|
});
|