changed to direct charge and changed designs

This commit is contained in:
Rajpreet0 2026-10-01 23:28:32 +02:00
parent b5703aaf8e
commit 55d8838c0e
86 changed files with 5854 additions and 3349 deletions

View file

@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/auth/getUser", () => ({ requireManager: vi.fn() }));
vi.mock("@/src/lib/prisma", () => ({
prisma: { business: { findUnique: vi.fn() } },
}));
vi.mock("@/src/lib/stripe", () => ({
stripe: { accountSessions: { create: vi.fn() } },
}));
import { POST } from "@/src/app/api/stripe/account-session/route";
import { requireManager } from "@/src/lib/auth/getUser";
import { prisma } from "@/src/lib/prisma";
import { stripe } from "@/src/lib/stripe";
function request(body: unknown) {
return new Request("http://localhost/api/stripe/account-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}) as never;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireManager).mockResolvedValue({ id: "manager-1" } as never);
vi.mocked(stripe.accountSessions.create).mockResolvedValue({
client_secret: "accs_secret_1",
} as never);
});
describe("POST /api/stripe/account-session", () => {
it("gibt das Secret für das eigene Studio heraus", async () => {
vi.mocked(prisma.business.findUnique).mockResolvedValue({
managerId: "manager-1",
stripeAccountId: "acct_studio",
} as never);
const res = await POST(request({ businessId: "biz-1" }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ clientSecret: "accs_secret_1" });
const params = vi.mocked(stripe.accountSessions.create).mock.calls[0][0];
expect(params.account).toBe("acct_studio");
// Erstattungen nur über Kumasys Stornierung - sonst wüsste die Buchung nichts davon.
expect(params.components.disputes_list?.features?.refund_management).toBe(false);
expect(params.components.payouts?.features?.external_account_collection).toBe(true);
});
it("weist fremde Studios ab, ohne Stripe zu fragen", async () => {
vi.mocked(prisma.business.findUnique).mockResolvedValue({
managerId: "manager-2",
stripeAccountId: "acct_fremd",
} as never);
const res = await POST(request({ businessId: "biz-2" }));
expect(res.status).toBe(403);
expect(stripe.accountSessions.create).not.toHaveBeenCalled();
});
it("meldet ein fehlendes Stripe-Konto verständlich", async () => {
vi.mocked(prisma.business.findUnique).mockResolvedValue({
managerId: "manager-1",
stripeAccountId: null,
} as never);
const res = await POST(request({ businessId: "biz-1" }));
expect(res.status).toBe(409);
expect((await res.json()).error).toMatch(/Stripe-Konto/);
});
it("lehnt Anfragen ohne businessId ab", async () => {
const res = await POST(request({}));
expect(res.status).toBe(400);
});
});

View file

@ -19,14 +19,14 @@ vi.mock("@/src/lib/prisma", () => ({
vi.mock("@/src/lib/stripe", () => ({
stripe: { checkout: { sessions: { create: vi.fn() } } },
calculatePlatformFeeCents: vi.fn().mockReturnValue(100),
getPlatformFeeCents: vi.fn().mockReturnValue(50),
eurosToCents: vi.fn().mockReturnValue(2900),
}));
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import { prisma } from "@/src/lib/prisma";
import { stripe } from "@/src/lib/stripe";
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
let ipCounter = 0;
@ -127,3 +127,106 @@ describe("POST /api/stripe/checkout/course – Gast mit Team-Account-E-Mail (Tic
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
});
});
describe("POST /api/stripe/checkout/course – Servicegebühr", () => {
it("schlägt die Gebühr als eigene Position auf und behält sie als application_fee ein", async () => {
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
const [args, options] = vi.mocked(stripe.checkout.sessions.create).mock.calls[0] as never as [
{
line_items: { price_data: { unit_amount: number; product_data: { name: string } } }[];
payment_intent_data: { application_fee_amount?: number; transfer_data?: unknown };
metadata: Record<string, string>;
},
{ stripeAccount?: string },
];
// Direct Charge: die Session entsteht auf dem Studio-Account, ohne
// Transfer von der Plattform.
expect(options).toEqual({ stripeAccount: "acct_123" });
expect(args.payment_intent_data.transfer_data).toBeUndefined();
// Kurs zum vollen Preis + separate Gebührenposition
expect(args.line_items).toHaveLength(2);
expect(args.line_items[0].price_data.unit_amount).toBe(2900);
expect(args.line_items[1].price_data.product_data.name).toBe("Servicegebühr");
expect(args.line_items[1].price_data.unit_amount).toBe(50);
// Kumasy bekommt exakt die Gebühr als application_fee.
expect(args.payment_intent_data.application_fee_amount).toBe(50);
// Der Webhook erkennt daran, dass die Gebühr obendrauf kam.
expect(args.metadata.platformFeeModel).toBe("on_top");
});
it("erzeugt keine Gebührenposition, wenn die Gebühr auf 0 konfiguriert ist", async () => {
vi.mocked(getPlatformFeeCents).mockReturnValueOnce(0);
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
line_items: unknown[];
payment_intent_data: { application_fee_amount?: number };
};
expect(args.line_items).toHaveLength(1);
// Ohne Gebühr wird gar keine application_fee gesetzt statt einer von 0.
expect(args.payment_intent_data.application_fee_amount).toBeUndefined();
});
});
describe("POST /api/stripe/checkout/course – Checkout-Seite", () => {
function loggedIn() {
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
}
it("verlangt die Rechnungsadresse und trägt das Branding des Studios", async () => {
loggedIn();
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
billing_address_collection: string;
branding_settings: Record<string, unknown>;
};
expect(args.billing_address_collection).toBe("required");
// Ohne eigenes Studio-Branding greifen die Kumasy-Farben.
expect(args.branding_settings).toMatchObject({
display_name: "Studio",
background_color: "#1E293B",
button_color: "#F4C00C",
border_style: "rounded",
});
});
it("bucht ohne Branding weiter, wenn Stripe das Branding ablehnt", async () => {
loggedIn();
vi.mocked(stripe.checkout.sessions.create)
.mockRejectedValueOnce(
Object.assign(new Error("Invalid logo"), { param: "branding_settings[logo][url]" })
)
.mockResolvedValueOnce({ url: "https://checkout.stripe.com/ohne-branding" } as never);
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
expect((await res.json()).url).toBe("https://checkout.stripe.com/ohne-branding");
const retry = vi.mocked(stripe.checkout.sessions.create).mock.calls[1][0] as never as {
branding_settings?: unknown;
billing_address_collection: string;
};
expect(retry.branding_settings).toBeUndefined();
expect(retry.billing_address_collection).toBe("required");
});
});

View file

@ -0,0 +1,281 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/stripe", () => ({
stripe: {
webhooks: { constructEvent: vi.fn() },
paymentIntents: { retrieve: vi.fn() },
},
getWebhookSecrets: () => ["whsec_platform", "whsec_connect"],
onAccount: (id: string | null) => (id ? { stripeAccount: id } : undefined),
}));
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
vi.mock("@/src/lib/stripe-fee", () => ({
recordStripeFee: vi.fn(),
recordStripeFeeForCharge: vi.fn(),
}));
vi.mock("@/src/lib/prisma", () => ({
prisma: {
$transaction: vi.fn(),
user: { findUnique: vi.fn(), findFirst: vi.fn() },
course: { findUnique: vi.fn() },
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), count: vi.fn() },
courseWaitlistEntry: { deleteMany: vi.fn() },
business: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/lib/mail/templates/booking-confirmation", () => ({
generateBookingConfirmationEmail: () => ({ subject: "s", html: "h", text: "t" }),
}));
vi.mock("@/src/lib/mail/templates/booking-refund", () => ({
generateBookingRefundEmail: () => ({ subject: "s", html: "h", text: "t" }),
}));
vi.mock("@/src/modules/settings/lib/to-email-branding", () => ({ toEmailBranding: () => ({}) }));
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
notifyBusinessManager: vi.fn(),
}));
vi.mock("@/src/lib/auth/account-role-by-email", () => ({
isManagerOrEmployeeEmail: vi.fn(),
}));
vi.mock("@/src/modules/settings/lib/discount-validation", () => ({
confirmDiscountReservation: vi.fn(),
releaseDiscountReservation: vi.fn(),
}));
import { POST } from "@/src/app/api/stripe/webhook/route";
import { stripe } from "@/src/lib/stripe";
import { prisma } from "@/src/lib/prisma";
import { refundStripePayment } from "@/src/lib/stripe-refund";
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";
// Die Datenbank-Transaktion der Kapazitätsprüfung.
const tx = {
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), count: vi.fn(), upsert: vi.fn() },
course: { findUnique: vi.fn() },
};
function request() {
return new Request("http://localhost/api/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "t=1,v1=abc" },
body: "{}",
}) as never;
}
/** Ein Event, das nur mit dem angegebenen Secret als gültig signiert gilt. */
function deliver(event: object, signedWith = "whsec_platform") {
vi.mocked(stripe.webhooks.constructEvent).mockImplementation((_body, _sig, secret) => {
if (secret !== signedWith)
throw new Error("No signatures found matching the expected signature");
return event as never;
});
return POST(request());
}
function sessionEvent(
type: string,
opts: { account?: string; paymentStatus?: string; metadata?: Record<string, string> } = {}
) {
return {
type,
account: opts.account,
data: {
object: {
id: "cs_1",
payment_status: opts.paymentStatus ?? "paid",
amount_total: 2950,
payment_intent: "pi_1",
customer_email: "kunde@example.com",
customer_details: { email: "kunde@example.com", name: "Kunde" },
metadata: {
courseId: "course-1",
userId: "user-1",
isGuest: "false",
platformFeeModel: "on_top",
...opts.metadata,
},
},
},
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.$transaction).mockImplementation(((callback: (t: typeof tx) => unknown) =>
callback(tx)) as never);
tx.courseBooking.findUnique.mockResolvedValue(null);
tx.courseBooking.findFirst.mockResolvedValue(null);
tx.courseBooking.count.mockResolvedValue(0);
tx.courseBooking.upsert.mockResolvedValue({ id: "booking-1" });
tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 });
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
application_fee_amount: 50,
} as never);
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false);
vi.mocked(recordStripeFee).mockResolvedValue(undefined);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "user-1" } as never);
vi.mocked(prisma.course.findUnique).mockResolvedValue({
name: "Yoga",
date: new Date("2099-01-01"),
timeFrom: "10:00",
timeTo: "11:00",
coverImage: null,
businessId: "biz-1",
maxParticipants: 10,
business: { name: "Studio", address: "Weg 1", branding: null },
} as never);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({ userId: "user-1" } as never);
vi.mocked(prisma.courseBooking.count).mockResolvedValue(1 as never);
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_1",
amountRefundedCents: 2950,
});
});
describe("Stripe-Webhook – Signatur", () => {
it("akzeptiert Events vom Connect-Endpoint mit dessen eigenem Secret", async () => {
const res = await deliver({ type: "customer.created", data: { object: {} } }, "whsec_connect");
expect(res.status).toBe(200);
expect(stripe.webhooks.constructEvent).toHaveBeenCalledTimes(2);
});
it("lehnt ab, wenn kein Secret passt", async () => {
const res = await deliver({ type: "customer.created", data: { object: {} } }, "whsec_fremd");
expect(res.status).toBe(400);
});
});
describe("Stripe-Webhook – Buchung", () => {
it("bucht eine Direct Charge auf dem Studio-Account mit Gebühr obendrauf", async () => {
const res = await deliver(
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
"whsec_connect"
);
expect(res.status).toBe(200);
// Die application_fee liegt am PaymentIntent des Studio-Accounts.
expect(stripe.paymentIntents.retrieve).toHaveBeenCalledWith("pi_1", undefined, {
stripeAccount: "acct_studio",
});
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
paymentStatus: "paid",
stripeSessionId: "cs_1",
stripePaymentIntentId: "pi_1",
stripeAccountId: "acct_studio",
amountPaid: 2950,
platformFee: 50,
platformFeeOnTop: true,
});
expect(recordStripeFee).toHaveBeenCalledWith("pi_1", "acct_studio");
});
it("speichert die Rechnungsadresse aus dem Checkout an der Buchung", async () => {
const event = sessionEvent("checkout.session.completed", { account: "acct_studio" });
(event.data.object as Record<string, unknown>).customer_details = {
email: "kunde@example.com",
name: "Erika Mustermann",
address: {
line1: "Sandweg 91",
line2: null,
postal_code: "60316",
city: "Frankfurt",
country: "DE",
},
};
await deliver(event, "whsec_connect");
expect(tx.courseBooking.upsert.mock.calls[0][0].create.billingAddress).toEqual({
name: "Erika Mustermann",
line1: "Sandweg 91",
line2: null,
postalCode: "60316",
city: "Frankfurt",
country: "DE",
});
});
it("verbucht eine Session von vor der Umstellung als Altbuchung", async () => {
// Prozentuale Gebühr, Destination Charge auf der Plattform: kein
// event.account, kein platformFeeModel in den Metadaten.
const event = sessionEvent("checkout.session.completed");
delete (event.data.object.metadata as Record<string, string>).platformFeeModel;
await deliver(event);
expect(stripe.paymentIntents.retrieve).toHaveBeenCalledWith("pi_1", undefined, undefined);
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
stripeAccountId: null,
platformFeeOnTop: false,
});
// Bei Altbuchungen trug die Plattform die Stripe-Gebühr.
expect(recordStripeFee).not.toHaveBeenCalled();
});
it("bucht SEPA erst, wenn die verzögerte Zahlung angekommen ist", async () => {
await deliver(
sessionEvent("checkout.session.completed", {
account: "acct_studio",
paymentStatus: "unpaid",
}),
"whsec_connect"
);
expect(prisma.$transaction).not.toHaveBeenCalled();
await deliver(
sessionEvent("checkout.session.async_payment_succeeded", { account: "acct_studio" }),
"whsec_connect"
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
it("erstattet eine Überbuchung auf dem Studio-Account und gibt die Gebühr ans Studio zurück", async () => {
tx.courseBooking.count.mockResolvedValue(10);
await deliver(
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
"whsec_connect"
);
expect(refundStripePayment).toHaveBeenCalledWith({
paymentIntentId: "pi_1",
stripeAccountId: "acct_studio",
refundApplicationFee: true,
});
expect(recordStripeFee).not.toHaveBeenCalled();
});
});
describe("Stripe-Webhook – nie bezahlt und Gebühren", () => {
it("gibt bei gescheiterter SEPA-Zahlung die Rabatt-Reservierung frei", async () => {
await deliver(
sessionEvent("checkout.session.async_payment_failed", {
account: "acct_studio",
paymentStatus: "unpaid",
metadata: { discountReservationToken: "tok_1" },
}),
"whsec_connect"
);
expect(releaseDiscountReservation).toHaveBeenCalledWith(prisma, "tok_1");
});
it("trägt die Stripe-Gebühr aus charge.updated nach - nur für Direct Charges", async () => {
const charge = { payment_intent: "pi_1", balance_transaction: "txn_1" };
await deliver({ type: "charge.updated", account: "acct_studio", data: { object: charge } });
expect(recordStripeFeeForCharge).toHaveBeenCalledWith(charge, "acct_studio");
vi.mocked(recordStripeFeeForCharge).mockClear();
await deliver({ type: "charge.updated", data: { object: charge } });
expect(recordStripeFeeForCharge).not.toHaveBeenCalled();
});
});