stack/apps/e2e/tests/js/payments.test.ts
Aman Ganapathy 1de8a17183
Payments bulldozer txn rework (#1315)
### Object of this PR
This PR is NOT a monolithic series of fixes for the payments suite + a
complete rework. Its aims were
a) introducing and robustly testing the bulldozer db system 
b) reworking the payments underlying architecture to use bulldozer for
correctness and scalability
c) Achieving parity with the old payments system excepting a few changes
like ensuring correctness of the ledger algo
There may still be some work to do with handling refunds, decoupling the
concepts of purchases from that of products, and some other things.

### Ledger Algorithm
This has been tuned and fixed. Item removals i.e negative item quantity
changes will apply to the soonest expiring item grant i.e positive item
quantity change. This is what is best for the user. Item grants can also
expire, and when they expire we obviate whatever is left of their
original capacity (meaning after all the removals that were applied to
it). Our ledger algo is applied via Bulldozer, so automatic
re-computation is handled when a new grant/ removal is inserted in the
middle of the existing ones.

### Things we got rid of 
* No more automatic support for default products. You can use $0 plan
provisions to accomplish the same effect but it's manual
* Negative item quantity changes (i.e item removals) no longer can have
expiries



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced payment processing pipeline with improved data consistency
and state management.
  * Advanced refund handling with comprehensive transaction tracking.
* Better tracking and management of customer item quantities and owned
products.
* Improved subscription lifecycle management including period-end
handling.

* **Bug Fixes**
  * Fixed payment data integrity verification.
  * Improved handling of edge cases in refund scenarios.

* **Chores**
  * Updated cSpell configuration with additional words.
  * Expanded developer documentation for linting workflows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
Co-authored-by: Aadesh Kheria <kheriaaadesh@gmail.com>
Co-authored-by: Mantra <87142457+mantrakp04@users.noreply.github.com>
2026-04-17 22:11:21 +00:00

417 lines
15 KiB
TypeScript

import { it } from "../helpers";
import { withPortPrefix } from "../helpers/ports";
import { createApp } from "./js-helpers";
it("createCheckoutUrl supports optional returnUrl and embeds it", async ({ expect }) => {
const { clientApp, adminApp } = await createApp({ config: {} });
const project = await adminApp.getProject();
await adminApp.setupPayments();
await project.updateConfig({
"payments.offers.test-offer": {
displayName: "Test Offer",
customerType: "user",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
});
await clientApp.signUpWithCredential({ email: "checkout-return@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" });
await clientApp.signInWithCredential({ email: "checkout-return@test.com", password: "password" });
const user = await clientApp.getUser();
if (!user) throw new Error("User not found");
const url = await user.createCheckoutUrl({ productId: "test-offer", returnUrl: "http://stack-test.localhost/after" });
expect(url).toMatch(new RegExp(`^https?:\\/\\/localhost:${withPortPrefix("01")}\\/purchase\\/[a-z0-9-_]+\\?return_url=`));
const urlObj = new URL(url);
expect(urlObj.searchParams.get("return_url")).toBe("http://stack-test.localhost/after");
}, { timeout: 60_000 });
it("returns default item quantity for a team", async ({ expect }) => {
const { clientApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
const itemId = "test_item";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Test Item",
customerType: "team",
},
});
await clientApp.signUpWithCredential({
email: "test@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "test@test.com",
password: "password",
});
const user = await clientApp.getUser();
expect(user).not.toBeNull();
if (!user) throw new Error("User not found");
const team = await user.createTeam({ displayName: "Test Team" });
const item = await team.getItem(itemId);
expect(item.displayName).toBe("Test Item");
expect(item.quantity).toBe(0);
expect(item.nonNegativeQuantity).toBe(0);
}, {
timeout: 40_000,
});
it("root-level getItem works for user and team", async ({ expect }) => {
const { clientApp, adminApp } = await createApp({
config: { clientTeamCreationEnabled: true },
});
const project = await adminApp.getProject();
const itemId = "root_level_item";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Root Level Item",
customerType: "team",
},
});
await clientApp.signUpWithCredential({ email: "rl@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" });
await clientApp.signInWithCredential({ email: "rl@test.com", password: "password" });
const user = await clientApp.getUser();
if (!user) throw new Error("User not found");
const team = await user.createTeam({ displayName: "RL Team" });
const teamItem = await clientApp.getItem({ itemId, teamId: team.id });
expect(teamItem.quantity).toBe(0);
const userItemId = "root_level_user_item";
await project.updateConfig({
[`payments.items.${userItemId}`]: {
displayName: "Root Level User Item",
customerType: "user",
},
});
const userItem = await clientApp.getItem({ itemId: userItemId, userId: user.id });
expect(userItem.quantity).toBe(0);
}, { timeout: 60_000 });
it("customCustomerId is supported via root-level getItem and admin quantity change", async ({ expect }) => {
const { serverApp, clientApp, adminApp } = await createApp({
config: {},
});
const project = await adminApp.getProject();
const itemId = "custom_item_rl";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Custom RL Item",
customerType: "custom",
},
});
const customCustomerId = "custom-abc";
const before = await serverApp.getItem({ itemId, customCustomerId });
expect(before.quantity).toBe(0);
await adminApp.createItemQuantityChange({ customCustomerId, itemId, quantity: 5 });
const after = await serverApp.getItem({ itemId, customCustomerId });
expect(after.quantity).toBe(5);
}, { timeout: 60_000 });
it("admin can increase team item quantity and client sees updated value", async ({ expect }) => {
const { clientApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
const itemId = "test_item_inc";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Test Item Inc",
customerType: "team",
},
});
await clientApp.signUpWithCredential({
email: "inc@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({ email: "inc@test.com", password: "password" });
const user = await clientApp.getUser();
expect(user).not.toBeNull();
if (!user) throw new Error("User not found");
const team = await user.createTeam({ displayName: "Team Inc" });
const before = await team.getItem(itemId);
expect(before.quantity).toBe(0);
// Increase by 3 via admin API
await adminApp.createItemQuantityChange({ teamId: team.id, itemId, quantity: 3 });
const after = await team.getItem(itemId);
expect(after.quantity).toBe(3);
expect(after.nonNegativeQuantity).toBe(3);
}, { timeout: 40_000 });
it("cannot decrease team item quantity below zero", async ({ expect }) => {
const { clientApp, serverApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
const itemId = "test_item_dec";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Test Item Dec",
customerType: "team",
},
});
await clientApp.signUpWithCredential({
email: "dec@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({ email: "dec@test.com", password: "password" });
const user = await clientApp.getUser();
expect(user).not.toBeNull();
if (!user) throw new Error("User not found");
const team = await user.createTeam({ displayName: "Team Dec" });
const current = await team.getItem(itemId);
expect(current.quantity).toBe(0);
// Try to decrease by 1 (should fail with KnownErrors.ItemQuantityInsufficientAmount)
const item = await serverApp.getItem({ teamId: team.id, itemId });
const success = await item.tryDecreaseQuantity(1);
expect(success).toBe(false);
const still = await team.getItem(itemId);
expect(still.quantity).toBe(0);
}, { timeout: 40_000 });
it("client can cancel their own subscription", async ({ expect }) => {
const { clientApp, serverApp, adminApp } = await createApp({ config: {} });
const project = await adminApp.getProject();
await adminApp.setupPayments();
await project.updateConfig({
"payments.offers.pro-sub": {
displayName: "Pro Subscription",
customerType: "user",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
});
await clientApp.signUpWithCredential({ email: "cancel-sub@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" });
await clientApp.signInWithCredential({ email: "cancel-sub@test.com", password: "password" });
const user = await clientApp.getUser({ or: "throw" });
await serverApp.grantProduct({ userId: user.id, productId: "pro-sub" });
const before = await user.listProducts();
expect(before.some((p) => p.id === "pro-sub")).toBe(true);
await clientApp.cancelSubscription({ productId: "pro-sub" });
const after = await user.listProducts();
// Cancel marks the subscription as cancelAtPeriodEnd but doesn't end it immediately —
// the product stays owned until the subscription period ends.
expect(after.some((p) => p.id === "pro-sub")).toBe(true);
}, { timeout: 60_000 });
it("team admin can cancel a team's subscription", async ({ expect }) => {
const { clientApp, serverApp, adminApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
const project = await adminApp.getProject();
await adminApp.setupPayments();
await project.updateConfig({
"payments.offers.team-sub": {
displayName: "Team Subscription",
customerType: "team",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
});
await clientApp.signUpWithCredential({ email: "cancel-team-sub@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" });
await clientApp.signInWithCredential({ email: "cancel-team-sub@test.com", password: "password" });
const user = await clientApp.getUser({ or: "throw" });
const team = await user.createTeam({ displayName: "Cancel Team" });
await serverApp.grantProduct({ teamId: team.id, productId: "team-sub" });
const before = await team.listProducts();
expect(before.some((p) => p.id === "team-sub")).toBe(true);
await clientApp.cancelSubscription({ productId: "team-sub", teamId: team.id });
const after = await team.listProducts();
// Cancel marks the subscription as cancelAtPeriodEnd but doesn't end it immediately —
// the product stays owned until the subscription period ends.
expect(after.some((p) => p.id === "team-sub")).toBe(true);
}, { timeout: 60_000 });
it("can create item quantity change from server app", { timeout: 40_000 }, async ({ expect }) => {
const { serverApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
const itemId = "test_item_change";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Test Item Change",
customerType: "user",
},
});
const user = await serverApp.createUser({ primaryEmail: "test@test.com" });
const item = await user.getItem(itemId);
expect(item.quantity).toBe(0);
expect(item.nonNegativeQuantity).toBe(0);
expect(item.displayName).toBe("Test Item Change");
await item.increaseQuantity(2);
const newItem = await user.getItem(itemId);
expect(newItem.quantity).toBe(2);
await newItem.decreaseQuantity(1);
const newItem2 = await user.getItem(itemId);
expect(newItem2.quantity).toBe(1);
const resultSuccess = await newItem2.tryDecreaseQuantity(1);
expect(resultSuccess).toBe(true);
const newItem3 = await user.getItem(itemId);
expect(newItem3.quantity).toBe(0);
const resultFailure = await newItem3.tryDecreaseQuantity(1);
expect(resultFailure).toBe(false);
const newItem4 = await user.getItem(itemId);
expect(newItem4.quantity).toBe(0);
});
it("supports granting and listing customer products", { timeout: 60_000 }, async ({ expect }) => {
const { clientApp, serverApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
await project.updateConfig({
"payments.offers.test-offer": {
displayName: "Config Offer",
customerType: "user",
serverOnly: false,
stackable: true,
prices: { monthly: { USD: "1500", interval: [1, "month"] } },
includedItems: {},
},
});
await clientApp.signUpWithCredential({
email: "products@example.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "products@example.com",
password: "password",
});
const user = await clientApp.getUser();
if (!user) throw new Error("User not found");
await serverApp.grantProduct({ userId: user.id, productId: "test-offer", quantity: 2 });
const inlineUserProduct = {
display_name: "Inline User Offer",
customer_type: "user",
server_only: false,
stackable: true,
prices: {
onetime: { USD: "500" },
},
included_items: {},
} as const;
await serverApp.grantProduct({ userId: user.id, product: inlineUserProduct });
const allUserProducts = await serverApp.listProducts({ userId: user.id });
expect(allUserProducts).toHaveLength(2);
expect(allUserProducts.nextCursor).toBeNull();
const configGrant = allUserProducts.find((product) => product.displayName === "Config Offer");
expect(configGrant?.quantity).toBe(2);
const inlineGrant = allUserProducts.find((product) => product.displayName === inlineUserProduct.display_name);
expect(inlineGrant?.quantity).toBe(1);
const paginatedUserProducts = await serverApp.listProducts({ userId: user.id, limit: 1 });
expect(paginatedUserProducts).toHaveLength(1);
expect(paginatedUserProducts.nextCursor).not.toBeNull();
const nextPage = await serverApp.listProducts({ userId: user.id, cursor: paginatedUserProducts.nextCursor!, limit: 1 });
expect(nextPage).toHaveLength(1);
expect(nextPage.nextCursor).toBeNull();
const userProductsFromCustomer = await serverApp.listProducts({ userId: user.id });
expect(userProductsFromCustomer).toHaveLength(2);
const team = await user.createTeam({ displayName: "Products Team" });
const inlineTeamProduct = {
display_name: "Team Inline Offer",
customer_type: "team",
server_only: false,
stackable: true,
prices: {
quarterly: { USD: "2500" },
},
included_items: {},
} as const;
await serverApp.grantProduct({ teamId: team.id, product: inlineTeamProduct, quantity: 1 });
const teamProducts = await serverApp.listProducts({ teamId: team.id });
expect(teamProducts).toHaveLength(1);
expect(teamProducts[0].quantity).toBe(1);
expect(teamProducts[0].displayName).toBe(inlineTeamProduct.display_name);
const teamProductsFromCustomer = await serverApp.listProducts({ teamId: team.id });
expect(teamProductsFromCustomer).toHaveLength(1);
expect(teamProductsFromCustomer[0].displayName).toBe(inlineTeamProduct.display_name);
const customCustomerId = "custom-products-id";
const inlineCustomProduct = {
display_name: "Custom Inline Offer",
customer_type: "custom",
server_only: false,
stackable: false,
prices: {
yearly: { USD: "10000" },
},
included_items: {},
} as const;
await serverApp.grantProduct({ customCustomerId, product: inlineCustomProduct, quantity: 1 });
const customProducts = await serverApp.listProducts({ customCustomerId });
expect(customProducts).toHaveLength(1);
expect(customProducts[0].quantity).toBe(1);
expect(customProducts[0].displayName).toBe(inlineCustomProduct.display_name);
});