Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3164522
refactor/fix: remove default prod creation
nams1570 Apr 18, 2026
d3d0a0b
Implement migration to remove legacy `include-by-default` price senti…
mantrakp04 Apr 22, 2026
dca5096
Remove legacy invoice entries from dummy data seeding in `seed-dummy-…
mantrakp04 Apr 22, 2026
a7dab36
fix(payments): improve price validation and handling in product dialogs
mantrakp04 Apr 22, 2026
cb24516
fix(payments): drop dead include-by-default comparison in validate-code
mantrakp04 Apr 22, 2026
360c50e
fix(payments): enhance product price validation and subscription swit…
mantrakp04 Apr 22, 2026
65dd2d1
fix(payments): enhance subscription switching logic for free plans
mantrakp04 Apr 22, 2026
5279a49
chore(payments): drop dead include-by-default branches in dev-merged …
nams1570 May 5, 2026
6cbb3de
test(migrations): cover include-by-default snapshot rewrite
nams1570 May 5, 2026
c88bca3
refactor: error handling on poor product saves
nams1570 May 5, 2026
3d49b92
chore: clear up comments
nams1570 May 5, 2026
5286ce9
refactor: payments dashboard consistency changes
nams1570 May 5, 2026
07f38c0
fix: schema protections against non usd currencies
nams1570 May 5, 2026
51cbe5f
feat(schema-fuzzer): enhance price validation and add test for config…
mantrakp04 May 5, 2026
acdb502
feat(payments-demo): implement payments demo page and related API end…
mantrakp04 May 5, 2026
a256924
Merge branch 'dev' into remove-default-prod-support
mantrakp04 May 6, 2026
b798f43
Merge branch 'dev' into remove-default-prod-support
nams1570 May 12, 2026
ff32fc4
chore: switch schema tests away from default prrice
nams1570 May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Rewrite legacy `include-by-default` price sentinel in historical product JSON
-- snapshots to an empty price map, and coalesce any missing `includedItems` to
-- an empty record so downstream readers (e.g. mapProductSnapshotToInlineProduct)
-- don't throw on legacy snapshots. Include-by-default was deprecated in the
-- bulldozer payments rework and is no longer supported.
--
-- Scale note: prod has ~5 products affected at the time of writing, so a
-- single-statement UPDATE inside Prisma's default migration transaction is fine.
-- If this ever needs to run against a larger affected row set, batch it or
-- split the migration so it runs outside a transaction.

UPDATE "Subscription"
SET "product" = jsonb_set(
jsonb_set("product"::jsonb, '{prices}', '{}'::jsonb),
'{includedItems}',
COALESCE("product"::jsonb->'includedItems', '{}'::jsonb)
)::json
WHERE "product"->>'prices' = 'include-by-default';

UPDATE "OneTimePurchase"
SET "product" = jsonb_set(
jsonb_set("product"::jsonb, '{prices}', '{}'::jsonb),
'{includedItems}',
COALESCE("product"::jsonb->'includedItems', '{}'::jsonb)
)::json
WHERE "product"->>'prices' = 'include-by-default';

UPDATE "ProductVersion"
SET "productJson" = jsonb_set(
jsonb_set("productJson"::jsonb, '{prices}', '{}'::jsonb),
'{includedItems}',
COALESCE("productJson"::jsonb->'includedItems', '{}'::jsonb)
)::json
WHERE "productJson"->>'prices' = 'include-by-default';
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';

/**
* Migration-level test for `20260421000000_drop_include_by_default_snapshots`.
*
* The migration's job is to rewrite historical product JSON snapshots in
* three tables (`Subscription`, `OneTimePurchase`, `ProductVersion`) so that
* the legacy `"include-by-default"` price sentinel is replaced with an empty
* price record, and any missing `includedItems` field is filled in with `{}`
* (downstream readers like `mapProductSnapshotToInlineProduct` assume both
* fields exist as records).
*
* Edge cases covered:
* 1. `Subscription`: sentinel + missing `includedItems` → prices `{}`, items `{}`.
* 2. `Subscription`: sentinel + existing `includedItems` → items preserved.
* 3. `Subscription`: NO sentinel (real prices) → row untouched.
* 4. `OneTimePurchase`: sentinel → migrated identically to Subscription.
* 5. `ProductVersion`: sentinel (in `productJson` not `product`) → migrated.
*
* `tenancyId` on these tables is a UUID column without an enforced FK to
* `Tenancy`, so we can use random UUIDs without seeding the parent rows.
*/

type Ctx = {
// Subscription IDs
subSentinelMissingItemsId: string,
subSentinelWithItemsId: string,
subRealPricesId: string,
subSentinelMissingItemsTenancy: string,
subSentinelWithItemsTenancy: string,
subRealPricesTenancy: string,
// OneTimePurchase
otpId: string,
otpTenancy: string,
// ProductVersion
pvProductVersionId: string,
pvTenancy: string,
};

export const preMigration = async (sql: Sql): Promise<Ctx> => {
const ctx: Ctx = {
subSentinelMissingItemsId: randomUUID(),
subSentinelWithItemsId: randomUUID(),
subRealPricesId: randomUUID(),
subSentinelMissingItemsTenancy: randomUUID(),
subSentinelWithItemsTenancy: randomUUID(),
subRealPricesTenancy: randomUUID(),
otpId: randomUUID(),
otpTenancy: randomUUID(),
pvProductVersionId: `pv-${randomUUID()}`,
pvTenancy: randomUUID(),
};

// Case 1: Subscription with sentinel + no includedItems field at all.
// `updatedAt` must be set explicitly — Prisma's `@updatedAt` annotation is
// client-side, raw SQL inserts skip it and the column is NOT NULL.
await sql`
INSERT INTO "Subscription" (
"id", "tenancyId", "customerId", "customerType",
"productId", "priceId", "product", "quantity",
"status", "currentPeriodStart", "currentPeriodEnd",
"cancelAtPeriodEnd", "creationSource", "updatedAt"
) VALUES (
${ctx.subSentinelMissingItemsId}::uuid,
${ctx.subSentinelMissingItemsTenancy}::uuid,
'customer-1', 'TEAM',
'legacy-default', NULL,
${sql.json({
displayName: 'Legacy Default',
customerType: 'team',
prices: 'include-by-default',
})},
1,
'active'::"SubscriptionStatus",
NOW(),
NOW() + interval '30 days',
false,
'API_GRANT'::"PurchaseCreationSource",
NOW()
)
`;

// Case 2: Subscription with sentinel + already-populated includedItems.
// The migration must NOT overwrite this — it only fills in when missing.
await sql`
INSERT INTO "Subscription" (
"id", "tenancyId", "customerId", "customerType",
"productId", "priceId", "product", "quantity",
"status", "currentPeriodStart", "currentPeriodEnd",
"cancelAtPeriodEnd", "creationSource", "updatedAt"
) VALUES (
${ctx.subSentinelWithItemsId}::uuid,
${ctx.subSentinelWithItemsTenancy}::uuid,
'customer-2', 'TEAM',
'legacy-default-2', NULL,
${sql.json({
displayName: 'Legacy Default With Items',
customerType: 'team',
prices: 'include-by-default',
includedItems: {
'item-a': { quantity: 5, repeat: 'never', expires: 'never' },
},
})},
1,
'active'::"SubscriptionStatus",
NOW(),
NOW() + interval '30 days',
false,
'API_GRANT'::"PurchaseCreationSource",
NOW()
)
`;

// Case 3: Subscription with REAL prices — must remain untouched.
await sql`
INSERT INTO "Subscription" (
"id", "tenancyId", "customerId", "customerType",
"productId", "priceId", "product", "quantity",
"status", "currentPeriodStart", "currentPeriodEnd",
"cancelAtPeriodEnd", "creationSource", "updatedAt"
) VALUES (
${ctx.subRealPricesId}::uuid,
${ctx.subRealPricesTenancy}::uuid,
'customer-3', 'USER',
'paid-plan', 'monthly',
${sql.json({
displayName: 'Paid Plan',
customerType: 'user',
prices: {
monthly: { USD: '10.00', interval: [1, 'month'], serverOnly: false },
},
includedItems: {},
})},
1,
'active'::"SubscriptionStatus",
NOW(),
NOW() + interval '30 days',
false,
'PURCHASE_PAGE'::"PurchaseCreationSource",
NOW()
)
`;

// Case 4: OneTimePurchase with sentinel.
await sql`
INSERT INTO "OneTimePurchase" (
"id", "tenancyId", "customerId", "customerType",
"productId", "priceId", "product", "quantity",
"creationSource"
) VALUES (
${ctx.otpId}::uuid,
${ctx.otpTenancy}::uuid,
'customer-4', 'USER',
'legacy-otp', NULL,
${sql.json({
displayName: 'Legacy OTP',
customerType: 'user',
prices: 'include-by-default',
})},
1,
'API_GRANT'::"PurchaseCreationSource"
)
`;

// Case 5: ProductVersion with sentinel (note: column is `productJson`, not `product`).
await sql`
INSERT INTO "ProductVersion" (
"tenancyId", "productVersionId", "productId", "productJson"
) VALUES (
${ctx.pvTenancy}::uuid,
${ctx.pvProductVersionId},
'legacy-pv',
${sql.json({
displayName: 'Legacy PV',
customerType: 'team',
prices: 'include-by-default',
})}
)
`;

return ctx;
};

export const postMigration = async (sql: Sql, ctx: Ctx) => {
// ---- Case 1 ----
const sub1 = await sql<Array<{ product: unknown }>>`
SELECT "product" FROM "Subscription"
WHERE "id" = ${ctx.subSentinelMissingItemsId}::uuid
`;
expect(sub1).toHaveLength(1);
expect(sub1[0].product).toEqual({
displayName: 'Legacy Default',
customerType: 'team',
prices: {},
includedItems: {},
});

// ---- Case 2 ----
const sub2 = await sql<Array<{ product: unknown }>>`
SELECT "product" FROM "Subscription"
WHERE "id" = ${ctx.subSentinelWithItemsId}::uuid
`;
expect(sub2).toHaveLength(1);
expect(sub2[0].product).toEqual({
displayName: 'Legacy Default With Items',
customerType: 'team',
prices: {},
includedItems: {
'item-a': { quantity: 5, repeat: 'never', expires: 'never' },
},
});

// ---- Case 3 (regression guard: don't touch real-price rows) ----
const sub3 = await sql<Array<{ product: unknown }>>`
SELECT "product" FROM "Subscription"
WHERE "id" = ${ctx.subRealPricesId}::uuid
`;
expect(sub3).toHaveLength(1);
expect(sub3[0].product).toEqual({
displayName: 'Paid Plan',
customerType: 'user',
prices: {
monthly: { USD: '10.00', interval: [1, 'month'], serverOnly: false },
},
includedItems: {},
});

// ---- Case 4 ----
const otp = await sql<Array<{ product: unknown }>>`
SELECT "product" FROM "OneTimePurchase"
WHERE "id" = ${ctx.otpId}::uuid
`;
expect(otp).toHaveLength(1);
expect(otp[0].product).toEqual({
displayName: 'Legacy OTP',
customerType: 'user',
prices: {},
includedItems: {},
});

// ---- Case 5 ----
const pv = await sql<Array<{ productJson: unknown }>>`
SELECT "productJson" FROM "ProductVersion"
WHERE "tenancyId" = ${ctx.pvTenancy}::uuid
AND "productVersionId" = ${ctx.pvProductVersionId}
`;
expect(pv).toHaveLength(1);
expect(pv[0].productJson).toEqual({
displayName: 'Legacy PV',
customerType: 'team',
prices: {},
includedItems: {},
});

// ---- Cross-table sanity: no row anywhere still has the sentinel ----
const remainingSubs = await sql`
SELECT 1 FROM "Subscription" WHERE "product"->>'prices' = 'include-by-default'
`;
const remainingOtps = await sql`
SELECT 1 FROM "OneTimePurchase" WHERE "product"->>'prices' = 'include-by-default'
`;
const remainingPvs = await sql`
SELECT 1 FROM "ProductVersion" WHERE "productJson"->>'prices' = 'include-by-default'
`;
expect(remainingSubs).toHaveLength(0);
expect(remainingOtps).toHaveLength(0);
expect(remainingPvs).toHaveLength(0);
};
3 changes: 1 addition & 2 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,7 @@ export async function seed() {
},
});
if (!existingGrowthSub) {
const growthPrices = growthProduct.prices === 'include-by-default' ? {} : growthProduct.prices;
const firstPriceId = Object.keys(growthPrices)[0] ?? null;
const firstPriceId = Object.keys(growthProduct.prices)[0] ?? null;
const now = new Date();
// Clone to ensure the stored JSON snapshot is independent of the config object
// (mirrors the pattern used in seed-dummy-data.ts).
Expand Down
Loading
Loading