fix: 🐛 invoice needs to be agnostic
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
"workers": "bun src/workers.ts",
|
"workers": "bun src/workers.ts",
|
||||||
"cron": "bun src/cron.ts",
|
"cron": "bun src/cron.ts",
|
||||||
"check": "bun src/check.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": "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: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",
|
"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 { z } from "zod/v4";
|
||||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||||
@@ -32,10 +32,15 @@ export const handleGetInvoiceLineItems = createRoute({
|
|||||||
org: ctx.org,
|
org: ctx.org,
|
||||||
env: ctx.env,
|
env: ctx.env,
|
||||||
});
|
});
|
||||||
const autumnInvoices = await InvoiceService.getMany({
|
const autumnInvoices = (
|
||||||
db,
|
await InvoiceService.getMany({
|
||||||
ids: invoice_ids,
|
db,
|
||||||
});
|
ids: invoice_ids,
|
||||||
|
})
|
||||||
|
).filter(
|
||||||
|
(inv) =>
|
||||||
|
(inv.processor_type ?? ProcessorType.Stripe) === ProcessorType.Stripe,
|
||||||
|
);
|
||||||
const stripeInvoices = [];
|
const stripeInvoices = [];
|
||||||
for (const invoice of autumnInvoices) {
|
for (const invoice of autumnInvoices) {
|
||||||
// Throttle to avoid Stripe rate limits.
|
// Throttle to avoid Stripe rate limits.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type InvoiceStatus,
|
type InvoiceStatus,
|
||||||
invoices,
|
invoices,
|
||||||
type Organization,
|
type Organization,
|
||||||
|
ProcessorType,
|
||||||
RecaseError,
|
RecaseError,
|
||||||
stripeToAtmnAmount,
|
stripeToAtmnAmount,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
@@ -33,6 +34,7 @@ export const processInvoice = ({
|
|||||||
// product_ids: invoice.product_ids,
|
// product_ids: invoice.product_ids,
|
||||||
plan_ids: invoice.product_ids,
|
plan_ids: invoice.product_ids,
|
||||||
stripe_id: invoice.stripe_id,
|
stripe_id: invoice.stripe_id,
|
||||||
|
processor_type: invoice.processor_type ?? ProcessorType.Stripe,
|
||||||
status: invoice.status ?? "",
|
status: invoice.status ?? "",
|
||||||
total: invoice.total,
|
total: invoice.total,
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
@@ -227,6 +229,7 @@ export class InvoiceService {
|
|||||||
product_ids: uniqueProductIds,
|
product_ids: uniqueProductIds,
|
||||||
created_at: stripeInvoice.created * 1000,
|
created_at: stripeInvoice.created * 1000,
|
||||||
stripe_id: stripeInvoice.id!,
|
stripe_id: stripeInvoice.id!,
|
||||||
|
processor_type: ProcessorType.Stripe,
|
||||||
hosted_invoice_url: stripeInvoice.hosted_invoice_url || null,
|
hosted_invoice_url: stripeInvoice.hosted_invoice_url || null,
|
||||||
status: status || (stripeInvoice.status as InvoiceStatus | null),
|
status: status || (stripeInvoice.status as InvoiceStatus | null),
|
||||||
internal_product_ids: uniqueInternalProductIds,
|
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 { StatusCodes } from "http-status-codes";
|
||||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||||
import { createRoute } from "@/honoMiddlewares/routeHandler.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 {
|
try {
|
||||||
const org = invoice.customer.org;
|
const org = invoice.customer.org;
|
||||||
const env = invoice.customer.env;
|
const env = invoice.customer.env;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type FullCustomer,
|
type FullCustomer,
|
||||||
type FullProduct,
|
type FullProduct,
|
||||||
type InsertInvoice,
|
type InsertInvoice,
|
||||||
|
ProcessorType,
|
||||||
secondsToMs,
|
secondsToMs,
|
||||||
stripeToAtmnAmount,
|
stripeToAtmnAmount,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
@@ -62,6 +63,7 @@ export const initInvoiceFromStripe = async ({
|
|||||||
internal_product_ids: [...new Set(internalProductIds)],
|
internal_product_ids: [...new Set(internalProductIds)],
|
||||||
created_at: secondsToMs(stripeInvoice.created),
|
created_at: secondsToMs(stripeInvoice.created),
|
||||||
stripe_id: stripeInvoice.id!,
|
stripe_id: stripeInvoice.id!,
|
||||||
|
processor_type: ProcessorType.Stripe,
|
||||||
hosted_invoice_url: stripeInvoice.hosted_invoice_url || null,
|
hosted_invoice_url: stripeInvoice.hosted_invoice_url || null,
|
||||||
status: stripeInvoice.status as string | undefined,
|
status: stripeInvoice.status as string | undefined,
|
||||||
internal_entity_id: internalEntityId || null,
|
internal_entity_id: internalEntityId || null,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
FeatureType,
|
FeatureType,
|
||||||
type InvoiceRow,
|
type InvoiceRow,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
|
ProcessorType,
|
||||||
type SubscriptionRow,
|
type SubscriptionRow,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { AllowanceType } from "@shared/models/productModels/entModels/entModels.js";
|
import { AllowanceType } from "@shared/models/productModels/entModels/entModels.js";
|
||||||
@@ -401,6 +402,7 @@ const buildInvoice = ({
|
|||||||
internal_customer_id: customer.internal_id,
|
internal_customer_id: customer.internal_id,
|
||||||
internal_entity_id: internalEntityId,
|
internal_entity_id: internalEntityId,
|
||||||
stripe_id: `stripe_inv_${key}_${suffix}`,
|
stripe_id: `stripe_inv_${key}_${suffix}`,
|
||||||
|
processor_type: ProcessorType.Stripe,
|
||||||
status: InvoiceStatus.Paid,
|
status: InvoiceStatus.Paid,
|
||||||
hosted_invoice_url: null,
|
hosted_invoice_url: null,
|
||||||
total: 1000,
|
total: 1000,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import {
|
import {
|
||||||
type AggregatedFeatureBalanceSchema,
|
type AggregatedFeatureBalanceSchema,
|
||||||
AppEnv,
|
AppEnv,
|
||||||
|
ProcessorType,
|
||||||
ProductSchema,
|
ProductSchema,
|
||||||
type SubjectBalance,
|
type SubjectBalance,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
@@ -142,6 +143,66 @@ describe("normalizeFromSchema (core walker)", () => {
|
|||||||
normalizeFromSchema({ schema, data: "not an object" }),
|
normalizeFromSchema({ schema, data: "not an object" }),
|
||||||
).not.toThrow();
|
).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", () => {
|
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", () => {
|
describe("sanitizeCachedAggregatedFeatureBalance", () => {
|
||||||
test("fills undefined at nullable positions for AggregatedFeatureBalance", () => {
|
test("fills undefined at nullable positions for AggregatedFeatureBalance", () => {
|
||||||
// AggregatedFeatureBalance entities is .nullish() — undefined should
|
// 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 { z } from "zod/v4";
|
||||||
|
import { ProcessorType } from "../../../models/genModels/genEnums";
|
||||||
|
|
||||||
export const ApiInvoiceV1Schema = z.object({
|
export const ApiInvoiceV1Schema = z.object({
|
||||||
plan_ids: z.array(z.string()).meta({
|
plan_ids: z.array(z.string()).meta({
|
||||||
@@ -9,6 +10,13 @@ export const ApiInvoiceV1Schema = z.object({
|
|||||||
description: "The Stripe invoice ID",
|
description: "The Stripe invoice ID",
|
||||||
example: "in_1A2B3C4D5E6F7G8H",
|
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({
|
status: z.string().meta({
|
||||||
description: "The status of the invoice",
|
description: "The status of the invoice",
|
||||||
example: "paid",
|
example: "paid",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
import { ProcessorType } from "../../genModels/genEnums.js";
|
||||||
|
|
||||||
export enum InvoiceStatus {
|
export enum InvoiceStatus {
|
||||||
Draft = "draft",
|
Draft = "draft",
|
||||||
@@ -34,6 +35,7 @@ export const InvoiceSchema = z.object({
|
|||||||
|
|
||||||
// Stripe fields
|
// Stripe fields
|
||||||
stripe_id: z.string(),
|
stripe_id: z.string(),
|
||||||
|
processor_type: z.enum(ProcessorType).default(ProcessorType.Stripe),
|
||||||
status: z.nativeEnum(InvoiceStatus).nullable().optional(),
|
status: z.nativeEnum(InvoiceStatus).nullable().optional(),
|
||||||
hosted_invoice_url: z.string().nullable(),
|
hosted_invoice_url: z.string().nullable(),
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const invoices = pgTable(
|
|||||||
internal_entity_id: text("internal_entity_id"),
|
internal_entity_id: text("internal_entity_id"),
|
||||||
|
|
||||||
stripe_id: text("stripe_id").notNull(),
|
stripe_id: text("stripe_id").notNull(),
|
||||||
|
processor_type: text("processor_type"),
|
||||||
status: text("status").notNull().default("draft"),
|
status: text("status").notNull().default("draft"),
|
||||||
hosted_invoice_url: text("hosted_invoice_url"),
|
hosted_invoice_url: text("hosted_invoice_url"),
|
||||||
total: numeric({ mode: "number" }).notNull().default(0),
|
total: numeric({ mode: "number" }).notNull().default(0),
|
||||||
|
|||||||
Reference in New Issue
Block a user