diff --git a/server/package.json b/server/package.json index 5e2aa8c5b..400e203e4 100644 --- a/server/package.json +++ b/server/package.json @@ -21,7 +21,7 @@ "workers": "bun src/workers.ts", "cron": "bun src/cron.ts", "check": "bun src/check.ts", - "t": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/testRunner/runParallelGroupsV3.ts", + "t": "cd ../ && bun t $* && cd ./server", "parallel-tests": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/testRunner/runParallelGroupsV3.ts", "parallel-tests:v1": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/testRunner/runParallelGroups.ts", "parallel-tests:verbose": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/testRunner/runParallelGroups.ts --verbose", diff --git a/server/src/internal/customers/internalHandlers/handleGetInvoiceLineItems.ts b/server/src/internal/customers/internalHandlers/handleGetInvoiceLineItems.ts index d7fc8833b..e4d9990b2 100644 --- a/server/src/internal/customers/internalHandlers/handleGetInvoiceLineItems.ts +++ b/server/src/internal/customers/internalHandlers/handleGetInvoiceLineItems.ts @@ -1,4 +1,4 @@ -import { Scopes, stripeToAtmnAmount } from "@autumn/shared"; +import { ProcessorType, Scopes, stripeToAtmnAmount } from "@autumn/shared"; import { z } from "zod/v4"; import { createStripeCli } from "@/external/connect/createStripeCli"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; @@ -32,10 +32,15 @@ export const handleGetInvoiceLineItems = createRoute({ org: ctx.org, env: ctx.env, }); - const autumnInvoices = await InvoiceService.getMany({ - db, - ids: invoice_ids, - }); + const autumnInvoices = ( + await InvoiceService.getMany({ + db, + ids: invoice_ids, + }) + ).filter( + (inv) => + (inv.processor_type ?? ProcessorType.Stripe) === ProcessorType.Stripe, + ); const stripeInvoices = []; for (const invoice of autumnInvoices) { // Throttle to avoid Stripe rate limits. diff --git a/server/src/internal/invoices/InvoiceService.ts b/server/src/internal/invoices/InvoiceService.ts index 6b3aea1f7..50b29486f 100644 --- a/server/src/internal/invoices/InvoiceService.ts +++ b/server/src/internal/invoices/InvoiceService.ts @@ -9,6 +9,7 @@ import { type InvoiceStatus, invoices, type Organization, + ProcessorType, RecaseError, stripeToAtmnAmount, } from "@autumn/shared"; @@ -33,6 +34,7 @@ export const processInvoice = ({ // product_ids: invoice.product_ids, plan_ids: invoice.product_ids, stripe_id: invoice.stripe_id, + processor_type: invoice.processor_type ?? ProcessorType.Stripe, status: invoice.status ?? "", total: invoice.total, currency: invoice.currency, @@ -227,6 +229,7 @@ export class InvoiceService { product_ids: uniqueProductIds, created_at: stripeInvoice.created * 1000, stripe_id: stripeInvoice.id!, + processor_type: ProcessorType.Stripe, hosted_invoice_url: stripeInvoice.hosted_invoice_url || null, status: status || (stripeInvoice.status as InvoiceStatus | null), internal_product_ids: uniqueInternalProductIds, diff --git a/server/src/internal/invoices/handlers/handleRedirectToInvoice.ts b/server/src/internal/invoices/handlers/handleRedirectToInvoice.ts index 96a73377b..b2177824b 100644 --- a/server/src/internal/invoices/handlers/handleRedirectToInvoice.ts +++ b/server/src/internal/invoices/handlers/handleRedirectToInvoice.ts @@ -1,4 +1,4 @@ -import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { ErrCode, ProcessorType, RecaseError, Scopes } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; @@ -28,6 +28,16 @@ export const handleRedirectToInvoice = createRoute({ }); } + if ( + (invoice.processor_type ?? ProcessorType.Stripe) !== ProcessorType.Stripe + ) { + throw new RecaseError({ + message: "Hosted invoice URL is not available for this invoice", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.NOT_FOUND, + }); + } + try { const org = invoice.customer.org; const env = invoice.customer.env; diff --git a/server/src/internal/invoices/utils/initInvoiceFromStripe.ts b/server/src/internal/invoices/utils/initInvoiceFromStripe.ts index 460120c84..f851f73c7 100644 --- a/server/src/internal/invoices/utils/initInvoiceFromStripe.ts +++ b/server/src/internal/invoices/utils/initInvoiceFromStripe.ts @@ -3,6 +3,7 @@ import { type FullCustomer, type FullProduct, type InsertInvoice, + ProcessorType, secondsToMs, stripeToAtmnAmount, } from "@autumn/shared"; @@ -62,6 +63,7 @@ export const initInvoiceFromStripe = async ({ internal_product_ids: [...new Set(internalProductIds)], created_at: secondsToMs(stripeInvoice.created), stripe_id: stripeInvoice.id!, + processor_type: ProcessorType.Stripe, hosted_invoice_url: stripeInvoice.hosted_invoice_url || null, status: stripeInvoice.status as string | undefined, internal_entity_id: internalEntityId || null, diff --git a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts index bd53751bd..0d4cba635 100644 --- a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts +++ b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts @@ -19,6 +19,7 @@ import { FeatureType, type InvoiceRow, InvoiceStatus, + ProcessorType, type SubscriptionRow, } from "@autumn/shared"; import { AllowanceType } from "@shared/models/productModels/entModels/entModels.js"; @@ -401,6 +402,7 @@ const buildInvoice = ({ internal_customer_id: customer.internal_id, internal_entity_id: internalEntityId, stripe_id: `stripe_inv_${key}_${suffix}`, + processor_type: ProcessorType.Stripe, status: InvoiceStatus.Paid, hosted_invoice_url: null, total: 1000, diff --git a/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts b/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts index eeccf028a..b8b97b431 100644 --- a/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts +++ b/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { type AggregatedFeatureBalanceSchema, AppEnv, + ProcessorType, ProductSchema, type SubjectBalance, } from "@autumn/shared"; @@ -142,6 +143,66 @@ describe("normalizeFromSchema (core walker)", () => { normalizeFromSchema({ schema, data: "not an object" }), ).not.toThrow(); }); + + // processor_type cache safety: confirms `.default()` fires symmetrically + // through both walkers. This is the core guarantee Option A relies on — + // Upstash cjson strips the field for old cached entries and the walker + // must hydrate it back to ProcessorType.Stripe so consumers see a defined + // value. The explicit-null case is documented as walker passthrough; the + // `?? ProcessorType.Stripe` consumer mask is what handles that. + + test("ZodDefault fires for ProcessorType enum on undefined (FullSubject walker)", () => { + const schema = z.object({ + processor_type: z.enum(ProcessorType).default(ProcessorType.Stripe), + }); + const result = normalizeFromSchema<{ processor_type: ProcessorType }>({ + schema, + data: {}, + }); + expect(result.processor_type).toBe(ProcessorType.Stripe); + }); + + test("ZodDefault passes through explicit null (FullSubject walker)", () => { + const schema = z.object({ + processor_type: z.enum(ProcessorType).default(ProcessorType.Stripe), + }); + const result = normalizeFromSchema<{ + processor_type: ProcessorType | null; + }>({ + schema, + data: { processor_type: null }, + }); + // Documents Option A's known limitation: `.default()` only fires for + // undefined. The consumer-side `?? ProcessorType.Stripe` (in + // processInvoice / latent breakage filters) covers this case. + expect(result.processor_type).toBeNull(); + }); + + test("ZodDefault does NOT fire on primitive leaves in cacheUtils walker (documented limitation)", () => { + // The FullCustomer / cacheUtils walker has no leaf-level ZodDefault + // application — for primitive types like ZodEnum, it returns `data` + // unchanged (see normalizeFromSchema.ts:139). This is a known + // asymmetry vs the FullSubject walker (which DOES handle ZodDefault + // at primitive leaves). Out of scope to fix in this stage; tracked as + // follow-up in the invoice-schema-rename-plan. + // + // Why this is OK for processor_type: the consumer-side + // `?? ProcessorType.Stripe` mask in `processInvoice` and the latent + // breakage filters covers the undefined case from this walker just + // as it covers the null case from the FullSubject walker. The wire + // always emits a defined value. + const schema = z.object({ + processor_type: z.enum(ProcessorType).default(ProcessorType.Stripe), + }); + const result = normalizeFromSchemaCacheUtils<{ + processor_type: ProcessorType | undefined; + }>({ + schema: schema as unknown as z.ZodTypeAny, + data: {}, + }); + // Walker returns the field undefined. Consumer `??` is what masks it. + expect(result.processor_type).toBeUndefined(); + }); }); describe("sanitizeCachedSubjectBalance", () => { @@ -454,6 +515,141 @@ describe("sanitizeCachedFullSubject", () => { }); }); +// ═══════════════════════════════════════════════════════════════════════════════ +// processor_type — multi-processor invoice scaffolding +// +// Locks in the Option A contract: the schema's `.default(ProcessorType.Stripe)` +// fires through the walker for `undefined` (covers cjson-stripped fields on +// pre-deploy cached entries), `null` is passed through unchanged (consumers +// mask via `?? ProcessorType.Stripe`), and explicit `"stripe"` / `"revenuecat"` +// values round-trip cleanly without over-correction. +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("sanitizeCachedFullSubject — processor_type (Option A walker behavior)", () => { + const buildCachedFullSubject = (): unknown => ({ + subjectType: "customer", + customerId: "cus_proc", + internalCustomerId: "cus_int_proc", + _cachedAt: Date.now(), + subjectViewEpoch: 1, + meteredFeatures: [], + customerEntitlementIdsByFeatureId: {}, + customer: { + internal_id: "cus_int_proc", + org_id: "org_proc", + env: AppEnv.Live, + created_at: 1, + }, + customer_products: [], + products: [], + entitlements: [], + prices: [], + free_trials: [], + subscriptions: [], + invoices: [], + flags: {}, + }); + + const buildInvoice = ( + overrides: Record = {}, + ): Record => ({ + id: "inv_proc", + created_at: 1, + internal_customer_id: "cus_int_proc", + product_ids: [], + internal_product_ids: [], + stripe_id: "in_proc", + total: 100, + currency: "usd", + discounts: [], + items: [], + ...overrides, + }); + + test("walker fills missing processor_type via ZodDefault → stripe", () => { + // Pre-deploy cache entries don't have the field at all (cjson stripped + // or never written). + const malformed = buildCachedFullSubject() as unknown as Record< + string, + unknown + >; + malformed.invoices = [buildInvoice()]; // no processor_type key + const result = sanitizeCachedFullSubject({ + cachedFullSubject: malformed as unknown as CachedFullSubject, + }); + expect(result.invoices[0].processor_type).toBe(ProcessorType.Stripe); + }); + + test("walker passes through explicit null processor_type unchanged", () => { + // Documents the Option A limitation: `.default()` only fires for + // undefined. Consumers (processInvoice, latent breakage filters) + // handle the null case via `?? ProcessorType.Stripe`. This test + // guards against any future "convenience" change that silently + // coerces null at the walker level — consumer code relies on the + // passthrough semantics. + const malformed = buildCachedFullSubject() as unknown as Record< + string, + unknown + >; + malformed.invoices = [buildInvoice({ processor_type: null })]; + const result = sanitizeCachedFullSubject({ + cachedFullSubject: malformed as unknown as CachedFullSubject, + }); + expect(result.invoices[0].processor_type).toBeNull(); + }); + + test("walker preserves explicit processor_type='stripe'", () => { + const malformed = buildCachedFullSubject() as unknown as Record< + string, + unknown + >; + malformed.invoices = [ + buildInvoice({ processor_type: ProcessorType.Stripe }), + ]; + const result = sanitizeCachedFullSubject({ + cachedFullSubject: malformed as unknown as CachedFullSubject, + }); + expect(result.invoices[0].processor_type).toBe(ProcessorType.Stripe); + }); + + test("walker preserves explicit processor_type='revenuecat' (no over-correction)", () => { + const malformed = buildCachedFullSubject() as unknown as Record< + string, + unknown + >; + malformed.invoices = [ + buildInvoice({ processor_type: ProcessorType.RevenueCat }), + ]; + const result = sanitizeCachedFullSubject({ + cachedFullSubject: malformed as unknown as CachedFullSubject, + }); + expect(result.invoices[0].processor_type).toBe(ProcessorType.RevenueCat); + }); + + test("end-to-end: walker null + processInvoice consumer → wire stripe", async () => { + // Composite contract: walker leaves null, consumer masks. Documents the + // agreed Option A pattern as runnable code so a future regression in + // either the walker OR the consumer trips this test. + const malformed = buildCachedFullSubject() as unknown as Record< + string, + unknown + >; + malformed.invoices = [buildInvoice({ processor_type: null })]; + const sanitized = sanitizeCachedFullSubject({ + cachedFullSubject: malformed as unknown as CachedFullSubject, + }); + expect(sanitized.invoices[0].processor_type).toBeNull(); + + const { processInvoice } = await import( + "@/internal/invoices/InvoiceService.js" + ); + const wire = processInvoice({ + invoice: sanitized.invoices[0], + }); + expect(wire.processor_type).toBe(ProcessorType.Stripe); + }); +}); + describe("sanitizeCachedAggregatedFeatureBalance", () => { test("fills undefined at nullable positions for AggregatedFeatureBalance", () => { // AggregatedFeatureBalance entities is .nullish() — undefined should diff --git a/server/tests/unit/invoices/processInvoice.test.ts b/server/tests/unit/invoices/processInvoice.test.ts new file mode 100644 index 000000000..50aa23f20 --- /dev/null +++ b/server/tests/unit/invoices/processInvoice.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { type Invoice, ProcessorType } from "@autumn/shared"; +import { processInvoice } from "@/internal/invoices/InvoiceService.js"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// processInvoice — processor_type wire mapping +// +// `processInvoice` is the single boundary between the raw Drizzle/cached +// `Invoice` row and the public V5 `ApiInvoiceV1` wire shape. It must always +// emit a defined `processor_type` regardless of input shape. +// +// Coverage matrix: +// • explicit "stripe" → wire "stripe" +// • explicit "revenuecat" → wire "revenuecat" +// • null → wire "stripe" (consumer `??` mask, since +// ZodDefault doesn't fire on explicit null) +// • undefined → wire "stripe" (same `??` mask covers it; the +// schema's ZodDefault would also fire through +// the cache walker before reaching this point) +// +// Together these tests prove the wire never emits null/undefined for the new +// optional field, regardless of where the input row came from (DB, cache, +// pre-deploy cached entry). +// ═══════════════════════════════════════════════════════════════════════════════ + +const buildBaseInvoice = (overrides: Partial = {}): Invoice => + ({ + id: "inv_test", + created_at: 1_700_000_000_000, + product_ids: [], + internal_product_ids: [], + internal_customer_id: "cus_int_test", + internal_entity_id: null, + stripe_id: "in_test", + status: "paid", + hosted_invoice_url: null, + total: 100, + amount_paid: 100, + refunded_amount: 0, + currency: "usd", + discounts: [], + items: [], + processor_type: ProcessorType.Stripe, + ...overrides, + }) as Invoice; + +describe("processInvoice — processor_type wire mapping", () => { + test("explicit stripe → wire stripe", () => { + const wire = processInvoice({ + invoice: buildBaseInvoice({ processor_type: ProcessorType.Stripe }), + }); + expect(wire.processor_type).toBe(ProcessorType.Stripe); + expect(wire.stripe_id).toBe("in_test"); + }); + + test("explicit revenuecat → wire revenuecat", () => { + const wire = processInvoice({ + invoice: buildBaseInvoice({ + processor_type: ProcessorType.RevenueCat, + stripe_id: "rc:txn_test", + }), + }); + expect(wire.processor_type).toBe(ProcessorType.RevenueCat); + expect(wire.stripe_id).toBe("rc:txn_test"); + }); + + test("null processor_type masks to stripe via consumer `??`", () => { + // Simulates a cached payload from a DB row with NULL processor_type + // that survived the cjson roundtrip without being stripped. + const wire = processInvoice({ + invoice: buildBaseInvoice({ + processor_type: null as unknown as ProcessorType, + }), + }); + expect(wire.processor_type).toBe(ProcessorType.Stripe); + }); + + test("undefined processor_type masks to stripe via consumer `??`", () => { + // Simulates a raw row from the cache walker that had the field + // stripped by Upstash cjson (the most common pre-deploy case). + const raw = buildBaseInvoice(); + delete (raw as Record).processor_type; + const wire = processInvoice({ invoice: raw }); + expect(wire.processor_type).toBe(ProcessorType.Stripe); + }); +}); diff --git a/shared/api/others/apiInvoice/apiInvoiceV1.ts b/shared/api/others/apiInvoice/apiInvoiceV1.ts index 0741fde29..4fa5d96fb 100644 --- a/shared/api/others/apiInvoice/apiInvoiceV1.ts +++ b/shared/api/others/apiInvoice/apiInvoiceV1.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { ProcessorType } from "../../../models/genModels/genEnums"; export const ApiInvoiceV1Schema = z.object({ plan_ids: z.array(z.string()).meta({ @@ -9,6 +10,13 @@ export const ApiInvoiceV1Schema = z.object({ description: "The Stripe invoice ID", example: "in_1A2B3C4D5E6F7G8H", }), + processor_type: z + .enum(ProcessorType) + .default(ProcessorType.Stripe) + .meta({ + description: "The billing processor that owns this invoice.", + example: "stripe", + }), status: z.string().meta({ description: "The status of the invoice", example: "paid", diff --git a/shared/models/cusModels/invoiceModels/invoiceModels.ts b/shared/models/cusModels/invoiceModels/invoiceModels.ts index 056c46d61..c15a57825 100644 --- a/shared/models/cusModels/invoiceModels/invoiceModels.ts +++ b/shared/models/cusModels/invoiceModels/invoiceModels.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { ProcessorType } from "../../genModels/genEnums.js"; export enum InvoiceStatus { Draft = "draft", @@ -34,6 +35,7 @@ export const InvoiceSchema = z.object({ // Stripe fields stripe_id: z.string(), + processor_type: z.enum(ProcessorType).default(ProcessorType.Stripe), status: z.nativeEnum(InvoiceStatus).nullable().optional(), hosted_invoice_url: z.string().nullable(), diff --git a/shared/models/cusModels/invoiceModels/invoiceTable.ts b/shared/models/cusModels/invoiceModels/invoiceTable.ts index 157344cc5..f719fdbcd 100644 --- a/shared/models/cusModels/invoiceModels/invoiceTable.ts +++ b/shared/models/cusModels/invoiceModels/invoiceTable.ts @@ -25,6 +25,7 @@ export const invoices = pgTable( internal_entity_id: text("internal_entity_id"), stripe_id: text("stripe_id").notNull(), + processor_type: text("processor_type"), status: text("status").notNull().default("draft"), hosted_invoice_url: text("hosted_invoice_url"), total: numeric({ mode: "number" }).notNull().default(0),