fix: 🐛 invoice needs to be agnostic
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
86
server/tests/unit/invoices/processInvoice.test.ts
Normal file
86
server/tests/unit/invoices/processInvoice.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user