This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -0,0 +1,333 @@
|
||||
import Stripe from "stripe";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { startCheckout, startInstallmentCheckout } from "@/lib/checkout";
|
||||
import { applyRefundState, MAX_OFF_SESSION_ATTEMPTS } from "@/lib/payment-events";
|
||||
import { verifyStripeWebhook } from "@/lib/payment-gateway";
|
||||
import { handleStripeEvent, settleCheckoutSession } from "@/lib/webhook";
|
||||
import { chargeDueInstallments, detachFinishedPaymentMethods, releaseExpiredReservations } from "@/lib/worker-jobs";
|
||||
import { STRIPE_ACTOR } from "@/lib/audit";
|
||||
import type { PlanType } from "@/lib/payment-plan";
|
||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||
import { FakeGateway } from "./support/fake-gateway";
|
||||
|
||||
const ctx = { now: EARLY_BIRD_NOW, secret: SECRET, adminEmail: "[email protected]" };
|
||||
let gateway: FakeGateway;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await setupCamp();
|
||||
gateway = new FakeGateway("test");
|
||||
});
|
||||
|
||||
async function checkout(planType: PlanType = "FULL", voucherCode?: string) {
|
||||
const request = bookingRequest({ planType, voucherCode });
|
||||
const result = await startCheckout(db, gateway, request, ctx);
|
||||
if (!result.ok) throw new Error(`Checkout fehlgeschlagen: ${result.error.code}`);
|
||||
const booking = await db.booking.findUniqueOrThrow({ where: { reference: result.reference } });
|
||||
return { request, result, booking };
|
||||
}
|
||||
|
||||
function sessionEvent(type: string, session: ReturnType<FakeGateway["completeSession"]>, id = `evt_${crypto.randomUUID()}`) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
livemode: false,
|
||||
data: {
|
||||
object: {
|
||||
id: session.id,
|
||||
object: "checkout.session",
|
||||
status: session.status,
|
||||
payment_status: session.paymentStatus,
|
||||
payment_intent: session.paymentIntentId,
|
||||
customer: session.customerId,
|
||||
url: null,
|
||||
metadata: session.metadata,
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe.Event;
|
||||
}
|
||||
|
||||
async function paidBooking(planType: PlanType = "FULL") {
|
||||
const { booking } = await checkout(planType);
|
||||
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), ctx);
|
||||
return db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
|
||||
}
|
||||
|
||||
describe("Checkout-Erstellung", () => {
|
||||
test("idempotent: derselbe Buchungsversuch liefert dieselbe Stripe-Session", async () => {
|
||||
const { request, result } = await checkout();
|
||||
const again = await startCheckout(db, gateway, request, ctx);
|
||||
expect(again.ok && again.url).toBe(result.url);
|
||||
expect(gateway.sessions.size).toBe(1);
|
||||
});
|
||||
|
||||
test("Session laeuft mit der Preisbindung ab; Ratenplan speichert die Zahlungsmethode", async () => {
|
||||
const { booking } = await checkout("THREE_INSTALLMENTS");
|
||||
const session = gateway.sessions.get(booking.stripeCheckoutSessionId!)!;
|
||||
expect(session.params.amountCents).toBe(80_000);
|
||||
expect(session.params.saveForFutureUsage).toBe(true);
|
||||
expect(session.params.expiresAt.getTime()).toBe(booking.priceLockedUntil.getTime());
|
||||
expect(session.params.submitMessage).toContain("Heute fällig: 800,00 €");
|
||||
});
|
||||
|
||||
test("Stripe-Ausfall gibt Platz und Gutschein sofort wieder frei", async () => {
|
||||
await db.voucher.create({ data: { code: "CODE-X" } });
|
||||
gateway.failNextCheckout = true;
|
||||
const result = await startCheckout(db, gateway, bookingRequest({ voucherCode: "CODE-X" }), ctx);
|
||||
expect(result.ok ? "ok" : result.error.code).toBe("PAYMENT_PROVIDER");
|
||||
expect(await db.seatReservation.count({ where: { status: "RELEASED" } })).toBe(1);
|
||||
expect(await db.voucherRedemption.count({ where: { status: "RELEASED" } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webhook", () => {
|
||||
test("Signatur wird gegen den unveraenderten Body geprueft", () => {
|
||||
const secret = "whsec_test_secret";
|
||||
const payload = JSON.stringify({ id: "evt_1", object: "event", type: "checkout.session.completed", data: { object: {} } });
|
||||
const header = Stripe.webhooks.generateTestHeaderString({ payload, secret });
|
||||
expect(verifyStripeWebhook(payload, header, secret).id).toBe("evt_1");
|
||||
expect(() => verifyStripeWebhook(payload.replace("evt_1", "evt_2"), header, secret)).toThrow();
|
||||
expect(() => verifyStripeWebhook(payload, header, "whsec_falsch")).toThrow();
|
||||
});
|
||||
|
||||
test("Einmalzahlung: bestaetigt und vollstaendig bezahlt, Mail im Postausgang", async () => {
|
||||
const booking = await paidBooking("FULL");
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
expect(booking.paymentStatus).toBe("PAID");
|
||||
expect(booking.paidCents).toBe(220_000);
|
||||
expect(await db.seatReservation.count({ where: { status: "CONVERTED" } })).toBe(1);
|
||||
expect(await db.emailMessage.count({ where: { dedupeKey: `booking-confirmed:${booking.id}` } })).toBe(1);
|
||||
});
|
||||
|
||||
test("Ratenzahlung: erste Rate bezahlt heisst Teilzahlung, nicht vollstaendig bezahlt", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
expect(booking.paymentStatus).toBe("PARTIALLY_PAID");
|
||||
expect(booking.paidCents).toBe(110_000);
|
||||
expect(booking.stripePaymentMethodId).toMatch(/^pm_/);
|
||||
expect(booking.installments.map((i) => i.status)).toEqual(["PAID", "PENDING"]);
|
||||
});
|
||||
|
||||
test("doppelt zugestelltes Event wird nur einmal verbucht", async () => {
|
||||
const { booking } = await checkout();
|
||||
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
|
||||
const event = sessionEvent("checkout.session.completed", session, "evt_dup");
|
||||
expect((await handleStripeEvent(db, gateway, event, ctx)).outcome).toBe("processed");
|
||||
expect((await handleStripeEvent(db, gateway, event, ctx)).outcome).toBe("duplicate");
|
||||
// Anderes Event, gleicher Inhalt (z. B. async_payment_succeeded): idempotent.
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_succeeded", session), ctx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
});
|
||||
|
||||
test("ungeordnet: 'expired' nach 'completed' hebt die Buchung nicht auf", async () => {
|
||||
const { booking } = await checkout();
|
||||
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), ctx);
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.expired", { ...session, status: "expired" }), ctx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
test("Abbruch/Ablauf: unbezahlte Buchung verfaellt, Platz wird frei", async () => {
|
||||
const { booking } = await checkout();
|
||||
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
|
||||
await handleStripeEvent(
|
||||
db,
|
||||
gateway,
|
||||
sessionEvent("checkout.session.expired", { ...session, status: "expired" } as ReturnType<FakeGateway["completeSession"]>),
|
||||
ctx,
|
||||
);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
|
||||
expect(after.status).toBe("EXPIRED");
|
||||
expect(after.reservation!.status).toBe("RELEASED");
|
||||
});
|
||||
|
||||
test("Livemodus-Event im Testbetrieb wird ignoriert", async () => {
|
||||
const { booking } = await checkout();
|
||||
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
|
||||
const event = { ...sessionEvent("checkout.session.completed", session), livemode: true } as Stripe.Event;
|
||||
expect((await handleStripeEvent(db, gateway, event, ctx)).outcome).toBe("ignored");
|
||||
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("PENDING");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verzoegerte und verspaetete Zahlung", () => {
|
||||
test("verzoegerte Zahlung haelt den Platz, Fehlschlag gibt ihn frei", async () => {
|
||||
const { booking } = await checkout();
|
||||
const session = gateway.completeSession(booking.stripeCheckoutSessionId!, { paid: false });
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), ctx);
|
||||
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
|
||||
expect(after.paymentStatus).toBe("PROCESSING");
|
||||
expect(after.reservation!.status).toBe("HELD");
|
||||
|
||||
// Reservierungsfrist laeuft ab, der Platz bleibt trotzdem gehalten.
|
||||
const later = { ...ctx, now: new Date(EARLY_BIRD_NOW.getTime() + 3 * 86_400_000) };
|
||||
await releaseExpiredReservations(db, gateway, later);
|
||||
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
|
||||
expect(after.reservation!.status).toBe("HELD");
|
||||
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_failed", session), later);
|
||||
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
|
||||
expect(after.status).toBe("EXPIRED");
|
||||
expect(after.paymentStatus).toBe("FAILED");
|
||||
expect(after.reservation!.status).toBe("RELEASED");
|
||||
});
|
||||
|
||||
test("verlorener Webhook: Worker fragt Stripe und verbucht die Zahlung statt freizugeben", async () => {
|
||||
const { booking } = await checkout();
|
||||
gateway.completeSession(booking.stripeCheckoutSessionId!);
|
||||
const later = { ...ctx, now: new Date(EARLY_BIRD_NOW.getTime() + 60 * 60_000) };
|
||||
const result = await releaseExpiredReservations(db, gateway, later);
|
||||
expect(result.settled).toBe(1);
|
||||
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
test("verspaetete Zahlung nach Freigabe: Platz noch frei -> bestaetigt", async () => {
|
||||
const { booking } = await checkout();
|
||||
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
|
||||
// Platz wurde (z. B. durch einen Fehler) bereits freigegeben.
|
||||
await db.seatReservation.update({ where: { bookingId: booking.id }, data: { status: "RELEASED" } });
|
||||
await db.booking.update({ where: { id: booking.id }, data: { status: "EXPIRED" } });
|
||||
await settleCheckoutSession(db, gateway, session, ctx, STRIPE_ACTOR);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
|
||||
expect(after.status).toBe("CONFIRMED");
|
||||
expect(after.reservation!.status).toBe("CONVERTED");
|
||||
});
|
||||
|
||||
test("verspaetete Zahlung nach Freigabe ohne freien Platz -> Pruefung statt Ueberbuchung", async () => {
|
||||
await setupCamp({ capacity: 1 });
|
||||
const { booking } = await checkout();
|
||||
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
|
||||
await db.seatReservation.update({ where: { bookingId: booking.id }, data: { status: "RELEASED" } });
|
||||
await db.booking.update({ where: { id: booking.id }, data: { status: "EXPIRED" } });
|
||||
await paidBooking("FULL"); // jemand anderes bekommt den letzten Platz
|
||||
await settleCheckoutSession(db, gateway, session, ctx, STRIPE_ACTOR);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.status).toBe("NEEDS_REVIEW");
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
expect(await db.seatReservation.count({ where: { status: "CONVERTED" } })).toBe(1);
|
||||
expect(await db.emailMessage.count({ where: { toAddress: "[email protected]" } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Folgeraten", () => {
|
||||
const afterLastDue = (booking: { installments: { dueAt: Date | null }[] }) => ({
|
||||
...ctx,
|
||||
now: new Date(Math.max(...booking.installments.map((i) => i.dueAt?.getTime() ?? 0)) + 60_000),
|
||||
});
|
||||
|
||||
test("Einzug aller Raten, danach keine weitere Abbuchung und Zahlungsmethode getrennt", async () => {
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const runCtx = afterLastDue(booking);
|
||||
const summary = await chargeDueInstallments(db, gateway, runCtx);
|
||||
expect(summary.charged).toBe(2);
|
||||
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
|
||||
await detachFinishedPaymentMethods(db, gateway);
|
||||
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentMethodDetachedAt).not.toBeNull();
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
|
||||
// Weitere Laeufe buchen nichts mehr ab.
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 30 * 86_400_000) });
|
||||
expect(gateway.offSessionCharges).toHaveLength(2);
|
||||
expect(gateway.offSessionCharges.reduce((s, c) => s + c.amountCents, 0)).toBe(140_000);
|
||||
});
|
||||
|
||||
test("nur faellige Raten werden eingezogen", async () => {
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const between = { ...ctx, now: new Date(booking.installments[1]!.dueAt!.getTime() + 60_000) };
|
||||
expect((await chargeDueInstallments(db, gateway, between)).charged).toBe(1);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
||||
expect(after.paidCents).toBe(150_000);
|
||||
});
|
||||
|
||||
test("Fehlschlag: Wiederholung nach Wartezeit, nach dem letzten Versuch manuelle Pruefung", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
gateway.offSessionOutcomes = ["failed", "failed", "failed"];
|
||||
let runCtx = afterLastDue(booking);
|
||||
for (let attempt = 1; attempt <= MAX_OFF_SESSION_ATTEMPTS; attempt++) {
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
||||
expect(inst.status).toBe("FAILED");
|
||||
expect(inst.attemptCount).toBe(attempt);
|
||||
// Vor Ablauf der Wartezeit kein erneuter Versuch.
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
expect((await db.installment.findFirstOrThrow({ where: { id: inst.id } })).attemptCount).toBe(attempt);
|
||||
runCtx = { ...runCtx, now: new Date(runCtx.now.getTime() + 4 * 86_400_000) };
|
||||
}
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.status).toBe("NEEDS_REVIEW");
|
||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Bank verlangt Authentifizierung: Kundin bekommt Link und zahlt selbst", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
gateway.offSessionOutcomes = ["requires_action"];
|
||||
const runCtx = afterLastDue(booking);
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
||||
expect(inst.status).toBe("REQUIRES_ACTION");
|
||||
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${inst.id}` } } })).toBe(1);
|
||||
|
||||
const pay = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: inst.id }, runCtx);
|
||||
expect(pay.ok).toBe(true);
|
||||
// Waehrend die Session offen ist, zieht der Worker nicht parallel ein.
|
||||
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 60_000) });
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
|
||||
const sessionId = (await db.installment.findUniqueOrThrow({ where: { id: inst.id } })).checkoutSessionId!;
|
||||
const session = gateway.completeSession(sessionId);
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), runCtx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
});
|
||||
|
||||
test("Webhook und Worker melden denselben Erfolg: nur einmal verbucht", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
const runCtx = afterLastDue(booking);
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
||||
const pi = await gateway.retrievePaymentIntent(inst.stripePaymentIntentId!);
|
||||
const event = {
|
||||
id: "evt_pi_ok",
|
||||
type: "payment_intent.succeeded",
|
||||
livemode: false,
|
||||
data: {
|
||||
object: {
|
||||
id: pi.id,
|
||||
object: "payment_intent",
|
||||
amount_received: pi.amountReceived,
|
||||
latest_charge: pi.chargeId,
|
||||
payment_method: pi.paymentMethodId,
|
||||
customer: null,
|
||||
metadata: pi.metadata,
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe.Event;
|
||||
await handleStripeEvent(db, gateway, event, runCtx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
expect(after.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
test("Erstattung aktualisiert Zahlungsstatus absolut (ungeordnete Events harmlos)", async () => {
|
||||
const booking = await paidBooking("FULL");
|
||||
const pi = booking.installments[0]!.stripePaymentIntentId!;
|
||||
const ectx = { ...ctx, actor: STRIPE_ACTOR };
|
||||
await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: pi, amountRefundedCents: 220_000 }, ectx));
|
||||
await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: pi, amountRefundedCents: 50_000 }, ectx));
|
||||
await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: pi, amountRefundedCents: 220_000 }, ectx));
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.refundedCents).toBe(220_000);
|
||||
expect(after.paymentStatus).toBe("REFUNDED");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user