mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
see comments
This commit is contained in:
parent
7aa887c5aa
commit
d91b353dc2
20 changed files with 593 additions and 151 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
49
tests/products/pass-refund.test.ts
Normal file
49
tests/products/pass-refund.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue