This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import type Stripe from "stripe";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { cancelBooking, refundInstallment, saveVoucher, voucherInputSchema } from "@/lib/admin-service";
|
||||
import { startCheckout } from "@/lib/checkout";
|
||||
import { handleStripeEvent } from "@/lib/webhook";
|
||||
import { chargeDueInstallments } from "@/lib/worker-jobs";
|
||||
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: null };
|
||||
const admin = { type: "ADMIN" as const, id: "admin-test" };
|
||||
let gateway: FakeGateway;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await setupCamp();
|
||||
gateway = new FakeGateway("test");
|
||||
});
|
||||
|
||||
async function paidInstallmentBooking() {
|
||||
const result = await startCheckout(db, gateway, bookingRequest({ planType: "THREE_INSTALLMENTS" }), ctx);
|
||||
if (!result.ok) throw new Error(result.error.code);
|
||||
const booking = await db.booking.findUniqueOrThrow({ where: { reference: result.reference } });
|
||||
const s = gateway.completeSession(booking.stripeCheckoutSessionId!);
|
||||
const event = {
|
||||
id: `evt_${crypto.randomUUID()}`,
|
||||
type: "checkout.session.completed",
|
||||
livemode: false,
|
||||
data: { object: { id: s.id, status: s.status, payment_status: s.paymentStatus, payment_intent: s.paymentIntentId, customer: s.customerId, metadata: s.metadata } },
|
||||
} as unknown as Stripe.Event;
|
||||
await handleStripeEvent(db, gateway, event, ctx);
|
||||
return db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
|
||||
}
|
||||
|
||||
describe("Stornierung", () => {
|
||||
test("stoppt alle weiteren Abbuchungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true, reservation: true } });
|
||||
expect(after.status).toBe("CANCELED");
|
||||
expect(after.reservation!.status).toBe("RELEASED");
|
||||
expect(after.installments.filter((i) => i.seq > 1).every((i) => i.status === "CANCELED")).toBe(true);
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
|
||||
const later = { ...ctx, now: new Date("2027-06-01T12:00:00Z") };
|
||||
await chargeDueInstallments(db, gateway, later);
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
expect(await db.auditLog.count({ where: { action: "booking.canceled", actorId: "admin-test" } })).toBe(1);
|
||||
});
|
||||
|
||||
test("verlangt einen Grund", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
expect((await cancelBooking(db, gateway, { bookingId: booking.id, reason: " " }, admin)).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Erstattung", () => {
|
||||
test("nur bezahlte Raten, nie mehr als gezahlt, idempotent je Anfrage", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
const [first, second] = booking.installments;
|
||||
const key = crypto.randomUUID();
|
||||
|
||||
expect((await refundInstallment(db, gateway, { installmentId: second!.id, amountCents: 100, requestKey: key, reason: "" }, admin)).ok).toBe(false);
|
||||
expect((await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 80_001, requestKey: key, reason: "" }, admin)).ok).toBe(false);
|
||||
|
||||
expect((await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 30_000, requestKey: key, reason: "Storno" }, admin)).ok).toBe(true);
|
||||
// Doppelt abgeschicktes Formular (gleicher Schluessel) -> keine zweite Erstattung.
|
||||
await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 30_000, requestKey: key, reason: "Storno" }, admin);
|
||||
expect(gateway.refunds).toHaveLength(1);
|
||||
// Rest bleibt begrenzt auf die noch nicht erstatteten 500 €.
|
||||
const tooMuch = await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 50_001, requestKey: crypto.randomUUID(), reason: "" }, admin);
|
||||
expect(tooMuch.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gutscheinverwaltung", () => {
|
||||
test("Gueltigkeit als Kalendertage in Berlin, Codes normalisiert, keine Dubletten", async () => {
|
||||
const input = voucherInputSchema.parse({
|
||||
code: " stammmodel-laura ",
|
||||
active: true,
|
||||
validFrom: "2026-11-01",
|
||||
validUntil: "2026-12-31",
|
||||
maxRedemptions: "1",
|
||||
boundEmail: "[email protected]",
|
||||
internalNote: "",
|
||||
});
|
||||
expect(input.code).toBe("STAMMMODEL-LAURA");
|
||||
expect(input.validFrom!.toISOString()).toBe("2026-10-31T23:00:00.000Z");
|
||||
// "bis einschliesslich 31.12." = exklusiv 01.01.2027 00:00 Berlin
|
||||
expect(input.validUntil!.toISOString()).toBe("2026-12-31T23:00:00.000Z");
|
||||
expect(input.boundEmail).toBe("[email protected]");
|
||||
|
||||
expect((await saveVoucher(db, null, input, admin)).ok).toBe(true);
|
||||
expect((await saveVoucher(db, null, input, admin)).ok).toBe(false);
|
||||
});
|
||||
|
||||
test("der Beispielcode aus dem Briefing existiert nicht automatisch", async () => {
|
||||
expect(await db.voucher.findUnique({ where: { code: "STAMMMODELLAURA1X0" } })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { countOccupiedSeats, createBooking } from "@/lib/booking-service";
|
||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||
|
||||
const testCtx = { now: EARLY_BIRD_NOW, mode: "test" as const, secret: SECRET };
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await setupCamp();
|
||||
});
|
||||
|
||||
async function voucher(code: string, data: Partial<{ maxRedemptions: number; active: boolean; validUntil: Date; validFrom: Date; boundEmail: string }> = {}) {
|
||||
return db.voucher.create({ data: { code, maxRedemptions: 1, ...data } });
|
||||
}
|
||||
|
||||
describe("Kapazitaet", () => {
|
||||
test("gleichzeitige Buchungen bei knapper Kapazitaet: nie mehr Zusagen als Plaetze", async () => {
|
||||
await setupCamp({ capacity: 2 });
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => createBooking(db, bookingRequest(), testCtx)));
|
||||
const ok = results.filter((r) => r.ok);
|
||||
const soldOut = results.filter((r) => !r.ok && r.error.code === "SOLD_OUT");
|
||||
expect(ok).toHaveLength(2);
|
||||
expect(soldOut).toHaveLength(6);
|
||||
expect(await countOccupiedSeats(db, false, EARLY_BIRD_NOW)).toBe(2);
|
||||
});
|
||||
|
||||
test("abgelaufene Reservierung gibt den Platz wieder frei", async () => {
|
||||
await setupCamp({ capacity: 1 });
|
||||
const first = await createBooking(db, bookingRequest(), testCtx);
|
||||
expect(first.ok).toBe(true);
|
||||
const blocked = await createBooking(db, bookingRequest(), testCtx);
|
||||
expect(blocked.ok ? "ok" : blocked.error.code).toBe("SOLD_OUT");
|
||||
// 45 Minuten spaeter: Stripe-Session (31 min) und Kulanzpuffer (10 min) vorbei.
|
||||
const later = new Date(EARLY_BIRD_NOW.getTime() + 45 * 60_000);
|
||||
const again = await createBooking(db, bookingRequest(), { ...testCtx, now: later });
|
||||
expect(again.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("Testbuchungen belegen keine Live-Plaetze", async () => {
|
||||
await setupCamp({ capacity: 1 });
|
||||
await createBooking(db, bookingRequest(), testCtx);
|
||||
expect(await countOccupiedSeats(db, true, EARLY_BIRD_NOW)).toBe(0);
|
||||
});
|
||||
|
||||
test("doppelt abgeschicktes Formular erzeugt genau eine Buchung", async () => {
|
||||
const request = bookingRequest();
|
||||
const [a, b] = await Promise.all([createBooking(db, request, testCtx), createBooking(db, request, testCtx)]);
|
||||
expect(a.ok && b.ok).toBe(true);
|
||||
expect(await db.booking.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Preis und Snapshot", () => {
|
||||
test("Early-Bird-Preis wird serverseitig berechnet und als Snapshot gespeichert", async () => {
|
||||
const result = await createBooking(db, bookingRequest({ planType: "THREE_INSTALLMENTS" }), testCtx);
|
||||
if (!result.ok) throw new Error(result.error.code);
|
||||
expect(result.booking.totalCents).toBe(220_000);
|
||||
expect(result.booking.installments.map((i) => i.amountCents)).toEqual([80_000, 70_000, 70_000]);
|
||||
expect(result.booking.priceLockedUntil.getTime() - EARLY_BIRD_NOW.getTime()).toBe(31 * 60_000);
|
||||
});
|
||||
|
||||
test("Preis-Snapshot und Raten sind per Datenbank-Trigger unveraenderlich", async () => {
|
||||
const result = await createBooking(db, bookingRequest({ planType: "TWO_INSTALLMENTS" }), testCtx);
|
||||
if (!result.ok) throw new Error(result.error.code);
|
||||
await expect(db.booking.update({ where: { id: result.booking.id }, data: { totalCents: 1 } })).rejects.toThrow();
|
||||
await expect(
|
||||
db.installment.update({ where: { id: result.booking.installments[1]!.id }, data: { amountCents: 1 } }),
|
||||
).rejects.toThrow();
|
||||
// Statusfelder bleiben aenderbar.
|
||||
await db.booking.update({ where: { id: result.booking.id }, data: { reviewReason: "ok" } });
|
||||
});
|
||||
|
||||
test("Regulaerer Preis ab 01.01.2027 00:00 Berlin, Stammmodel 2.475 €", async () => {
|
||||
await voucher("STAMM-REG");
|
||||
const now = new Date("2026-12-31T23:00:00Z");
|
||||
const result = await createBooking(db, bookingRequest({ voucherCode: "stamm-reg" }), { ...testCtx, now });
|
||||
if (!result.ok) throw new Error(result.error.code);
|
||||
expect(result.booking.pricePhase).toBe("REGULAR");
|
||||
expect(result.booking.totalCents).toBe(247_500);
|
||||
});
|
||||
|
||||
test("Ratenzahlung ohne Camp-Termin wird serverseitig abgelehnt", async () => {
|
||||
await setupCamp({ campStartDate: null });
|
||||
const result = await createBooking(db, bookingRequest({ planType: "TWO_INSTALLMENTS" }), testCtx);
|
||||
expect(result.ok ? "ok" : result.error.code).toBe("PLAN_UNAVAILABLE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gutscheine", () => {
|
||||
test("gueltiger Code: Early Bird + Stammmodel = 1.980 €", async () => {
|
||||
await voucher("STAMMMODELTEST1");
|
||||
const result = await createBooking(db, bookingRequest({ voucherCode: " stammmodeltest1 " }), testCtx);
|
||||
if (!result.ok) throw new Error(result.error.code);
|
||||
expect(result.booking.totalCents).toBe(198_000);
|
||||
expect(await db.voucherRedemption.count({ where: { status: "RESERVED" } })).toBe(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["unbekannt", "GIBTESNICHT", {}, "UNKNOWN"],
|
||||
["inaktiv", "CODE-INAKTIV", { active: false }, "INACTIVE"],
|
||||
["abgelaufen", "CODE-ALT", { validUntil: new Date("2026-11-01T00:00:00Z") }, "EXPIRED"],
|
||||
["noch nicht gueltig", "CODE-SPAET", { validFrom: new Date("2026-12-01T00:00:00Z") }, "NOT_YET_VALID"],
|
||||
["an andere E-Mail gebunden", "CODE-BOUND", { boundEmail: "[email protected]" }, "EMAIL_MISMATCH"],
|
||||
] as const)("%s wird abgelehnt", async (_label, code, data, reason) => {
|
||||
if (reason !== "UNKNOWN") await voucher(code, data);
|
||||
const result = await createBooking(db, bookingRequest({ voucherCode: code }), testCtx);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok && result.error.code === "VOUCHER_REJECTED") expect(result.error.reason).toBe(reason);
|
||||
expect(await db.booking.count()).toBe(0);
|
||||
});
|
||||
|
||||
test("E-Mail-Bindung akzeptiert die richtige Adresse unabhaengig von Grossschreibung", async () => {
|
||||
await voucher("CODE-MAIL", { boundEmail: "[email protected]" });
|
||||
const result = await createBooking(db, bookingRequest({ voucherCode: "CODE-MAIL", email: "[email protected]" }), testCtx);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("ausgeschoepfter Code wird abgelehnt", async () => {
|
||||
await voucher("CODE-EINMAL");
|
||||
expect((await createBooking(db, bookingRequest({ voucherCode: "CODE-EINMAL" }), testCtx)).ok).toBe(true);
|
||||
const second = await createBooking(db, bookingRequest({ voucherCode: "CODE-EINMAL" }), testCtx);
|
||||
expect(second.ok ? "ok" : second.error.code === "VOUCHER_REJECTED" && second.error.reason).toBe("EXHAUSTED");
|
||||
});
|
||||
|
||||
test("gleichzeitige Einloesung eines begrenzten Codes: genau so viele wie erlaubt", async () => {
|
||||
await voucher("CODE-ZWEI", { maxRedemptions: 2 });
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 7 }, () => createBooking(db, bookingRequest({ voucherCode: "CODE-ZWEI" }), testCtx)),
|
||||
);
|
||||
expect(results.filter((r) => r.ok)).toHaveLength(2);
|
||||
expect(await db.voucherRedemption.count()).toBe(2);
|
||||
});
|
||||
|
||||
test("Gesamtkontingent fuer Stammmodel-Buchungen greift ueber alle Codes", async () => {
|
||||
await setupCamp({ stammmodelQuota: 2 });
|
||||
await voucher("CODE-A", { maxRedemptions: 5 });
|
||||
await voucher("CODE-B", { maxRedemptions: 5 });
|
||||
const results = await Promise.all([
|
||||
createBooking(db, bookingRequest({ voucherCode: "CODE-A" }), testCtx),
|
||||
createBooking(db, bookingRequest({ voucherCode: "CODE-B" }), testCtx),
|
||||
createBooking(db, bookingRequest({ voucherCode: "CODE-A" }), testCtx),
|
||||
createBooking(db, bookingRequest({ voucherCode: "CODE-B" }), testCtx),
|
||||
]);
|
||||
expect(results.filter((r) => r.ok)).toHaveLength(2);
|
||||
const rejected = results.find((r) => !r.ok);
|
||||
expect(rejected && !rejected.ok && rejected.error.code === "VOUCHER_REJECTED" && rejected.error.reason).toBe("QUOTA_EXHAUSTED");
|
||||
});
|
||||
|
||||
test("abgelaufene Reservierung gibt die Einloesung wieder frei", async () => {
|
||||
await voucher("CODE-FREI");
|
||||
await createBooking(db, bookingRequest({ voucherCode: "CODE-FREI" }), testCtx);
|
||||
const later = new Date(EARLY_BIRD_NOW.getTime() + 45 * 60_000);
|
||||
const again = await createBooking(db, bookingRequest({ voucherCode: "CODE-FREI" }), { ...testCtx, now: later });
|
||||
expect(again.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verkaufsfreigabe im Buchungsservice", () => {
|
||||
test("Livemodus ohne Freigaben: keine Buchung, auch bei direktem Aufruf", async () => {
|
||||
const result = await createBooking(db, bookingRequest(), { ...testCtx, mode: "live" });
|
||||
expect(result.ok ? "ok" : result.error.code).toBe("SALES_CLOSED");
|
||||
expect(await db.booking.count()).toBe(0);
|
||||
});
|
||||
|
||||
test("Vorschauphase: im Testmodus ohne Override gesperrt, mit Override buchbar", async () => {
|
||||
const preview = new Date("2026-10-01T10:00:00Z");
|
||||
const closed = await createBooking(db, bookingRequest(), { ...testCtx, now: preview });
|
||||
expect(closed.ok ? "ok" : closed.error.code).toBe("SALES_CLOSED");
|
||||
await setupCamp({ testPhaseOverride: "EARLY_BIRD" });
|
||||
const open = await createBooking(db, bookingRequest(), { ...testCtx, now: preview });
|
||||
expect(open.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildSchedule, splitIntoInstallments } from "@/lib/payment-plan";
|
||||
import { calculatePrice } from "@/lib/pricing";
|
||||
|
||||
describe("Exakte Zahlungsplaene laut Vorgabe", () => {
|
||||
test.each([
|
||||
["Early Bird", "EARLY_BIRD", false, [[220_000], [110_000, 110_000], [80_000, 70_000, 70_000]]],
|
||||
["Early Bird Stammmodel", "EARLY_BIRD", true, [[198_000], [99_000, 99_000], [66_000, 66_000, 66_000]]],
|
||||
["Regulaer", "REGULAR", false, [[275_000], [137_500, 137_500], [95_000, 90_000, 90_000]]],
|
||||
["Regulaer Stammmodel", "REGULAR", true, [[247_500], [123_750, 123_750], [82_500, 82_500, 82_500]]],
|
||||
] as const)("%s", (_label, phase, code, expected) => {
|
||||
const total = calculatePrice(phase, code).totalCents;
|
||||
for (const [index, plan] of expected.entries()) {
|
||||
const split = splitIntoInstallments(total, index + 1);
|
||||
expect(split).toEqual(plan);
|
||||
expect(split.reduce((a, b) => a + b, 0)).toBe(total);
|
||||
}
|
||||
});
|
||||
|
||||
test("1.237,50 € je Rate bleibt centgenau", () => {
|
||||
expect(splitIntoInstallments(247_500, 2)).toEqual([123_750, 123_750]);
|
||||
});
|
||||
|
||||
test("mehr als drei Raten sind ausgeschlossen", () => {
|
||||
expect(() => splitIntoInstallments(220_000, 4)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Faelligkeiten", () => {
|
||||
const base = {
|
||||
totalCents: 220_000,
|
||||
campStartDate: "2027-06-05",
|
||||
finalInstallmentLeadDays: 30,
|
||||
minInstallmentGapDays: 14,
|
||||
};
|
||||
|
||||
test("letzte Rate 30 Tage vor Anreise, zweite in der Mitte, Einzug 09:00 Berlin", () => {
|
||||
const result = buildSchedule({ ...base, planType: "THREE_INSTALLMENTS", now: new Date("2026-11-15T10:00:00Z") });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
// 172 Tage bis zur Frist -> zweite Rate nach 86 Tagen.
|
||||
expect(result.installments.map((i) => i.dueDate)).toEqual([null, "2027-02-09", "2027-05-06"]);
|
||||
// 06.05.2027 09:00 Sommerzeit = 07:00 UTC
|
||||
expect(result.installments[2]!.dueAt!.toISOString()).toBe("2027-05-06T07:00:00.000Z");
|
||||
expect(result.installments[0]!.amountCents).toBe(80_000);
|
||||
});
|
||||
|
||||
test("ohne Camp-Termin keine Ratenzahlung, Einmalzahlung bleibt moeglich", () => {
|
||||
const now = new Date("2026-11-15T10:00:00Z");
|
||||
expect(buildSchedule({ ...base, campStartDate: null, planType: "TWO_INSTALLMENTS", now })).toEqual({
|
||||
ok: false,
|
||||
reason: "CAMP_DATE_MISSING",
|
||||
});
|
||||
expect(buildSchedule({ ...base, campStartDate: null, planType: "FULL", now }).ok).toBe(true);
|
||||
});
|
||||
|
||||
test("zu knapp vor der Frist wird die Variante serverseitig deaktiviert", () => {
|
||||
// Frist 06.05.2027; 20 Tage vorher reichen fuer zwei, nicht fuer drei Raten.
|
||||
const now = new Date("2027-04-16T10:00:00Z");
|
||||
expect(buildSchedule({ ...base, planType: "TWO_INSTALLMENTS", now }).ok).toBe(true);
|
||||
expect(buildSchedule({ ...base, planType: "THREE_INSTALLMENTS", now })).toEqual({ ok: false, reason: "TOO_LATE" });
|
||||
const later = new Date("2027-04-30T10:00:00Z");
|
||||
expect(buildSchedule({ ...base, planType: "TWO_INSTALLMENTS", now: later })).toEqual({ ok: false, reason: "TOO_LATE" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { calculatePrice, EARLY_BIRD_START, pricePhaseAt, REGULAR_START } from "@/lib/pricing";
|
||||
import { formatEuro } from "@/lib/money";
|
||||
|
||||
describe("Gesamtpreise laut Preistabelle", () => {
|
||||
test.each([
|
||||
["EARLY_BIRD", false, 220_000],
|
||||
["EARLY_BIRD", true, 198_000],
|
||||
["REGULAR", false, 275_000],
|
||||
["REGULAR", true, 247_500],
|
||||
] as const)("%s mit Stammmodel-Code=%s kostet %i Cent", (phase, code, expected) => {
|
||||
expect(calculatePrice(phase, code).totalCents).toBe(expected);
|
||||
});
|
||||
|
||||
test("kombinierter Nachlass im Early Bird betraegt 28 %, nicht 30 %", () => {
|
||||
const price = calculatePrice("EARLY_BIRD", true);
|
||||
const discount = price.basePriceCents - price.totalCents;
|
||||
expect(discount / price.basePriceCents).toBe(0.28);
|
||||
expect(price.phaseDiscountCents).toBe(55_000);
|
||||
expect(price.voucherDiscountCents).toBe(22_000);
|
||||
});
|
||||
|
||||
test("Formatierung in Euro", () => {
|
||||
expect(formatEuro(220_000)).toBe("2.200,00 €");
|
||||
expect(formatEuro(123_750)).toBe("1.237,50 €");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zeitgrenzen in Europe/Berlin", () => {
|
||||
test("Early Bird beginnt am 01.11.2026 um 00:00 Berliner Zeit (= 31.10. 23:00 UTC, Winterzeit)", () => {
|
||||
expect(EARLY_BIRD_START.toISOString()).toBe("2026-10-31T23:00:00.000Z");
|
||||
expect(pricePhaseAt(new Date("2026-10-31T22:59:59.999Z"))).toBe("PREVIEW");
|
||||
expect(pricePhaseAt(new Date("2026-10-31T23:00:00.000Z"))).toBe("EARLY_BIRD");
|
||||
});
|
||||
|
||||
test("Sommerzeit-Ende am 25.10.2026 verschiebt die Grenze nicht", () => {
|
||||
// 31.10.2026 20:00 Berlin (Winterzeit) ist noch Vorschau.
|
||||
expect(pricePhaseAt(new Date("2026-10-31T19:00:00Z"))).toBe("PREVIEW");
|
||||
});
|
||||
|
||||
test("Early Bird gilt bis einschliesslich 31.12.2026, regulaer ab 01.01.2027 00:00 Berlin", () => {
|
||||
expect(REGULAR_START.toISOString()).toBe("2026-12-31T23:00:00.000Z");
|
||||
expect(pricePhaseAt(new Date("2026-12-31T22:59:59.999Z"))).toBe("EARLY_BIRD");
|
||||
expect(pricePhaseAt(new Date("2026-12-31T23:00:00.000Z"))).toBe("REGULAR");
|
||||
expect(pricePhaseAt(new Date("2027-06-01T12:00:00Z"))).toBe("REGULAR");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { evaluateSales, type CampGateFields } from "@/lib/sales-gate";
|
||||
|
||||
const unreleased: CampGateFields = {
|
||||
salesEnabled: false,
|
||||
capacityConfirmed: false,
|
||||
stammmodelQuotaConfirmed: false,
|
||||
campStartDate: null,
|
||||
dateConfirmed: false,
|
||||
locationName: null,
|
||||
locationConfirmed: false,
|
||||
accommodationText: null,
|
||||
accommodationConfirmed: false,
|
||||
servicesConfirmed: false,
|
||||
organizerName: null,
|
||||
organizerAddress: null,
|
||||
organizerEmail: null,
|
||||
organizerConfirmed: false,
|
||||
taxNote: null,
|
||||
taxConfirmed: false,
|
||||
termsVersion: null,
|
||||
termsApproved: false,
|
||||
travelLawChecked: false,
|
||||
installmentsApproved: false,
|
||||
installmentRuleApproved: false,
|
||||
testPhaseOverride: null,
|
||||
};
|
||||
|
||||
const released: CampGateFields = {
|
||||
...unreleased,
|
||||
salesEnabled: true,
|
||||
capacityConfirmed: true,
|
||||
stammmodelQuotaConfirmed: true,
|
||||
campStartDate: "2027-06-05",
|
||||
dateConfirmed: true,
|
||||
locationName: "Villa",
|
||||
locationConfirmed: true,
|
||||
accommodationText: "Beschreibung",
|
||||
accommodationConfirmed: true,
|
||||
servicesConfirmed: true,
|
||||
organizerName: "Veranstalter",
|
||||
organizerAddress: "Adresse",
|
||||
organizerEmail: "[email protected]",
|
||||
organizerConfirmed: true,
|
||||
taxNote: "Hinweis",
|
||||
taxConfirmed: true,
|
||||
termsVersion: "1",
|
||||
termsApproved: true,
|
||||
travelLawChecked: true,
|
||||
};
|
||||
|
||||
const earlyBird = new Date("2026-11-10T12:00:00Z");
|
||||
|
||||
describe("Verkaufsfreigabe", () => {
|
||||
test("Livemodus ohne Freigaben: Checkout gesperrt, alle Blocker benannt", () => {
|
||||
const state = evaluateSales(earlyBird, unreleased, "live");
|
||||
expect(state.checkoutOpen).toBe(false);
|
||||
expect(state.closedReason).toBe("NOT_RELEASED");
|
||||
expect(state.releaseBlockers).toHaveLength(11);
|
||||
});
|
||||
|
||||
test("Livemodus: eine einzige fehlende Freigabe sperrt den Verkauf", () => {
|
||||
const state = evaluateSales(earlyBird, { ...released, travelLawChecked: false }, "live");
|
||||
expect(state.checkoutOpen).toBe(false);
|
||||
expect(state.releaseBlockers).toEqual(["TRAVEL_LAW"]);
|
||||
});
|
||||
|
||||
test("Livemodus mit allen Freigaben in der Early-Bird-Phase: offen", () => {
|
||||
expect(evaluateSales(earlyBird, released, "live").checkoutOpen).toBe(true);
|
||||
});
|
||||
|
||||
test("Livemodus vor dem 01.11.2026: Vorschau ohne Kaufmoeglichkeit, Override wirkungslos", () => {
|
||||
const state = evaluateSales(new Date("2026-10-01T12:00:00Z"), { ...released, testPhaseOverride: "EARLY_BIRD" }, "live");
|
||||
expect(state.checkoutOpen).toBe(false);
|
||||
expect(state.closedReason).toBe("PREVIEW");
|
||||
});
|
||||
|
||||
test("Testmodus ohne Freigaben: Checkout offen, Preisphase simulierbar", () => {
|
||||
const state = evaluateSales(new Date("2026-10-01T12:00:00Z"), { ...unreleased, testPhaseOverride: "REGULAR" }, "test");
|
||||
expect(state.checkoutOpen).toBe(true);
|
||||
expect(state.effectivePhase).toBe("REGULAR");
|
||||
expect(state.phaseSimulated).toBe(true);
|
||||
});
|
||||
|
||||
test("Testmodus ohne Override vor Verkaufsstart bleibt Vorschau", () => {
|
||||
expect(evaluateSales(new Date("2026-10-01T12:00:00Z"), unreleased, "test").checkoutOpen).toBe(false);
|
||||
});
|
||||
|
||||
test("Ratenzahlung live erst nach ausdruecklicher Freigabe", () => {
|
||||
expect(evaluateSales(earlyBird, released, "live").installmentsAllowed).toBe(false);
|
||||
expect(
|
||||
evaluateSales(earlyBird, { ...released, installmentsApproved: true, installmentRuleApproved: true }, "live")
|
||||
.installmentsAllowed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("ab Anreisetag ist der Verkauf geschlossen", () => {
|
||||
const state = evaluateSales(new Date("2027-06-05T08:00:00Z"), released, "live");
|
||||
expect(state.closedReason).toBe("CAMP_STARTED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { CAMP_ID, getDb } from "@/lib/db";
|
||||
import type { BookingRequest } from "@/lib/booking-service";
|
||||
import type { PlanType } from "@/lib/payment-plan";
|
||||
|
||||
export const db = getDb();
|
||||
export const SECRET = "test-secret-test-secret-test-secret-123";
|
||||
|
||||
/** Leert alle Anwendungstabellen der Testdatenbank. */
|
||||
export async function resetDb(): Promise<void> {
|
||||
await db.$executeRawUnsafe(`TRUNCATE TABLE
|
||||
"Refund", "EmailMessage", "AuditLog", "StripeEvent", "RateLimitBucket",
|
||||
"VoucherRedemption", "SeatReservation", "Installment", "Booking", "Voucher", "Camp",
|
||||
"session", "account", "verification", "twoFactor", "user", "rateLimit"
|
||||
RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
|
||||
export async function setupCamp(data: Prisma.CampUpdateInput = {}): Promise<void> {
|
||||
await db.camp.upsert({
|
||||
where: { id: CAMP_ID },
|
||||
create: { id: CAMP_ID },
|
||||
update: {},
|
||||
});
|
||||
await db.camp.update({
|
||||
where: { id: CAMP_ID },
|
||||
data: {
|
||||
campStartDate: new Date("2027-06-05T00:00:00Z"),
|
||||
testPhaseOverride: null,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export function bookingRequest(overrides: { planType?: PlanType; voucherCode?: string; email?: string } = {}): BookingRequest {
|
||||
counter++;
|
||||
const planType = overrides.planType ?? "FULL";
|
||||
return {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
participant: {
|
||||
firstName: "Test",
|
||||
lastName: `Person ${counter}`,
|
||||
email: overrides.email ?? `test${counter}@example.org`,
|
||||
phone: "+49 30 1234567",
|
||||
country: "Deutschland",
|
||||
notes: undefined,
|
||||
},
|
||||
planType,
|
||||
voucherCode: overrides.voucherCode,
|
||||
consents: { adult: true, terms: true, accommodation: true, privacy: true, installmentMandate: planType !== "FULL" },
|
||||
};
|
||||
}
|
||||
|
||||
/** Mitten in der Early-Bird-Phase. */
|
||||
export const EARLY_BIRD_NOW = new Date("2026-11-15T10:00:00Z");
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
CheckoutSessionInfo,
|
||||
CreateCheckoutParams,
|
||||
OffSessionResult,
|
||||
PaymentGateway,
|
||||
PaymentIntentInfo,
|
||||
} from "@/lib/payment-gateway";
|
||||
import type { StripeMode } from "@/lib/sales-gate";
|
||||
|
||||
type StoredSession = CheckoutSessionInfo & { params: CreateCheckoutParams };
|
||||
|
||||
/**
|
||||
* Stripe-Attrappe im Speicher. Bildet die fuer die Geschaeftslogik
|
||||
* relevanten Eigenschaften nach: Idempotenzschluessel, Session-Zustaende,
|
||||
* PaymentIntents und das Ergebnis von Off-Session-Einzuegen.
|
||||
*/
|
||||
export class FakeGateway implements PaymentGateway {
|
||||
private seq = 0;
|
||||
private readonly byKey = new Map<string, unknown>();
|
||||
readonly sessions = new Map<string, StoredSession>();
|
||||
readonly paymentIntents = new Map<string, PaymentIntentInfo>();
|
||||
readonly offSessionCharges: { key: string; amountCents: number; installmentId: string }[] = [];
|
||||
readonly detached: string[] = [];
|
||||
readonly refunds: { id: string; paymentIntentId: string; amountCents: number }[] = [];
|
||||
/** Ergebnisse fuer die naechsten Off-Session-Einzuege (Standard: Erfolg). */
|
||||
offSessionOutcomes: OffSessionResult["outcome"][] = [];
|
||||
failNextCheckout = false;
|
||||
|
||||
constructor(readonly mode: StripeMode = "test") {}
|
||||
|
||||
private id(prefix: string): string {
|
||||
this.seq++;
|
||||
return `${prefix}_test_${this.seq}`;
|
||||
}
|
||||
|
||||
private once<T>(key: string, create: () => T): T {
|
||||
if (!this.byKey.has(key)) this.byKey.set(key, create());
|
||||
return this.byKey.get(key) as T;
|
||||
}
|
||||
|
||||
async createCustomer(_params: { email: string; name: string }, key: string) {
|
||||
return this.once(`cus:${key}`, () => ({ id: this.id("cus") }));
|
||||
}
|
||||
|
||||
async createCheckoutSession(params: CreateCheckoutParams, key: string) {
|
||||
if (this.failNextCheckout) {
|
||||
this.failNextCheckout = false;
|
||||
throw new Error("Stripe nicht erreichbar (simuliert)");
|
||||
}
|
||||
return this.once(`cs:${key}`, () => {
|
||||
const id = this.id("cs");
|
||||
const url = `https://checkout.stripe.test/${id}`;
|
||||
this.sessions.set(id, {
|
||||
id,
|
||||
status: "open",
|
||||
paymentStatus: "unpaid",
|
||||
paymentIntentId: null,
|
||||
customerId: params.customerId,
|
||||
url,
|
||||
metadata: params.metadata,
|
||||
params,
|
||||
});
|
||||
return { id, url };
|
||||
});
|
||||
}
|
||||
|
||||
async retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo> {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) throw new Error(`Unbekannte Session ${id}`);
|
||||
return { ...s };
|
||||
}
|
||||
|
||||
async expireCheckoutSession(id: string): Promise<void> {
|
||||
const s = this.sessions.get(id);
|
||||
if (s && s.status === "open") s.status = "expired";
|
||||
}
|
||||
|
||||
/** Simuliert die Zahlung durch die Kundin in Stripe Checkout. */
|
||||
completeSession(id: string, opts: { paid?: boolean; paymentMethodId?: string; amountCents?: number } = {}): CheckoutSessionInfo {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) throw new Error(`Unbekannte Session ${id}`);
|
||||
const piId = this.id("pi");
|
||||
const paid = opts.paid ?? true;
|
||||
this.paymentIntents.set(piId, {
|
||||
id: piId,
|
||||
status: paid ? "succeeded" : "processing",
|
||||
amountReceived: paid ? (opts.amountCents ?? s.params.amountCents) : 0,
|
||||
paymentMethodId: opts.paymentMethodId ?? this.id("pm"),
|
||||
chargeId: paid ? this.id("ch") : null,
|
||||
metadata: s.metadata,
|
||||
});
|
||||
s.status = "complete";
|
||||
s.paymentStatus = paid ? "paid" : "unpaid";
|
||||
s.paymentIntentId = piId;
|
||||
return { ...s };
|
||||
}
|
||||
|
||||
async retrievePaymentIntent(id: string): Promise<PaymentIntentInfo> {
|
||||
const pi = this.paymentIntents.get(id);
|
||||
if (!pi) throw new Error(`Unbekannter PaymentIntent ${id}`);
|
||||
return { ...pi };
|
||||
}
|
||||
|
||||
async chargeOffSession(
|
||||
params: { customerId: string; paymentMethodId: string; amountCents: number; metadata: Record<string, string> },
|
||||
key: string,
|
||||
): Promise<OffSessionResult> {
|
||||
return this.once(`pi:${key}`, () => {
|
||||
const outcome = this.offSessionOutcomes.shift() ?? "succeeded";
|
||||
const piId = this.id("pi");
|
||||
const chargeId = outcome === "succeeded" ? this.id("ch") : null;
|
||||
this.paymentIntents.set(piId, {
|
||||
id: piId,
|
||||
status: outcome === "succeeded" ? "succeeded" : outcome === "processing" ? "processing" : outcome === "requires_action" ? "requires_action" : "requires_payment_method",
|
||||
amountReceived: outcome === "succeeded" ? params.amountCents : 0,
|
||||
paymentMethodId: params.paymentMethodId,
|
||||
chargeId,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
if (outcome === "succeeded") {
|
||||
this.offSessionCharges.push({ key, amountCents: params.amountCents, installmentId: params.metadata.installmentId! });
|
||||
}
|
||||
return {
|
||||
outcome,
|
||||
paymentIntentId: piId,
|
||||
chargeId,
|
||||
failureCode: outcome === "failed" ? "card_declined" : outcome === "requires_action" ? "authentication_required" : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async detachPaymentMethod(id: string): Promise<void> {
|
||||
this.detached.push(id);
|
||||
}
|
||||
|
||||
async createRefund(params: { paymentIntentId: string; amountCents: number }, key: string) {
|
||||
return this.once(`re:${key}`, () => {
|
||||
const refund = { id: this.id("re"), paymentIntentId: params.paymentIntentId, amountCents: params.amountCents };
|
||||
this.refunds.push(refund);
|
||||
return { id: refund.id, status: "succeeded", amount: params.amountCents };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Startet fuer die Testlaeufe eine echte, frische PostgreSQL-Instanz.
|
||||
*
|
||||
* Bewusst kein Mock und kein SQLite: die Tests fuer gleichzeitige Buchungen
|
||||
* und Gutscheineinloesungen pruefen Zeilensperren (SELECT … FOR UPDATE) und
|
||||
* Datenbank-Trigger, die nur eine echte PostgreSQL-Instanz abbildet.
|
||||
*
|
||||
* Alternativ laesst sich eine vorhandene Datenbank per TEST_DATABASE_URL
|
||||
* nutzen (z. B. in CI mit einem postgres-Service). Deren Inhalt wird dabei
|
||||
* zurueckgesetzt.
|
||||
*/
|
||||
import EmbeddedPostgres from "embedded-postgres";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
let pg: EmbeddedPostgres | null = null;
|
||||
let dir: string | null = null;
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
let url = process.env.TEST_DATABASE_URL;
|
||||
if (!url) {
|
||||
dir = mkdtempSync(path.join(os.tmpdir(), "artista-test-db-"));
|
||||
const port = 55000 + Math.floor(Math.random() * 1000);
|
||||
pg = new EmbeddedPostgres({
|
||||
databaseDir: dir,
|
||||
user: "test",
|
||||
password: "test",
|
||||
port,
|
||||
persistent: false,
|
||||
onLog: () => {},
|
||||
});
|
||||
await pg.initialise();
|
||||
await pg.start();
|
||||
await pg.createDatabase("artista_test");
|
||||
url = `postgresql://test:test@localhost:${port}/artista_test`;
|
||||
}
|
||||
process.env.DATABASE_URL = url;
|
||||
const prismaCli = path.resolve("node_modules/prisma/build/index.js");
|
||||
try {
|
||||
// Nicht-destruktiv: wendet nur fehlende Migrationen an. Die Tests leeren
|
||||
// ihre Tabellen selbst (tests/support/db.ts).
|
||||
execFileSync(process.execPath, [prismaCli, "migrate", "deploy"], {
|
||||
env: { ...process.env, DATABASE_URL: url },
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const stderr = (error as { stderr?: Buffer }).stderr?.toString() ?? "";
|
||||
throw new Error(`Migration der Testdatenbank fehlgeschlagen:\n${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function teardown(): Promise<void> {
|
||||
if (pg) await pg.stop();
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user