mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
c01c052ac9
commit
3a5153f4db
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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") {
|
||||
|
||||
91
apps/backend/src/lib/payments/platform-fees.ts
Normal file
91
apps/backend/src/lib/payments/platform-fees.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
128
apps/e2e/tests/backend/helpers/payments.ts
Normal file
128
apps/e2e/tests/backend/helpers/payments.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user