see comments

This commit is contained in:
Rajpreet0 2026-10-02 15:36:12 +02:00
parent 7aa887c5aa
commit d91b353dc2
20 changed files with 593 additions and 151 deletions

View file

@ -53,6 +53,7 @@ vi.mock("@/src/modules/products/lib/pass-webhook", async (importOriginal) => ({
...(await importOriginal<object>()),
handlePassCheckoutCompleted: vi.fn(),
}));
vi.mock("@/src/modules/products/lib/pass-refund", () => ({ reconcilePassRefund: vi.fn() }));
import { POST } from "@/src/app/api/stripe/webhook/route";
import { stripe } from "@/src/lib/stripe";
@ -62,6 +63,7 @@ import { recordStripeFee, recordStripeFeeForCharge } from "@/src/lib/stripe-fee"
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import { releaseDiscountReservation } from "@/src/modules/settings/lib/discount-validation";
import { handlePassCheckoutCompleted } from "@/src/modules/products/lib/pass-webhook";
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
// Die Datenbank-Transaktion der Kapazitätsprüfung.
const tx = {
@ -569,4 +571,13 @@ describe("Stripe-Webhook – Karten", () => {
);
expect(tx.courseBooking.upsert.mock.calls[0][0].update).toMatchObject({ passId: null });
});
it("bestätigt eine offene Kartenerstattung über charge.refunded", async () => {
const charge = { id: "ch_1", refunded: true, payment_intent: "pi_1", amount_refunded: 18500 };
await deliver(
{ type: "charge.refunded", account: "acct_studio", data: { object: charge } },
"whsec_connect"
);
expect(reconcilePassRefund).toHaveBeenCalledWith(charge);
});
});

View file

@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const tx = {
courseBooking: { updateMany: vi.fn() },
customerPass: { update: vi.fn() },
customerPass: { updateMany: vi.fn() },
};
vi.mock("@/src/lib/prisma", () => ({
@ -85,7 +85,7 @@ beforeEach(() => {
text: "t",
});
tx.courseBooking.updateMany.mockResolvedValue({ count: 1 });
tx.customerPass.update.mockResolvedValue({});
tx.customerPass.updateMany.mockResolvedValue({ count: 1 });
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 100,
@ -100,10 +100,10 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
expect(result).toMatchObject({ success: true, creditsReturned: 2, refundedAmountCents: 0 });
expect(refundStripePayment).not.toHaveBeenCalled();
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("refunded");
expect(tx.customerPass.update).toHaveBeenCalledWith({
where: { id: "pass-1" },
data: { creditsRemaining: { increment: 2 } },
});
const { where, data } = tx.customerPass.updateMany.mock.calls[0][0];
// Gutschrift nur auf eine Karte, die noch einlösbar ist.
expect(where).toMatchObject({ id: "pass-1", status: "active" });
expect(data).toEqual({ creditsRemaining: { increment: 2 } });
});
it("lässt die Einheiten verfallen, wenn die Frist verstrichen ist", async () => {
@ -117,10 +117,11 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
expect(result).toMatchObject({ success: true, creditsReturned: 0 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("cancelled");
expect(tx.customerPass.update).not.toHaveBeenCalled();
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0].creditsReturned).toBe(
false
);
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0]).toMatchObject({
creditsReturned: false,
forfeitReason: "policy",
});
});
it("gibt bei einer Kursabsage immer zurück, ohne die Stornoregel zu fragen", async () => {
@ -135,6 +136,22 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
);
});
it("schreibt einer abgelaufenen oder beendeten Karte nichts gut - auch nicht bei Kursabsage", async () => {
// Die Karte ist nicht mehr aktiv: die bedingte Gutschrift trifft keine Zeile.
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
const result = await executeBookingCancellation("booking-1", "manager", {
forceFullRefund: true,
});
expect(result).toMatchObject({ success: true, creditsReturned: 0 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("cancelled");
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0]).toMatchObject({
creditsReturned: false,
forfeitReason: "pass_inactive",
});
});
it("storniert einzelne Plätze und gibt nur deren Einheiten zurück", async () => {
const result = await executeBookingCancellation("booking-1", "user", {
seatSelection: { seats: 1 },
@ -142,7 +159,7 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
expect(result).toMatchObject({ success: true, remainingSeats: 1, creditsReturned: 1 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data).toMatchObject({ seats: 1 });
expect(tx.customerPass.update.mock.calls[0][0].data).toEqual({
expect(tx.customerPass.updateMany.mock.calls[0][0].data).toEqual({
creditsRemaining: { increment: 1 },
});
});
@ -152,7 +169,10 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
const result = await executeBookingCancellation("booking-1", "user");
expect(result.success).toBe(false);
expect(tx.customerPass.update).not.toHaveBeenCalled();
// Die Gutschrift lief in derselben Transaktion und wird mit ihr zurückgerollt.
expect(result).toEqual({
success: false,
error: "Stornierung läuft bereits oder wurde bereits verarbeitet.",
});
});
});

View file

@ -51,6 +51,28 @@ describe("refundStripePayment", () => {
refundApplicationFee: false,
});
expect(result).toEqual({ success: false, error: "insufficient balance" });
expect(result).toEqual({ success: false, error: "insufficient balance", definitive: false });
});
it("unterscheidet eine sichere Ablehnung von einem offenen Ausgang", async () => {
const rejected = Object.assign(new Error("Charge already refunded"), {
type: "StripeInvalidRequestError",
code: "charge_already_refunded",
});
vi.mocked(stripe.refunds.create).mockRejectedValueOnce(rejected);
const timeout = Object.assign(new Error("timeout"), { type: "StripeConnectionError" });
vi.mocked(stripe.refunds.create).mockRejectedValueOnce(timeout);
const params = {
paymentIntentId: "pi_1",
stripeAccountId: "acct",
refundApplicationFee: false,
};
expect(await refundStripePayment(params)).toMatchObject({
success: false,
definitive: true,
code: "charge_already_refunded",
});
expect(await refundStripePayment(params)).toMatchObject({ success: false, definitive: false });
});
});

View file

@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const tx = {
customerPass: { updateMany: vi.fn() },
passAdjustment: { create: vi.fn() },
passAdjustment: { create: vi.fn(), findFirst: vi.fn(), count: vi.fn() },
};
vi.mock("@/src/lib/prisma", () => ({
@ -22,6 +22,7 @@ vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
import {
adjustPassCredits,
endPass,
issuePass,
refundPass,
} from "@/src/modules/products/actions/pass-admin-actions";
@ -55,6 +56,8 @@ beforeEach(() => {
vi.mocked(sendMail).mockResolvedValue(undefined as never);
tx.customerPass.updateMany.mockResolvedValue({ count: 1 });
tx.passAdjustment.create.mockResolvedValue({});
tx.passAdjustment.findFirst.mockResolvedValue({ creditsDelta: -10 });
tx.passAdjustment.count.mockResolvedValue(0);
});
describe("issuePass", () => {
@ -152,22 +155,61 @@ describe("adjustPassCredits", () => {
});
});
describe("refundPass", () => {
it("erstattet eine ungenutzte Karte komplett, die Gebühr trägt das Studio", async () => {
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_1",
amountRefundedCents: 12500,
describe("endPass", () => {
it("beendet und protokolliert in einer Transaktion", async () => {
const result = await endPass({ passId: "pass-1", reason: "Kunde zieht weg" });
expect(result.success).toBe(true);
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
where: { id: "pass-1", status: "active" },
data: { status: "ended" },
});
vi.mocked(prisma.customerPass.update).mockResolvedValue({} as never);
expect(tx.passAdjustment.create.mock.calls[0][0].data).toMatchObject({ type: "end" });
expect(prisma.passAdjustment.create).not.toHaveBeenCalled();
});
it("protokolliert nichts, wenn die Karte schon nicht mehr aktiv war", async () => {
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
expect((await endPass({ passId: "pass-1", reason: "" })).success).toBe(false);
expect(tx.passAdjustment.create).not.toHaveBeenCalled();
});
});
describe("refundPass", () => {
const refunded = {
success: true as const,
stripeRefundId: "re_1",
amountRefundedCents: 12500,
};
it("sperrt und protokolliert zuerst, dann erstattet Stripe komplett - die Gebühr trägt das Studio", async () => {
vi.mocked(refundStripePayment).mockResolvedValue(refunded);
const result = await refundPass({ passId: "pass-1" });
expect(result.success).toBe(true);
// Sperre: nicht mehr einlösbar, an den gelesenen Stand gebunden.
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
where: { id: "pass-1", status: { in: ["active", "ended"] }, creditsRemaining: 10 },
data: { status: "refunding", creditsRemaining: 0 },
});
expect(tx.passAdjustment.create.mock.calls[0][0].data).toMatchObject({
type: "refund",
creditsDelta: -10,
});
expect(refundStripePayment).toHaveBeenCalledWith(
expect.objectContaining({ paymentIntentId: "pi_1", refundApplicationFee: false })
expect.objectContaining({
paymentIntentId: "pi_1",
refundApplicationFee: false,
idempotencyKey: "refund-pass-pass-1",
})
);
expect(vi.mocked(refundStripePayment).mock.calls[0][0].amountCents).toBeUndefined();
// Abschluss erst nach Stripes Bestätigung.
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
where: { id: "pass-1", status: "refunding" },
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
});
});
it("erstattet keine Karte, mit der schon gebucht wurde", async () => {
@ -176,6 +218,7 @@ describe("refundPass", () => {
const result = await refundPass({ passId: "pass-1" });
expect(result.success).toBe(false);
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
expect(refundStripePayment).not.toHaveBeenCalled();
});
@ -191,16 +234,81 @@ describe("refundPass", () => {
expect(refundStripePayment).not.toHaveBeenCalled();
});
it("setzt die Karte zurück, wenn Stripe die Erstattung ablehnt", async () => {
vi.mocked(refundStripePayment).mockResolvedValue({ success: false, error: "kaputt" });
vi.mocked(prisma.customerPass.update).mockResolvedValue({} as never);
it("setzt die Karte nur zurück, wenn Stripe sicher abgelehnt hat", async () => {
vi.mocked(refundStripePayment).mockResolvedValue({
success: false,
error: "Insufficient funds",
definitive: true,
});
const result = await refundPass({ passId: "pass-1" });
expect(result.success).toBe(false);
expect(prisma.customerPass.update).toHaveBeenCalledWith({
where: { id: "pass-1" },
// Status und Einheiten von vor der Sperre, aus dem Protokoll.
expect(tx.customerPass.updateMany).toHaveBeenLastCalledWith({
where: { id: "pass-1", status: "refunding" },
data: { status: "active", creditsRemaining: 10 },
});
expect(tx.passAdjustment.create).toHaveBeenLastCalledWith({
data: expect.objectContaining({ type: "refund_failed", creditsDelta: 10 }),
});
});
it("stellt eine vorher beendete Karte als beendet wieder her", async () => {
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
...purchasedPass,
status: "ended",
} as never);
tx.passAdjustment.count.mockResolvedValue(1);
vi.mocked(refundStripePayment).mockResolvedValue({
success: false,
error: "nein",
definitive: true,
});
await refundPass({ passId: "pass-1" });
expect(tx.customerPass.updateMany).toHaveBeenLastCalledWith({
where: { id: "pass-1", status: "refunding" },
data: { status: "ended", creditsRemaining: 10 },
});
});
it("lässt die Karte gesperrt, wenn der Ausgang offen ist", async () => {
vi.mocked(refundStripePayment).mockResolvedValue({
success: false,
error: "timeout",
definitive: false,
});
const result = await refundPass({ passId: "pass-1" });
expect(result.success).toBe(false);
// Nur die Sperre - kein Zurücksetzen, kein Abschluss.
expect(tx.customerPass.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.customerPass.updateMany).not.toHaveBeenCalled();
});
it("fragt bei einer gesperrten Karte nur erneut bei Stripe nach", async () => {
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
...purchasedPass,
status: "refunding",
creditsRemaining: 0,
} as never);
vi.mocked(refundStripePayment).mockResolvedValue({
success: false,
error: "Charge already refunded",
definitive: true,
code: "charge_already_refunded",
});
const result = await refundPass({ passId: "pass-1" });
expect(result.success).toBe(true);
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
where: { id: "pass-1", status: "refunding" },
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
});
});
});

View file

@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: { customerPass: { findFirst: vi.fn(), updateMany: vi.fn() } },
}));
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
import { prisma } from "@/src/lib/prisma";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.customerPass.updateMany).mockResolvedValue({ count: 1 } as never);
});
describe("reconcilePassRefund", () => {
it("schließt eine offene Kartenerstattung ab, sobald Stripe sie meldet", async () => {
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({ id: "pass-1" } as never);
await reconcilePassRefund({
refunded: true,
payment_intent: "pi_1",
amount_refunded: 12500,
} as never);
expect(prisma.customerPass.findFirst).toHaveBeenCalledWith({
where: { stripePaymentIntentId: "pi_1", status: "refunding" },
select: { id: true },
});
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
where: { id: "pass-1", status: "refunding" },
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
});
});
it("ignoriert Teilerstattungen - eine Karte wird nur komplett erstattet", async () => {
await reconcilePassRefund({ refunded: false, payment_intent: "pi_1" } as never);
expect(prisma.customerPass.findFirst).not.toHaveBeenCalled();
});
it("tut nichts, wenn keine Kartenerstattung auf diese Zahlung wartet", async () => {
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue(null);
await reconcilePassRefund({
refunded: true,
payment_intent: "pi_x",
amount_refunded: 1,
} as never);
expect(prisma.customerPass.updateMany).not.toHaveBeenCalled();
});
});