Use when a payment integration needs refund, dispute or payment-webhook tests: a refund matrix (full, partial, over-refund) for Stripe, Adyen, PayPal and Braintree, chargebacks with card-network reason codes, and webhook replay through each gateway's simulator.
95%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Deep reference for payment-flow-test-author SKILL.md. Consult once the core
signature + idempotency + replay-simulator surface is in place and the suite
needs the harder recovery scenarios: out-of-order delivery, mid-handler crash
recovery, replay of old events from the gateway archive, and the per-gateway
suite layout.
Webhooks can arrive out of order:
test('out-of-order event delivery handled', async () => {
const completedEvent = makeEvent({ type: 'payment_intent.succeeded' });
const creatingEvent = makeEvent({ type: 'payment_intent.created' });
// Deliver completed BEFORE created
await postWebhook(completedEvent);
await postWebhook(creatingEvent);
// Final state should still be correct
const record = await db.payments.findOne({ intent_id: completedEvent.data.id });
expect(record.status).toBe('succeeded');
});The handler must use versioned events or state-machine gates to handle this:
# Don't blindly overwrite state
def handle_event(event):
record = db.payments.get(event.intent_id)
new_state = event.data.status
if state_transition_allowed(record.status, new_state):
record.status = new_state
record.save()
# else: stale event, ignoreWhat happens when the handler crashes mid-processing?
test('crash mid-processing → retry succeeds', async () => {
let crashOnce = true;
const handler = makeHandler({
onProcessEvent: () => {
if (crashOnce) {
crashOnce = false;
throw new Error('simulated crash');
}
},
});
await expect(handler.process(event)).rejects.toThrow(); // First attempt crashes
await handler.process(event); // Retry succeeds; idempotent
const record = await db.payments.findOne({ event_id: event.id });
expect(record).toBeTruthy();
});Handlers should commit state changes atomically - either the processing succeeds and the event is marked handled, or both roll back.
Production sometimes loses webhooks (network outage, deploy issue). Per gateway docs, all support some form of replay:
| Gateway | Replay window | Method |
|---|---|---|
| Stripe | 30 days | stripe events resend <event_id> |
| Adyen | Unlimited (Customer Area) | Manual or notification-resend API |
| PayPal | 30 days | Webhook resend endpoint |
| Braintree | Unlimited (Control Panel) | Manual or webhookTesting.sampleNotification |
Test:
test('handler accepts replay from 7-day-old event', async () => {
const oldEvent = makeEvent({ created: Math.floor(Date.now()/1000) - 7*86400 });
const result = await handler.process(oldEvent);
expect(result).toBe(200);
});One directory per gateway so per-gateway quirks stay isolated:
tests/payment/webhooks/
stripe/
signature.test.ts
idempotency.test.ts
order-tolerance.test.ts
replay.test.ts
adyen/
... (same structure)
paypal/
...
braintree/
...
fixtures/
payloads/