fix: 🐛 invoice needs to be agnostic

This commit is contained in:
amianthus
2026-05-07 15:09:28 +01:00
parent db9eb9d772
commit f0ada2883b
11 changed files with 322 additions and 7 deletions

View File

@@ -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",

View File

@@ -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.

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string, unknown> = {},
): Record<string, unknown> => ({
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

View File

@@ -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> = {}): 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<string, unknown>).processor_type;
const wire = processInvoice({ invoice: raw });
expect(wire.processor_type).toBe(ProcessorType.Stripe);
});
});

View File

@@ -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",

View File

@@ -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(),

View File

@@ -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),