mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-09 21:02:19 +08:00
<!--
Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md
-->
<!-- ELLIPSIS_HIDDEN -->
----
> [!IMPORTANT]
> Enhances payments system with stackable items, Stripe account
management, and improved purchase flow, including schema updates and new
tests.
>
> - **Behavior**:
> - Adds quantity support for stackable offers in
`apps/backend/src/lib/payments.tsx` and
`apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx`.
> - Introduces Stripe account info viewing and onboarding in
`apps/backend/src/app/api/latest/internal/payments/stripe/account-info/route.ts`.
> - Implements "Include by default" pricing and "Plans" group in
`apps/backend/prisma/seed.ts`.
> - **Schema Changes**:
> - Adds `quantity` and `offerId` columns to `Subscription` table in
`apps/backend/prisma/migrations/20250821212828_subscription_quantity/migration.sql`
and
`apps/backend/prisma/migrations/20250822203223_subscription_offer_id/migration.sql`.
> - Adds `stripeAccountId` column to `Project` table in
`apps/backend/prisma/migrations/20250825221947_stripe_account_id/migration.sql`.
> - **Improvements**:
> - Enhances purchase flow to return Stripe `client_secret` and handle
subscription upgrades/downgrades in
`apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx`.
> - Updates item management with new actions and protections in
`apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts`.
> - Tightens validation for customer type and offer conflicts in
`apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts`.
> - **Testing**:
> - Adds extensive tests for new payment features in
`apps/e2e/tests/backend/endpoints/api/v1/internal/payments/setup.test.ts`
and
`apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts`.
> - **Misc**:
> - Removes unused `stripeAccountId` and `stripeAccountSetupComplete`
from `branchPaymentsSchema` in
`packages/stack-shared/src/config/schema.ts`.
> - Refactors currency constants into `currency-constants.tsx` in
`packages/stack-shared/src/utils`.
>
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 264563541d. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>
----
<!-- ELLIPSIS_HIDDEN -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **New Features**
- Quantity support for stackable offers across checkout, test purchases,
and admin flows.
- View Stripe account info and interactive payments onboarding per
project.
- "Include-by-default" pricing and new "Plans" group (Free, Extra
Admins).
- **Improvements**
- Purchase flow returns Stripe client_secret and handles group-based
subscription upgrades/downgrades.
- Item management: Update Customer Quantity action, edit/delete
protections, and read-only form mode.
- Validation surfaces offer conflicts (already_bought_non_stackable,
conflicting_group_offers).
- **Changes**
- Default item quantities now start at 0 unless explicitly granted.
- Stripe account linkage is stored per project.
- **Tests**
- Expanded tests for quantities, stackable behavior, and group
transition scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
255 lines
7.6 KiB
TypeScript
255 lines
7.6 KiB
TypeScript
import { it } from "../../../../../helpers";
|
|
import { Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers";
|
|
|
|
|
|
it("should error on invalid code", async ({ expect }) => {
|
|
await Project.createAndSwitch();
|
|
const response = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: {
|
|
full_code: "invalid-code",
|
|
},
|
|
});
|
|
expect(response).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 404,
|
|
"body": {
|
|
"code": "VERIFICATION_CODE_NOT_FOUND",
|
|
"error": "The verification code does not exist for this project.",
|
|
},
|
|
"headers": Headers {
|
|
"x-stack-known-error": "VERIFICATION_CODE_NOT_FOUND",
|
|
<some fields may have been hidden>,
|
|
},
|
|
}
|
|
`);
|
|
});
|
|
|
|
it("should allow valid code and return offer data", async ({ expect }) => {
|
|
const { code } = await Payments.createPurchaseUrlAndGetCode();
|
|
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { full_code: code },
|
|
});
|
|
expect(validateResponse).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 200,
|
|
"body": {
|
|
"already_bought_non_stackable": false,
|
|
"conflicting_group_offers": [],
|
|
"offer": {
|
|
"customer_type": "user",
|
|
"display_name": "Test Offer",
|
|
"prices": {
|
|
"monthly": {
|
|
"USD": "1000",
|
|
"interval": [
|
|
1,
|
|
"month",
|
|
],
|
|
},
|
|
},
|
|
"stackable": false,
|
|
},
|
|
"project_id": "<stripped UUID>",
|
|
"stripe_account_id": <stripped field 'stripe_account_id'>,
|
|
},
|
|
"headers": Headers { <some fields may have been hidden> },
|
|
}
|
|
`);
|
|
});
|
|
|
|
it("should set already_bought_non_stackable when user already owns non-stackable offer", async ({ expect }) => {
|
|
await Project.createAndSwitch();
|
|
await Payments.setup();
|
|
await Project.updateConfig({
|
|
payments: {
|
|
offers: {
|
|
"test-offer": {
|
|
displayName: "Test Offer",
|
|
customerType: "user",
|
|
serverOnly: false,
|
|
stackable: false,
|
|
prices: {
|
|
monthly: {
|
|
USD: "1000",
|
|
interval: [1, "month"],
|
|
},
|
|
},
|
|
includedItems: {},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const { userId } = await User.create();
|
|
// Create a code for test-offer and purchase it in test mode (creates DB subscription)
|
|
const createUrlRes1 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: {
|
|
customer_type: "user",
|
|
customer_id: userId,
|
|
offer_id: "test-offer",
|
|
},
|
|
});
|
|
expect(createUrlRes1.status).toBe(200);
|
|
const code1 = (createUrlRes1.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
|
|
expect(code1).toBeDefined();
|
|
|
|
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
|
|
method: "POST",
|
|
accessType: "admin",
|
|
body: {
|
|
full_code: code1,
|
|
price_id: "monthly",
|
|
quantity: 1,
|
|
},
|
|
});
|
|
expect(testModeRes.status).toBe(200);
|
|
|
|
// Create a second code for the same offer and validate; should report already_bought_non_stackable
|
|
const createUrlRes2 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: {
|
|
customer_type: "user",
|
|
customer_id: userId,
|
|
offer_id: "test-offer",
|
|
},
|
|
});
|
|
expect(createUrlRes2.status).toBe(200);
|
|
const code2 = (createUrlRes2.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
|
|
expect(code2).toBeDefined();
|
|
|
|
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { full_code: code2 },
|
|
});
|
|
expect(validateResponse).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 200,
|
|
"body": {
|
|
"already_bought_non_stackable": true,
|
|
"conflicting_group_offers": [],
|
|
"offer": {
|
|
"customer_type": "user",
|
|
"display_name": "Test Offer",
|
|
"prices": {
|
|
"monthly": {
|
|
"USD": "1000",
|
|
"interval": [
|
|
1,
|
|
"month",
|
|
],
|
|
},
|
|
},
|
|
"stackable": false,
|
|
},
|
|
"project_id": "<stripped UUID>",
|
|
"stripe_account_id": <stripped field 'stripe_account_id'>,
|
|
},
|
|
"headers": Headers { <some fields may have been hidden> },
|
|
}
|
|
`);
|
|
});
|
|
|
|
it("should include conflicting_group_offers when switching within the same group", async ({ expect }) => {
|
|
await Project.createAndSwitch();
|
|
await Payments.setup();
|
|
await Project.updateConfig({
|
|
payments: {
|
|
groups: { grp: { displayName: "Group" } },
|
|
offers: {
|
|
offerA: {
|
|
displayName: "Offer A",
|
|
customerType: "user",
|
|
serverOnly: false,
|
|
groupId: "grp",
|
|
stackable: false,
|
|
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
|
|
includedItems: {},
|
|
},
|
|
offerB: {
|
|
displayName: "Offer B",
|
|
customerType: "user",
|
|
serverOnly: false,
|
|
groupId: "grp",
|
|
stackable: false,
|
|
prices: { monthly: { USD: "2000", interval: [1, "month"] } },
|
|
includedItems: {},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const { userId } = await User.create();
|
|
|
|
// Subscribe to offerA in test mode
|
|
const resUrlA = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { customer_type: "user", customer_id: userId, offer_id: "offerA" },
|
|
});
|
|
expect(resUrlA.status).toBe(200);
|
|
const codeA = (resUrlA.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
|
|
expect(codeA).toBeDefined();
|
|
|
|
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
|
|
method: "POST",
|
|
accessType: "admin",
|
|
body: { full_code: codeA, price_id: "monthly", quantity: 1 },
|
|
});
|
|
expect(testModeRes.status).toBe(200);
|
|
|
|
// Now validate code for offerB; should report conflict with offerA
|
|
const resUrlB = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { customer_type: "user", customer_id: userId, offer_id: "offerB" },
|
|
});
|
|
expect(resUrlB.status).toBe(200);
|
|
const codeB = (resUrlB.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
|
|
expect(codeB).toBeDefined();
|
|
|
|
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { full_code: codeB },
|
|
});
|
|
expect(validateResponse).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 200,
|
|
"body": {
|
|
"already_bought_non_stackable": false,
|
|
"conflicting_group_offers": [
|
|
{
|
|
"display_name": "Offer A",
|
|
"offer_id": "offerA",
|
|
},
|
|
],
|
|
"offer": {
|
|
"customer_type": "user",
|
|
"display_name": "Offer B",
|
|
"prices": {
|
|
"monthly": {
|
|
"USD": "2000",
|
|
"interval": [
|
|
1,
|
|
"month",
|
|
],
|
|
},
|
|
},
|
|
"stackable": false,
|
|
},
|
|
"project_id": "<stripped UUID>",
|
|
"stripe_account_id": <stripped field 'stripe_account_id'>,
|
|
},
|
|
"headers": Headers { <some fields may have been hidden> },
|
|
}
|
|
`);
|
|
});
|