feat(payments): collect 0.9% platform fee on every stripe money movement (#1378)

## Summary

Charges the platform 0.9% on both legs of each transaction on
non-internal projects.

- **Charge leg** — rides along via Stripe's native
\`application_fee_amount\` / \`application_fee_percent\` params on the
PaymentIntent / Subscription.
- **Refund leg** — Stripe's default reverses our charge-leg fee on
refund, netting us zero. We disable that with \`refund_application_fee:
false\`

## Refs
-
https://docs.stripe.com/api/subscriptions/create#create_subscription-application_fee_percent
-
https://docs.stripe.com/api/payment_intents/object#payment_intent_object-application_fee_amount

---------

Co-authored-by: nams1570 <amanganapathy@gmail.com>
This commit is contained in:
Mantra 2026-05-05 09:22:53 -07:00 committed by GitHub
parent c01c052ac9
commit 3a5153f4db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 304 additions and 143 deletions

View File

@ -10,11 +10,35 @@ import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yup
import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies";
import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import type Stripe from "stripe";
import { InferType } from "yup";
const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD")
?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES");
/**
* Builds the parameters object for `stripe.refunds.create`. Centralised so the
* platform-fee invariant that we never let Stripe reverse our charge-leg
* 0.9% application fee on refund has exactly one source of truth and one
* place to test.
*
* Stripe's default for `refund_application_fee` on a Connect direct charge is
* `true`, which proportionally reverses the application fee along with the
* refund. We always set it to `false` so the platform retains its cut.
*/
export function buildStripeRefundParams(args: {
paymentIntentId: string,
amountStripeUnits: number,
metadata?: Record<string, string>,
}): Stripe.RefundCreateParams {
return {
payment_intent: args.paymentIntentId,
amount: args.amountStripeUnits,
...(args.metadata ? { metadata: args.metadata } : {}),
refund_application_fee: false,
};
}
function getTotalUsdStripeUnits(options: { product: InferType<typeof productSchema>, priceId: string | null, quantity: number }) {
const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null);
const usdPrice = selectedPrice?.USD;
@ -262,10 +286,10 @@ export const POST = createSmartRouteHandler({
if (refundAmountStripeUnits > totalStripeUnits) {
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
}
await stripe.refunds.create({
payment_intent: paymentIntentId,
amount: refundAmountStripeUnits,
});
await stripe.refunds.create(buildStripeRefundParams({
paymentIntentId,
amountStripeUnits: refundAmountStripeUnits,
}));
const refundedAt = new Date();
if (refundedQuantity > 0) {
if (!subscription.stripeSubscriptionId) {
@ -363,14 +387,14 @@ export const POST = createSmartRouteHandler({
if (refundAmountStripeUnits > totalStripeUnits) {
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
}
await stripe.refunds.create({
payment_intent: purchase.stripePaymentIntentId,
amount: refundAmountStripeUnits,
await stripe.refunds.create(buildStripeRefundParams({
paymentIntentId: purchase.stripePaymentIntentId,
amountStripeUnits: refundAmountStripeUnits,
metadata: {
tenancyId: auth.tenancy.id,
purchaseId: purchase.id,
},
});
}));
const refundedAt = new Date();
await prisma.oneTimePurchase.update({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
@ -405,3 +429,31 @@ export const POST = createSmartRouteHandler({
};
},
});
import.meta.vitest?.describe("buildStripeRefundParams", (test) => {
test("always sets refund_application_fee: false to keep our 0.9% with the platform", ({ expect }) => {
const params = buildStripeRefundParams({ paymentIntentId: "pi_test", amountStripeUnits: 5000 });
expect(params.refund_application_fee).toBe(false);
});
test("propagates payment_intent and amount as-is", ({ expect }) => {
const params = buildStripeRefundParams({ paymentIntentId: "pi_abc", amountStripeUnits: 1234 });
expect(params.payment_intent).toBe("pi_abc");
expect(params.amount).toBe(1234);
});
test("propagates metadata when provided and omits the key when not", ({ expect }) => {
const withMeta = buildStripeRefundParams({
paymentIntentId: "pi_x",
amountStripeUnits: 1,
metadata: { tenancyId: "t1", purchaseId: "p1" },
});
expect(withMeta.metadata).toEqual({ tenancyId: "t1", purchaseId: "p1" });
// refund_application_fee invariant must hold even when metadata is set —
// pin this explicitly so a future change to the metadata branch can't
// accidentally strip the fee flag.
expect(withMeta.refund_application_fee).toBe(false);
const withoutMeta = buildStripeRefundParams({ paymentIntentId: "pi_x", amountStripeUnits: 1 });
expect("metadata" in withoutMeta).toBe(false);
expect(withoutMeta.refund_application_fee).toBe(false);
});
});

View File

@ -2,6 +2,7 @@ import { SubscriptionStatus } from "@/generated/prisma/client";
import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, isActiveSubscription, isAddOnProduct } from "@/lib/payments";
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
import { getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees";
import { upsertProductVersion } from "@/lib/product-versions";
import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe";
import { getPrismaClientForTenancy } from "@/prisma-client";
@ -204,6 +205,11 @@ export const POST = createSmartRouteHandler({
throw new StackAssertionError("Stripe subscription has no items", { subscriptionId: existingSub.id });
}
const existingItem = existingStripeSub.items.data[0];
// Intentional: switching an existing (possibly pre-platform-fee)
// subscription to a new plan attaches the 0.9% application fee from
// this point forward. Subscriptions that never switch plans stay
// fee-less until a separate migration applies fees retroactively.
const applicationFeePercent = getApplicationFeePercentOrUndefined(auth.tenancy.project.id);
const updated = await stripe.subscriptions.update(existingSub.stripeSubscriptionId, {
payment_behavior: "error_if_incomplete",
payment_settings: { save_default_payment_method: "on_subscription" },
@ -226,6 +232,7 @@ export const POST = createSmartRouteHandler({
productVersionId,
priceId: selectedPriceId,
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
const updatedSubscription = updated as Stripe.Subscription;
const sanitizedUpdateDates = sanitizeStripePeriodDates(
@ -261,6 +268,7 @@ export const POST = createSmartRouteHandler({
// DEPRECATED: this path handles switching from include-by-default (free) products
// to paid subscriptions. Default products are being removed; this code is kept
// for backward compatibility only.
const applicationFeePercent = getApplicationFeePercentOrUndefined(auth.tenancy.project.id);
const created = await stripe.subscriptions.create({
customer: stripeCustomer.id,
payment_behavior: "error_if_incomplete",
@ -283,6 +291,7 @@ export const POST = createSmartRouteHandler({
productVersionId,
priceId: selectedPriceId,
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
const createdSubscription = created as Stripe.Subscription;
if (createdSubscription.items.data.length === 0) {

View File

@ -1,6 +1,7 @@
import { SubscriptionStatus } from "@/generated/prisma/client";
import { getClientSecretFromStripeSubscription, validatePurchaseSession } from "@/lib/payments";
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { computeApplicationFeeAmount, getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees";
import { upsertProductVersion } from "@/lib/product-versions";
import { getStripeForAccount } from "@/lib/stripe";
import { getTenancy } from "@/lib/tenancies";
@ -92,6 +93,7 @@ export const POST = createSmartRouteHandler({
const existingItem = existingStripeSub.items.data[0];
const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" });
if (selectedPrice.interval) {
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
const updated = await stripe.subscriptions.update(conflicting.stripeSubscriptionId, {
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
@ -114,6 +116,7 @@ export const POST = createSmartRouteHandler({
productVersionId,
priceId: price_id,
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
const clientSecretUpdated = getClientSecretFromStripeSubscription(updated);
await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
@ -145,6 +148,10 @@ export const POST = createSmartRouteHandler({
// One-time payment path after conflicts handled
if (!selectedPrice.interval) {
const amountCents = Number(selectedPrice.USD) * 100 * Math.max(1, quantity);
const applicationFeeAmount = computeApplicationFeeAmount({
amountStripeUnits: amountCents,
projectId: tenancy.project.id,
});
const paymentIntent = await stripe.paymentIntents.create({
amount: amountCents,
currency: "usd",
@ -160,6 +167,7 @@ export const POST = createSmartRouteHandler({
tenancyId: data.tenancyId,
priceId: price_id,
},
...(applicationFeeAmount > 0 ? { application_fee_amount: applicationFeeAmount } : {}),
});
const clientSecret = paymentIntent.client_secret;
if (typeof clientSecret !== "string") {
@ -172,6 +180,7 @@ export const POST = createSmartRouteHandler({
const product = await stripe.products.create({
name: data.product.displayName ?? "Subscription",
});
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
const created = await stripe.subscriptions.create({
customer: data.stripeCustomerId,
payment_behavior: 'default_incomplete',
@ -194,6 +203,7 @@ export const POST = createSmartRouteHandler({
productVersionId,
priceId: price_id,
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
const clientSecret = getClientSecretFromStripeSubscription(created);
if (typeof clientSecret !== "string") {

View File

@ -0,0 +1,91 @@
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
// 0.9% of every Stripe money movement on a non-internal project is collected
// as a platform fee, ridden along via Stripe's native application_fee_*
// params on the PaymentIntent / Subscription. Refunds keep our charge-leg
// fee with the platform via `refund_application_fee: false` at the refund
// site — there is no separate refund-leg collection.
//
// Stored as basis points (1 bps = 1/10000 = 0.01%) instead of a decimal
// percentage so all fee math is integer arithmetic — `0.9 * 5000 / 100` is
// `45.000000000000004` in IEEE-754, but `90 * 5000 / 10000` is exactly `45`.
export const APPLICATION_FEE_BPS = 90;
export function getApplicationFeeBps(projectId: string): number {
if (projectId === "internal") return 0;
return APPLICATION_FEE_BPS;
}
/**
* Half-to-nearest rounding. Stripe's `application_fee_amount` is an integer
* in stripe-units, so we can't represent 0.9% exactly when the charge isn't
* a multiple of $10. Round-nearest is unbiased on average over many
* charges the over- and under-rounding cancel at the cost of producing a
* 0 fee on charges in Stripe's min-charge band ($0.50$0.55) where 0.9%
* falls below half a cent. That clip-to-zero band is small enough to be
* acceptable lost revenue; the alternative (ceil) over-collects on every
* non-multiple-of-$10 charge, and a fractional-cents ledger is more
* complexity than the precision is worth here.
*/
export function computeApplicationFeeAmount(options: { amountStripeUnits: number, projectId: string }): number {
if (options.amountStripeUnits < 0) {
throwErr("computeApplicationFeeAmount received negative amount", { amountStripeUnits: options.amountStripeUnits });
}
const bps = getApplicationFeeBps(options.projectId);
if (bps === 0) return 0;
return Math.round(options.amountStripeUnits * bps / 10000);
}
/**
* Returns the fee as a decimal percent for Stripe's `application_fee_percent`
* (subscription) parameter, or `undefined` for projects that aren't billed.
*
* `bps / 100` is intentional float division the rest of the module uses
* integer arithmetic to avoid IEEE-754 noise on charge-amount math, but the
* subscription path requires a decimal because that's the shape Stripe's API
* accepts. This is safe for the current 90 bps ( 0.9, which serialises
* cleanly), and any future bps value must produce a number with at most 4
* decimal places after IEEE-754 rounding that's the maximum precision
* Stripe documents for `application_fee_percent`.
*/
export function getApplicationFeePercentOrUndefined(projectId: string): number | undefined {
const bps = getApplicationFeeBps(projectId);
if (bps === 0) return undefined;
return bps / 100;
}
import.meta.vitest?.describe("platform fee helpers", (test) => {
test("getApplicationFeeBps returns 0 for internal project", ({ expect }) => {
expect(getApplicationFeeBps("internal")).toBe(0);
});
test("getApplicationFeeBps returns APPLICATION_FEE_BPS for any other project", ({ expect }) => {
expect(getApplicationFeeBps("proj_abc123")).toBe(APPLICATION_FEE_BPS);
expect(getApplicationFeeBps("some-uuid")).toBe(APPLICATION_FEE_BPS);
});
test("computeApplicationFeeAmount is 0.9% of the charge, rounded half-to-nearest", ({ expect }) => {
expect(computeApplicationFeeAmount({ amountStripeUnits: 10000, projectId: "p" })).toBe(90);
expect(computeApplicationFeeAmount({ amountStripeUnits: 12345, projectId: "p" })).toBe(111);
expect(computeApplicationFeeAmount({ amountStripeUnits: 500000, projectId: "p" })).toBe(4500);
});
test("computeApplicationFeeAmount clips to 0 below the half-cent threshold (~$0.56)", ({ expect }) => {
// Documented tradeoff: charges in Stripe's min-charge band whose 0.9%
// is under half a cent round to a 0 fee. Pinned here so a future reader
// doesn't accidentally "fix" the clipping without weighing the
// alternatives (see the JSDoc on computeApplicationFeeAmount).
expect(computeApplicationFeeAmount({ amountStripeUnits: 50, projectId: "p" })).toBe(0);
expect(computeApplicationFeeAmount({ amountStripeUnits: 55, projectId: "p" })).toBe(0);
expect(computeApplicationFeeAmount({ amountStripeUnits: 56, projectId: "p" })).toBe(1);
});
test("computeApplicationFeeAmount is 0 for internal project even on large charges", ({ expect }) => {
expect(computeApplicationFeeAmount({ amountStripeUnits: 10000, projectId: "internal" })).toBe(0);
});
test("computeApplicationFeeAmount throws on negative amounts", ({ expect }) => {
expect(() => computeApplicationFeeAmount({ amountStripeUnits: -1, projectId: "p" })).toThrow(/negative amount/);
});
test("getApplicationFeePercentOrUndefined returns 0.9 for non-internal", ({ expect }) => {
expect(getApplicationFeePercentOrUndefined("proj_abc")).toBe(0.9);
});
test("getApplicationFeePercentOrUndefined returns undefined for internal", ({ expect }) => {
expect(getApplicationFeePercentOrUndefined("internal")).toBeUndefined();
});
});

View File

@ -1,141 +1,12 @@
import { randomUUID } from "node:crypto";
import { expect } from "vitest";
import { it } from "../../../../../helpers";
import { Auth, Payments, Project, niceBackendFetch } from "../../../../backend-helpers";
function createDefaultPaymentsConfig(testMode: boolean | undefined) {
return {
payments: {
testMode: testMode ?? true,
products: {
"sub-product": {
displayName: "Sub Product",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
monthly: { USD: "1000", interval: [1, "month"] },
},
includedItems: {},
},
"otp-product": {
displayName: "One-Time Product",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
single: { USD: "5000" },
},
includedItems: {},
},
},
items: {},
},
};
}
async function setupProjectWithPaymentsConfig(options: { testMode?: boolean } = {}) {
await Project.createAndSwitch();
await Payments.setup();
const config = createDefaultPaymentsConfig(options.testMode);
await Project.updateConfig(config);
return config;
}
async function createPurchaseCode(options: { userId: string, productId: string }) {
const res = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: options.userId,
product_id: options.productId,
},
});
expect(res.status).toBe(200);
const codeMatch = (res.body.url as string).match(/\/purchase\/([a-z0-9-_]+)/);
const code = codeMatch ? codeMatch[1] : undefined;
expect(code).toBeDefined();
return code as string;
}
async function createTestModeTransaction(productId: string, priceId: string) {
const { userId } = await Auth.fastSignUp();
const code = await createPurchaseCode({ userId, productId });
const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
accessType: "admin",
method: "POST",
body: { full_code: code, price_id: priceId, quantity: 1 },
});
expect(response.status).toBe(200);
const transactions = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
});
expect(transactions.status).toBe(200);
expect(transactions.body.transactions.length).toBeGreaterThan(0);
const transaction = transactions.body.transactions[0];
return { transactionId: transaction.id, userId };
}
async function createLiveModeOneTimePurchaseTransaction(options: { quantity?: number } = {}) {
const config = await setupProjectWithPaymentsConfig({ testMode: false });
const { userId } = await Auth.fastSignUp();
const quantity = options.quantity ?? 1;
const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", {
accessType: "admin",
});
expect(accountInfo.status).toBe(200);
const accountId: string = accountInfo.body.account_id;
const code = await createPurchaseCode({ userId, productId: "otp-product" });
const stackTestTenancyId = code.split("_")[0];
const product = config.payments.products["otp-product"];
const idSuffix = randomUUID().replace(/-/g, "");
const eventId = `evt_otp_refund_${idSuffix}`;
const paymentIntentId = `pi_otp_refund_${idSuffix}`;
const paymentIntentPayload = {
id: eventId,
type: "payment_intent.succeeded",
account: accountId,
data: {
object: {
id: paymentIntentId,
customer: userId,
stack_stripe_mock_data: {
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
"customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } },
"subscriptions.list": { data: [] },
},
metadata: {
productId: "otp-product",
product: JSON.stringify(product),
customerId: userId,
customerType: "user",
purchaseQuantity: String(quantity),
purchaseKind: "ONE_TIME",
priceId: "single",
},
},
},
};
const webhookSecret = process.env.STACK_STRIPE_WEBHOOK_SECRET ?? "mock_stripe_webhook_secret";
const webhookRes = await Payments.sendStripeWebhook(paymentIntentPayload, { secret: webhookSecret });
expect(webhookRes.status).toBe(200);
expect(webhookRes.body).toEqual({ received: true });
const transactionsRes = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
});
expect(transactionsRes.status).toBe(200);
const purchaseTransaction = transactionsRes.body.transactions.find((tx: any) => tx.type === "purchase");
expect(purchaseTransaction).toBeDefined();
return { userId, transactionsRes, purchaseTransaction };
}
import { niceBackendFetch } from "../../../../backend-helpers";
import {
createLiveModeOneTimePurchaseTransaction,
createTestModeTransaction,
setupProjectWithPaymentsConfig,
} from "../../../../helpers/payments";
it("returns TestModePurchaseNonRefundable when refunding test mode one-time purchases", async () => {
await setupProjectWithPaymentsConfig();

View File

@ -0,0 +1,128 @@
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { randomUUID } from "node:crypto";
import { expect } from "vitest";
import { Auth, Payments, Project, niceBackendFetch } from "../backend-helpers";
export function createDefaultPaymentsConfig(testMode: boolean | undefined) {
return {
payments: {
testMode: testMode ?? true,
products: {
"otp-product": {
displayName: "One-Time Product",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
single: { USD: "5000" },
},
includedItems: {},
},
},
items: {},
},
};
}
export async function setupProjectWithPaymentsConfig(options: { testMode?: boolean } = {}) {
await Project.createAndSwitch();
await Payments.setup();
const config = createDefaultPaymentsConfig(options.testMode);
await Project.updateConfig(config);
return config;
}
export async function createPurchaseCode(options: { userId: string, productId: string }) {
const res = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: options.userId,
product_id: options.productId,
},
});
expect(res.status).toBe(200);
const codeMatch = (res.body.url as string).match(/\/purchase\/([a-z0-9-_]+)/);
const code = codeMatch ? codeMatch[1] : undefined;
expect(code).toBeDefined();
return code as string;
}
export async function createTestModeTransaction(productId: string, priceId: string) {
const { userId } = await Auth.fastSignUp();
const code = await createPurchaseCode({ userId, productId });
const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
accessType: "admin",
method: "POST",
body: { full_code: code, price_id: priceId, quantity: 1 },
});
expect(response.status).toBe(200);
const transactions = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
});
expect(transactions.status).toBe(200);
expect(transactions.body.transactions.length).toBeGreaterThan(0);
const transaction = transactions.body.transactions[0];
return { transactionId: transaction.id, userId };
}
export async function createLiveModeOneTimePurchaseTransaction(options: { quantity?: number } = {}) {
const config = await setupProjectWithPaymentsConfig({ testMode: false });
const { userId } = await Auth.fastSignUp();
const quantity = options.quantity ?? 1;
const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", {
accessType: "admin",
});
expect(accountInfo.status).toBe(200);
const accountId: string = accountInfo.body.account_id;
const code = await createPurchaseCode({ userId, productId: "otp-product" });
const stackTestTenancyId = code.split("_")[0];
const product = config.payments.products["otp-product"];
const idSuffix = randomUUID().replace(/-/g, "");
const eventId = `evt_otp_purchase_${idSuffix}`;
const paymentIntentId = `pi_otp_purchase_${idSuffix}`;
const paymentIntentPayload = {
id: eventId,
type: "payment_intent.succeeded",
account: accountId,
data: {
object: {
id: paymentIntentId,
customer: userId,
stack_stripe_mock_data: {
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
"customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } },
"subscriptions.list": { data: [] },
},
metadata: {
productId: "otp-product",
product: JSON.stringify(product),
customerId: userId,
customerType: "user",
purchaseQuantity: String(quantity),
purchaseKind: "ONE_TIME",
priceId: "single",
},
},
},
};
const webhookSecret = getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET", "mock_stripe_webhook_secret");
const webhookRes = await Payments.sendStripeWebhook(paymentIntentPayload, { secret: webhookSecret });
expect(webhookRes.status).toBe(200);
expect(webhookRes.body).toEqual({ received: true });
const transactionsRes = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
});
expect(transactionsRes.status).toBe(200);
const purchaseTransaction = transactionsRes.body.transactions.find((tx: any) => tx.type === "purchase");
expect(purchaseTransaction).toBeDefined();
return { userId, transactionsRes, purchaseTransaction };
}