stack/apps/backend/src/lib/payments.test.tsx
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

156 lines
6.6 KiB
TypeScript

import { KnownErrors } from '@stackframe/stack-shared';
import { describe, expect, it } from 'vitest';
import { validatePurchaseSession } from './payments';
import { bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { globalPrismaClient } from "@/prisma-client";
// Uses globalPrismaClient which connects to the real dev DB (with BulldozerStorageEngine).
// customerType: 'custom' avoids needing a real ProjectUser/Team in the DB.
// Each test writes data to Bulldozer stored tables via the dual-write functions,
// then calls validatePurchaseSession which reads from the owned products LFold.
describe.sequential('validatePurchaseSession - purchase guards (real DB)', () => {
const prisma = globalPrismaClient;
const testId = Math.random().toString(36).slice(2, 8);
const tenancyId = `test-tenancy-${testId}`;
const customerId = `test-customer-${testId}`;
const makeProduct = (overrides: Record<string, unknown> = {}) => ({
displayName: 'Test Product',
productLineId: null as string | null,
customerType: 'custom' as const,
prices: { p1: { USD: '10' } },
includedItems: {},
isAddOnTo: false as false | Record<string, true>,
stackable: false,
...overrides,
});
const grantOtp = async (id: string, productId: string, product: ReturnType<typeof makeProduct>) => {
await bulldozerWriteOneTimePurchase(prisma as any, {
id, tenancyId, customerId, customerType: 'CUSTOM',
productId, priceId: null, product: product as any, quantity: 1,
stripePaymentIntentId: null, revokedAt: null, refundedAt: null,
creationSource: 'TEST_MODE', createdAt: new Date(),
});
};
const grantSub = async (id: string, productId: string, product: ReturnType<typeof makeProduct>) => {
await bulldozerWriteSubscription(prisma as any, {
id, tenancyId, customerId, customerType: 'CUSTOM',
productId, priceId: null, product: product as any, quantity: 1,
stripeSubscriptionId: `stripe-${id}`, status: 'active',
currentPeriodStart: new Date(), currentPeriodEnd: new Date(Date.now() + 86400000),
cancelAtPeriodEnd: false, canceledAt: null, endedAt: null, refundedAt: null,
creationSource: 'TEST_MODE', createdAt: new Date(),
});
};
const callValidate = (product: ReturnType<typeof makeProduct>, overrides: Record<string, unknown> = {}) =>
validatePurchaseSession({
prisma: prisma as any,
tenancyId,
customerType: 'custom',
customerId,
product: product as any,
productId: `prod-new-${testId}`,
priceId: undefined,
quantity: 1,
...overrides,
});
it('blocks non-stackable product if customer already owns it', async () => {
const prodId = `prod-dup-${testId}`;
await grantOtp(`otp-dup-${testId}`, prodId, makeProduct());
await expect(callValidate(makeProduct(), { productId: prodId })).rejects.toThrowError(/already owns/);
});
it('allows stackable product even if customer already owns it', async () => {
const prodId = `prod-stack-${testId}`;
await grantOtp(`otp-stack-${testId}`, prodId, makeProduct({ stackable: true }));
const res = await callValidate(makeProduct({ stackable: true }), { productId: prodId });
expect(res.selectedPrice).toBeDefined();
});
it('blocks non-stackable quantity > 1', async () => {
await expect(callValidate(makeProduct(), { quantity: 3 }))
.rejects.toThrowError('not stackable');
});
it('blocks purchase when OTP exists in same product line (no sub to cancel)', async () => {
const lineId = `line-block-${testId}`;
await grantOtp(`otp-line-${testId}`, `prod-in-line-${testId}`, makeProduct({ productLineId: lineId }));
await expect(callValidate(makeProduct({ productLineId: lineId }), { productId: `prod-other-${testId}` }))
.rejects.toThrowError('one-time purchase in this product line');
});
it('allows purchase when existing product is in different product line', async () => {
const res = await callValidate(
makeProduct({ productLineId: `line-different-${testId}` }),
{ productId: `prod-diff-${testId}` },
);
expect(res.conflictingSubscriptions).toHaveLength(0);
});
it('finds conflicting subscription in same product line', async () => {
const lineId = `line-conflict-${testId}`;
const subId = `sub-conflict-${testId}`;
await grantSub(subId, `prod-sub-${testId}`, makeProduct({ productLineId: lineId }));
const res = await callValidate(
makeProduct({ productLineId: lineId }),
{ productId: `prod-replace-${testId}` },
);
expect(res.conflictingSubscriptions).toHaveLength(1);
expect(res.conflictingSubscriptions[0].id).toBe(subId);
});
it('blocks add-on if base product not owned', async () => {
await expect(callValidate(makeProduct({ isAddOnTo: { [`base-${testId}`]: true } })))
.rejects.toThrowError('add-on');
});
it('allows add-on if base product is owned', async () => {
const baseId = `base-addon-${testId}`;
await grantOtp(`otp-base-${testId}`, baseId, makeProduct());
const res = await callValidate(makeProduct({ isAddOnTo: { [baseId]: true } }));
expect(res.selectedPrice).toBeDefined();
});
it('allows add-on in same product line as its base product', async () => {
const lineId = `line-addon-${testId}`;
const baseId = `base-sameline-${testId}`;
await grantOtp(`otp-sameline-${testId}`, baseId, makeProduct({ productLineId: lineId }));
const res = await callValidate(
makeProduct({ productLineId: lineId, isAddOnTo: { [baseId]: true } }),
{ productId: `addon-sameline-${testId}` },
);
expect(res.selectedPrice).toBeDefined();
expect(res.conflictingSubscriptions).toHaveLength(0);
});
// TODO: reconsider coupling — product-line blocking infers OTP vs subscription
// ownership. OTPs can be refunded, so "blocked because OTP" is debatable.
it('resolves first price when no priceId given', async () => {
const res = await callValidate(makeProduct({ prices: { p1: { USD: '10' }, p2: { USD: '20' } } }));
expect(res.selectedPrice).toBeDefined();
expect((res.selectedPrice as any).USD).toBe('10');
});
it('resolves specific priceId when given', async () => {
const res = await callValidate(
makeProduct({ prices: { p1: { USD: '10' }, p2: { USD: '20' } } }),
{ priceId: 'p2' },
);
expect(res.selectedPrice).toBeDefined();
expect((res.selectedPrice as any).USD).toBe('20');
});
it('rejects invalid priceId', async () => {
await expect(callValidate(
makeProduct({ prices: { p1: { USD: '10' } } }),
{ priceId: 'nonexistent' },
)).rejects.toThrowError('Price not found');
});
});