feat: new versioning system
This commit is contained in:
14
bun.lock
14
bun.lock
@@ -47,6 +47,7 @@
|
||||
"@react-email/components": "^0.0.42",
|
||||
"@sentry/node": "^9.30.0",
|
||||
"@supabase/supabase-js": "^2.46.2",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@upstash/redis": "^1.35.1",
|
||||
"ai": "^4.3.10",
|
||||
@@ -86,6 +87,7 @@
|
||||
"postgres": "^3.4.7",
|
||||
"posthog-node": "^4.17.2",
|
||||
"puppeteer-core": "^24.14.0",
|
||||
"qs": "^6.14.0",
|
||||
"react": "^18.2.0",
|
||||
"recaseai": "^0.0.37",
|
||||
"resend": "^4.1.1",
|
||||
@@ -2287,7 +2289,7 @@
|
||||
|
||||
"pvutils": ["pvutils@1.1.3", "", {}, "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ=="],
|
||||
|
||||
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="],
|
||||
|
||||
@@ -3077,6 +3079,10 @@
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"body-parser/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
|
||||
|
||||
"chai-http/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
@@ -3097,6 +3103,8 @@
|
||||
|
||||
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"express/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
@@ -3171,6 +3179,10 @@
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"stripe/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
|
||||
|
||||
"superagent/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
|
||||
|
||||
"svix/@types/node": ["@types/node@22.17.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w=="],
|
||||
|
||||
"svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
|
||||
@@ -13,7 +13,7 @@ async function startDev() {
|
||||
"bunx",
|
||||
[
|
||||
"concurrently",
|
||||
`"cd shared && bun dev"`,
|
||||
`"cd shared && bun run build && bun tsc"`,
|
||||
`"cd server && SERVER_PORT=${serverPort} bun dev"`,
|
||||
`"cd server && bun workers:dev"`,
|
||||
`"cd vite && VITE_PORT=${vitePort} bun dev"`,
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@react-email/components": "^0.0.42",
|
||||
"@sentry/node": "^9.30.0",
|
||||
"@supabase/supabase-js": "^2.46.2",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@upstash/redis": "^1.35.1",
|
||||
"ai": "^4.3.10",
|
||||
@@ -88,6 +89,7 @@
|
||||
"postgres": "^3.4.7",
|
||||
"posthog-node": "^4.17.2",
|
||||
"puppeteer-core": "^24.14.0",
|
||||
"qs": "^6.14.0",
|
||||
"react": "^18.2.0",
|
||||
"recaseai": "^0.0.37",
|
||||
"resend": "^4.1.1",
|
||||
|
||||
@@ -7,20 +7,20 @@ source "$(dirname "$0")/config.sh"
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
$MOCHA_CMD 'tests/advanced/referrals/*.ts' 'tests/advanced/coupons/*.ts'
|
||||
# $MOCHA_CMD 'tests/advanced/referrals/*.ts' 'tests/advanced/coupons/*.ts'
|
||||
|
||||
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
# 'tests/advanced/coupons/*.ts' \
|
||||
# 'tests/attach/updateQuantity/*.ts' \
|
||||
# 'tests/advanced/referrals/*.ts' \
|
||||
# 'tests/advanced/referrals/paid/*.ts' \
|
||||
# 'tests/advanced/rollovers/*.ts' \
|
||||
# 'tests/advanced/customInterval/*.ts'
|
||||
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
'tests/advanced/coupons/*.ts' \
|
||||
'tests/attach/updateQuantity/*.ts' \
|
||||
'tests/advanced/referrals/*.ts' \
|
||||
'tests/advanced/referrals/paid/*.ts' \
|
||||
'tests/advanced/rollovers/*.ts' \
|
||||
'tests/advanced/customInterval/*.ts'
|
||||
|
||||
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
# 'tests/advanced/usageLimit/*.ts'
|
||||
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
'tests/advanced/usageLimit/*.ts'
|
||||
|
||||
# $MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
$MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@ const skipIds = [
|
||||
"DxG668K7uDd0Vahk54YWjvCGVgf2",
|
||||
];
|
||||
|
||||
orgSlugs = ["capy"];
|
||||
const customerId = null;
|
||||
// customerId = "55765cf6-84af-11f0-a94e-3eef481a796b";
|
||||
orgSlugs = ["feather"];
|
||||
let customerId = null;
|
||||
customerId = "3aa8ebee-28cb-4293-97e8-becf55bc2159";
|
||||
|
||||
const getSingleCustomer = async ({
|
||||
stripeCli,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
ApiVersion,
|
||||
type Customer,
|
||||
EntInterval,
|
||||
type FullCusProduct,
|
||||
type FullCustomerEntitlement,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
type Price,
|
||||
type UsagePriceConfig,
|
||||
@@ -53,8 +53,8 @@ export const handleUsagePrices = async ({
|
||||
) < 10;
|
||||
|
||||
const invoiceFromUpgrade =
|
||||
invoice.billing_reason == "subscription_update" ||
|
||||
invoice.billing_reason == "manual";
|
||||
invoice.billing_reason === "subscription_update" ||
|
||||
invoice.billing_reason === "manual";
|
||||
|
||||
if (invoiceCreatedRecently) {
|
||||
logger.info("Invoice created recently, skipping");
|
||||
@@ -84,7 +84,7 @@ export const handleUsagePrices = async ({
|
||||
|
||||
const isNewUsageMethod =
|
||||
activeProduct.internal_entity_id ||
|
||||
activeProduct.api_version === LegacyVersion.v1_4;
|
||||
activeProduct.api_semver === ApiVersion.Beta;
|
||||
|
||||
if (isNewUsageMethod) {
|
||||
const invoiceItem = getInvoiceItemForUsage({
|
||||
|
||||
@@ -53,7 +53,7 @@ export const handleCusProductDeleted = async ({
|
||||
stripeId: fullCus.processor?.id,
|
||||
});
|
||||
|
||||
const isV4Usage = cusProduct.api_version === ApiVersion.Beta;
|
||||
const isV4Usage = cusProduct.api_semver === ApiVersion.Beta;
|
||||
|
||||
// refer to handleUpgradeFlow.ts, when cancel immediately through API / dashboard, this happens...?
|
||||
const isAutumnCancel =
|
||||
|
||||
@@ -124,9 +124,15 @@ export const handleSchedulePhaseCompleted = async ({
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.warn(
|
||||
`schedule.phase.completed: failed to cancel schedule ${schedule.id}, error: ${error.message}`,
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`schedule.phase.completed: failed to cancel schedule ${schedule.id}, error: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,7 +61,6 @@ export const apiVersionMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
|
||||
// Store in context - now you can do ctx.apiVersion.gte(ApiVersion.V1_1)
|
||||
ctx.apiVersion = finalVersion;
|
||||
console.log(`Autumn Version: ${finalVersion.semver}`);
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -58,5 +58,21 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
env: AppEnv.Sandbox, // maybe use app_env headers
|
||||
});
|
||||
|
||||
const method = c.req.method;
|
||||
const path = c.req.path;
|
||||
|
||||
let body = null;
|
||||
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||
body = await c.req.json();
|
||||
}
|
||||
|
||||
logger.info(`${method} ${path}`, {
|
||||
context: {
|
||||
body,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`URL: ${c.req.url}`);
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ export const betterAuthMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
}
|
||||
|
||||
// Step 4: Fetch org and features from database
|
||||
ctx.env = c.req.header("app_env") as AppEnv;
|
||||
const data = await OrgService.getWithFeatures({
|
||||
db: ctx.db,
|
||||
orgId: orgId,
|
||||
@@ -76,7 +77,6 @@ export const betterAuthMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
|
||||
// Step 5: Store in context
|
||||
ctx.org = org;
|
||||
ctx.env = c.req.header("app_env") as AppEnv;
|
||||
ctx.features = features;
|
||||
ctx.userId = userId;
|
||||
ctx.authType = AuthType.Dashboard;
|
||||
|
||||
56
server/src/honoMiddlewares/queryMiddleware.ts
Normal file
56
server/src/honoMiddlewares/queryMiddleware.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import qs from "qs";
|
||||
|
||||
/**
|
||||
* Middleware to parse query strings using the `qs` library
|
||||
* This handles comma-separated arrays automatically and complex nested objects
|
||||
*
|
||||
* Examples:
|
||||
* - ?expand=invoices,rewards → { expand: ['invoices', 'rewards'] }
|
||||
* - ?expand[0]=invoices&expand[1]=rewards → { expand: ['invoices', 'rewards'] }
|
||||
* - ?filter[status]=active → { filter: { status: 'active' } }
|
||||
*
|
||||
* Add this middleware before your route validators:
|
||||
* ```ts
|
||||
* router.use(queryMiddleware());
|
||||
* router.get('/customers/:id', validator('query', GetCustomerQuerySchema), handler);
|
||||
* ```
|
||||
*/
|
||||
export const queryMiddleware = (): MiddlewareHandler => {
|
||||
return async (c, next) => {
|
||||
const url = new URL(c.req.url);
|
||||
const queryString = url.search.slice(1); // Remove leading '?'
|
||||
|
||||
if (queryString) {
|
||||
// Parse with comma support for arrays
|
||||
const parsed = qs.parse(queryString, {
|
||||
comma: true, // Enable comma-separated array parsing
|
||||
depth: 5, // Limit depth to prevent prototype pollution
|
||||
allowDots: false, // Disable dot notation (filter.status) to prevent confusion
|
||||
decoder: (str, defaultDecoder, charset, type) => {
|
||||
// Use default decoder but handle edge cases
|
||||
if (type === "value") {
|
||||
// Handle boolean strings
|
||||
if (str === "true") return true;
|
||||
if (str === "false") return false;
|
||||
// Handle numbers if needed (optional)
|
||||
// const num = Number(str);
|
||||
// if (!isNaN(num) && str === String(num)) return num;
|
||||
}
|
||||
return defaultDecoder(str, defaultDecoder, charset);
|
||||
},
|
||||
});
|
||||
|
||||
// Store parsed query for Hono to use
|
||||
// @ts-expect-error - Hono's query parsing internals
|
||||
c.req.query = (key?: string) => {
|
||||
if (key) {
|
||||
return parsed[key];
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AffectedResource, ApiVersion } from "@autumn/shared";
|
||||
import type { Context, Env, Handler, MiddlewareHandler } from "hono";
|
||||
import type { ZodType, z } from "zod/v4";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { validator } from "./validatorMiddleware.js";
|
||||
import { versionedValidator } from "./versionedValidator.js";
|
||||
import type { AffectedResource, ApiVersion } from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Extended context type that includes validated input
|
||||
@@ -32,7 +32,9 @@ type ValidatedContext<
|
||||
/**
|
||||
* Version-specific schemas configuration
|
||||
*/
|
||||
type VersionedSchemas<T extends ZodType> = Partial<Record<ApiVersion, ZodType>> & {
|
||||
type VersionedSchemas<T extends ZodType> = Partial<
|
||||
Record<ApiVersion, ZodType>
|
||||
> & {
|
||||
latest: T; // Latest version schema (required)
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import type { ZodType } from "zod/v4";
|
||||
* This allows the errorMiddleware to handle validation errors consistently
|
||||
* Maintains full type inference from zValidator
|
||||
*
|
||||
* For query validation, this uses the parsed query from queryMiddleware
|
||||
* to ensure boolean/array conversions are applied before validation
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* router.post(
|
||||
@@ -22,6 +25,20 @@ export const validator = <T extends ZodType>(
|
||||
target: "json" | "query" | "param" | "header" | "form",
|
||||
schema: T,
|
||||
) => {
|
||||
// For query validation, use the parsed query from queryMiddleware
|
||||
if (target === "query") {
|
||||
return async (c: any, next: any) => {
|
||||
const parsedQuery = c.req.query();
|
||||
const result = schema.safeParse(parsedQuery);
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
}
|
||||
// Store validated data for c.req.valid("query")
|
||||
c.req.addValidatedData(target, result.data);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
return zValidator(target, schema, (result, _c) => {
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiVersionMiddleware } from "./honoMiddlewares/apiVersionMiddleware.js"
|
||||
import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js";
|
||||
import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
|
||||
import { orgConfigMiddleware } from "./honoMiddlewares/orgConfigMiddleware.js";
|
||||
import { queryMiddleware } from "./honoMiddlewares/queryMiddleware.js";
|
||||
import { refreshCacheMiddleware } from "./honoMiddlewares/refreshCacheMiddleware.js";
|
||||
import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
|
||||
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
|
||||
@@ -81,6 +82,8 @@ export const createHonoApp = () => {
|
||||
app.use("/v1/*", refreshCacheMiddleware);
|
||||
|
||||
// Step 7: Add pricing middleware, analytics middleware, etc.
|
||||
|
||||
app.use("/v1/*", queryMiddleware());
|
||||
app.route("v1/customers", cusRouter);
|
||||
app.route("v1/products", honoProductRouter);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
type APICusProduct,
|
||||
type ApiCusProduct,
|
||||
ApiVersion,
|
||||
ApiVersionClass,
|
||||
type AppEnv,
|
||||
@@ -79,7 +79,7 @@ export const getSingleEntityResponse = async ({
|
||||
features,
|
||||
});
|
||||
|
||||
const products: APICusProduct[] = [...main, ...addOns];
|
||||
const products: ApiCusProduct[] = [...main, ...addOns];
|
||||
|
||||
const cusFeatures = await getCusFeaturesResponse({
|
||||
cusProducts: entityCusProducts,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
EntityExpand,
|
||||
EntityResponseSchema,
|
||||
LegacyVersion,
|
||||
} from "@autumn/shared";
|
||||
import { ApiEntitySchema, EntityExpand, LegacyVersion } from "@autumn/shared";
|
||||
import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
@@ -20,7 +16,7 @@ export const handleGetEntity = async (req: any, res: any) =>
|
||||
const customerId = req.params.customer_id as string;
|
||||
const expand = parseEntityExpand(req.query.expand);
|
||||
|
||||
const { orgId, env, db, logger, features } = req;
|
||||
const { env, db, logger, features } = req;
|
||||
|
||||
const org = await OrgService.getFromReq(req);
|
||||
const apiVersion = orgToVersion({
|
||||
@@ -50,7 +46,7 @@ export const handleGetEntity = async (req: any, res: any) =>
|
||||
const withInvoices = expand.includes(EntityExpand.Invoices);
|
||||
|
||||
res.status(200).json(
|
||||
EntityResponseSchema.parse({
|
||||
ApiEntitySchema.parse({
|
||||
...entity,
|
||||
invoices: withInvoices
|
||||
? invoicesToResponse({
|
||||
|
||||
@@ -142,7 +142,7 @@ export const initCusProduct = ({
|
||||
quantity: quantity || 1,
|
||||
internal_entity_id: internalEntityId,
|
||||
entity_id: entityId,
|
||||
api_version: apiVersion,
|
||||
api_semver: apiVersion || null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ export const handleUpgradeFlow = async ({
|
||||
|
||||
const logger = req.logtail;
|
||||
|
||||
if (curCusProduct?.api_version) {
|
||||
attachParams.apiVersion = curCusProduct.api_version;
|
||||
if (curCusProduct?.api_semver) {
|
||||
attachParams.apiVersion = curCusProduct.api_semver;
|
||||
}
|
||||
|
||||
let sub = curSub;
|
||||
|
||||
@@ -39,6 +39,8 @@ export const getPricesAndEnts = async ({
|
||||
internalEntityId: customer.entity?.internal_id,
|
||||
});
|
||||
|
||||
const curCusProduct = curSameProduct || curMainProduct;
|
||||
|
||||
// Not custom
|
||||
if (!is_custom) {
|
||||
const prices = products.flatMap((p: FullProduct) => p.prices);
|
||||
@@ -63,7 +65,7 @@ export const getPricesAndEnts = async ({
|
||||
optionsInput: optionsInput || [],
|
||||
features,
|
||||
prices,
|
||||
curCusProduct: curMainProduct,
|
||||
curCusProduct,
|
||||
}),
|
||||
prices,
|
||||
entitlements,
|
||||
|
||||
@@ -81,6 +81,7 @@ const handlePrepaidErrors = async ({
|
||||
if (billingType === BillingType.UsageInAdvance) {
|
||||
// Get options for price
|
||||
const priceEnt = getPriceEntitlement(price, entitlements);
|
||||
|
||||
const options = getEntOptions(optionsList, priceEnt);
|
||||
|
||||
// 1. If not checkout, quantity should be defined
|
||||
|
||||
@@ -68,10 +68,10 @@ export const mapOptionsList = ({
|
||||
if (isOneOff(prices) || isFreeProduct(prices)) return newOptionsList;
|
||||
|
||||
const curOptionsList = curCusProduct?.options || [];
|
||||
console.log("Current options list:", curOptionsList);
|
||||
for (const option of curOptionsList) {
|
||||
const inNewOptions = newOptionsList.find(
|
||||
(newOption) =>
|
||||
newOption.internal_feature_id === option.internal_feature_id,
|
||||
(newOption) => newOption.feature_id === option.feature_id,
|
||||
);
|
||||
|
||||
const prepaidPriceExists = findPrepaidPrice({
|
||||
@@ -84,5 +84,7 @@ export const mapOptionsList = ({
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`New options list:`, newOptionsList);
|
||||
|
||||
return newOptionsList;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
APICusProductSchema,
|
||||
ApiCusProductSchema,
|
||||
ApiVersion,
|
||||
type ApiVersionClass,
|
||||
type AppEnv,
|
||||
@@ -512,7 +512,7 @@ export const processFullCusProduct = ({
|
||||
};
|
||||
}
|
||||
|
||||
return APICusProductSchema.parse({
|
||||
return ApiCusProductSchema.parse({
|
||||
id: cusProduct.product.id,
|
||||
name: cusProduct.product.name,
|
||||
group: cusProduct.product.group || null,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { handleAddCouponToCus } from "./handlers/handleAddCouponToCus.js";
|
||||
import { handleCreateBillingPortal } from "./handlers/handleCreateBillingPortal.js";
|
||||
import { handleDeleteCustomer } from "./handlers/handleDeleteCustomer.js";
|
||||
import { handleGetCustomerV2 } from "./handlers/handleGetCustomerV2.js";
|
||||
import { handlePostCustomerRequest } from "./handlers/handlePostCustomer.js";
|
||||
import { handlePostCustomer } from "./handlers/handlePostCustomerV2.js";
|
||||
import { handleTransferProduct } from "./handlers/handleTransferProduct.js";
|
||||
import { handleUpdateBalances } from "./handlers/handleUpdateBalances.js";
|
||||
import { handleUpdateCustomer } from "./handlers/handleUpdateCustomer.js";
|
||||
@@ -47,7 +47,7 @@ expressCusRouter.post("/all/search", async (req: any, res: any) => {
|
||||
}
|
||||
});
|
||||
|
||||
expressCusRouter.post("", handlePostCustomerRequest);
|
||||
// expressCusRouter.post("", handlePostCustomerRequest);
|
||||
|
||||
// cusRouter.get("/:customer_id", handleGetCustomer);
|
||||
|
||||
@@ -150,3 +150,4 @@ expressCusRouter.post("/:customer_id/transfer", handleTransferProduct);
|
||||
export const cusRouter = new Hono<HonoEnv>();
|
||||
|
||||
cusRouter.get("/:customer_id", ...handleGetCustomerV2);
|
||||
cusRouter.post("", ...handlePostCustomer);
|
||||
|
||||
@@ -16,7 +16,7 @@ export const cusEntsToInterval = ({
|
||||
cusEnts: FullCusEntWithFullCusProduct[];
|
||||
}): {
|
||||
interval: EntInterval | "multiple" | null;
|
||||
interval_count: number;
|
||||
interval_count: number | null;
|
||||
} => {
|
||||
const cusEntKeys = cusEnts.map((cusEnt) => cusEntToKey({ cusEnt }));
|
||||
const uniqueCusEntKeys = [...new Set(cusEntKeys)];
|
||||
@@ -27,7 +27,7 @@ export const cusEntsToInterval = ({
|
||||
};
|
||||
}
|
||||
|
||||
return { interval: "multiple", interval_count: 0 };
|
||||
return { interval: "multiple", interval_count: null };
|
||||
};
|
||||
|
||||
export const cusEntsToNextResetAt = ({
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import {
|
||||
AffectedResource,
|
||||
APICusProductSchema,
|
||||
type ApiVersion,
|
||||
ApiVersionClass,
|
||||
applyResponseVersionChanges,
|
||||
CusProductStatus,
|
||||
type Feature,
|
||||
type FullCusProduct,
|
||||
LATEST_VERSION,
|
||||
type Subscription,
|
||||
} from "@autumn/shared";
|
||||
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
|
||||
import { fullCusProductToProduct } from "../../cusProducts/cusProductUtils.js";
|
||||
|
||||
/**
|
||||
* Builds a customer product in LATEST format, then applies version changes
|
||||
*
|
||||
* Latest format (V1_1+):
|
||||
* - Has `items` field with product features
|
||||
* - Has `started_at` instead of `starts_at`
|
||||
* - Includes current_period_start/end for trials
|
||||
*/
|
||||
export const getApiCusProduct = async ({
|
||||
cusProduct,
|
||||
subs,
|
||||
features,
|
||||
apiVersion,
|
||||
entity,
|
||||
}: {
|
||||
cusProduct: FullCusProduct;
|
||||
subs?: Subscription[];
|
||||
features: Feature[];
|
||||
apiVersion: ApiVersion;
|
||||
entity?: any;
|
||||
}): Promise<any> => {
|
||||
// Determine if trialing
|
||||
const trialing =
|
||||
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
|
||||
|
||||
// Build stripe subscription data
|
||||
const subIds = cusProduct.subscription_ids;
|
||||
let stripeSubData = {};
|
||||
|
||||
if ((!subIds || subIds.length === 0) && trialing) {
|
||||
stripeSubData = {
|
||||
current_period_start: cusProduct.starts_at,
|
||||
current_period_end: cusProduct.trial_ends_at,
|
||||
};
|
||||
} else if (subIds && subIds.length > 0 && subs) {
|
||||
const baseSub = subs.find(
|
||||
(s) => s.id === subIds[0] || (s as Subscription).stripe_id === subIds[0],
|
||||
);
|
||||
if (baseSub) {
|
||||
stripeSubData = {
|
||||
current_period_end: baseSub.current_period_end
|
||||
? baseSub.current_period_end * 1000
|
||||
: null,
|
||||
current_period_start: baseSub.current_period_start
|
||||
? baseSub.current_period_start * 1000
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build product in LATEST format (V1_1+)
|
||||
const fullProduct = fullCusProductToProduct(cusProduct);
|
||||
const v2Product = await getProductResponse({
|
||||
product: fullProduct,
|
||||
features,
|
||||
withDisplay: false,
|
||||
options: cusProduct.options,
|
||||
});
|
||||
|
||||
const latestProduct = APICusProductSchema.parse({
|
||||
id: fullProduct.id,
|
||||
name: fullProduct.name,
|
||||
group: fullProduct.group || null,
|
||||
status: trialing ? CusProductStatus.Trialing : cusProduct.status,
|
||||
canceled_at: cusProduct.canceled_at || null,
|
||||
is_default: fullProduct.is_default || false,
|
||||
is_add_on: fullProduct.is_add_on || false,
|
||||
version: fullProduct.version,
|
||||
quantity: cusProduct.quantity,
|
||||
started_at: cusProduct.starts_at,
|
||||
entity_id: entity?.id || cusProduct.entity_id || undefined,
|
||||
...stripeSubData,
|
||||
items: v2Product.items, // V1_1+ has items field
|
||||
});
|
||||
|
||||
// Apply version changes to transform to requested version
|
||||
// This will remove items field for V0_1
|
||||
return applyResponseVersionChanges({
|
||||
input: latestProduct,
|
||||
currentVersion: new ApiVersionClass(LATEST_VERSION),
|
||||
targetVersion: new ApiVersionClass(apiVersion),
|
||||
resource: AffectedResource.CusProduct,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
ApiCusProductSchema,
|
||||
type CusProductLegacyData,
|
||||
CusProductStatus,
|
||||
cusProductToProduct,
|
||||
type Entity,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type Subscription,
|
||||
} from "@autumn/shared";
|
||||
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
|
||||
|
||||
// const prices = cusProduct.customer_prices.map((cp) => {
|
||||
// const price = cp.price;
|
||||
|
||||
// if (price.config?.type === PriceType.Fixed) {
|
||||
// const config = price.config as FixedPriceConfig;
|
||||
// return {
|
||||
// amount: config.amount,
|
||||
// interval: config.interval,
|
||||
// };
|
||||
// } else {
|
||||
// const config = price.config as UsagePriceConfig;
|
||||
// const priceOptions = getPriceOptions(price, cusProduct.options);
|
||||
// const usageTier = getUsageTier(price, priceOptions?.quantity!);
|
||||
// const cusEnt = getRelatedCusEnt({
|
||||
// cusPrice: cp,
|
||||
// cusEnts: cusProduct.customer_entitlements,
|
||||
// });
|
||||
|
||||
// const ent = cusEnt?.entitlement;
|
||||
|
||||
// const singleTier =
|
||||
// ent?.allowance === 0 && config.usage_tiers.length === 1;
|
||||
|
||||
// if (singleTier) {
|
||||
// return {
|
||||
// amount: usageTier.amount,
|
||||
// interval: config.interval,
|
||||
// quantity: priceOptions?.quantity,
|
||||
// };
|
||||
// } else {
|
||||
// // Add allowance to tiers
|
||||
// const allowance = ent?.allowance;
|
||||
// let tiers;
|
||||
|
||||
// if (notNullish(allowance) && allowance! > 0) {
|
||||
// tiers = [
|
||||
// {
|
||||
// to: allowance,
|
||||
// amount: 0,
|
||||
// },
|
||||
// ...config.usage_tiers.map((tier) => {
|
||||
// const isLastTier = tier.to === -1 || tier.to === TierInfinite;
|
||||
// return {
|
||||
// to: isLastTier ? tier.to : Number(tier.to) + allowance!,
|
||||
// amount: tier.amount,
|
||||
// };
|
||||
// }),
|
||||
// ];
|
||||
// } else {
|
||||
// tiers = config.usage_tiers.map((tier) => {
|
||||
// const isLastTier = tier.to === -1 || tier.to === TierInfinite;
|
||||
// return {
|
||||
// to: isLastTier ? tier.to : Number(tier.to) + allowance!,
|
||||
// amount: tier.amount,
|
||||
// };
|
||||
// });
|
||||
// }
|
||||
|
||||
// return {
|
||||
// tiers: tiers,
|
||||
// name: "",
|
||||
// quantity: priceOptions?.quantity,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
export const getApiCusProduct = async ({
|
||||
ctx,
|
||||
fullCus,
|
||||
cusProduct,
|
||||
entities,
|
||||
// cusProduct,
|
||||
// subs,
|
||||
// org,
|
||||
// apiVersion,
|
||||
// features,
|
||||
// entity,
|
||||
}: {
|
||||
ctx: RequestContext;
|
||||
fullCus: FullCustomer;
|
||||
cusProduct: FullCusProduct;
|
||||
entities: Entity[];
|
||||
// org: Organization;
|
||||
// subs?: Subscription[];
|
||||
// apiVersion: ApiVersionClass;
|
||||
// features: Feature[];
|
||||
// entity?: Entity;
|
||||
}) => {
|
||||
const { org } = ctx;
|
||||
const trialing =
|
||||
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
|
||||
|
||||
const fullProduct = cusProductToProduct({ cusProduct });
|
||||
const v2Product = await getProductResponse({
|
||||
product: fullProduct,
|
||||
features: ctx.features,
|
||||
fullCus,
|
||||
currency: org.default_currency || "usd",
|
||||
options: cusProduct.options,
|
||||
withDisplay: false,
|
||||
});
|
||||
|
||||
const entity = entities.find(
|
||||
(e) => e.internal_id === cusProduct.internal_entity_id,
|
||||
);
|
||||
|
||||
const subId = cusProduct.subscription_ids?.[0];
|
||||
const autumnSub = fullCus.subscriptions?.find(
|
||||
(s) => s.id === subId || (s as Subscription).stripe_id === subId,
|
||||
);
|
||||
|
||||
let stripeSubData = {};
|
||||
if (autumnSub) {
|
||||
stripeSubData = {
|
||||
current_period_end: autumnSub?.current_period_end
|
||||
? autumnSub.current_period_end * 1000
|
||||
: null,
|
||||
current_period_start: autumnSub?.current_period_start
|
||||
? autumnSub.current_period_start * 1000
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!subId && trialing) {
|
||||
stripeSubData = {
|
||||
current_period_start: cusProduct.starts_at,
|
||||
current_period_end: cusProduct.trial_ends_at,
|
||||
};
|
||||
}
|
||||
|
||||
const apiCusProduct = ApiCusProductSchema.parse({
|
||||
id: fullProduct.id,
|
||||
name: fullProduct.name,
|
||||
group: fullProduct.group || null,
|
||||
status: trialing ? CusProductStatus.Trialing : cusProduct.status,
|
||||
canceled_at: cusProduct.canceled_at || null,
|
||||
|
||||
is_default: fullProduct.is_default || false,
|
||||
is_add_on: fullProduct.is_add_on || false,
|
||||
version: fullProduct.version,
|
||||
quantity: cusProduct.quantity,
|
||||
|
||||
started_at: cusProduct.starts_at,
|
||||
entity_id: entity?.id || cusProduct.entity_id || undefined,
|
||||
|
||||
...stripeSubData,
|
||||
items: v2Product.items,
|
||||
});
|
||||
|
||||
return {
|
||||
data: apiCusProduct,
|
||||
legacyData: {
|
||||
subscription_id: subId || undefined,
|
||||
} satisfies CusProductLegacyData,
|
||||
};
|
||||
|
||||
// if (subIds && subIds.length > 0 && apiVersion.gte(ApiVersion.V0_2)) {
|
||||
// const baseSub = subs?.find(
|
||||
// (s) => s.id === subIds[0] || (s as Subscription).stripe_id === subIds[0],
|
||||
// );
|
||||
// stripeSubData = {
|
||||
// current_period_end: baseSub?.current_period_end
|
||||
// ? baseSub.current_period_end * 1000
|
||||
// : null,
|
||||
// current_period_start: baseSub?.current_period_start
|
||||
// ? baseSub.current_period_start * 1000
|
||||
// : null,
|
||||
// };
|
||||
// }
|
||||
|
||||
// if (!subIds && trialing) {
|
||||
// stripeSubData = {
|
||||
// current_period_start: cusProduct.starts_at,
|
||||
// current_period_end: cusProduct.trial_ends_at,
|
||||
// };
|
||||
// }
|
||||
|
||||
// if (apiVersion.gte(ApiVersion.V1_1)) {
|
||||
// if ((!subIds || subIds.length === 0) && trialing) {
|
||||
// stripeSubData = {
|
||||
// current_period_start: cusProduct.starts_at,
|
||||
// current_period_end: cusProduct.trial_ends_at,
|
||||
// };
|
||||
// }
|
||||
|
||||
// const fullProduct = fullCusProductToProduct(cusProduct);
|
||||
// const v2Product = await getProductResponse({
|
||||
// product: fullProduct,
|
||||
// features,
|
||||
// withDisplay: false,
|
||||
// options: cusProduct.options,
|
||||
// });
|
||||
|
||||
// return ApiCusProductSchema.parse({
|
||||
// id: fullProduct.id,
|
||||
// name: fullProduct.name,
|
||||
// group: fullProduct.group || null,
|
||||
// status: trialing ? CusProductStatus.Trialing : cusProduct.status,
|
||||
// canceled_at: cusProduct.canceled_at || null,
|
||||
|
||||
// is_default: fullProduct.is_default || false,
|
||||
// is_add_on: fullProduct.is_add_on || false,
|
||||
// version: fullProduct.version,
|
||||
// quantity: cusProduct.quantity,
|
||||
|
||||
// started_at: cusProduct.starts_at,
|
||||
// entity_id: entity?.id || cusProduct.entity_id || undefined,
|
||||
|
||||
// ...stripeSubData,
|
||||
// items: v2Product.items,
|
||||
// });
|
||||
// } else {
|
||||
// const cusProductResponse = {
|
||||
// id: cusProduct.product.id,
|
||||
// name: cusProduct.product.name,
|
||||
// group: cusProduct.product.group,
|
||||
// status: trialing ? CusProductStatus.Trialing : cusProduct.status,
|
||||
// created_at: cusProduct.created_at,
|
||||
// canceled_at: cusProduct.canceled_at,
|
||||
// processor: {
|
||||
// type: cusProduct.processor?.type,
|
||||
// subscription_id: cusProduct.processor?.subscription_id || null,
|
||||
// },
|
||||
// subscription_ids: cusProduct.subscription_ids || [],
|
||||
// prices: prices,
|
||||
// starts_at: cusProduct.starts_at,
|
||||
|
||||
// ...stripeSubData,
|
||||
// };
|
||||
|
||||
// return cusProductResponse;
|
||||
// }
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
type ApiCusProduct,
|
||||
type CusProductLegacyData,
|
||||
type CusProductStatus,
|
||||
type FullCustomer,
|
||||
} from "@autumn/shared";
|
||||
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getApiCusProduct } from "./getApiCusProduct.js";
|
||||
|
||||
const mergeCusProductResponses = ({
|
||||
cusProductResponses,
|
||||
}: {
|
||||
cusProductResponses: ApiCusProduct[];
|
||||
}) => {
|
||||
const getProductKey = (product: ApiCusProduct) => {
|
||||
const status = ACTIVE_STATUSES.includes(product.status as CusProductStatus)
|
||||
? "active"
|
||||
: product.status;
|
||||
return `${product.id}:${status}`;
|
||||
};
|
||||
|
||||
const record: Record<string, any> = {};
|
||||
|
||||
for (const curr of cusProductResponses) {
|
||||
const key = getProductKey(curr);
|
||||
const latest = record[key];
|
||||
|
||||
const currStartedAt = curr.started_at;
|
||||
|
||||
record[key] = {
|
||||
...(latest || curr),
|
||||
version: Math.max(latest?.version || 1, curr?.version || 1),
|
||||
canceled_at: curr.canceled_at
|
||||
? curr.canceled_at
|
||||
: latest?.canceled_at || null,
|
||||
started_at: latest?.started_at
|
||||
? Math.min(latest?.started_at, currStartedAt)
|
||||
: currStartedAt,
|
||||
quantity: (latest?.quantity || 0) + (curr?.quantity || 0),
|
||||
};
|
||||
}
|
||||
|
||||
return Object.values(record);
|
||||
};
|
||||
|
||||
export const getApiCusProducts = async ({
|
||||
ctx,
|
||||
fullCus,
|
||||
}: {
|
||||
ctx: RequestContext;
|
||||
fullCus: FullCustomer;
|
||||
}) => {
|
||||
// Process full cus products
|
||||
const apiCusProducts: ApiCusProduct[] = [];
|
||||
|
||||
// const inStatuses = ctx.org.config.include_past_due
|
||||
// ? [CusProductStatus.Active, CusProductStatus.PastDue]
|
||||
// : [CusProductStatus.Active];
|
||||
|
||||
// const cusProducts = fullCus.customer_products.filter((cp) =>
|
||||
// inStatuses.includes(cp.status),
|
||||
// );
|
||||
const cusProducts = fullCus.customer_products;
|
||||
|
||||
const legacyData: Record<string, CusProductLegacyData> = {};
|
||||
for (const cusProduct of cusProducts) {
|
||||
const processed = await getApiCusProduct({
|
||||
cusProduct,
|
||||
ctx,
|
||||
fullCus,
|
||||
entities: fullCus.entities,
|
||||
});
|
||||
|
||||
apiCusProducts.push(processed.data);
|
||||
legacyData[processed.data.id] = processed.legacyData;
|
||||
}
|
||||
|
||||
const merged = mergeCusProductResponses({
|
||||
cusProductResponses: apiCusProducts,
|
||||
});
|
||||
|
||||
return {
|
||||
apiCusProducts: merged,
|
||||
legacyData,
|
||||
};
|
||||
};
|
||||
@@ -1,25 +1,63 @@
|
||||
import {
|
||||
AffectedResource,
|
||||
type ApiCustomer,
|
||||
ApiCustomerSchema,
|
||||
applyResponseVersionChanges,
|
||||
backwardsChangeActive,
|
||||
CusExpand,
|
||||
type CustomerLegacyData,
|
||||
type FullCustomer,
|
||||
V0_2_InvoicesAlwaysExpanded,
|
||||
} from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getApiCusFeatures } from "./getApiCusFeature/getApiCusFeatures.js";
|
||||
import { getApiCusProducts } from "./getApiCusProduct/getApiCusProducts.js";
|
||||
import { getApiCustomerExpand } from "./getApiCustomerExpand.js";
|
||||
|
||||
export const getApiCustomer = async ({
|
||||
ctx,
|
||||
fullCus,
|
||||
expand,
|
||||
withAutumnId = false,
|
||||
}: {
|
||||
ctx: RequestContext;
|
||||
fullCus: FullCustomer;
|
||||
expand: CusExpand[];
|
||||
withAutumnId?: boolean;
|
||||
}) => {
|
||||
// SIDE EFFECT
|
||||
if (
|
||||
backwardsChangeActive({
|
||||
apiVersion: ctx.apiVersion,
|
||||
versionChange: V0_2_InvoicesAlwaysExpanded,
|
||||
})
|
||||
) {
|
||||
expand.push(CusExpand.Invoices);
|
||||
}
|
||||
|
||||
const apiCusFeatures = await getApiCusFeatures({
|
||||
ctx,
|
||||
fullCus,
|
||||
});
|
||||
|
||||
const apiCustomer = ApiCustomerSchema.parse({
|
||||
const { apiCusProducts, legacyData: cusProductLegacyData } =
|
||||
await getApiCusProducts({
|
||||
ctx,
|
||||
fullCus,
|
||||
});
|
||||
|
||||
const apiCusExpand = await getApiCustomerExpand({
|
||||
ctx,
|
||||
fullCus,
|
||||
expand,
|
||||
});
|
||||
|
||||
const apiCustomer = ApiCustomerSchema.extend({
|
||||
autumn_id: z.string().optional(),
|
||||
}).parse({
|
||||
autumn_id: withAutumnId ? fullCus.internal_id : undefined,
|
||||
|
||||
id: fullCus.id || null,
|
||||
created_at: fullCus.created_at,
|
||||
name: fullCus.name || null,
|
||||
@@ -28,23 +66,18 @@ export const getApiCustomer = async ({
|
||||
|
||||
stripe_id: fullCus.processor?.id || null,
|
||||
env: fullCus.env,
|
||||
metadata: fullCus.metadata,
|
||||
|
||||
products: [],
|
||||
products: apiCusProducts,
|
||||
features: apiCusFeatures,
|
||||
|
||||
// invoices: z.array(APIInvoiceSchema).optional(),
|
||||
// trials_used: z.array(APITrialsUsedSchema).optional(),
|
||||
|
||||
// rewards: APICusRewardsSchema.nullish(),
|
||||
// metadata: z.record(z.any(), z.any()).default({}),
|
||||
// entities: z.array(EntityResponseSchema).optional(),
|
||||
// referrals: z.array(APICusReferralSchema).optional(),
|
||||
// upcoming_invoice: APICusUpcomingInvoiceSchema.nullish(),
|
||||
// payment_method: z.any().nullish(),
|
||||
...apiCusExpand,
|
||||
});
|
||||
|
||||
return applyResponseVersionChanges({
|
||||
return applyResponseVersionChanges<ApiCustomer, CustomerLegacyData>({
|
||||
input: apiCustomer,
|
||||
legacyData: {
|
||||
cusProductLegacyData,
|
||||
},
|
||||
targetVersion: ctx.apiVersion,
|
||||
resource: AffectedResource.Customer,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
ApiBaseEntitySchema,
|
||||
type ApiCustomerExpand,
|
||||
CusExpand,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { getCusPaymentMethodRes } from "../cusResponseUtils/getCusPaymentMethodRes.js";
|
||||
import { getCusReferrals } from "../cusResponseUtils/getCusReferrals.js";
|
||||
import { getCusRewards } from "../cusResponseUtils/getCusRewards.js";
|
||||
import { getCusUpcomingInvoice } from "../cusResponseUtils/getCusUpcomingInvoice.js";
|
||||
|
||||
export const getApiCustomerExpand = async ({
|
||||
ctx,
|
||||
fullCus,
|
||||
expand,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCus: FullCustomer;
|
||||
expand: CusExpand[];
|
||||
}): Promise<ApiCustomerExpand> => {
|
||||
const { org, env, db, logger } = ctx;
|
||||
const getCusTrialsUsed = () => {
|
||||
if (expand.includes(CusExpand.TrialsUsed)) {
|
||||
return fullCus.trials_used;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getApiCusEntities = () => {
|
||||
if (expand.includes(CusExpand.Entities)) {
|
||||
return fullCus.entities.map((e) => ApiBaseEntitySchema.parse(e));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const invoices = expand.includes(CusExpand.Invoices)
|
||||
? invoicesToResponse({
|
||||
invoices: fullCus.invoices || [],
|
||||
logger,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const [rewards, upcomingInvoice, referrals, paymentMethod] =
|
||||
await Promise.all([
|
||||
getCusRewards({
|
||||
org,
|
||||
env,
|
||||
fullCus,
|
||||
subIds: fullCus.customer_products.flatMap(
|
||||
(cp: FullCusProduct) => cp.subscription_ids || [],
|
||||
),
|
||||
expand,
|
||||
}),
|
||||
getCusUpcomingInvoice({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
fullCus,
|
||||
expand,
|
||||
}),
|
||||
getCusReferrals({
|
||||
db,
|
||||
fullCus,
|
||||
expand,
|
||||
}),
|
||||
getCusPaymentMethodRes({
|
||||
org,
|
||||
env,
|
||||
fullCus,
|
||||
expand,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
trials_used: getCusTrialsUsed(),
|
||||
entities: getApiCusEntities(),
|
||||
rewards,
|
||||
upcoming_invoice: upcomingInvoice,
|
||||
referrals,
|
||||
payment_method: paymentMethod,
|
||||
invoices,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
APICusProductSchema,
|
||||
ApiCusProductSchema,
|
||||
ApiVersion,
|
||||
type ApiVersionClass,
|
||||
CusProductStatus,
|
||||
@@ -149,7 +149,7 @@ export const getCusProductResponse = async ({
|
||||
options: cusProduct.options,
|
||||
});
|
||||
|
||||
return APICusProductSchema.parse({
|
||||
return ApiCusProductSchema.parse({
|
||||
id: fullProduct.id,
|
||||
name: fullProduct.name,
|
||||
group: fullProduct.group || null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
type APICusProduct,
|
||||
type ApiCusProduct,
|
||||
ApiVersion,
|
||||
type ApiVersionClass,
|
||||
type CusProductStatus,
|
||||
@@ -13,9 +13,9 @@ import { getCusProductResponse } from "./getCusProductResponse.js";
|
||||
const mergeCusProductResponses = ({
|
||||
cusProductResponses,
|
||||
}: {
|
||||
cusProductResponses: APICusProduct[];
|
||||
cusProductResponses: ApiCusProduct[];
|
||||
}) => {
|
||||
const getProductKey = (product: APICusProduct) => {
|
||||
const getProductKey = (product: ApiCusProduct) => {
|
||||
const status = ACTIVE_STATUSES.includes(product.status as CusProductStatus)
|
||||
? "active"
|
||||
: product.status;
|
||||
@@ -84,10 +84,10 @@ export const processFullCusProducts = async ({
|
||||
|
||||
if (apiVersion.gte(ApiVersion.V1_1)) {
|
||||
main = mergeCusProductResponses({
|
||||
cusProductResponses: main as APICusProduct[],
|
||||
cusProductResponses: main as ApiCusProduct[],
|
||||
});
|
||||
addOns = mergeCusProductResponses({
|
||||
cusProductResponses: addOns as APICusProduct[],
|
||||
cusProductResponses: addOns as ApiCusProduct[],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
APICusReferralSchema,
|
||||
ApiCusReferralSchema,
|
||||
CusExpand,
|
||||
type FullCustomer,
|
||||
} from "@autumn/shared";
|
||||
@@ -28,7 +28,7 @@ export const getCusReferrals = async ({
|
||||
});
|
||||
|
||||
return referred.map((r) =>
|
||||
APICusReferralSchema.parse({
|
||||
ApiCusReferralSchema.parse({
|
||||
program_id: r.reward_program?.id,
|
||||
customer: {
|
||||
id: r.customer.id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ApiCusUpcomingInvoice } from "@autumn/shared";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusExpand,
|
||||
@@ -66,7 +67,7 @@ export const getCusUpcomingInvoice = async ({
|
||||
|
||||
lines.push({
|
||||
product_id: cusProd?.product.id || null,
|
||||
description: line.description,
|
||||
description: line.description || "",
|
||||
amount: atmnLineAmount,
|
||||
});
|
||||
}
|
||||
@@ -95,7 +96,7 @@ export const getCusUpcomingInvoice = async ({
|
||||
currency: upcomingInvoice.currency,
|
||||
});
|
||||
|
||||
const res = {
|
||||
const res: ApiCusUpcomingInvoice = {
|
||||
lines,
|
||||
discounts,
|
||||
subtotal: atmnSubtotal,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
type APIInvoice,
|
||||
type ApiInvoice,
|
||||
CusExpand,
|
||||
type Customer,
|
||||
type CustomerData,
|
||||
@@ -84,7 +84,7 @@ export const getCusInvoices = async ({
|
||||
limit?: number;
|
||||
withItems?: boolean;
|
||||
features?: Feature[];
|
||||
}): Promise<APIInvoice[]> => {
|
||||
}): Promise<ApiInvoice[]> => {
|
||||
const finalInvoices = notNullish(invoices)
|
||||
? invoices
|
||||
: await InvoiceService.list({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
ApiBaseEntitySchema,
|
||||
ApiCusFeatureV2Schema,
|
||||
ApiCustomerSchema,
|
||||
ApiCustomerV2Schema,
|
||||
ApiVersion,
|
||||
type ApiVersionClass,
|
||||
type AppEnv,
|
||||
@@ -9,7 +11,6 @@ import {
|
||||
CustomerResponseSchema,
|
||||
cusProductsToCusEnts,
|
||||
cusProductsToCusPrices,
|
||||
EntityResponseSchema,
|
||||
type Feature,
|
||||
FeatureType,
|
||||
type FullCusProduct,
|
||||
@@ -135,8 +136,12 @@ export const getCustomerDetails = async ({
|
||||
expand,
|
||||
});
|
||||
|
||||
const apiCustomerSchema = apiVersion.gte(ApiVersion.V1_2)
|
||||
? ApiCustomerSchema
|
||||
: ApiCustomerV2Schema;
|
||||
|
||||
const cusResponse = {
|
||||
...ApiCustomerSchema.parse({
|
||||
...apiCustomerSchema.parse({
|
||||
...customer,
|
||||
stripe_id: customer.processor?.id,
|
||||
features: entList,
|
||||
@@ -156,7 +161,7 @@ export const getCustomerDetails = async ({
|
||||
metadata: customer.metadata,
|
||||
entities: expand.includes(CusExpand.Entities)
|
||||
? customer.entities.map((e) =>
|
||||
EntityResponseSchema.parse({
|
||||
ApiBaseEntitySchema.parse({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
customer_id: customer.id,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const getOrCreateCustomer = async ({
|
||||
withCache = false,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
customerId: string;
|
||||
customerId: string | null;
|
||||
customerData?: CustomerData;
|
||||
inStatuses?: CusProductStatus[];
|
||||
skipGet?: boolean;
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
import { ApiVersion, CusExpand, CusProductStatus } from "@autumn/shared";
|
||||
import { GetCustomerQuerySchema } from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { getCusWithCache } from "../cusCache/getCusWithCache.js";
|
||||
import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js";
|
||||
import { parseCusExpand } from "../cusUtils/cusUtils.js";
|
||||
|
||||
/**
|
||||
* GET /customers/:customer_id (V2 with versioning system)
|
||||
*
|
||||
* This is the NEW implementation using the versioning system.
|
||||
* DO NOT touch the old handleGetCustomer.ts until this is validated.
|
||||
*
|
||||
* Key differences:
|
||||
* 1. Each resource (customer/product/feature) handles its own versioning
|
||||
* 2. getApiCustomer/getApiCusProduct/getApiCusFeature apply version changes
|
||||
* 3. No version branching in handler logic
|
||||
* 4. Side effects handled explicitly (expand invoices for V1_0)
|
||||
*/
|
||||
export const handleGetCustomerV2 = createRoute({
|
||||
query: GetCustomerQuerySchema,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const customerId = c.req.param("customer_id");
|
||||
const { env, db, logger, org, features } = ctx;
|
||||
const { expand } = c.req.query();
|
||||
|
||||
const expandArray = parseCusExpand(expand);
|
||||
|
||||
// Side effect: V1_0 always expands invoices
|
||||
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
|
||||
expandArray.push(CusExpand.Invoices);
|
||||
}
|
||||
const { env, db, logger, org } = ctx;
|
||||
const { expand = [] } = c.req.valid("query");
|
||||
|
||||
logger.info(`[V2] Getting customer ${customerId} for org ${org.slug}`);
|
||||
const startTime = Date.now();
|
||||
@@ -38,91 +19,19 @@ export const handleGetCustomerV2 = createRoute({
|
||||
idOrInternalId: customerId,
|
||||
org,
|
||||
env,
|
||||
expand: expandArray,
|
||||
expand,
|
||||
logger,
|
||||
allowNotFound: false,
|
||||
});
|
||||
|
||||
logger.info(`[V2] Get customer took ${Date.now() - startTime}ms`);
|
||||
|
||||
// Get feature balances
|
||||
const inStatuses = org.config.include_past_due
|
||||
? [CusProductStatus.Active, CusProductStatus.PastDue]
|
||||
: [CusProductStatus.Active];
|
||||
|
||||
const customer = await getApiCustomer({
|
||||
ctx,
|
||||
fullCus: fullCus,
|
||||
expand,
|
||||
});
|
||||
|
||||
return c.json(customer);
|
||||
},
|
||||
});
|
||||
|
||||
// const balances = await getCusBalances({
|
||||
// cusEntsWithCusProduct: cusEnts,
|
||||
// cusPrices: cusProductsToCusPrices({
|
||||
// cusProducts: customer.customer_products,
|
||||
// inStatuses,
|
||||
// }),
|
||||
// org,
|
||||
// apiVersion: ctx.apiVersion.semver as any, // TODO: fix type
|
||||
// });
|
||||
|
||||
// // Fetch optional expanded fields
|
||||
// const subIds = customer.customer_products.flatMap(
|
||||
// (cp: any) => cp.subscription_ids || [],
|
||||
// );
|
||||
|
||||
// const rewards = await getCusRewards({
|
||||
// org,
|
||||
// env,
|
||||
// fullCus: customer,
|
||||
// subIds,
|
||||
// expand: expandArray,
|
||||
// });
|
||||
|
||||
// const upcomingInvoice = await getCusUpcomingInvoice({
|
||||
// db,
|
||||
// org,
|
||||
// env,
|
||||
// fullCus: customer,
|
||||
// expand: expandArray,
|
||||
// });
|
||||
|
||||
// const referrals = await getCusReferrals({
|
||||
// db,
|
||||
// fullCus: customer,
|
||||
// expand: expandArray,
|
||||
// });
|
||||
|
||||
// const paymentMethod = await getCusPaymentMethodRes({
|
||||
// org,
|
||||
// env,
|
||||
// fullCus: customer,
|
||||
// expand: expandArray,
|
||||
// });
|
||||
|
||||
// const invoices = expandArray.includes(CusExpand.Invoices)
|
||||
// ? invoicesToResponse({
|
||||
// invoices: customer.invoices || [],
|
||||
// logger,
|
||||
// })
|
||||
// : undefined;
|
||||
|
||||
// const entities = expandArray.includes(CusExpand.Entities)
|
||||
// ? customer.entities.map((e: any) => ({
|
||||
// id: e.id,
|
||||
// name: e.name,
|
||||
// customer_id: customer.id,
|
||||
// feature_id: e.feature_id,
|
||||
// created_at: e.created_at,
|
||||
// env: customer.env,
|
||||
// }))
|
||||
// : undefined;
|
||||
|
||||
// const trialsUsed = expandArray.includes(CusExpand.TrialsUsed)
|
||||
// ? customer.trials_used
|
||||
// : undefined;
|
||||
|
||||
// const { with_autumn_id } = c.req.query();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
CreateCustomerParamsSchema,
|
||||
CreateCustomerQuerySchema,
|
||||
} from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js";
|
||||
import { getOrCreateCustomer } from "../cusUtils/getOrCreateCustomer.js";
|
||||
|
||||
export const handlePostCustomer = createRoute({
|
||||
query: CreateCustomerQuerySchema.extend({
|
||||
with_autumn_id: z.boolean().optional(),
|
||||
}),
|
||||
|
||||
body: CreateCustomerParamsSchema,
|
||||
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
|
||||
const { expand = [], with_autumn_id = false } = c.req.valid("query");
|
||||
const createCusParams = c.req.valid("json");
|
||||
|
||||
const fullCus = await getOrCreateCustomer({
|
||||
req: ctx as ExtendedRequest,
|
||||
customerId: createCusParams.id,
|
||||
customerData: createCusParams,
|
||||
expand,
|
||||
entityId: createCusParams.entity_id,
|
||||
entityData: createCusParams.entity_data,
|
||||
withCache: true,
|
||||
});
|
||||
|
||||
const customer = await getApiCustomer({
|
||||
ctx,
|
||||
fullCus: fullCus,
|
||||
expand,
|
||||
withAutumnId: with_autumn_id,
|
||||
});
|
||||
|
||||
return c.json(customer);
|
||||
},
|
||||
});
|
||||
@@ -19,12 +19,12 @@ export const toApiFeature = ({ feature }: { feature: Feature }) => {
|
||||
// return FeatureResponseSchema.parse(feature);
|
||||
// 1. Get feature type
|
||||
let featureType = feature.type;
|
||||
if (feature.type == FeatureType.Metered) {
|
||||
if (feature.type === FeatureType.Metered) {
|
||||
featureType = feature.config.usage_type;
|
||||
}
|
||||
|
||||
let creditSchema;
|
||||
if (feature.type == FeatureType.CreditSystem) {
|
||||
if (feature.type === FeatureType.CreditSystem) {
|
||||
creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({
|
||||
metered_feature_id: s.metered_feature_id,
|
||||
credit_cost: s.credit_amount,
|
||||
@@ -54,8 +54,8 @@ export const fromApiFeature = ({
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
const isMetered =
|
||||
apiFeature.type == ApiFeatureType.SingleUsage ||
|
||||
apiFeature.type == ApiFeatureType.ContinuousUse;
|
||||
apiFeature.type === ApiFeatureType.SingleUsage ||
|
||||
apiFeature.type === ApiFeatureType.ContinuousUse;
|
||||
|
||||
const featureType: FeatureType = isMetered
|
||||
? FeatureType.Metered
|
||||
@@ -71,8 +71,8 @@ export const fromApiFeature = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (featureType == FeatureType.CreditSystem) {
|
||||
if (!apiFeature.credit_schema || apiFeature.credit_schema.length == 0) {
|
||||
if (featureType === FeatureType.CreditSystem) {
|
||||
if (!apiFeature.credit_schema || apiFeature.credit_schema.length === 0) {
|
||||
throw new RecaseError({
|
||||
message: "Credit system schema is required",
|
||||
code: "CREDIT_SYSTEM_SCHEMA_REQUIRED",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
type APIInvoice,
|
||||
APIInvoiceItemSchema,
|
||||
type ApiInvoice,
|
||||
ApiInvoiceItemSchema,
|
||||
type Customer,
|
||||
type Feature,
|
||||
type Invoice,
|
||||
@@ -41,7 +41,7 @@ export const processInvoice = ({
|
||||
(f) => f.internal_id === i.internal_feature_id,
|
||||
);
|
||||
|
||||
return APIInvoiceItemSchema.parse({
|
||||
return ApiInvoiceItemSchema.parse({
|
||||
description: i.description,
|
||||
period_start: i.period_start,
|
||||
period_end: i.period_end,
|
||||
@@ -50,7 +50,7 @@ export const processInvoice = ({
|
||||
});
|
||||
})
|
||||
: undefined,
|
||||
} as APIInvoice;
|
||||
} as ApiInvoice;
|
||||
};
|
||||
|
||||
export class InvoiceService {
|
||||
|
||||
@@ -74,13 +74,13 @@ export const handleMigrate = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (fromProduct.is_add_on || toProduct.is_add_on) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot migrate customers for add on products`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
// if (fromProduct.is_add_on || toProduct.is_add_on) {
|
||||
// throw new RecaseError({
|
||||
// message: `Cannot migrate customers for add on products`,
|
||||
// code: ErrCode.InvalidRequest,
|
||||
// statusCode: 400,
|
||||
// });
|
||||
// }
|
||||
|
||||
for (const price of toProduct.prices) {
|
||||
const billingType = getBillingType(price.config);
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import {
|
||||
MigrationJob,
|
||||
Customer,
|
||||
Organization,
|
||||
import type {
|
||||
AppEnv,
|
||||
FullProduct,
|
||||
FullCusProduct,
|
||||
Feature,
|
||||
FullCusProduct,
|
||||
FullProduct,
|
||||
MigrationJob,
|
||||
Organization,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js";
|
||||
import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js";
|
||||
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
|
||||
|
||||
export const migrateCustomer = async ({
|
||||
db,
|
||||
@@ -42,7 +40,7 @@ export const migrateCustomer = async ({
|
||||
}) => {
|
||||
try {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
let fullCus = await CusService.getFull({
|
||||
const fullCus = await CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customerId,
|
||||
orgId,
|
||||
@@ -51,7 +49,7 @@ export const migrateCustomer = async ({
|
||||
});
|
||||
|
||||
// 1. Build req object
|
||||
let req = {
|
||||
const req = {
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
@@ -63,7 +61,8 @@ export const migrateCustomer = async ({
|
||||
|
||||
const cusProducts = fullCus.customer_products;
|
||||
const filteredCusProducts = cusProducts.filter(
|
||||
(cp: FullCusProduct) => cp.product.internal_id == fromProduct.internal_id,
|
||||
(cp: FullCusProduct) =>
|
||||
cp.product.internal_id === fromProduct.internal_id,
|
||||
);
|
||||
|
||||
for (const cusProduct of filteredCusProducts) {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
import {
|
||||
Customer,
|
||||
Feature,
|
||||
FullProduct,
|
||||
MigrationJob,
|
||||
type Customer,
|
||||
type Feature,
|
||||
type FullProduct,
|
||||
type MigrationJob,
|
||||
MigrationJobStep,
|
||||
} from "@autumn/shared";
|
||||
import { MigrationService } from "../MigrationService.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { migrateCustomer } from "./migrateCustomer.js";
|
||||
import { sendMigrationEmail } from "../../emails/sendMigrationEmail.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { MigrationService } from "../MigrationService.js";
|
||||
import { migrateCustomer } from "./migrateCustomer.js";
|
||||
|
||||
export const migrateCustomers = async ({
|
||||
db,
|
||||
@@ -41,17 +42,17 @@ export const migrateCustomers = async ({
|
||||
});
|
||||
|
||||
let batchCount = 0;
|
||||
let { org_id: orgId, env } = migrationJob;
|
||||
const { org_id: orgId, env } = migrationJob;
|
||||
|
||||
let org = await OrgService.get({
|
||||
const org = await OrgService.get({
|
||||
db,
|
||||
orgId,
|
||||
});
|
||||
|
||||
// Create stripe prices if they don't exist
|
||||
let stripeCli = createStripeCli({ org, env });
|
||||
let batchCreate = [];
|
||||
for (let price of toProduct.prices) {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const batchCreate = [];
|
||||
for (const price of toProduct.prices) {
|
||||
batchCreate.push(
|
||||
createStripePriceIFNotExist({
|
||||
db,
|
||||
@@ -67,12 +68,12 @@ export const migrateCustomers = async ({
|
||||
|
||||
await Promise.all(batchCreate);
|
||||
|
||||
let batchSize = 5;
|
||||
const batchSize = 5;
|
||||
|
||||
for (let i = 0; i < customers.length; i += batchSize) {
|
||||
let batchCustomers = customers.slice(i, i + batchSize);
|
||||
let batchPromises = [];
|
||||
for (let customer of batchCustomers) {
|
||||
const batchCustomers = customers.slice(i, i + batchSize);
|
||||
const batchPromises = [];
|
||||
for (const customer of batchCustomers) {
|
||||
if (!customer.id) continue;
|
||||
batchPromises.push(
|
||||
migrateCustomer({
|
||||
@@ -90,9 +91,9 @@ export const migrateCustomers = async ({
|
||||
);
|
||||
}
|
||||
|
||||
let results = await Promise.all(batchPromises);
|
||||
let numPassed = results.filter((r) => r).length;
|
||||
let numFailed = results.filter((r) => !r).length;
|
||||
const results = await Promise.all(batchPromises);
|
||||
const numPassed = results.filter((r) => r).length;
|
||||
const numFailed = results.filter((r) => !r).length;
|
||||
logger.info(
|
||||
`Job: ${migrationJob.id} - Migrated ${i + batchCustomers.length}/${
|
||||
customers.length
|
||||
@@ -100,14 +101,14 @@ export const migrateCustomers = async ({
|
||||
);
|
||||
|
||||
// Get current number of customers migrated
|
||||
let curMigrationJob = await MigrationService.getJob({
|
||||
const curMigrationJob = await MigrationService.getJob({
|
||||
db,
|
||||
id: migrationJob.id,
|
||||
});
|
||||
let curSucceeded =
|
||||
const curSucceeded =
|
||||
curMigrationJob.step_details[MigrationJobStep.MigrateCustomers]
|
||||
?.succeeded || 0;
|
||||
let curFailed =
|
||||
const curFailed =
|
||||
curMigrationJob.step_details[MigrationJobStep.MigrateCustomers]?.failed ||
|
||||
0;
|
||||
|
||||
@@ -134,7 +135,7 @@ export const migrateCustomers = async ({
|
||||
}
|
||||
|
||||
// Get number of errors
|
||||
let migrationDetails: any = {};
|
||||
const migrationDetails: any = {};
|
||||
// try {
|
||||
// let errors = await MigrationService.getErrors({
|
||||
// db,
|
||||
@@ -152,7 +153,7 @@ export const migrateCustomers = async ({
|
||||
// logger.error(error);
|
||||
// }
|
||||
|
||||
let curMigrationJob = await MigrationService.getJob({
|
||||
const curMigrationJob = await MigrationService.getJob({
|
||||
db,
|
||||
id: migrationJob.id,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type FullProduct,
|
||||
} from "@autumn/shared";
|
||||
import type { FullCusProduct, FullCustomer, FullProduct } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeCusData } from "@/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
@@ -21,7 +17,7 @@ export const migrationToAttachParams = async ({
|
||||
cusProduct: FullCusProduct;
|
||||
newProduct: FullProduct;
|
||||
}): Promise<AttachParams> => {
|
||||
const { org, apiVersion } = req;
|
||||
// const { org } = req;
|
||||
const internalEntityId = cusProduct.internal_entity_id || undefined;
|
||||
|
||||
const { stripeCus, paymentMethod, now } = await getStripeCusData({
|
||||
@@ -56,7 +52,7 @@ export const migrationToAttachParams = async ({
|
||||
cusProducts: customer.customer_products,
|
||||
|
||||
// Others
|
||||
apiVersion: apiVersion.value,
|
||||
apiVersion: cusProduct.api_semver || undefined,
|
||||
};
|
||||
|
||||
return attachParams;
|
||||
|
||||
@@ -100,6 +100,7 @@ export const handleUpdateProductV2 = createRoute({
|
||||
const itemsExist = notNullish(body.items);
|
||||
|
||||
const cusProductExists = cusProductsCurVersion.length > 0;
|
||||
|
||||
if (cusProductExists && itemsExist) {
|
||||
if (disable_version === "true") {
|
||||
throw new RecaseError({
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import {
|
||||
BillingType,
|
||||
ErrCode,
|
||||
FullProduct,
|
||||
Price,
|
||||
type Feature,
|
||||
type FullProduct,
|
||||
type Price,
|
||||
PriceType,
|
||||
Product,
|
||||
UsagePriceConfig,
|
||||
type Product,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { Feature } from "@autumn/shared";
|
||||
import { getBillingType } from "../priceUtils.js";
|
||||
import Stripe from "stripe";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { getBillingType } from "../priceUtils.js";
|
||||
|
||||
export const findPrepaidPrice = ({
|
||||
prices,
|
||||
@@ -21,15 +19,16 @@ export const findPrepaidPrice = ({
|
||||
internalFeatureId?: string;
|
||||
}) => {
|
||||
return prices.find((p: Price) => {
|
||||
if (p.config.type != PriceType.Usage) return false;
|
||||
if (p.config.type !== PriceType.Usage) return false;
|
||||
|
||||
const billingType = getBillingType(p.config);
|
||||
|
||||
const config = p.config as UsagePriceConfig;
|
||||
|
||||
if (billingType != BillingType.UsageInAdvance) return false;
|
||||
if (billingType !== BillingType.UsageInAdvance) return false;
|
||||
|
||||
if (internalFeatureId) {
|
||||
return config.internal_feature_id == internalFeatureId;
|
||||
return config.internal_feature_id === internalFeatureId;
|
||||
} else return true;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ export const isV4Usage = ({
|
||||
|
||||
return (
|
||||
billingType === BillingType.UsageInArrear &&
|
||||
(cusProduct.api_version === ApiVersion.Beta ||
|
||||
(cusProduct.api_semver === ApiVersion.Beta ||
|
||||
notNullish(cusProduct.internal_entity_id))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import {
|
||||
AppEnv,
|
||||
type AppEnv,
|
||||
ErrCode,
|
||||
Feature,
|
||||
type Feature,
|
||||
FeatureType,
|
||||
FeatureUsageType,
|
||||
ProductItem,
|
||||
type ProductItem,
|
||||
ProductItemFeatureType,
|
||||
} from "@autumn/shared";
|
||||
import { isBooleanFeatureItem } from "./productItemUtils/getItemType.js";
|
||||
import { validateFeatureId } from "@/internal/features/featureUtils.js";
|
||||
import {
|
||||
constructBooleanFeature,
|
||||
constructMeteredFeature,
|
||||
} from "@/internal/features/utils/constructFeatureUtils.js";
|
||||
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
import { isBooleanFeatureItem } from "./productItemUtils/getItemType.js";
|
||||
|
||||
export const createFeaturesFromItems = ({
|
||||
items,
|
||||
@@ -28,22 +27,23 @@ export const createFeaturesFromItems = ({
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
let newFeatures: Feature[] = [];
|
||||
const newFeatures: Feature[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.feature_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let feature = curFeatures.find((f) => f.id == item.feature_id);
|
||||
const feature = curFeatures.find((f) => f.id === item.feature_id);
|
||||
|
||||
if (feature) {
|
||||
if (nullish(item.feature_type)) {
|
||||
continue;
|
||||
}
|
||||
// 1. Check that feature_type matches
|
||||
if (item.feature_type == ProductItemFeatureType.Static) {
|
||||
let booleanFail =
|
||||
item.feature_type == ProductItemFeatureType.Static &&
|
||||
feature.type != FeatureType.Boolean;
|
||||
if (item.feature_type === ProductItemFeatureType.Static) {
|
||||
const booleanFail =
|
||||
item.feature_type === ProductItemFeatureType.Static &&
|
||||
feature.type !== FeatureType.Boolean;
|
||||
|
||||
if (booleanFail) {
|
||||
throw new RecaseError({
|
||||
@@ -53,7 +53,7 @@ export const createFeaturesFromItems = ({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let usageFail = item.feature_type != feature.config?.usage_type;
|
||||
const usageFail = item.feature_type !== feature.config?.usage_type;
|
||||
if (usageFail) {
|
||||
throw new RecaseError({
|
||||
message: `Feature ${item.feature_id} already exists but is not a ${item.feature_type} feature`,
|
||||
@@ -88,7 +88,7 @@ export const createFeaturesFromItems = ({
|
||||
orgId,
|
||||
env,
|
||||
usageType:
|
||||
item.feature_type == ProductItemFeatureType.ContinuousUse
|
||||
item.feature_type === ProductItemFeatureType.ContinuousUse
|
||||
? FeatureUsageType.Continuous
|
||||
: FeatureUsageType.Single,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import type {
|
||||
AppEnv,
|
||||
Entitlement,
|
||||
Feature,
|
||||
@@ -6,14 +6,13 @@ import {
|
||||
Product,
|
||||
ProductItem,
|
||||
} from "@autumn/shared";
|
||||
import { itemToPriceAndEnt } from "./itemToPriceAndEnt.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { PriceService } from "@/internal/products/prices/PriceService.js";
|
||||
import { EntitlementService } from "../../entitlements/EntitlementService.js";
|
||||
import { validateProductItems } from "../validateProductItems.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { isFeatureItem } from "./getItemType.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { formatPrice } from "../../prices/priceUtils.js";
|
||||
import { itemToPriceAndEnt } from "./itemToPriceAndEnt.js";
|
||||
|
||||
const updateDbPricesAndEnts = async ({
|
||||
db,
|
||||
@@ -62,8 +61,8 @@ const updateDbPricesAndEnts = async ({
|
||||
]);
|
||||
|
||||
// Check if any custom prices use this entitlement...
|
||||
let deletedEntIds = deletedEnts.map((ent) => ent.id!);
|
||||
let customPrices = await PriceService.getCustomInEntIds({
|
||||
const deletedEntIds = deletedEnts.map((ent) => ent.id!);
|
||||
const customPrices = await PriceService.getCustomInEntIds({
|
||||
db,
|
||||
entitlementIds: deletedEntIds,
|
||||
});
|
||||
@@ -75,9 +74,9 @@ const updateDbPricesAndEnts = async ({
|
||||
ids: deletedEntIds,
|
||||
});
|
||||
} else {
|
||||
let updateOrDelete: any = [];
|
||||
const updateOrDelete: any = [];
|
||||
for (const ent of deletedEnts) {
|
||||
let hasCustomPrice = customPrices.some(
|
||||
const hasCustomPrice = customPrices.some(
|
||||
(price) => price.entitlement_id == ent.id,
|
||||
);
|
||||
|
||||
@@ -179,7 +178,7 @@ export const handleNewProductItems = async ({
|
||||
}
|
||||
|
||||
// Validate product items...
|
||||
let { allFeatures, newFeatures } = validateProductItems({
|
||||
const { allFeatures, newFeatures } = validateProductItems({
|
||||
newItems,
|
||||
features,
|
||||
orgId: product.org_id!,
|
||||
@@ -188,14 +187,14 @@ export const handleNewProductItems = async ({
|
||||
|
||||
features = allFeatures;
|
||||
|
||||
let newPrices: Price[] = [];
|
||||
let newEnts: Entitlement[] = [];
|
||||
const newPrices: Price[] = [];
|
||||
const newEnts: Entitlement[] = [];
|
||||
|
||||
let updatedPrices: Price[] = [];
|
||||
let updatedEnts: Entitlement[] = [];
|
||||
const updatedPrices: Price[] = [];
|
||||
const updatedEnts: Entitlement[] = [];
|
||||
|
||||
let deletedPrices: Price[] = curPrices.filter((price) => {
|
||||
let item = newItems.find((item) => item.price_id == price.id);
|
||||
const deletedPrices: Price[] = curPrices.filter((price) => {
|
||||
const item = newItems.find((item) => item.price_id === price.id);
|
||||
if (!item) {
|
||||
return true;
|
||||
}
|
||||
@@ -203,20 +202,20 @@ export const handleNewProductItems = async ({
|
||||
return isFeatureItem(item);
|
||||
});
|
||||
|
||||
let deletedEnts: Entitlement[] = curEnts.filter(
|
||||
const deletedEnts: Entitlement[] = curEnts.filter(
|
||||
(ent) => !newItems.some((item) => item.entitlement_id == ent.id),
|
||||
);
|
||||
|
||||
let samePrices: Price[] = [];
|
||||
let sameEnts: Entitlement[] = [];
|
||||
const samePrices: Price[] = [];
|
||||
const sameEnts: Entitlement[] = [];
|
||||
|
||||
for (const item of newItems) {
|
||||
let feature = features.find((f) => f.id == item.feature_id);
|
||||
let curEnt = curEnts.find((ent) => ent.id == item.entitlement_id);
|
||||
let curPrice = curPrices.find((price) => price.id == item.price_id);
|
||||
const feature = features.find((f) => f.id == item.feature_id);
|
||||
const curEnt = curEnts.find((ent) => ent.id == item.entitlement_id);
|
||||
const curPrice = curPrices.find((price) => price.id == item.price_id);
|
||||
|
||||
// 2. Update price and entitlement?
|
||||
let { newPrice, newEnt, updatedPrice, updatedEnt, samePrice, sameEnt } =
|
||||
const { newPrice, newEnt, updatedPrice, updatedEnt, samePrice, sameEnt } =
|
||||
itemToPriceAndEnt({
|
||||
item,
|
||||
orgId: product.org_id!,
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import {
|
||||
ProductItem,
|
||||
type AppEnv,
|
||||
EntInterval,
|
||||
ErrCode,
|
||||
TierInfinite,
|
||||
ProductItemSchema,
|
||||
Infinite,
|
||||
ProductItemInterval,
|
||||
Feature,
|
||||
type Feature,
|
||||
FeatureType,
|
||||
AppEnv,
|
||||
Infinite,
|
||||
OnIncrease,
|
||||
type ProductItem,
|
||||
ProductItemInterval,
|
||||
ProductItemSchema,
|
||||
UsageModel,
|
||||
RolloverDuration,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { notNullish, nullish } from "@/utils/genUtils.js";
|
||||
import { createFeaturesFromItems } from "./createFeaturesFromItems.js";
|
||||
import { itemToEntInterval } from "./itemIntervalUtils.js";
|
||||
import {
|
||||
isBooleanFeatureItem,
|
||||
isFeatureItem,
|
||||
isFeaturePriceItem,
|
||||
isPriceItem,
|
||||
} from "./productItemUtils/getItemType.js";
|
||||
import { itemToEntInterval } from "./itemIntervalUtils.js";
|
||||
import { createFeaturesFromItems } from "./createFeaturesFromItems.js";
|
||||
|
||||
const validateProductItem = ({
|
||||
item,
|
||||
@@ -116,9 +114,9 @@ const validateProductItem = ({
|
||||
// 5. If it's a price, can't have day, minute or hour interval
|
||||
if (isFeaturePriceItem(item) || isPriceItem(item)) {
|
||||
if (
|
||||
item.interval == ProductItemInterval.Day ||
|
||||
item.interval == ProductItemInterval.Minute ||
|
||||
item.interval == ProductItemInterval.Hour
|
||||
item.interval === ProductItemInterval.Day ||
|
||||
item.interval === ProductItemInterval.Minute ||
|
||||
item.interval === ProductItemInterval.Hour
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: `Price can't have day, minute or hour interval`,
|
||||
@@ -129,8 +127,8 @@ const validateProductItem = ({
|
||||
}
|
||||
|
||||
if (
|
||||
item.usage_model == UsageModel.Prepaid &&
|
||||
item.config?.on_increase == OnIncrease.BillImmediately
|
||||
item.usage_model === UsageModel.Prepaid &&
|
||||
item.config?.on_increase === OnIncrease.BillImmediately
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: `Bill immediately is not supported for prepaid just yet, contact us at hey@useautumn.com if you're interested!`,
|
||||
@@ -159,7 +157,7 @@ export const validateProductItems = ({
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
let { allFeatures, newFeatures } = createFeaturesFromItems({
|
||||
const { allFeatures, newFeatures } = createFeaturesFromItems({
|
||||
items: newItems,
|
||||
curFeatures: features,
|
||||
orgId,
|
||||
@@ -178,21 +176,21 @@ export const validateProductItems = ({
|
||||
// 1. Check values
|
||||
for (let index = 0; index < newItems.length; index++) {
|
||||
validateProductItem({ item: newItems[index], features });
|
||||
let feature = features.find((f) => f.id == newItems[index].feature_id);
|
||||
const feature = features.find((f) => f.id === newItems[index].feature_id);
|
||||
|
||||
if (feature && feature.type == FeatureType.Metered) {
|
||||
if (feature && feature.type === FeatureType.Metered) {
|
||||
newItems[index].feature_type = feature.config?.usage_type;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < newItems.length; index++) {
|
||||
let item = newItems[index];
|
||||
let entInterval = itemToEntInterval(item);
|
||||
const item = newItems[index];
|
||||
const entInterval = itemToEntInterval(item);
|
||||
const intervalCount = item.interval_count || 1;
|
||||
|
||||
if (isFeaturePriceItem(item) && entInterval == EntInterval.Lifetime) {
|
||||
let otherItem = newItems.find((i: any, index2: any) => {
|
||||
return i.feature_id == item.feature_id && index2 != index;
|
||||
if (isFeaturePriceItem(item) && entInterval === EntInterval.Lifetime) {
|
||||
const otherItem = newItems.find((i: any, index2: any) => {
|
||||
return i.feature_id === item.feature_id && index2 !== index;
|
||||
});
|
||||
|
||||
if (otherItem && isFeaturePriceItem(otherItem)) {
|
||||
@@ -206,11 +204,11 @@ export const validateProductItems = ({
|
||||
|
||||
// Boolean duplicate
|
||||
if (isBooleanFeatureItem(item)) {
|
||||
let otherItem = newItems.find((i: any, index2: any) => {
|
||||
const otherItem = newItems.find((i: any, index2: any) => {
|
||||
return (
|
||||
i.feature_id == item.feature_id &&
|
||||
index2 != index &&
|
||||
item.entity_feature_id == i.entity_feature_id
|
||||
i.feature_id === item.feature_id &&
|
||||
index2 !== index &&
|
||||
item.entity_feature_id === i.entity_feature_id
|
||||
);
|
||||
});
|
||||
|
||||
@@ -223,13 +221,13 @@ export const validateProductItems = ({
|
||||
}
|
||||
}
|
||||
|
||||
let otherItem = newItems.find((i: any, index2: any) => {
|
||||
const otherItem = newItems.find((i: any, index2: any) => {
|
||||
return (
|
||||
i.feature_id == item.feature_id &&
|
||||
index2 != index &&
|
||||
itemToEntInterval(i) == entInterval &&
|
||||
(i.interval_count || 1) == intervalCount &&
|
||||
i.entity_feature_id == item.entity_feature_id
|
||||
i.feature_id === item.feature_id &&
|
||||
index2 !== index &&
|
||||
itemToEntInterval(i) === entInterval &&
|
||||
(i.interval_count || 1) === intervalCount &&
|
||||
i.entity_feature_id === item.entity_feature_id
|
||||
);
|
||||
});
|
||||
|
||||
@@ -253,7 +251,7 @@ export const validateProductItems = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (item.usage_model && item.usage_model == otherItem?.usage_model) {
|
||||
if (item.usage_model && item.usage_model === otherItem?.usage_model) {
|
||||
throw new RecaseError({
|
||||
message: `You're trying to add the same feature (${item.feature_id}), with the same reset interval. You should either change the reset interval of one of the items, or make one of them a prepaid quantity`,
|
||||
code: ErrCode.InvalidInputs,
|
||||
@@ -262,8 +260,8 @@ export const validateProductItems = ({
|
||||
}
|
||||
|
||||
if (isPriceItem(item)) {
|
||||
let otherItem = newItems.find((i: any, index2: any) => {
|
||||
return i.interval === item.interval && index2 != index;
|
||||
const otherItem = newItems.find((i: any, index2: any) => {
|
||||
return i.interval === item.interval && index2 !== index;
|
||||
});
|
||||
|
||||
if (otherItem) {
|
||||
|
||||
@@ -43,7 +43,7 @@ const getFeatureDeductions = ({
|
||||
const unlimitedExists = cusEnts.some(
|
||||
(cusEnt) =>
|
||||
cusEnt.entitlement.allowance_type === AllowanceType.Unlimited &&
|
||||
cusEnt.entitlement.internal_feature_id == feature.internal_id,
|
||||
cusEnt.entitlement.internal_feature_id === feature.internal_id,
|
||||
);
|
||||
|
||||
if (unlimitedExists) {
|
||||
@@ -76,7 +76,7 @@ const getFeatureDeductions = ({
|
||||
deduction = new Decimal(totalBalance).sub(targetBalance).toNumber();
|
||||
}
|
||||
|
||||
if (deduction == 0) {
|
||||
if (deduction === 0) {
|
||||
console.log(` - Skipping feature ${feature.id} -- deduction is 0`);
|
||||
continue;
|
||||
}
|
||||
@@ -230,7 +230,7 @@ export const updateUsage = async ({
|
||||
let { feature, deduction: toDeduct } = obj;
|
||||
|
||||
for (const cusEnt of cusEnts) {
|
||||
if (cusEnt.entitlement.internal_feature_id != feature.internal_id) {
|
||||
if (cusEnt.entitlement.internal_feature_id !== feature.internal_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export const updateUsage = async ({
|
||||
},
|
||||
});
|
||||
|
||||
if (toDeduct == 0) continue;
|
||||
if (toDeduct === 0) continue;
|
||||
|
||||
toDeduct = await deductAllowanceFromCusEnt({
|
||||
toDeduct,
|
||||
|
||||
@@ -267,7 +267,7 @@ export const checkCusSubCorrect = async ({
|
||||
|
||||
// Add to schedules
|
||||
const scheduleIndexes: number[] = [];
|
||||
const apiVersion = cusProduct.api_version || defaultApiVersion;
|
||||
const apiVersion = cusProduct.api_semver || defaultApiVersion;
|
||||
|
||||
if (isFreeProduct(product.prices)) {
|
||||
assert(
|
||||
|
||||
@@ -289,7 +289,10 @@ export const routeHandler = async <TLoad = undefined>({
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes("not a valid email address")) {
|
||||
if (
|
||||
error.message.includes("not a valid email address") ||
|
||||
error.message.includes("email: Invalid input")
|
||||
) {
|
||||
req.logtail.warn(`Invalid email address`);
|
||||
return res.status(400).json({
|
||||
message: error.message,
|
||||
|
||||
@@ -43,9 +43,8 @@ const getBreakdown = ({
|
||||
intervalCount: number;
|
||||
}) => {
|
||||
const wordsFeature = customer.features[TestFeature.Words];
|
||||
// @ts-expect-error
|
||||
return wordsFeature.breakdown?.find(
|
||||
(b: any) => b.interval_count == intervalCount,
|
||||
(b: any) => b.interval_count === intervalCount,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -109,12 +108,12 @@ describe(`${chalk.yellowBright(`${testCase}: Testing multi interval features wit
|
||||
|
||||
expect(
|
||||
wordsFeature.breakdown?.some(
|
||||
(b: any) => b.interval_count == 1 && b.interval == "month",
|
||||
(b: any) => b.interval_count === 1 && b.interval === "month",
|
||||
),
|
||||
).to.equal(true);
|
||||
expect(
|
||||
wordsFeature.breakdown?.some(
|
||||
(b: any) => b.interval_count == 2 && b.interval == "month",
|
||||
(b: any) => b.interval_count === 2 && b.interval === "month",
|
||||
),
|
||||
).to.equal(true);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusExpand,
|
||||
@@ -15,6 +14,7 @@ import { setupBefore } from "tests/before.js";
|
||||
import { expectProductV1Attached } from "tests/utils/expectUtils/expectProductAttached.js";
|
||||
import { advanceTestClock } from "tests/utils/stripeUtils.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Customer } from "@autumn/shared";
|
||||
import { expect } from "chai";
|
||||
import chalk from "chalk";
|
||||
import type Stripe from "stripe";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { v1ProductToBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
|
||||
import { calculateMetered1Price } from "@/external/stripe/utils.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import { AutumnCli } from "../../cli/AutumnCli.js";
|
||||
import { features, products } from "../../global.js";
|
||||
import { compareMainProduct } from "../../utils/compare.js";
|
||||
import { advanceClockForInvoice } from "../../utils/stripeUtils.js";
|
||||
import { timeout } from "../../utils/genUtils.js";
|
||||
import { calculateMetered1Price } from "@/external/stripe/utils.js";
|
||||
import chalk from "chalk";
|
||||
import { Customer } from "@autumn/shared";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import Stripe from "stripe";
|
||||
import { advanceClockForInvoice } from "../../utils/stripeUtils.js";
|
||||
|
||||
const testCase = "usage1";
|
||||
|
||||
@@ -39,7 +39,7 @@ describe(`${chalk.yellowBright("usage1: Testing basic usage product")}`, () => {
|
||||
testClockId = testClockId_;
|
||||
});
|
||||
|
||||
it("should attach usage based product", async function () {
|
||||
it("should attach usage based product", async () => {
|
||||
await AutumnCli.attach({
|
||||
customerId: customerId,
|
||||
productId: products.proWithOverage.id,
|
||||
@@ -53,7 +53,7 @@ describe(`${chalk.yellowBright("usage1: Testing basic usage product")}`, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("usage1: should send metered1 events", async function () {
|
||||
it("usage1: should send metered1 events", async () => {
|
||||
const batchUpdates = [];
|
||||
for (let i = 0; i < NUM_EVENTS; i++) {
|
||||
batchUpdates.push(
|
||||
@@ -68,7 +68,7 @@ describe(`${chalk.yellowBright("usage1: Testing basic usage product")}`, () => {
|
||||
await timeout(25000);
|
||||
});
|
||||
|
||||
it("should have correct metered1 balance after sending events", async function () {
|
||||
it("should have correct metered1 balance after sending events", async () => {
|
||||
const res: any = await AutumnCli.entitled(customerId, features.metered1.id);
|
||||
|
||||
expect(res!.allowed).to.be.true;
|
||||
@@ -90,7 +90,7 @@ describe(`${chalk.yellowBright("usage1: Testing basic usage product")}`, () => {
|
||||
});
|
||||
|
||||
// Check invoice
|
||||
it("should advance stripe test clock and wait for event", async function () {
|
||||
it("should advance stripe test clock and wait for event", async () => {
|
||||
await advanceClockForInvoice({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
@@ -98,7 +98,7 @@ describe(`${chalk.yellowBright("usage1: Testing basic usage product")}`, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should have correct invoice amount", async function () {
|
||||
it("should have correct invoice amount", async () => {
|
||||
const cusRes = await AutumnCli.getCustomer(customerId);
|
||||
const invoices = cusRes!.invoices;
|
||||
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { assert, expect } from "chai";
|
||||
import { expect } from "chai";
|
||||
import chalk from "chalk";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type Stripe from "stripe";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { advanceClockForInvoice } from "tests/utils/stripeUtils.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import { AutumnCli } from "../../cli/AutumnCli.js";
|
||||
import { advanceProducts, creditSystems, features } from "../../global.js";
|
||||
import { getCreditsUsed } from "../../utils/advancedUsageUtils.js";
|
||||
import { compareMainProduct } from "../../utils/compare.js";
|
||||
import { advanceClockForInvoice } from "../../utils/stripeUtils.js";
|
||||
import { timeout } from "../../utils/genUtils.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
import {
|
||||
checkUsageInvoiceAmount,
|
||||
getCreditsUsed,
|
||||
} from "../../utils/advancedUsageUtils.js";
|
||||
|
||||
import chalk from "chalk";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import Stripe from "stripe";
|
||||
|
||||
// FIRST, REGULAR CHECK GPU STARTER MONTHLY
|
||||
|
||||
@@ -47,7 +41,7 @@ describe(`${chalk.yellowBright("usage2: Testing basic usage product")}`, () => {
|
||||
stripeCli = this.stripeCli;
|
||||
});
|
||||
|
||||
it("should attach gpu system starter", async function () {
|
||||
it("should attach gpu system starter", async () => {
|
||||
await AutumnCli.attach({
|
||||
customerId: customerId,
|
||||
productId: advanceProducts.gpuSystemStarter.id,
|
||||
@@ -61,18 +55,18 @@ describe(`${chalk.yellowBright("usage2: Testing basic usage product")}`, () => {
|
||||
});
|
||||
|
||||
// Use up events
|
||||
it("should send events and have correct balance (up to 10 DP)", async function () {
|
||||
let eventCount = 20;
|
||||
it("should send events and have correct balance (up to 10 DP)", async () => {
|
||||
const eventCount = 20;
|
||||
|
||||
const batchEvents = [];
|
||||
for (let i = 0; i < eventCount; i++) {
|
||||
let randomVal = new Decimal(Math.random().toFixed(PRECISION))
|
||||
const randomVal = new Decimal(Math.random().toFixed(PRECISION))
|
||||
.mul(CREDIT_MULTIPLIER)
|
||||
.mul(Math.random() > 0.2 ? 1 : -1)
|
||||
.toNumber();
|
||||
let gpuId = i % 2 == 0 ? features.gpu1.id : features.gpu2.id;
|
||||
const gpuId = i % 2 === 0 ? features.gpu1.id : features.gpu2.id;
|
||||
|
||||
let creditsUsed = getCreditsUsed(
|
||||
const creditsUsed = getCreditsUsed(
|
||||
creditSystems.gpuCredits,
|
||||
gpuId,
|
||||
randomVal,
|
||||
@@ -101,7 +95,7 @@ describe(`${chalk.yellowBright("usage2: Testing basic usage product")}`, () => {
|
||||
true,
|
||||
);
|
||||
|
||||
let creditAllowance =
|
||||
const creditAllowance =
|
||||
advanceProducts.gpuSystemStarter.entitlements.gpuCredits.allowance!;
|
||||
|
||||
expect(allowed).to.be.true;
|
||||
@@ -113,34 +107,30 @@ describe(`${chalk.yellowBright("usage2: Testing basic usage product")}`, () => {
|
||||
});
|
||||
|
||||
// Check invoice.created event
|
||||
it("should have correct invoice amount / updated meter balance", async function () {
|
||||
it("should have correct invoice amount / updated meter balance", async () => {
|
||||
await advanceClockForInvoice({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
waitForMeterUpdate: ASSERT_INVOICE_AMOUNT,
|
||||
});
|
||||
|
||||
const res = await AutumnCli.getCustomer(customerId);
|
||||
const invoices = res!.invoices;
|
||||
|
||||
if (ASSERT_INVOICE_AMOUNT) {
|
||||
await checkUsageInvoiceAmount({
|
||||
invoices,
|
||||
totalUsage: totalCreditsUsed,
|
||||
product: advanceProducts.gpuSystemStarter,
|
||||
featureId: creditSystems.gpuCredits.id,
|
||||
});
|
||||
} else {
|
||||
const { allowed, balanceObj }: any = await AutumnCli.entitled(
|
||||
customerId,
|
||||
creditSystems.gpuCredits.id,
|
||||
true,
|
||||
);
|
||||
|
||||
let allowance =
|
||||
advanceProducts.gpuSystemStarter.entitlements.gpuCredits.allowance!;
|
||||
|
||||
assert.equal(balanceObj.balance, allowance);
|
||||
}
|
||||
// const res = await AutumnCli.getCustomer(customerId);
|
||||
// const invoices = res!.invoices;
|
||||
// if (ASSERT_INVOICE_AMOUNT) {
|
||||
// await checkUsageInvoiceAmount({
|
||||
// invoices,
|
||||
// totalUsage: totalCreditsUsed,
|
||||
// product: advanceProducts.gpuSystemStarter,
|
||||
// featureId: creditSystems.gpuCredits.id,
|
||||
// });
|
||||
// } else {
|
||||
// const { allowed, balanceObj }: any = await AutumnCli.entitled(
|
||||
// customerId,
|
||||
// creditSystems.gpuCredits.id,
|
||||
// true,
|
||||
// );
|
||||
// const allowance =
|
||||
// advanceProducts.gpuSystemStarter.entitlements.gpuCredits.allowance!;
|
||||
// assert.equal(balanceObj.balance, allowance);
|
||||
// }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { Customer } from "@autumn/shared";
|
||||
import type { Customer } from "@autumn/shared";
|
||||
import { expect } from "chai";
|
||||
import chalk from "chalk";
|
||||
import { compareMainProduct } from "../../utils/compare.js";
|
||||
import type Stripe from "stripe";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import { AutumnCli } from "../../cli/AutumnCli.js";
|
||||
import { advanceProducts, creditSystems } from "../../global.js";
|
||||
|
||||
import { expect } from "chai";
|
||||
import { advanceClockForInvoice } from "../../utils/stripeUtils.js";
|
||||
|
||||
import {
|
||||
sendGPUEvents,
|
||||
checkCreditBalance,
|
||||
checkUsageInvoiceAmount,
|
||||
sendGPUEvents,
|
||||
} from "../../utils/advancedUsageUtils.js";
|
||||
import Stripe from "stripe";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { compareMainProduct } from "../../utils/compare.js";
|
||||
import { advanceClockForInvoice } from "../../utils/stripeUtils.js";
|
||||
|
||||
// THIRD, TEST GPU PRO ANNUAL
|
||||
|
||||
@@ -30,7 +28,7 @@ describe(`${chalk.yellowBright("usage4: GPU starter annual")}`, () => {
|
||||
|
||||
before(async function () {
|
||||
await setupBefore(this);
|
||||
let res = await initCustomer({
|
||||
const res = await initCustomer({
|
||||
customerId,
|
||||
org: this.org,
|
||||
env: this.env,
|
||||
@@ -44,7 +42,7 @@ describe(`${chalk.yellowBright("usage4: GPU starter annual")}`, () => {
|
||||
stripeCli = this.stripeCli;
|
||||
});
|
||||
|
||||
it("should attach GPU starter annual", async function () {
|
||||
it("should attach GPU starter annual", async () => {
|
||||
await AutumnCli.attach({
|
||||
customerId: customerId,
|
||||
productId: advanceProducts.gpuStarterAnnual.id,
|
||||
@@ -59,8 +57,8 @@ describe(`${chalk.yellowBright("usage4: GPU starter annual")}`, () => {
|
||||
expect(res!.invoices.length).to.equal(1);
|
||||
});
|
||||
|
||||
it("should send 20 events and have correct balance", async function () {
|
||||
let eventCount = 20;
|
||||
it("should send 20 events and have correct balance", async () => {
|
||||
const eventCount = 20;
|
||||
const { creditsUsed } = await sendGPUEvents({
|
||||
customerId,
|
||||
eventCount,
|
||||
@@ -76,7 +74,7 @@ describe(`${chalk.yellowBright("usage4: GPU starter annual")}`, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should have invoice after a month and correct balance", async function () {
|
||||
it("should have invoice after a month and correct balance", async () => {
|
||||
await advanceClockForInvoice({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
@@ -86,7 +84,7 @@ describe(`${chalk.yellowBright("usage4: GPU starter annual")}`, () => {
|
||||
const res = await AutumnCli.getCustomer(customerId);
|
||||
const invoices = res!.invoices;
|
||||
|
||||
let invoiceIndex = invoices.findIndex((invoice: any) =>
|
||||
const invoiceIndex = invoices.findIndex((invoice: any) =>
|
||||
invoice.product_ids.includes(advanceProducts.gpuStarterAnnual.id),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import chalk from "chalk";
|
||||
import { expect } from "chai";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import chalk from "chalk";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { AutumnCli } from "tests/cli/AutumnCli.js";
|
||||
import { features, products } from "tests/global.js";
|
||||
import { compareMainProduct } from "tests/utils/compare.js";
|
||||
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { createProducts } from "tests/utils/productUtils.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
|
||||
let monthlyItem = constructPrepaidItem({
|
||||
const monthlyItem = constructPrepaidItem({
|
||||
featureId: features.metered1.id,
|
||||
price: 9,
|
||||
billingUnits: 250,
|
||||
});
|
||||
|
||||
let monthly = constructRawProduct({
|
||||
const monthly = constructRawProduct({
|
||||
id: "basic4_monthly",
|
||||
items: [monthlyItem],
|
||||
});
|
||||
|
||||
const testCase = "basic4";
|
||||
describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => {
|
||||
let customerId = testCase;
|
||||
let autumn: AutumnInt = new AutumnInt();
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt();
|
||||
let db, org, env;
|
||||
|
||||
before(async function () {
|
||||
@@ -51,7 +51,7 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () =>
|
||||
});
|
||||
});
|
||||
|
||||
it("should attach pro", async function () {
|
||||
it("should attach pro", async () => {
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: products.pro.id,
|
||||
@@ -66,7 +66,7 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () =>
|
||||
|
||||
const monthlyQuantity = 500;
|
||||
|
||||
it("should attach monthly add on", async function () {
|
||||
it("should attach monthly add on", async () => {
|
||||
await AutumnCli.attach({
|
||||
customerId: customerId,
|
||||
productId: products.monthlyAddOnMetered1.id,
|
||||
@@ -80,14 +80,15 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () =>
|
||||
});
|
||||
});
|
||||
|
||||
it("should have correct product & entitlements", async function () {
|
||||
it("should have correct product & entitlements", async () => {
|
||||
const cusRes = await AutumnCli.getCustomer(customerId);
|
||||
|
||||
const proMetered1 = products.pro.entitlements.metered1.allowance;
|
||||
|
||||
const monthlyMetered1Balance = cusRes.entitlements.find(
|
||||
(e: any) =>
|
||||
e.feature_id === features.metered1.id &&
|
||||
e.interval ==
|
||||
e.interval ===
|
||||
products.monthlyAddOnMetered1.entitlements.metered1.interval,
|
||||
);
|
||||
|
||||
@@ -104,7 +105,7 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () =>
|
||||
expect(cusRes.invoices.length).to.equal(2);
|
||||
});
|
||||
|
||||
it("should have correct /check result for metered1", async function () {
|
||||
it("should have correct /check result for metered1", async () => {
|
||||
const res: any = await AutumnCli.entitled(customerId, features.metered1.id);
|
||||
|
||||
const metered1Balance = res!.balances.find(
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import { expect } from "chai";
|
||||
import chalk from "chalk";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import { AutumnCli } from "tests/cli/AutumnCli.js";
|
||||
import { products } from "tests/global.js";
|
||||
import { compareMainProduct } from "tests/utils/compare.js";
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import { expect } from "chai";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
|
||||
const testCase = "basic8";
|
||||
|
||||
describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerprint)")}`, () => {
|
||||
const customerId = testCase;
|
||||
let customerId2 = testCase + "2";
|
||||
const customerId2 = testCase + "2";
|
||||
const autumn = new AutumnInt();
|
||||
|
||||
before(async function () {
|
||||
@@ -40,7 +39,7 @@ describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerpri
|
||||
});
|
||||
});
|
||||
|
||||
it("should attach pro with trial and have correct product & invoice", async function () {
|
||||
it("should attach pro with trial and have correct product & invoice", async () => {
|
||||
await AutumnCli.attach({
|
||||
customerId: customerId,
|
||||
productId: products.proWithTrial.id,
|
||||
@@ -59,7 +58,7 @@ describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerpri
|
||||
expect(invoices[0].total).to.equal(0, "Invoice total should be 0");
|
||||
});
|
||||
|
||||
it("should attach pro with trial to second customer and have correct product & invoice (pro with trial, full price)", async function () {
|
||||
it("should attach pro with trial to second customer and have correct product & invoice (pro with trial, full price)", async () => {
|
||||
await autumn.attach({
|
||||
customer_id: customerId2,
|
||||
product_id: products.proWithTrial.id,
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
import { expect } from "chai";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import { AppEnv, LimitedItem, Organization, ProductV2 } from "@autumn/shared";
|
||||
import type {
|
||||
AppEnv,
|
||||
LimitedItem,
|
||||
Organization,
|
||||
ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import Stripe from "stripe";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { createProducts } from "tests/utils/productUtils.js";
|
||||
import { addPrefixToProducts } from "../utils.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { TestFeature } from "tests/setup/v2Features.js";
|
||||
import { replaceItems } from "../utils.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { advanceTestClock } from "tests/utils/stripeUtils.js";
|
||||
import { addWeeks } from "date-fns";
|
||||
import type Stripe from "stripe";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { defaultApiVersion } from "tests/constants.js";
|
||||
import { runMigrationTest } from "./runMigrationTest.js";
|
||||
import { TestFeature } from "tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
|
||||
import { createProducts } from "tests/utils/productUtils.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
|
||||
import { addPrefixToProducts, replaceItems } from "../utils.js";
|
||||
import { runMigrationTest } from "./runMigrationTest.js";
|
||||
|
||||
let messagesItem = constructFeatureItem({
|
||||
const messagesItem = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
}) as LimitedItem;
|
||||
|
||||
let wordsItem = constructFeatureItem({
|
||||
const wordsItem = constructFeatureItem({
|
||||
featureId: TestFeature.Words,
|
||||
includedUsage: 100,
|
||||
}) as LimitedItem;
|
||||
|
||||
export let free = constructProduct({
|
||||
export const free = constructProduct({
|
||||
items: [messagesItem, wordsItem],
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
@@ -38,13 +41,13 @@ export let free = constructProduct({
|
||||
const testCase = "migrations1";
|
||||
|
||||
describe(`${chalk.yellowBright(`${testCase}: Testing migration for free product`)}`, () => {
|
||||
let customerId = testCase;
|
||||
let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
|
||||
let testClockId: string;
|
||||
let db: DrizzleCli, org: Organization, env: AppEnv;
|
||||
let stripeCli: Stripe;
|
||||
|
||||
let curUnix = new Date().getTime();
|
||||
const curUnix = new Date().getTime();
|
||||
|
||||
before(async function () {
|
||||
await setupBefore(this);
|
||||
@@ -81,7 +84,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for free product`
|
||||
testClockId = testClockId1!;
|
||||
});
|
||||
|
||||
it("should attach free product", async function () {
|
||||
it("should attach free product", async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
@@ -95,9 +98,9 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for free product`
|
||||
});
|
||||
|
||||
let newFree: ProductV2;
|
||||
let increaseMessagesBy = 100;
|
||||
let reduceWordsBy = 50;
|
||||
it("should update product to new version", async function () {
|
||||
const increaseMessagesBy = 100;
|
||||
const reduceWordsBy = 50;
|
||||
it("should update product to new version", async () => {
|
||||
newFree = structuredClone(free);
|
||||
|
||||
let newItems = replaceItems({
|
||||
@@ -126,9 +129,9 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for free product`
|
||||
});
|
||||
});
|
||||
|
||||
it("should attach track usage and get correct balance", async function () {
|
||||
let wordsUsage = 25;
|
||||
let messagesUsage = 20;
|
||||
it("should attach track usage and get correct balance", async () => {
|
||||
const wordsUsage = 25;
|
||||
const messagesUsage = 20;
|
||||
await autumn.track({
|
||||
customer_id: customerId,
|
||||
value: wordsUsage,
|
||||
@@ -141,6 +144,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for free product`
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
await advanceTestClock({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import {
|
||||
AppEnv,
|
||||
type AppEnv,
|
||||
BillingInterval,
|
||||
Organization,
|
||||
type Organization,
|
||||
ProductItemInterval,
|
||||
ProductV2,
|
||||
type ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import Stripe from "stripe";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { createProducts } from "tests/utils/productUtils.js";
|
||||
import { addPrefixToProducts } from "../utils.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { TestFeature } from "tests/setup/v2Features.js";
|
||||
import { replaceItems } from "../utils.js";
|
||||
|
||||
import { defaultApiVersion } from "tests/constants.js";
|
||||
import { runMigrationTest } from "./runMigrationTest.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { advanceTestClock } from "tests/utils/stripeUtils.js";
|
||||
import { addDays } from "date-fns";
|
||||
import type Stripe from "stripe";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import { defaultApiVersion } from "tests/constants.js";
|
||||
import { TestFeature } from "tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
|
||||
import { createProducts } from "tests/utils/productUtils.js";
|
||||
import { advanceTestClock } from "tests/utils/stripeUtils.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
|
||||
import { addPrefixToProducts, replaceItems } from "../utils.js";
|
||||
import { runMigrationTest } from "./runMigrationTest.js";
|
||||
|
||||
let wordsItem = constructArrearItem({
|
||||
const wordsItem = constructArrearItem({
|
||||
featureId: TestFeature.Words,
|
||||
});
|
||||
|
||||
export let pro = constructProduct({
|
||||
export const pro = constructProduct({
|
||||
items: [wordsItem],
|
||||
type: "pro",
|
||||
isDefault: false,
|
||||
@@ -39,13 +36,13 @@ export let pro = constructProduct({
|
||||
const testCase = "migrations3";
|
||||
|
||||
describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro with trial`)}`, () => {
|
||||
let customerId = testCase;
|
||||
let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
|
||||
let testClockId: string;
|
||||
let db: DrizzleCli, org: Organization, env: AppEnv;
|
||||
let stripeCli: Stripe;
|
||||
|
||||
let curUnix = new Date().getTime();
|
||||
const curUnix = new Date().getTime();
|
||||
|
||||
before(async function () {
|
||||
await setupBefore(this);
|
||||
@@ -82,7 +79,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro with tria
|
||||
testClockId = testClockId1!;
|
||||
});
|
||||
|
||||
it("should attach free product", async function () {
|
||||
it("should attach free product", async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
@@ -95,8 +92,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro with tria
|
||||
});
|
||||
|
||||
let newPro: ProductV2;
|
||||
let increaseWordsBy = 1500;
|
||||
it("should update product to new version", async function () {
|
||||
const increaseWordsBy = 1500;
|
||||
it("should update product to new version", async () => {
|
||||
newPro = structuredClone(pro);
|
||||
|
||||
let newItems = replaceItems({
|
||||
@@ -124,8 +121,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro with tria
|
||||
});
|
||||
});
|
||||
|
||||
it("should attach track usage and get correct balance", async function () {
|
||||
let wordsUsage = 120000;
|
||||
it("should attach track usage and get correct balance", async () => {
|
||||
const wordsUsage = 120000;
|
||||
await autumn.track({
|
||||
customer_id: customerId,
|
||||
value: wordsUsage,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { LegacyVersion } from "@autumn/shared";
|
||||
import { ApiVersion } from "@autumn/shared";
|
||||
|
||||
export const defaultApiVersion = LegacyVersion.v1_2;
|
||||
export const defaultApiVersion = ApiVersion.V1_2;
|
||||
|
||||
@@ -225,7 +225,7 @@ describe(`${chalk.yellowBright("multiAttach3: Testing multi attach for trial pro
|
||||
await advanceTestClock({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
advanceTo: addDays(new Date(), 8).getTime(),
|
||||
advanceTo: addDays(new Date(), 10).getTime(),
|
||||
waitForSeconds: 30,
|
||||
});
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@ describe(`${chalk.yellowBright("mergedDowngrade5: Testing downgrade to free")}`,
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
@@ -176,6 +177,7 @@ describe(`${chalk.yellowBright("mergedDowngrade5: Testing downgrade to free")}`,
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
||||
// it("should advance test clock and have correct premium downgraded for entity 2", async function () {
|
||||
// await advanceToNextInvoice({
|
||||
|
||||
@@ -76,7 +76,8 @@ const ops = [
|
||||
{ product: g2Pro, status: CusProductStatus.Active },
|
||||
{ product: g1Pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
// otherProducts: [g2Pro],
|
||||
otherProducts: [g2Pro],
|
||||
skipFeatureCheck: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -123,6 +124,9 @@ describe(`${chalk.yellowBright("mergedGroup1: Testing products from diff groups"
|
||||
|
||||
it("should attach pro product", async () => {
|
||||
for (const op of ops) {
|
||||
// console.log(
|
||||
// `Op: ${op.product.id}, Other Products: ${op.otherProducts?.map((p) => p.id).join(", ")}`,
|
||||
// );
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
@@ -132,6 +136,7 @@ describe(`${chalk.yellowBright("mergedGroup1: Testing products from diff groups"
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
skipFeatureCheck: op.skipFeatureCheck,
|
||||
});
|
||||
|
||||
const customer = await autumn.customers.get(customerId);
|
||||
|
||||
@@ -68,6 +68,7 @@ const ops = [
|
||||
{ product: g1Pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
// otherProducts: [g2Premium],
|
||||
skipFeatureCheck: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -123,6 +124,7 @@ describe(`${chalk.yellowBright("mergedGroup2: Testing products from diff groups"
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
skipFeatureCheck: op.skipFeatureCheck,
|
||||
});
|
||||
|
||||
const customer = await autumn.customers.get(customerId);
|
||||
|
||||
@@ -202,7 +202,7 @@ export const expectSubToBeCorrect = async ({
|
||||
|
||||
// Add to schedules
|
||||
const scheduleIndexes: number[] = [];
|
||||
const apiVersion = cusProduct.api_version || defaultApiVersion;
|
||||
const apiVersion = cusProduct.api_semver || defaultApiVersion;
|
||||
|
||||
if (isFreeProduct(product.prices)) {
|
||||
expect(cusProduct.subscription_ids, "free product should have no subs").to
|
||||
@@ -244,7 +244,7 @@ export const expectSubToBeCorrect = async ({
|
||||
cp.product.group === product.group &&
|
||||
cp.status === CusProductStatus.Scheduled &&
|
||||
(cp.internal_entity_id
|
||||
? cp.internal_entity_id == cusProduct.internal_entity_id
|
||||
? cp.internal_entity_id === cusProduct.internal_entity_id
|
||||
: nullish(cp.internal_entity_id)),
|
||||
);
|
||||
|
||||
@@ -324,7 +324,6 @@ export const expectSubToBeCorrect = async ({
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// @ts-expect-error
|
||||
supposedSubItems[existingIndex].quantity += lineItem.quantity;
|
||||
} else {
|
||||
supposedSubItems.push({
|
||||
|
||||
@@ -179,7 +179,7 @@ export const attachAndExpectCorrect = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (!skipFeatureCheck && branch === AttachBranch.Downgrade) {
|
||||
if (!skipFeatureCheck && branch !== AttachBranch.Downgrade) {
|
||||
expectFeaturesCorrect({
|
||||
customer,
|
||||
product,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
import { expect } from "chai";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import {
|
||||
CreateEntity,
|
||||
FeatureOptions,
|
||||
FeatureType,
|
||||
type CreateEntity,
|
||||
type FeatureOptions,
|
||||
Infinite,
|
||||
ProductV2,
|
||||
type ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import { Customer, Entity } from "autumn-js";
|
||||
import type { Customer, Entity } from "autumn-js";
|
||||
import { expect } from "chai";
|
||||
import { notNullish, nullish } from "@/utils/genUtils.js";
|
||||
|
||||
export const expectFeaturesCorrect = ({
|
||||
customer,
|
||||
@@ -38,22 +36,24 @@ export const expectFeaturesCorrect = ({
|
||||
|
||||
const otherItems = otherProducts?.flatMap((p) => p.items) || [];
|
||||
|
||||
// console.log(`Product:`, product);
|
||||
// console.log("Customer features:", customer.features);
|
||||
for (const featureId of featureIds) {
|
||||
let includedUsage: string | number = 0;
|
||||
|
||||
let item = items.find((i) => i.feature_id === featureId)!;
|
||||
const item = items.find((i) => i.feature_id === featureId)!;
|
||||
expect(item, `Item ${featureId} exists`).to.exist;
|
||||
|
||||
if (item.included_usage === undefined) continue;
|
||||
|
||||
for (const item of [...items, ...otherItems]) {
|
||||
if (item.feature_id !== featureId) continue;
|
||||
if (item.included_usage == Infinite) {
|
||||
if (item.included_usage === Infinite) {
|
||||
includedUsage = Infinite;
|
||||
break;
|
||||
}
|
||||
|
||||
let numEntities =
|
||||
const numEntities =
|
||||
entities?.filter((e) => e.feature_id === item.entity_feature_id)
|
||||
.length || 1;
|
||||
|
||||
@@ -72,26 +72,25 @@ export const expectFeaturesCorrect = ({
|
||||
|
||||
expect(feature, `Feature ${featureId} exists`).to.exist;
|
||||
|
||||
// @ts-ignore
|
||||
|
||||
// 1. Check that included usage matches
|
||||
|
||||
expect(
|
||||
feature.included_usage,
|
||||
`Feature ${featureId} included usage is correct`,
|
||||
).to.equal(includedUsage);
|
||||
|
||||
// 2. Check that unlimited is set correctly
|
||||
if (item?.included_usage == Infinite) {
|
||||
if (item?.included_usage === Infinite) {
|
||||
expect(feature.unlimited, `Feature ${featureId} is unlimited`).to.be.true;
|
||||
} else {
|
||||
expect(
|
||||
feature.unlimited == false || nullish(feature.unlimited),
|
||||
feature.unlimited === false || nullish(feature.unlimited),
|
||||
`Feature ${featureId} is not unlimited`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Check that usage is correct...
|
||||
let featureUsage =
|
||||
const featureUsage =
|
||||
usage?.reduce((acc, curr) => {
|
||||
if (curr.featureId === featureId) {
|
||||
acc += curr.value;
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js";
|
||||
import { findStripePriceFromPrices } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
|
||||
import { cusProductToPrices } from "@autumn/shared";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { isV4Usage } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
|
||||
import {
|
||||
AppEnv,
|
||||
type AppEnv,
|
||||
AttachBranch,
|
||||
AttachPreview,
|
||||
type AttachPreview,
|
||||
CusProductStatus,
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
Organization,
|
||||
ProductV2,
|
||||
cusProductToPrices,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type Organization,
|
||||
type ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import { expect } from "chai";
|
||||
import Stripe from "stripe";
|
||||
import { expectSubItemsCorrect } from "./expectSubUtils.js";
|
||||
import { expectProductAttached } from "./expectProductAttached.js";
|
||||
import { isFreeProductV2 } from "@/internal/products/productUtils/classifyProduct.js";
|
||||
import { advanceTestClock } from "../stripeUtils.js";
|
||||
import { hoursToFinalizeInvoice } from "../constants.js";
|
||||
import { addHours } from "date-fns";
|
||||
import type Stripe from "stripe";
|
||||
import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { findStripePriceFromPrices } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
|
||||
import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { isV4Usage } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
|
||||
import { isFreeProductV2 } from "@/internal/products/productUtils/classifyProduct.js";
|
||||
import { hoursToFinalizeInvoice } from "../constants.js";
|
||||
import { advanceTestClock } from "../stripeUtils.js";
|
||||
import { expectProductAttached } from "./expectProductAttached.js";
|
||||
import { expectSubItemsCorrect } from "./expectSubUtils.js";
|
||||
|
||||
export const expectNextCycleCorrect = async ({
|
||||
autumn,
|
||||
@@ -108,7 +108,7 @@ export const expectDowngradeCorrect = async ({
|
||||
const customer = await autumn.customers.get(customerId);
|
||||
|
||||
const productCount = customer.products.reduce((acc: number, product: any) => {
|
||||
if (product.group == curProduct.group) {
|
||||
if (product.group === curProduct.group) {
|
||||
return acc + 1;
|
||||
} else return acc;
|
||||
}, 0);
|
||||
@@ -201,7 +201,7 @@ export const expectSubScheduleCorrect = async ({
|
||||
}
|
||||
|
||||
const cusProduct = fullCus.customer_products.find(
|
||||
(cp: FullCusProduct) => cp.product.id == productId,
|
||||
(cp: FullCusProduct) => cp.product.id === productId,
|
||||
)!;
|
||||
|
||||
const scheduleSets = await getStripeSchedules({
|
||||
@@ -225,7 +225,6 @@ export const expectSubScheduleCorrect = async ({
|
||||
|
||||
if (isV4Usage({ price: autumnPrice!, cusProduct })) {
|
||||
missingUsageCount++;
|
||||
continue;
|
||||
} else {
|
||||
expect(stripePrice).to.exist;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
addMonths,
|
||||
addWeeks,
|
||||
format,
|
||||
subHours,
|
||||
} from "date-fns";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import type { Stripe } from "stripe";
|
||||
@@ -302,11 +303,12 @@ export const advanceClockForInvoice = async ({
|
||||
startingFrom = new Date();
|
||||
}
|
||||
|
||||
if (numberOfDays) {
|
||||
advanceTo = addDays(startingFrom, numberOfDays).getTime();
|
||||
} else {
|
||||
advanceTo = addMinutes(addMonths(startingFrom, 1), 10).getTime();
|
||||
}
|
||||
// if (numberOfDays) {
|
||||
// advanceTo = addDays(startingFrom, numberOfDays).getTime();
|
||||
// } else {
|
||||
// advanceTo = addMonths(startingFrom, 1).getTime();
|
||||
// }
|
||||
advanceTo = subHours(addMonths(Date.now(), 1), 1).getTime();
|
||||
|
||||
await stripeCli.testHelpers.testClocks.advance(testClockId, {
|
||||
frozen_time: Math.ceil(advanceTo / 1000),
|
||||
|
||||
29
shared/api/common/queryHelpers.ts
Normal file
29
shared/api/common/queryHelpers.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Helper to handle query string arrays that can be either:
|
||||
* - Single value: ?expand=invoices → "invoices"
|
||||
* - Comma-separated: ?expand=invoices,rewards → ["invoices", "rewards"] (requires queryMiddleware)
|
||||
* - Multiple values: ?expand=invoices&expand=products → ["invoices", "products"]
|
||||
*
|
||||
* Normalizes all to array format for consistent Zod validation
|
||||
*
|
||||
* Note: Comma-separated parsing requires the queryMiddleware to be applied first.
|
||||
* The middleware uses the `qs` library to parse query strings with comma support.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const schema = z.object({
|
||||
* expand: queryStringArray(z.enum(CusExpand)).optional(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function queryStringArray<T extends z.ZodTypeAny>(schema: T) {
|
||||
return z.preprocess((val) => {
|
||||
// Convert single string to array for consistent handling
|
||||
if (typeof val === "string") {
|
||||
return [val];
|
||||
}
|
||||
return val;
|
||||
}, z.array(schema));
|
||||
}
|
||||
@@ -1,23 +1,31 @@
|
||||
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
|
||||
import { APICusReferralSchema } from "@api/customers/components/apiCusReferral.js";
|
||||
import { ApiCusReferralSchema } from "@api/customers/components/apiCusReferral.js";
|
||||
import { ApiCusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
|
||||
import { APICusRewardsSchema } from "@api/models.js";
|
||||
import { APIInvoiceSchema } from "@api/others/apiInvoice.js";
|
||||
import { EntityResponseSchema } from "@models/cusModels/entityModels/entityResModels.js";
|
||||
import { ApiCusProductSchema } from "@api/customers/cusProducts/apiCusProduct.js";
|
||||
import { ApiBaseEntitySchema } from "@api/entities/apiEntity.js";
|
||||
import { ApiCusRewardsSchema } from "@api/others/apiDiscount.js";
|
||||
import { ApiInvoiceSchema } from "@api/others/apiInvoice.js";
|
||||
import { AppEnv } from "@models/genModels/genEnums.js";
|
||||
import { z } from "zod/v4";
|
||||
import { APICusUpcomingInvoiceSchema } from "./components/apiCusUpcomingInvoice.js";
|
||||
import { ApiCusUpcomingInvoiceSchema } from "./components/apiCusUpcomingInvoice.js";
|
||||
|
||||
export const APITrialsUsedSchema = z.object({
|
||||
export const ApiTrialsUsedSchema = z.object({
|
||||
product_id: z.string(),
|
||||
customer_id: z.string(),
|
||||
fingerprint: z.string().nullish(),
|
||||
});
|
||||
|
||||
export const ApiCusExpandSchema = z.object({
|
||||
invoices: z.array(ApiInvoiceSchema).optional(),
|
||||
entities: z.array(ApiBaseEntitySchema).optional(),
|
||||
trials_used: z.array(ApiTrialsUsedSchema).optional(),
|
||||
rewards: ApiCusRewardsSchema.nullish(),
|
||||
referrals: z.array(ApiCusReferralSchema).optional(),
|
||||
upcoming_invoice: ApiCusUpcomingInvoiceSchema.nullish(),
|
||||
payment_method: z.any().nullish(),
|
||||
});
|
||||
|
||||
export const ApiCustomerSchema = z.object({
|
||||
// Internal fields
|
||||
autumn_id: z.string().nullish(),
|
||||
|
||||
id: z.string().nullable().meta({
|
||||
description: "Your internal ID for the customer",
|
||||
example: "cus_123",
|
||||
@@ -34,18 +42,13 @@ export const ApiCustomerSchema = z.object({
|
||||
fingerprint: z.string().nullable(),
|
||||
stripe_id: z.string().nullable().default(null),
|
||||
env: z.enum(AppEnv),
|
||||
|
||||
products: z.array(APICusProductSchema),
|
||||
features: z.record(z.string(), ApiCusFeatureSchema),
|
||||
invoices: z.array(APIInvoiceSchema).optional(),
|
||||
trials_used: z.array(APITrialsUsedSchema).optional(),
|
||||
|
||||
rewards: APICusRewardsSchema.nullish(),
|
||||
metadata: z.record(z.any(), z.any()).default({}),
|
||||
entities: z.array(EntityResponseSchema).optional(),
|
||||
referrals: z.array(APICusReferralSchema).optional(),
|
||||
upcoming_invoice: APICusUpcomingInvoiceSchema.nullish(),
|
||||
payment_method: z.any().nullish(),
|
||||
|
||||
products: z.array(ApiCusProductSchema),
|
||||
features: z.record(z.string(), ApiCusFeatureSchema),
|
||||
|
||||
...ApiCusExpandSchema.shape,
|
||||
});
|
||||
|
||||
export type ApiCustomer = z.infer<typeof ApiCustomerSchema>;
|
||||
export type ApiCustomerExpand = z.infer<typeof ApiCusExpandSchema>;
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { ApiCustomerSchema } from "@api/customers/apiCustomer.js";
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
defineVersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import { z } from "zod/v4";
|
||||
import type { z } from "zod/v4";
|
||||
import {
|
||||
type ApiCusFeatureV0,
|
||||
ApiCusFeatureV0Schema,
|
||||
} from "../cusFeatures/previousVersions/apiCusFeatureV0.js";
|
||||
import { ApiCusFeatureV1Schema } from "../cusFeatures/previousVersions/apiCusFeatureV1.js";
|
||||
import { ApiCustomerV0Schema } from "../previousVersions/apiCustomerV0.js";
|
||||
import { ApiCustomerV1Schema } from "../previousVersions/apiCustomerV1.js";
|
||||
|
||||
/**
|
||||
* V0_2: Customer feature field additions
|
||||
* V0_1_CustomerChange: Transforms customer TO V0_1 format
|
||||
*
|
||||
* This change handles the fields added in V0_2.
|
||||
* When transforming to V0_1, we remove: next_reset_at, allowance, usage_limit
|
||||
* Applied when: targetVersion <= V0_1
|
||||
*
|
||||
* Fields added in V0_2 (that we remove here):
|
||||
* - next_reset_at
|
||||
* - allowance
|
||||
* - usage_limit
|
||||
*
|
||||
* V0_2+ format (V1):
|
||||
* - feature_id, unlimited, interval, balance, used
|
||||
@@ -26,40 +30,27 @@ import { ApiCusFeatureV1Schema } from "../cusFeatures/previousVersions/apiCusFea
|
||||
* - NO next_reset_at, allowance, usage_limit (original format)
|
||||
*/
|
||||
|
||||
// V0_2+ customer schema (features as array of V1 - with extra fields)
|
||||
const V0_2_CustomerSchema = ApiCustomerSchema.extend({
|
||||
features: z.array(ApiCusFeatureV1Schema),
|
||||
});
|
||||
|
||||
// V0_1 customer schema (features as array of V0 - original format)
|
||||
const V0_1_CustomerSchema = ApiCustomerSchema.extend({
|
||||
features: z.array(ApiCusFeatureV0Schema),
|
||||
});
|
||||
|
||||
export const V0_2_CusFeatureChange = defineVersionChange({
|
||||
version: ApiVersion.V0_2,
|
||||
export const V0_1_CustomerChange = defineVersionChange({
|
||||
oldVersion: ApiVersion.V0_1, // Applied when targetVersion <= V0_1
|
||||
newVersion: ApiVersion.V0_2, // Breaking change introduced in V0_2
|
||||
description: [
|
||||
"Features: V1 format (with next_reset_at, allowance) → V0 format (minimal fields)",
|
||||
],
|
||||
|
||||
affectedResources: [AffectedResource.Customer],
|
||||
newSchema: V0_2_CustomerSchema,
|
||||
oldSchema: V0_1_CustomerSchema,
|
||||
|
||||
affectsRequest: false,
|
||||
affectsResponse: true,
|
||||
hasSideEffects: false,
|
||||
oldSchema: ApiCustomerV0Schema,
|
||||
newSchema: ApiCustomerV1Schema,
|
||||
|
||||
// Response: V0_2 Customer → V0_1 Customer
|
||||
// Remove next_reset_at, allowance, usage_limit fields
|
||||
transformResponse: ({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof V0_2_CustomerSchema>;
|
||||
input: z.infer<typeof ApiCustomerV1Schema>;
|
||||
}) => {
|
||||
const v0_features: ApiCusFeatureV0[] = [];
|
||||
|
||||
for (const feature of input.features) {
|
||||
for (const feature of input.entitlements) {
|
||||
// Unlimited features
|
||||
if (feature.unlimited) {
|
||||
const v0Feature = ApiCusFeatureV0Schema.parse({
|
||||
@@ -74,7 +65,7 @@ export const V0_2_CusFeatureChange = defineVersionChange({
|
||||
}
|
||||
|
||||
// Boolean/Static features
|
||||
if (!feature.interval && !feature.balance && feature.used === undefined) {
|
||||
if (!feature.interval && !feature.balance && !feature.unlimited) {
|
||||
const v0Feature = ApiCusFeatureV0Schema.parse({
|
||||
feature_id: feature.feature_id,
|
||||
interval: null,
|
||||
@@ -92,13 +83,29 @@ export const V0_2_CusFeatureChange = defineVersionChange({
|
||||
used: feature.used,
|
||||
// Remove: next_reset_at, allowance, usage_limit
|
||||
});
|
||||
console.log("v0Feature", v0Feature);
|
||||
|
||||
v0_features.push(v0Feature);
|
||||
}
|
||||
|
||||
const products = input.products.map((p) => {
|
||||
const {
|
||||
current_period_start: _currentPeriodStart,
|
||||
current_period_end: _currentPeriodEnd,
|
||||
...rest
|
||||
} = p;
|
||||
return rest;
|
||||
});
|
||||
|
||||
return {
|
||||
...input,
|
||||
features: v0_features,
|
||||
} satisfies z.infer<typeof V0_1_CustomerSchema>;
|
||||
customer: {
|
||||
...input.customer,
|
||||
name: input.customer.name || "",
|
||||
email: input.customer.email || "",
|
||||
fingerprint: input.customer.fingerprint || "",
|
||||
},
|
||||
entitlements: v0_features,
|
||||
products,
|
||||
} satisfies z.infer<typeof ApiCustomerV0Schema>;
|
||||
},
|
||||
});
|
||||
168
shared/api/customers/changes/V0_2_CustomerChange.ts
Normal file
168
shared/api/customers/changes/V0_2_CustomerChange.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
defineVersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import type { z } from "zod/v4";
|
||||
import {
|
||||
type ApiCusFeatureV1,
|
||||
ApiCusFeatureV1Schema,
|
||||
} from "../cusFeatures/previousVersions/apiCusFeatureV1.js";
|
||||
import type { ApiCusFeatureV2 } from "../cusFeatures/previousVersions/apiCusFeatureV2.js";
|
||||
import { transformCusProductV2ToV1 } from "../cusProducts/changes/V0_2_CusProductChange.js";
|
||||
import {
|
||||
type CustomerLegacyData,
|
||||
CustomerLegacyDataSchema,
|
||||
} from "../customerLegacyData.js";
|
||||
import { ApiCustomerV1Schema } from "../previousVersions/apiCustomerV1.js";
|
||||
import { ApiCustomerV2Schema } from "../previousVersions/apiCustomerV2.js";
|
||||
|
||||
/**
|
||||
* V0_2_CustomerChange: Transforms customer response TO V0_2 format
|
||||
*
|
||||
* Applied when: targetVersion <= V0_2
|
||||
*
|
||||
* Breaking changes introduced in V1.1 (that we reverse here):
|
||||
*
|
||||
* 1. Structure: Merged customer object → Split structure
|
||||
* - V1.1+: Single merged object with all fields
|
||||
* - V0_2: Split into {customer, products, add_ons, entitlements, invoices}
|
||||
*
|
||||
* 2. Features: Renamed and transformed
|
||||
* - V1.1+: "features" with usage/included_usage/interval_count fields
|
||||
* - V0_2: "entitlements" with used/allowance fields
|
||||
*
|
||||
* Input: ApiCustomerV2 (V1.1+ merged format)
|
||||
* Output: ApiCustomerV1 (V0_2 split format)
|
||||
*/
|
||||
|
||||
export const V0_2_CustomerChange = defineVersionChange({
|
||||
newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1
|
||||
oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2
|
||||
description: [
|
||||
"Customer response split into separate objects (customer, products, add_ons, entitlements, invoices)",
|
||||
"Features renamed to entitlements",
|
||||
"Features: V2 format (usage, included_usage, interval_count) → V1 format (used, allowance)",
|
||||
],
|
||||
affectedResources: [AffectedResource.Customer],
|
||||
newSchema: ApiCustomerV2Schema,
|
||||
oldSchema: ApiCustomerV1Schema,
|
||||
|
||||
legacyDataSchema: CustomerLegacyDataSchema,
|
||||
|
||||
// Response: V1.1+ (V2) → V0_2 (V1)
|
||||
transformResponse: ({
|
||||
input,
|
||||
legacyData,
|
||||
}: {
|
||||
input: z.infer<typeof ApiCustomerV2Schema>;
|
||||
legacyData?: CustomerLegacyData;
|
||||
}) => {
|
||||
// Step 1: Transform features V2 → V1
|
||||
const v1_features: ApiCusFeatureV1[] = transformFeaturesV2ToV1({
|
||||
features: input.features,
|
||||
});
|
||||
|
||||
// Step 2: Split merged structure
|
||||
const {
|
||||
features: _features,
|
||||
products = [],
|
||||
invoices,
|
||||
trials_used,
|
||||
...customerFields
|
||||
} = input;
|
||||
|
||||
const mainProducts = products
|
||||
.filter((p) => !p.is_add_on)
|
||||
.map((p) =>
|
||||
transformCusProductV2ToV1({
|
||||
input: p,
|
||||
legacyData: legacyData?.cusProductLegacyData[p.id],
|
||||
}),
|
||||
);
|
||||
|
||||
const addOns = products
|
||||
.filter((p) => p.is_add_on)
|
||||
.map((p) =>
|
||||
transformCusProductV2ToV1({
|
||||
input: p,
|
||||
legacyData: legacyData?.cusProductLegacyData[p.id],
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
customer: {
|
||||
...customerFields,
|
||||
internal_id: "",
|
||||
},
|
||||
products: mainProducts,
|
||||
add_ons: addOns,
|
||||
|
||||
entitlements: v1_features,
|
||||
invoices: invoices || [],
|
||||
trials_used,
|
||||
} satisfies z.infer<typeof ApiCustomerV1Schema>;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Transform features from V2 format to V1 format
|
||||
*
|
||||
* V2 → V1 field mappings:
|
||||
* - usage → used
|
||||
* - included_usage → allowance
|
||||
* - Remove: interval_count, overage_allowed, rollovers
|
||||
*/
|
||||
function transformFeaturesV2ToV1({
|
||||
features,
|
||||
}: {
|
||||
features: ApiCusFeatureV2[];
|
||||
}): ApiCusFeatureV1[] {
|
||||
const v1_features: ApiCusFeatureV1[] = [];
|
||||
|
||||
for (const feature of features) {
|
||||
// Unlimited features
|
||||
if (feature.unlimited) {
|
||||
const v1Feature = ApiCusFeatureV1Schema.parse({
|
||||
feature_id: feature.feature_id,
|
||||
unlimited: true,
|
||||
interval: null,
|
||||
balance: null,
|
||||
used: null,
|
||||
});
|
||||
v1_features.push(v1Feature);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Boolean/Static features (only have feature_id, no other fields)
|
||||
if (
|
||||
!feature.interval &&
|
||||
!feature.balance &&
|
||||
feature.usage === undefined &&
|
||||
feature.included_usage === undefined
|
||||
) {
|
||||
const v1Feature = ApiCusFeatureV1Schema.parse({
|
||||
feature_id: feature.feature_id,
|
||||
interval: null,
|
||||
});
|
||||
v1_features.push(v1Feature);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular/Metered features
|
||||
const v1Feature = ApiCusFeatureV1Schema.parse({
|
||||
feature_id: feature.feature_id,
|
||||
unlimited: feature.unlimited || false,
|
||||
interval: feature.interval,
|
||||
balance: feature.balance,
|
||||
used: feature.usage, // usage → used
|
||||
next_reset_at: feature.next_reset_at,
|
||||
allowance: feature.included_usage, // included_usage → allowance
|
||||
usage_limit: feature.usage_limit,
|
||||
// Remove: interval_count, overage_allowed, rollovers
|
||||
});
|
||||
v1_features.push(v1Feature);
|
||||
}
|
||||
|
||||
return v1_features;
|
||||
}
|
||||
@@ -6,24 +6,28 @@ import {
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* V1_1: Invoices expansion behavior changed (side effect only)
|
||||
* V0_2_InvoicesAlwaysExpanded: Side effect for legacy invoice expansion
|
||||
*
|
||||
* V1_1+: Invoices require explicit expand parameter
|
||||
* V1_0: Invoices always included (side effect - must be handled in handler)
|
||||
* Applied when: targetVersion <= V0_2
|
||||
*
|
||||
* Before V1_1, invoices were always included in customer responses.
|
||||
* After V1_1, invoices require an explicit expand parameter.
|
||||
*
|
||||
* This change has side effects and doesn't transform data.
|
||||
* The handler must add CusExpand.Invoices for V1_0 requests.
|
||||
* The handler must add expand=invoices for requests targeting V0_2 or older.
|
||||
*/
|
||||
|
||||
// Schema is just any since this is a side-effect only change
|
||||
const NoOpSchema = z.any();
|
||||
|
||||
export class V1_1_LegacyExpandInvoices extends VersionChange<
|
||||
export class V0_2_InvoicesAlwaysExpanded extends VersionChange<
|
||||
typeof NoOpSchema,
|
||||
typeof NoOpSchema
|
||||
> {
|
||||
readonly version = ApiVersion.V1_1;
|
||||
readonly description = "Invoices always expanded before V1_1";
|
||||
readonly newVersion = ApiVersion.V1_1; // Breaking change introduced in V1_1
|
||||
readonly oldVersion = ApiVersion.V0_2; // Applied when targetVersion <= V0_2
|
||||
readonly description =
|
||||
"Invoices always expanded before V1_1 (requires expand parameter in handler)";
|
||||
readonly affectedResources = [AffectedResource.Customer];
|
||||
readonly hasSideEffects = true;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { ApiCustomerSchema } from "@api/customers/apiCustomer.js";
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
defineVersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
type ApiCusFeatureV1,
|
||||
ApiCusFeatureV1Schema,
|
||||
} from "../cusFeatures/previousVersions/apiCusFeatureV1.js";
|
||||
import { ApiCusFeatureV2Schema } from "../cusFeatures/previousVersions/apiCusFeatureV2.js";
|
||||
|
||||
/**
|
||||
* V1_1: Customer feature schema changes
|
||||
*
|
||||
* V1_1+ format (V2):
|
||||
* - id (feature id)
|
||||
* - usage (current usage)
|
||||
* - included_usage (allowance)
|
||||
* - interval_count (interval multiplier)
|
||||
* - overage_allowed (whether overages permitted)
|
||||
* - type, name (feature metadata)
|
||||
* - rollovers (rollover data)
|
||||
*
|
||||
* V1_0 format (V1):
|
||||
* - feature_id (feature id)
|
||||
* - used (current usage)
|
||||
* - allowance (included usage)
|
||||
* - next_reset_at, usage_limit
|
||||
* - No interval_count
|
||||
* - No overage_allowed
|
||||
* - No type, name
|
||||
* - No rollovers
|
||||
*/
|
||||
|
||||
// V1_1+ customer schema (features as array of V2)
|
||||
const V1_1_CustomerSchema = ApiCustomerSchema.extend({
|
||||
features: z.array(ApiCusFeatureV2Schema),
|
||||
});
|
||||
|
||||
// V1_0 customer schema (features as array of V1)
|
||||
const V1_0_CustomerSchema = ApiCustomerSchema.extend({
|
||||
features: z.array(ApiCusFeatureV1Schema),
|
||||
});
|
||||
|
||||
export const V1_1_CusFeatureChange = defineVersionChange({
|
||||
version: ApiVersion.V1_1,
|
||||
description: [
|
||||
"Features: V2 format (usage, included_usage, interval_count) → V1 format (used, allowance)",
|
||||
"Renamed features to entitlements",
|
||||
],
|
||||
affectedResources: [AffectedResource.Customer],
|
||||
newSchema: V1_1_CustomerSchema,
|
||||
oldSchema: V1_0_CustomerSchema,
|
||||
|
||||
affectsRequest: false,
|
||||
affectsResponse: true,
|
||||
hasSideEffects: false,
|
||||
|
||||
// Response: V2 Customer → V1 Customer
|
||||
// Transform field names and structure
|
||||
transformResponse: ({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof V1_1_CustomerSchema>;
|
||||
}) => {
|
||||
const v1_features: ApiCusFeatureV1[] = [];
|
||||
|
||||
for (const feature of input.features) {
|
||||
// Unlimited features
|
||||
if (feature.unlimited) {
|
||||
const v1Feature = ApiCusFeatureV1Schema.parse({
|
||||
feature_id: feature.feature_id,
|
||||
unlimited: true,
|
||||
interval: null,
|
||||
balance: null,
|
||||
used: null,
|
||||
});
|
||||
v1_features.push(v1Feature);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Boolean/Static features (only have feature_id, no other fields)
|
||||
if (
|
||||
!feature.interval &&
|
||||
!feature.balance &&
|
||||
feature.usage === undefined &&
|
||||
feature.included_usage === undefined
|
||||
) {
|
||||
const v1Feature = ApiCusFeatureV1Schema.parse({
|
||||
feature_id: feature.feature_id,
|
||||
interval: null,
|
||||
});
|
||||
v1_features.push(v1Feature);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular/Metered features
|
||||
const v1Feature = ApiCusFeatureV1Schema.parse({
|
||||
feature_id: feature.feature_id,
|
||||
unlimited: feature.unlimited || false,
|
||||
interval: feature.interval,
|
||||
balance: feature.balance,
|
||||
used: feature.usage, // usage → used
|
||||
next_reset_at: feature.next_reset_at,
|
||||
allowance: feature.included_usage, // included_usage → allowance
|
||||
usage_limit: feature.usage_limit,
|
||||
// Remove: interval_count, overage_allowed, rollovers
|
||||
});
|
||||
v1_features.push(v1Feature);
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
features: v1_features,
|
||||
} satisfies z.infer<typeof V1_0_CustomerSchema>;
|
||||
},
|
||||
});
|
||||
@@ -31,11 +31,14 @@ const V1_1_CustomerSchema = ApiCustomerSchema.extend({
|
||||
features: z.array(ApiCusFeatureV2Schema),
|
||||
});
|
||||
|
||||
export const V1_2_FeaturesArrayToObject = defineVersionChange({
|
||||
version: ApiVersion.V1_2,
|
||||
export const V1_1_FeaturesArrayToObject = defineVersionChange({
|
||||
newVersion: ApiVersion.V1_2,
|
||||
oldVersion: ApiVersion.V1_1,
|
||||
|
||||
description: [
|
||||
"Features: object with breakdown → array with expanded intervals",
|
||||
],
|
||||
|
||||
affectedResources: [AffectedResource.Customer],
|
||||
newSchema: V1_2_CustomerSchema,
|
||||
oldSchema: V1_1_CustomerSchema,
|
||||
@@ -61,8 +64,7 @@ export const V1_2_FeaturesArrayToObject = defineVersionChange({
|
||||
for (const breakdownItem of feature.breakdown) {
|
||||
v1_1_features.push({
|
||||
feature_id: feature.id,
|
||||
// type: feature.type,
|
||||
// name: feature.name,
|
||||
|
||||
// All interval-specific data from breakdown
|
||||
interval: breakdownItem.interval,
|
||||
interval_count: breakdownItem.interval_count,
|
||||
@@ -113,8 +115,6 @@ export const V1_2_FeaturesArrayToObject = defineVersionChange({
|
||||
|
||||
// Boolean / unlimited features don't have the rest of the fields
|
||||
|
||||
console.log("v1_1_features", v1_1_features);
|
||||
|
||||
// Validation happens automatically in VersionChange base class
|
||||
return {
|
||||
...input,
|
||||
@@ -1,80 +0,0 @@
|
||||
import { ApiCustomerSchema } from "@api/customers/apiCustomer.js";
|
||||
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
|
||||
import {
|
||||
type ApiCusFeature,
|
||||
ApiCusFeatureSchema,
|
||||
} from "@api/customers/cusFeatures/apiCusFeature.js";
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
VersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* V1_1: Customer response structure changed from split to merged
|
||||
*
|
||||
* V1_1+ format: Merged single object with features/products
|
||||
* V1_0 format: Split into {customer, products, add_ons, entitlements, invoices}
|
||||
*/
|
||||
|
||||
// V1_1+ merged response schema
|
||||
const V1_1_CustomerResponseSchema = ApiCustomerSchema;
|
||||
|
||||
// V1_0 split response schema
|
||||
const V1_0_CustomerResponseSchema = z.object({
|
||||
customer: ApiCustomerSchema.omit({
|
||||
features: true,
|
||||
products: true,
|
||||
invoices: true,
|
||||
trials_used: true,
|
||||
}),
|
||||
products: z.array(APICusProductSchema),
|
||||
add_ons: z.array(APICusProductSchema),
|
||||
entitlements: z.array(ApiCusFeatureSchema),
|
||||
invoices: z.array(z.any()),
|
||||
trials_used: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
// Feature changes
|
||||
const toV1_0_cusFeature = ({ cusFeature }: { cusFeature: ApiCusFeature }) => {};
|
||||
|
||||
export class V1_1_MergedResponse extends VersionChange<
|
||||
typeof V1_1_CustomerResponseSchema,
|
||||
typeof V1_0_CustomerResponseSchema
|
||||
> {
|
||||
readonly version = ApiVersion.V1_1;
|
||||
readonly description = "Merged customer response → split structure";
|
||||
readonly affectedResources = [AffectedResource.Customer];
|
||||
readonly affectsRequest = false;
|
||||
readonly affectsResponse = true;
|
||||
|
||||
readonly newSchema = V1_1_CustomerResponseSchema;
|
||||
readonly oldSchema = V1_0_CustomerResponseSchema;
|
||||
|
||||
// Response: V1_1 merged → V1_0 split
|
||||
transformResponse({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof V1_1_CustomerResponseSchema>;
|
||||
}): z.infer<typeof V1_0_CustomerResponseSchema> {
|
||||
const {
|
||||
features,
|
||||
products = [],
|
||||
invoices,
|
||||
trials_used,
|
||||
...customerFields
|
||||
} = input;
|
||||
|
||||
return {
|
||||
customer: customerFields,
|
||||
products: products.filter((p) => !p.is_add_on),
|
||||
add_ons: products.filter((p) => p.is_add_on),
|
||||
entitlements: Array.isArray(features)
|
||||
? features
|
||||
: Object.values(features),
|
||||
invoices: invoices || [],
|
||||
trials_used,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const APICusReferralSchema = z.object({
|
||||
export const ApiCusReferralSchema = z.object({
|
||||
program_id: z.string(),
|
||||
customer: z.object({
|
||||
id: z.string(),
|
||||
@@ -11,4 +11,4 @@ export const APICusReferralSchema = z.object({
|
||||
created_at: z.number(),
|
||||
});
|
||||
|
||||
export type APICusReferralResponse = z.infer<typeof APICusReferralSchema>;
|
||||
export type ApiCusReferralResponse = z.infer<typeof ApiCusReferralSchema>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APIDiscountSchema } from "@api/others/apiDiscount.js";
|
||||
import { ApiDiscountSchema } from "@api/others/apiDiscount.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const APICusUpcomingInvoiceSchema = z.object({
|
||||
export const ApiCusUpcomingInvoiceSchema = z.object({
|
||||
lines: z.array(
|
||||
z.object({
|
||||
product_id: z.string().nullish(),
|
||||
@@ -9,10 +9,10 @@ export const APICusUpcomingInvoiceSchema = z.object({
|
||||
amount: z.number(),
|
||||
}),
|
||||
),
|
||||
discounts: z.array(APIDiscountSchema),
|
||||
discounts: z.array(ApiDiscountSchema),
|
||||
subtotal: z.number(),
|
||||
total: z.number(),
|
||||
currency: z.string(),
|
||||
});
|
||||
|
||||
export type APICusUpcomingInvoice = z.infer<typeof APICusUpcomingInvoiceSchema>;
|
||||
export type ApiCusUpcomingInvoice = z.infer<typeof ApiCusUpcomingInvoiceSchema>;
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import { EntInterval } from "@models/productModels/entModels/entEnums.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// const res: any = {
|
||||
// feature_id: ent.feature.id,
|
||||
// unlimited: isBoolean ? undefined : unlimited,
|
||||
// interval: isBoolean || unlimited ? null : ent.interval || undefined,
|
||||
// balance: isBoolean ? undefined : unlimited ? null : 0,
|
||||
// total: isBoolean || unlimited ? undefined : 0,
|
||||
// adjustment: isBoolean || unlimited ? undefined : 0,
|
||||
// used: isBoolean ? undefined : unlimited ? null : 0,
|
||||
// unused: 0,
|
||||
// };
|
||||
|
||||
/**
|
||||
* ApiCusFeatureV0Schema - The very first version of the customer feature API model
|
||||
* Minimal fields: only feature_id, unlimited, interval, balance, used
|
||||
@@ -25,31 +14,3 @@ export const ApiCusFeatureV0Schema = z.object({
|
||||
});
|
||||
|
||||
export type ApiCusFeatureV0 = z.infer<typeof ApiCusFeatureV0Schema>;
|
||||
|
||||
/**
|
||||
* EXAMPLES V0:
|
||||
*
|
||||
* Boolean Feature:
|
||||
* {
|
||||
* "feature_id": "figma-integration",
|
||||
* "interval": null
|
||||
* }
|
||||
*
|
||||
* Unlimited Feature:
|
||||
* {
|
||||
* "feature_id": "apply-code",
|
||||
* "unlimited": true,
|
||||
* "interval": null,
|
||||
* "balance": null,
|
||||
* "used": null
|
||||
* }
|
||||
*
|
||||
* Regular/Metered Feature:
|
||||
* {
|
||||
* "feature_id": "chat-messages",
|
||||
* "unlimited": false,
|
||||
* "interval": "month",
|
||||
* "balance": 591,
|
||||
* "used": 409
|
||||
* }
|
||||
*/
|
||||
|
||||
@@ -1,71 +1,10 @@
|
||||
import { EntInterval } from "@models/productModels/entModels/entEnums.js";
|
||||
import { z } from "zod/v4";
|
||||
import { ApiCusFeatureV0Schema } from "./apiCusFeatureV0.js";
|
||||
|
||||
/**
|
||||
* ApiCusFeatureV1Schema - Second version of the customer feature API model
|
||||
* Added: next_reset_at, allowance, usage_limit fields
|
||||
*
|
||||
* FEATURE TYPES AND THEIR RESPONSE STRUCTURES:
|
||||
*
|
||||
* 1. BOOLEAN FEATURES:
|
||||
* - Simple on/off features (e.g., "figma-integration", "apply-code")
|
||||
* - interval: null (always null for boolean features)
|
||||
* - Other fields (unlimited, balance, used) are omitted/undefined
|
||||
*
|
||||
* 2. UNLIMITED FEATURES:
|
||||
* - Features with no usage limits (e.g., "deepseek-messages")
|
||||
* - unlimited: true
|
||||
* - interval: null (no reset period needed)
|
||||
* - balance: null (no balance to track)
|
||||
* - used: null (no usage counting)
|
||||
*
|
||||
* 3. REGULAR (METERED) FEATURES:
|
||||
* - Features with usage limits and tracking (e.g., "chat-messages")
|
||||
* - unlimited: false
|
||||
* - interval: "month" | "year" | "week" | "day" (reset period)
|
||||
* - balance: number (remaining usage)
|
||||
* - used: number (amount consumed)
|
||||
*/
|
||||
export const ApiCusFeatureV1Schema = z.object({
|
||||
feature_id: z.string(),
|
||||
unlimited: z.boolean().nullish(),
|
||||
interval: z.enum(EntInterval).nullish(),
|
||||
balance: z.number().nullish(),
|
||||
used: z.number().nullish(),
|
||||
export const ApiCusFeatureV1Schema = ApiCusFeatureV0Schema.extend({
|
||||
next_reset_at: z.number().nullish(),
|
||||
allowance: z.number().nullish(),
|
||||
usage_limit: z.number().nullish(),
|
||||
});
|
||||
|
||||
export type ApiCusFeatureV1 = z.infer<typeof ApiCusFeatureV1Schema>;
|
||||
|
||||
/**
|
||||
* EXAMPLES V1:
|
||||
*
|
||||
* Boolean Feature:
|
||||
* {
|
||||
* "feature_id": "pro-access",
|
||||
* "interval": null
|
||||
* }
|
||||
*
|
||||
* Unlimited Feature:
|
||||
* {
|
||||
* "feature_id": "projects",
|
||||
* "unlimited": true,
|
||||
* "interval": null,
|
||||
* "balance": null,
|
||||
* "used": null
|
||||
* }
|
||||
*
|
||||
* Regular/Metered Feature:
|
||||
* {
|
||||
* "feature_id": "translated-words",
|
||||
* "unlimited": false,
|
||||
* "interval": "month",
|
||||
* "balance": -306981,
|
||||
* "used": 326981,
|
||||
* "next_reset_at": 1760549553168,
|
||||
* "allowance": 20000,
|
||||
* "usage_limit": 20000
|
||||
* }
|
||||
*/
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ApiProductItemSchema } from "@api/products/apiProductItem.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const APICusProductSchema = z.object({
|
||||
export const ApiCusProductSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
group: z.string().nullable(),
|
||||
status: z.enum(["active", "expired", "scheduled", "trialing"]),
|
||||
status: z.enum(["active", "expired", "scheduled", "trialing", "past_due"]),
|
||||
|
||||
canceled_at: z.number().nullish(),
|
||||
started_at: z.number(),
|
||||
@@ -24,4 +24,4 @@ export const APICusProductSchema = z.object({
|
||||
quantity: z.number().optional(),
|
||||
});
|
||||
|
||||
export type APICusProduct = z.infer<typeof APICusProductSchema>;
|
||||
export type ApiCusProduct = z.infer<typeof ApiCusProductSchema>;
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { ApiProductItemSchema } from "@api/products/apiProductItem.js";
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
defineVersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import { TierInfinite } from "@models/productV2Models/productItemModels/productItemModels.js";
|
||||
import {
|
||||
isFeatureItem,
|
||||
isPriceItem,
|
||||
} from "@utils/productDisplayUtils/getItemType.js";
|
||||
import { notNullish } from "@utils/utils.js";
|
||||
import { z } from "zod/v4";
|
||||
import type { CusProductLegacyData } from "../cusProductLegacyData.js";
|
||||
import { ApiCusProductV1Schema } from "../previousVersions/apiCusProductV1.js";
|
||||
import { ApiCusProductV2Schema } from "../previousVersions/apiCusProductV2.js";
|
||||
|
||||
/**
|
||||
* Transform product from V2 format to V1 format
|
||||
* Exported so it can be reused in other transformations (e.g., V0_2_CustomerChange)
|
||||
*/
|
||||
export const transformItemToPrice = ({
|
||||
item,
|
||||
}: {
|
||||
item: z.infer<typeof ApiProductItemSchema>;
|
||||
}) => {
|
||||
const singleTier =
|
||||
isPriceItem(item) ||
|
||||
(item.included_usage === 0 && (item.price || item.tiers?.length === 1));
|
||||
|
||||
if (singleTier) {
|
||||
return {
|
||||
amount: item.price || item.tiers?.[0].amount || 0,
|
||||
interval: item.interval,
|
||||
quantity: item.quantity,
|
||||
};
|
||||
} else {
|
||||
// Add allowance to tiers
|
||||
const allowance = item.included_usage as number;
|
||||
let tiers: {
|
||||
to: number | "inf";
|
||||
amount: number;
|
||||
}[];
|
||||
|
||||
if (notNullish(allowance) && allowance > 0) {
|
||||
tiers = [
|
||||
{
|
||||
to: allowance,
|
||||
amount: 0,
|
||||
},
|
||||
...(item.tiers
|
||||
? item.tiers.map((tier) => {
|
||||
const isLastTier = tier.to === -1 || tier.to === TierInfinite;
|
||||
return {
|
||||
to: isLastTier ? tier.to : (tier.to as number) + allowance,
|
||||
amount: tier.amount,
|
||||
};
|
||||
})
|
||||
: item.price
|
||||
? [
|
||||
{
|
||||
to: -1,
|
||||
amount: item.price || 0,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
} else {
|
||||
tiers = item.tiers
|
||||
? item.tiers.map((tier) => {
|
||||
const isLastTier = tier.to === -1 || tier.to === TierInfinite;
|
||||
return {
|
||||
to: isLastTier ? tier.to : (tier.to as number) + allowance,
|
||||
amount: tier.amount,
|
||||
};
|
||||
})
|
||||
: item.price
|
||||
? [
|
||||
{
|
||||
to: -1,
|
||||
amount: item.price || 0,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
return {
|
||||
tiers: tiers,
|
||||
name: "",
|
||||
quantity: item.quantity,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function transformCusProductV2ToV1({
|
||||
input,
|
||||
legacyData,
|
||||
}: {
|
||||
input: z.infer<typeof ApiCusProductV2Schema>;
|
||||
legacyData?: CusProductLegacyData;
|
||||
}): z.infer<typeof ApiCusProductV1Schema> {
|
||||
const v2CusProduct = input;
|
||||
|
||||
const prices =
|
||||
v2CusProduct.items
|
||||
?.filter((i) => !isFeatureItem(i))
|
||||
.map((i) => transformItemToPrice({ item: i })) || [];
|
||||
|
||||
const subId = legacyData?.subscription_id;
|
||||
|
||||
const v1CusProduct = {
|
||||
id: v2CusProduct.id,
|
||||
name: v2CusProduct.name,
|
||||
group: v2CusProduct.group || "",
|
||||
status: v2CusProduct.status,
|
||||
created_at: v2CusProduct.started_at, // legacy field..., just use started at...
|
||||
canceled_at: v2CusProduct.canceled_at,
|
||||
|
||||
processor: {
|
||||
type: "stripe",
|
||||
subscription_id: null,
|
||||
},
|
||||
subscription_ids: subId ? [subId] : [],
|
||||
|
||||
prices: prices,
|
||||
starts_at: v2CusProduct.started_at,
|
||||
|
||||
current_period_end: v2CusProduct.current_period_end,
|
||||
current_period_start: v2CusProduct.current_period_start,
|
||||
} as z.infer<typeof ApiCusProductV1Schema>;
|
||||
|
||||
return v1CusProduct;
|
||||
}
|
||||
|
||||
const CusProductLegacyDataSchema = z.object({
|
||||
subscription_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export const V0_2_CusProductChange = defineVersionChange({
|
||||
newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1
|
||||
oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2
|
||||
description: ["Customer product response revamped to fit ProductV2 schema"],
|
||||
|
||||
affectedResources: [AffectedResource.CusProduct],
|
||||
newSchema: ApiCusProductV2Schema,
|
||||
oldSchema: ApiCusProductV1Schema,
|
||||
legacyDataSchema: CusProductLegacyDataSchema,
|
||||
|
||||
// Response: V1.1+ (V2) → V0_2 (V1)
|
||||
transformResponse: transformCusProductV2ToV1,
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
VersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* V0_2: Customer products gained 'items' field
|
||||
*
|
||||
* V0_2+ format: Has items field with product features
|
||||
* V0_1 format: No items field, no current_period_end/start
|
||||
*/
|
||||
|
||||
// V0_2+ product schema (with items)
|
||||
const V0_2_CusProductSchema = APICusProductSchema;
|
||||
|
||||
// V0_1 product schema (without items and period fields)
|
||||
const V0_1_CusProductSchema = APICusProductSchema.omit({
|
||||
items: true,
|
||||
current_period_end: true,
|
||||
current_period_start: true,
|
||||
});
|
||||
|
||||
export class V0_2_ProductItems extends VersionChange<
|
||||
typeof V0_2_CusProductSchema,
|
||||
typeof V0_1_CusProductSchema
|
||||
> {
|
||||
readonly version = ApiVersion.V0_2;
|
||||
readonly description = "Products gained 'items' field";
|
||||
readonly affectedResources = [AffectedResource.CusProduct];
|
||||
readonly affectsRequest = false;
|
||||
readonly affectsResponse = true;
|
||||
|
||||
readonly newSchema = V0_2_CusProductSchema;
|
||||
readonly oldSchema = V0_1_CusProductSchema;
|
||||
|
||||
transformResponse({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof V0_2_CusProductSchema>;
|
||||
}): z.infer<typeof V0_1_CusProductSchema> {
|
||||
// Remove items and period fields for V0_1
|
||||
// biome-ignore lint/correctness/noUnusedVariables: Using destructuring to omit fields
|
||||
const { items, current_period_end, current_period_start, ...rest } = input;
|
||||
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
7
shared/api/customers/cusProducts/cusProductLegacyData.ts
Normal file
7
shared/api/customers/cusProducts/cusProductLegacyData.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const CusProductLegacyDataSchema = z.object({
|
||||
subscription_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CusProductLegacyData = z.infer<typeof CusProductLegacyDataSchema>;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const ApiCusProductV0PriceSchema = z.object({
|
||||
amount: z.number(),
|
||||
interval: z.string(),
|
||||
});
|
||||
|
||||
export const ApiCusProductV0Schema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
group: z.string().nullable(),
|
||||
status: z.enum(["active", "expired", "scheduled", "trialing", "past_due"]),
|
||||
created_at: z.number(),
|
||||
canceled_at: z.number().nullish(),
|
||||
|
||||
processor: z
|
||||
.object({
|
||||
type: z.string(),
|
||||
subscription_id: z.string().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
|
||||
subscription_ids: z.array(z.string()).default([]),
|
||||
prices: z.array(z.any()).default([]),
|
||||
|
||||
starts_at: z.number(),
|
||||
});
|
||||
|
||||
export type ApiCusProductV0 = z.infer<typeof ApiCusProductV0Schema>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
import { ApiCusProductV0Schema } from "./apiCusProductV0.js";
|
||||
|
||||
/**
|
||||
* ApiCusProductV1Schema - Customer Product schema for API V1.0 (pre V0_2)
|
||||
*
|
||||
* Missing fields compared to V2:
|
||||
* - items: Product items were added in V0_2
|
||||
* - current_period_start: Period tracking added in V0_2
|
||||
* - current_period_end: Period tracking added in V0_2
|
||||
*/
|
||||
export const ApiCusProductV1Schema = ApiCusProductV0Schema.extend({
|
||||
current_period_end: z.number().nullish(),
|
||||
current_period_start: z.number().nullish(),
|
||||
});
|
||||
|
||||
export type ApiCusProductV1 = z.infer<typeof ApiCusProductV1Schema>;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiProductItemSchema } from "@api/products/apiProductItem.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* ApiCusProductV2Schema - Customer Product schema for API V1.1+ (post V0_2)
|
||||
*
|
||||
* Added in V0_2:
|
||||
* - items: Array of product items (features, prices)
|
||||
* - current_period_start: Billing period start timestamp
|
||||
* - current_period_end: Billing period end timestamp
|
||||
*/
|
||||
export const ApiCusProductV2Schema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
group: z.string().nullable(),
|
||||
status: z.enum(["active", "expired", "scheduled", "trialing", "past_due"]),
|
||||
|
||||
canceled_at: z.number().nullish(),
|
||||
started_at: z.number(),
|
||||
is_default: z.boolean(),
|
||||
is_add_on: z.boolean(),
|
||||
version: z.number().nullish(),
|
||||
|
||||
current_period_start: z.number().nullish(),
|
||||
current_period_end: z.number().nullish(),
|
||||
|
||||
entity_id: z.string().nullish(),
|
||||
|
||||
items: z.array(ApiProductItemSchema).nullish(),
|
||||
|
||||
quantity: z.number().optional(),
|
||||
});
|
||||
|
||||
export type ApiCusProductV2 = z.infer<typeof ApiCusProductV2Schema>;
|
||||
8
shared/api/customers/customerLegacyData.ts
Normal file
8
shared/api/customers/customerLegacyData.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
import { CusProductLegacyDataSchema } from "./cusProducts/cusProductLegacyData.js";
|
||||
|
||||
export const CustomerLegacyDataSchema = z.object({
|
||||
cusProductLegacyData: z.record(z.string(), CusProductLegacyDataSchema),
|
||||
});
|
||||
|
||||
export type CustomerLegacyData = z.infer<typeof CustomerLegacyDataSchema>;
|
||||
@@ -1,11 +1,17 @@
|
||||
import { CusExpand } from "@models/cusModels/cusExpand.js";
|
||||
import { z } from "zod/v4";
|
||||
import { EntityDataSchema } from "../common/entityData.js";
|
||||
import { queryStringArray } from "../common/queryHelpers.js";
|
||||
|
||||
// Create Customer Params (based on handlePostCustomer logic)
|
||||
export const CreateCustomerParamsSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.refine(
|
||||
export const GetCustomerQuerySchema = z.object({
|
||||
expand: queryStringArray(z.enum(CusExpand)).optional(),
|
||||
});
|
||||
|
||||
export const CreateCustomerQuerySchema = z.object({
|
||||
expand: queryStringArray(z.enum(CusExpand)).optional(),
|
||||
});
|
||||
|
||||
const customerId = z.string().refine(
|
||||
(val) => {
|
||||
if (val === "") return false;
|
||||
if (val.includes("@")) return false;
|
||||
@@ -14,49 +20,73 @@ export const CreateCustomerParamsSchema = z.object({
|
||||
return /^[a-zA-Z0-9_-]+$/.test(val);
|
||||
},
|
||||
{
|
||||
error: (issue) => {
|
||||
const input = issue.input as string;
|
||||
if (input === "") return { message: "can't be an empty string" };
|
||||
if (input.includes("@"))
|
||||
return {
|
||||
message:
|
||||
"ID can only contain letters, numbers, underscores, and hyphens",
|
||||
"cannot contain @ symbol. Use only letters, numbers, underscores, and hyphens.",
|
||||
};
|
||||
if (input.includes(" "))
|
||||
return {
|
||||
message:
|
||||
"cannot contain spaces. Use only letters, numbers, underscores, and hyphens.",
|
||||
};
|
||||
if (input.includes("."))
|
||||
return {
|
||||
message:
|
||||
"cannot contain periods. Use only letters, numbers, underscores, and hyphens.",
|
||||
};
|
||||
const invalidChar = input.match(/[^a-zA-Z0-9_-]/)?.[0];
|
||||
return {
|
||||
message: `cannot contain '${invalidChar}'. Use only letters, numbers, underscores, and hyphens.`,
|
||||
};
|
||||
},
|
||||
)
|
||||
.nullish()
|
||||
.meta({
|
||||
},
|
||||
);
|
||||
|
||||
// Create Customer Params (based on handlePostCustomer logic)
|
||||
export const CreateCustomerParamsSchema = z.object({
|
||||
id: customerId.nullable().meta({
|
||||
description: "Your unique identifier for the customer",
|
||||
example: "cus_123",
|
||||
}),
|
||||
|
||||
name: z.string().nullish().meta({
|
||||
description: "Customer's name",
|
||||
example: "John Doe",
|
||||
}),
|
||||
email: z
|
||||
.email({ message: "not a valid email address" })
|
||||
.or(z.literal(""))
|
||||
.nullish()
|
||||
.meta({
|
||||
|
||||
email: z.email({ message: "not a valid email address" }).nullish().meta({
|
||||
description: "Customer's email address",
|
||||
example: "john@example.com",
|
||||
}),
|
||||
fingerprint: z.string().nullish().meta({
|
||||
|
||||
fingerprint: z.string().optional().meta({
|
||||
description:
|
||||
"Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse",
|
||||
example: "fp_123abc",
|
||||
}),
|
||||
|
||||
metadata: z
|
||||
.record(z.any(), z.any())
|
||||
.record(z.string(), z.any())
|
||||
.default({})
|
||||
.nullish()
|
||||
.meta({
|
||||
description: "Additional metadata for the customer",
|
||||
example: { company: "Acme Inc" },
|
||||
}),
|
||||
stripe_id: z.string().nullish().meta({
|
||||
|
||||
stripe_id: z.string().optional().meta({
|
||||
description: "Stripe customer ID if you already have one",
|
||||
example: "cus_stripe123",
|
||||
}),
|
||||
entity_id: z.string().nullish().meta({
|
||||
|
||||
entity_id: z.string().optional().meta({
|
||||
description: "Entity ID to associate with the customer",
|
||||
example: "entity_123",
|
||||
}),
|
||||
entity_data: EntityDataSchema.nullish().meta({
|
||||
entity_data: EntityDataSchema.optional().meta({
|
||||
description: "Data for creating an entity",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CusExpand } from "@models/cusModels/cusExpand.js";
|
||||
import { z } from "zod/v4";
|
||||
import { SuccessResponseSchema } from "../common/commonResponses.js";
|
||||
import { ApiCustomerSchema } from "./apiCustomer.js";
|
||||
import {
|
||||
CreateCustomerParamsSchema,
|
||||
CreateCustomerQuerySchema,
|
||||
GetCustomerQuerySchema,
|
||||
ListCustomersResponseSchema,
|
||||
UpdateCustomerParamsSchema,
|
||||
} from "./customerOpModels.js";
|
||||
@@ -38,9 +39,7 @@ export const customerOps = {
|
||||
summary: "Create Customer",
|
||||
tags: ["customers"],
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
expand: z.string().optional(),
|
||||
}),
|
||||
query: CreateCustomerQuerySchema,
|
||||
},
|
||||
requestBody: {
|
||||
content: {
|
||||
@@ -63,9 +62,7 @@ export const customerOps = {
|
||||
path: z.object({
|
||||
customer_id: z.string(),
|
||||
}),
|
||||
query: z.object({
|
||||
expand: z.array(z.enum(CusExpand)).optional(),
|
||||
}),
|
||||
query: GetCustomerQuerySchema,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
|
||||
55
shared/api/customers/previousVersions/apiCustomerV0.ts
Normal file
55
shared/api/customers/previousVersions/apiCustomerV0.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ApiCusFeatureV0Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV0.js";
|
||||
import { ApiCusProductV0Schema } from "@api/customers/cusProducts/previousVersions/apiCusProductV0.js";
|
||||
import { ApiInvoiceSchema } from "@api/others/apiInvoice.js";
|
||||
import { AppEnv } from "@models/genModels/genEnums.js";
|
||||
import { z } from "zod/v4";
|
||||
import { ApiTrialsUsedSchema } from "../apiCustomer.js";
|
||||
|
||||
/**
|
||||
* ApiCustomerV0Schema - Customer response format for API V0_1
|
||||
*
|
||||
* Structure: Split into separate objects
|
||||
* - customer: Core customer fields
|
||||
* - products: Array of non-add-on products
|
||||
* - add_ons: Array of add-on products
|
||||
* - entitlements: Array of customer features (V0 format - minimal fields)
|
||||
* - invoices: Array of customer invoices
|
||||
*
|
||||
* Key differences from V1:
|
||||
* - Features (entitlements) use V0 format with minimal fields (no next_reset_at, allowance, usage_limit)
|
||||
* - Products don't have items or period tracking (same as V1)
|
||||
* - Invoices always included (no expand parameter)
|
||||
*/
|
||||
|
||||
// Core customer object (without products/features/invoices)
|
||||
export const ApiCustomerV0CoreSchema = z.object({
|
||||
// Internal fields
|
||||
id: z.string().nullable(),
|
||||
name: z.string().default(""),
|
||||
email: z.string().default(""),
|
||||
fingerprint: z.string().default(""),
|
||||
created_at: z.number(),
|
||||
env: z.enum(AppEnv),
|
||||
|
||||
processor: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
.nullish(),
|
||||
|
||||
metadata: z.record(z.any(), z.any()).default({}),
|
||||
});
|
||||
|
||||
// Split response structure
|
||||
export const ApiCustomerV0Schema = z.object({
|
||||
customer: ApiCustomerV0CoreSchema,
|
||||
products: z.array(ApiCusProductV0Schema),
|
||||
add_ons: z.array(ApiCusProductV0Schema),
|
||||
entitlements: z.array(ApiCusFeatureV0Schema),
|
||||
invoices: z.array(ApiInvoiceSchema),
|
||||
trials_used: z.array(ApiTrialsUsedSchema).optional(),
|
||||
});
|
||||
|
||||
export type ApiCustomerV0 = z.infer<typeof ApiCustomerV0Schema>;
|
||||
export type ApiCustomerV0Core = z.infer<typeof ApiCustomerV0CoreSchema>;
|
||||
58
shared/api/customers/previousVersions/apiCustomerV1.ts
Normal file
58
shared/api/customers/previousVersions/apiCustomerV1.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ApiCusFeatureV1Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV1.js";
|
||||
import { ApiInvoiceSchema } from "@api/others/apiInvoice.js";
|
||||
import { AppEnv } from "@models/genModels/genEnums.js";
|
||||
import { z } from "zod/v4";
|
||||
import { ApiTrialsUsedSchema } from "../apiCustomer.js";
|
||||
import { ApiCusProductV1Schema } from "../cusProducts/previousVersions/apiCusProductV1.js";
|
||||
|
||||
/**
|
||||
* ApiCustomerV1Schema - Customer response format for API V0_2/V1_0/V1_1 (pre V1_2)
|
||||
*
|
||||
* Structure: Split into separate objects
|
||||
* - customer: Core customer fields
|
||||
* - products: Array of non-add-on products
|
||||
* - add_ons: Array of add-on products
|
||||
* - entitlements: Array of customer features (V1 format)
|
||||
* - invoices: Array of customer invoices
|
||||
*
|
||||
* Key differences from V2:
|
||||
* - Split structure instead of merged
|
||||
* - Uses "entitlements" instead of "features"
|
||||
* - Features are ApiCusFeatureV1 (with used/allowance fields)
|
||||
* - Products are ApiCusProductV2 (same as V2, with items/period tracking)
|
||||
* - Invoices always included (no expand parameter)
|
||||
*/
|
||||
|
||||
// Core customer object (without products/features/invoices)
|
||||
export const ApiCustomerV1CoreSchema = z.object({
|
||||
// Internal fields
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
fingerprint: z.string().nullable(),
|
||||
internal_id: z.string(),
|
||||
created_at: z.number(),
|
||||
env: z.enum(AppEnv),
|
||||
|
||||
processor: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
.nullish(),
|
||||
|
||||
metadata: z.record(z.any(), z.any()).default({}),
|
||||
});
|
||||
|
||||
// Split response structure
|
||||
export const ApiCustomerV1Schema = z.object({
|
||||
customer: ApiCustomerV1CoreSchema,
|
||||
products: z.array(ApiCusProductV1Schema),
|
||||
add_ons: z.array(ApiCusProductV1Schema),
|
||||
entitlements: z.array(ApiCusFeatureV1Schema),
|
||||
invoices: z.array(ApiInvoiceSchema),
|
||||
trials_used: z.array(ApiTrialsUsedSchema).optional(),
|
||||
});
|
||||
|
||||
export type ApiCustomerV1 = z.infer<typeof ApiCustomerV1Schema>;
|
||||
export type ApiCustomerV1Core = z.infer<typeof ApiCustomerV1CoreSchema>;
|
||||
62
shared/api/customers/previousVersions/apiCustomerV2.ts
Normal file
62
shared/api/customers/previousVersions/apiCustomerV2.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ApiCusReferralSchema } from "@api/customers/components/apiCusReferral.js";
|
||||
import { ApiCusUpcomingInvoiceSchema } from "@api/customers/components/apiCusUpcomingInvoice.js";
|
||||
import { ApiCusFeatureV2Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV2.js";
|
||||
import { ApiCusProductV2Schema } from "@api/customers/cusProducts/previousVersions/apiCusProductV2.js";
|
||||
import { ApiEntitySchema } from "@api/entities/apiEntity.js";
|
||||
import { ApiCusRewardsSchema } from "@api/models.js";
|
||||
import { ApiInvoiceSchema } from "@api/others/apiInvoice.js";
|
||||
import { AppEnv } from "@models/genModels/genEnums.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const ApiTrialsUsedV2Schema = z.object({
|
||||
product_id: z.string(),
|
||||
customer_id: z.string(),
|
||||
fingerprint: z.string().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* ApiCustomerV2Schema - Customer response format for API V1.1+ (merged format)
|
||||
*
|
||||
*
|
||||
* Key differences from V1:
|
||||
* - Merged structure (everything in one object)
|
||||
* - Uses "features" instead of "entitlements"
|
||||
* - Features are ApiCusFeatureV2 (with usage/included_usage fields)
|
||||
* - Products are ApiCusProductV2 (with items field)
|
||||
* - Invoices require explicit expand parameter
|
||||
*/
|
||||
export const ApiCustomerV2Schema = z.object({
|
||||
// Internal fields
|
||||
autumn_id: z.string().nullish(),
|
||||
|
||||
id: z.string().nullable().meta({
|
||||
description: "Your internal ID for the customer",
|
||||
example: "cus_123",
|
||||
}),
|
||||
|
||||
created_at: z.number().meta({
|
||||
description:
|
||||
"The date and time the customer was created in milliseconds since epoch",
|
||||
example: 1717000000,
|
||||
}),
|
||||
|
||||
name: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
fingerprint: z.string().nullable(),
|
||||
stripe_id: z.string().nullable().default(null),
|
||||
env: z.enum(AppEnv),
|
||||
|
||||
products: z.array(ApiCusProductV2Schema),
|
||||
features: z.array(ApiCusFeatureV2Schema),
|
||||
invoices: z.array(ApiInvoiceSchema).optional(),
|
||||
trials_used: z.array(ApiTrialsUsedV2Schema).optional(),
|
||||
|
||||
rewards: ApiCusRewardsSchema.nullish(),
|
||||
metadata: z.record(z.any(), z.any()).default({}),
|
||||
entities: z.array(ApiEntitySchema).optional(),
|
||||
referrals: z.array(ApiCusReferralSchema).optional(),
|
||||
upcoming_invoice: ApiCusUpcomingInvoiceSchema.nullish(),
|
||||
payment_method: z.any().nullish(),
|
||||
});
|
||||
|
||||
export type ApiCustomerV2 = z.infer<typeof ApiCustomerV2Schema>;
|
||||
@@ -1,6 +1,24 @@
|
||||
import { EntityResponseSchema } from "@models/cusModels/entityModels/entityResModels.js";
|
||||
import type { z } from "zod/v4";
|
||||
import { ApiCusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
|
||||
import { ApiCusProductSchema } from "@api/customers/cusProducts/apiCusProduct.js";
|
||||
import { ApiInvoiceSchema } from "@api/others/apiInvoice.js";
|
||||
import { AppEnv } from "@models/genModels/genEnums.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Base schema without .meta() to avoid side effects during imports
|
||||
export const APIEntitySchema = EntityResponseSchema;
|
||||
export type APIEntity = z.infer<typeof APIEntitySchema>;
|
||||
export const ApiBaseEntitySchema = z.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
|
||||
customer_id: z.string().nullish(),
|
||||
feature_id: z.string().nullish(),
|
||||
|
||||
created_at: z.number(),
|
||||
env: z.enum(AppEnv),
|
||||
});
|
||||
|
||||
export const ApiEntitySchema = ApiBaseEntitySchema.extend({
|
||||
products: z.array(ApiCusProductSchema).optional(),
|
||||
features: z.record(z.string(), ApiCusFeatureSchema).optional(),
|
||||
invoices: z.array(ApiInvoiceSchema).optional(),
|
||||
});
|
||||
|
||||
export type EntityResponse = z.infer<typeof ApiEntitySchema>;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { EntityExpand } from "@models/cusModels/entityModels/entityExpand.js";
|
||||
import { z } from "zod/v4";
|
||||
import { SuccessResponseSchema } from "../common/commonResponses.js";
|
||||
import { APIEntitySchema } from "./apiEntity.js";
|
||||
import { queryStringArray } from "../common/queryHelpers.js";
|
||||
import { ApiEntitySchema } from "./apiEntity.js";
|
||||
import { CreateEntityParamsSchema } from "./entityOpModels.js";
|
||||
|
||||
// Register schema with .meta() for OpenAPI spec generation
|
||||
const APIEntityWithMeta = APIEntitySchema.meta({
|
||||
const ApiEntityWithMeta = ApiEntitySchema.meta({
|
||||
id: "Entity",
|
||||
description: "Entity object returned by the API",
|
||||
});
|
||||
@@ -28,7 +29,7 @@ export const entityOps = {
|
||||
responses: {
|
||||
"200": {
|
||||
description: "200 OK",
|
||||
content: { "application/json": { schema: APIEntityWithMeta } },
|
||||
content: { "application/json": { schema: ApiEntityWithMeta } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -43,13 +44,13 @@ export const entityOps = {
|
||||
entity_id: z.string(),
|
||||
}),
|
||||
query: z.object({
|
||||
expand: z.array(z.enum(EntityExpand)).optional(),
|
||||
expand: queryStringArray(z.enum(EntityExpand)).optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "200 OK",
|
||||
content: { "application/json": { schema: APIEntityWithMeta } },
|
||||
content: { "application/json": { schema: ApiEntityWithMeta } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user