Merge branch 'main' into attach-flows

This commit is contained in:
John Yeo
2025-06-09 10:50:33 +01:00
26 changed files with 245 additions and 194 deletions

View File

@@ -9,9 +9,10 @@
"start": "tsx src/index.ts",
"workers": "tsx watch src/workers.ts",
"workers:start": "node --no-deprecation dist/src/workers.js",
"build": "tsc -b",
"prod": "tsc -b && tsc-alias && node dist/src/index.js",
"prod:build": "tsc -b && tsc-alias",
"build": "tsc -b tsconfig.build.json",
"prod": "tsc -b tsconfig.build.json && tsc-alias && node dist/src/index.js",
"prod:build": "tsc -b tsconfig.build.json && tsc-alias",
"prod:start": "node dist/src/index.js",
"cron": "tsx src/cron.ts",
"cron:start": "node --no-deprecation dist/src/cron.js",

View File

@@ -18,7 +18,6 @@ import {
Organization,
Subscription,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
export const getEntityResponse = async ({
db,
@@ -101,8 +100,9 @@ export const getEntityResponse = async ({
...(withAutumnId ? { autumn_id: entity.internal_id } : {}),
id: entity.id,
name: entity.name,
customer_id: customerId,
created_at: entity.created_at,
// feature_id: entity.feature_id,
customer_id: customerId,
env,
products,
features,

View File

@@ -63,6 +63,16 @@ export const handleVersionProductV2 = async ({
env,
});
if (latestProduct.is_default) {
await ProductService.updateByInternalId({
db,
internalId: latestProduct.internal_id,
update: {
is_default: false,
},
});
}
await ProductService.insert({ db, product: newProduct });
const { customPrices, customEnts } = await handleNewProductItems({

View File

@@ -1,5 +1,7 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { AppEnv, customers, CusProductStatus } from "@autumn/shared";
import { and, desc, eq, ilike, or, lt, isNotNull, gt, sql } from "drizzle-orm";
import { customerProducts, products } from "@autumn/shared";
@@ -55,14 +57,13 @@ export class CusSearchService {
filters.product_id
? eq(customerProducts.product_id, filters.product_id)
: undefined,
filters.status ? eq(customerProducts.status, filters.status) : undefined,
filters.status === "canceled"
? and(activeProdFilter, isNotNull(customerProducts.canceled_at))
: undefined,
filters.status === "free_trial"
? and(
eq(customerProducts.status, CusProductStatus.Active),
gt(customerProducts.trial_ends_at, Date.now()),
isNotNull(customerProducts.free_trial_id),
)
: undefined,
);

View File

@@ -210,7 +210,8 @@ export const handleStripeSubUpdate = async ({
// CREATE NEW SUBSCRIPTIONS
for (const itemSet of newItemSets) {
// 1. Next billing date for first sub
const nextCycleAnchor = firstSub.current_period_end * 1000;
// const nextCycleAnchor = firstSub.current_period_end * 1000;
const nextCycleAnchor = subUpdate.current_period_end * 1000;
let nextCycleAnchorUnix = nextCycleAnchor;
const naturalBillingDate = addBillingIntervalUnix(
Date.now(),

View File

@@ -13,7 +13,16 @@ import {
import { customerProducts } from "@autumn/shared";
import { and, arrayContains, eq, inArray, or, sql } from "drizzle-orm";
import {
and,
arrayContains,
eq,
inArray,
isNotNull,
ne,
or,
sql,
} from "drizzle-orm";
export const ACTIVE_STATUSES = [
CusProductStatus.Active,
@@ -466,12 +475,14 @@ export class CusProductService {
static async getByFingerprint({
db,
freeTrialId,
productId,
internalCustomerId,
fingerprint,
}: {
db: DrizzleCli;
freeTrialId: string;
fingerprint: string;
productId: string;
internalCustomerId: string;
fingerprint?: string;
}) {
let data = await db
.select()
@@ -480,10 +491,18 @@ export class CusProductService {
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.innerJoin(
products,
eq(customerProducts.internal_product_id, products.internal_id),
)
.where(
and(
eq(customers.fingerprint, fingerprint),
eq(customerProducts.free_trial_id, freeTrialId),
or(
fingerprint ? eq(customers.fingerprint, fingerprint) : undefined,
eq(customers.internal_id, internalCustomerId),
),
eq(products.id, productId),
isNotNull(customerProducts.free_trial_id),
),
);

View File

@@ -337,10 +337,12 @@ export const getFullCusProductData = async ({
if (!isCustom) {
let freeTrial = null;
let freeTrialProduct = products.find((p) => notNullish(p.free_trial));
if (freeTrialProduct) {
freeTrial = await getFreeTrialAfterFingerprint({
db,
freeTrial: freeTrialProduct.free_trial,
productId: freeTrialProduct.id,
fingerprint: customer.fingerprint,
internalCustomerId: customer.internal_id,
multipleAllowed: org.config.multiple_trials,
@@ -427,6 +429,7 @@ export const getFullCusProductData = async ({
const uniqueFreeTrial = await getFreeTrialAfterFingerprint({
db,
freeTrial: freeTrial,
productId: product.id,
fingerprint: customer.fingerprint,
internalCustomerId: customer.internal_id,
multipleAllowed: org.config.multiple_trials,

View File

@@ -139,6 +139,7 @@ export const getCusBalances = async ({
data[key].balance += balance || 0;
data[key].adjustment += adjustment || 0;
let total =
(getResetBalance({
entitlement: ent,
@@ -158,13 +159,13 @@ export const getCusBalances = async ({
data[key].next_reset_at = cusEnt.next_reset_at;
}
data[key].allowance +=
(getResetBalance({
const resetBalance = getResetBalance({
entitlement: ent,
options: getEntOptions(cusProduct.options, ent),
relatedPrice: getRelatedCusPrice(cusEnt, cusPrices)?.price,
productQuantity: cusProduct.quantity || 1,
}) || 0) * count;
});
data[key].allowance += (resetBalance || 0) * count;
}
}

View File

@@ -22,6 +22,7 @@ import {
RewardType,
RewardResponse,
CouponDurationType,
EntityResponseSchema,
} from "@autumn/shared";
import { getCusInvoices, processFullCusProducts } from "./cusUtils.js";
@@ -295,6 +296,18 @@ export const getCustomerDetails = async ({
: undefined,
rewards: withRewards ? rewards : undefined,
metadata: customer.metadata,
entities: expand.includes(CusExpand.Entities)
? customer.entities.map((e) =>
EntityResponseSchema.parse({
id: e.id,
name: e.name,
customer_id: customer.id,
feature_id: e.feature_id,
created_at: e.created_at,
env: customer.env,
}),
)
: undefined,
}),
};

View File

@@ -99,11 +99,12 @@ const buildEntitiesCTE = (withEntities: boolean) => {
customer_entities AS (
SELECT
COALESCE(
json_agg(row_to_json(e)) FILTER (WHERE e.id IS NOT NULL),
json_agg(row_to_json(e) ORDER BY e.internal_id DESC) FILTER (WHERE e.id IS NOT NULL),
'[]'::json
) AS entities
FROM entities e
WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record)
LIMIT 100
)
`;
};

View File

@@ -74,6 +74,7 @@ export const verifyKey = async ({
key: string;
}) => {
const hashedKey = hashApiKey(key);
const env = key.startsWith("am_sk_test") ? AppEnv.Sandbox : AppEnv.Live;
const data = await getAPIKeyCache({

View File

@@ -92,64 +92,17 @@ export const freeTrialToStripeTimestamp = ({
return Math.ceil(trialEnd.getTime() / 1000);
};
export const freeTrialToNumDays = (freeTrial: FreeTrial | null) => {
if (!freeTrial) return undefined;
return freeTrial.length;
};
export const trialFingerprintExists = async ({
db,
freeTrialId,
fingerprint,
}: {
db: DrizzleCli;
freeTrialId: string;
fingerprint: string;
}) => {
const data = await CusProductService.getByFingerprint({
db,
freeTrialId,
fingerprint,
});
if (data && data.length > 0) {
return true;
}
return false;
};
export const trialWithCustomerExists = async ({
db,
internalCustomerId,
freeTrialId,
}: {
db: DrizzleCli;
internalCustomerId: string;
freeTrialId: string;
}) => {
const data = await CusProductService.getByFingerprint({
db,
freeTrialId,
fingerprint: internalCustomerId,
});
if (data && data.length > 0) {
return true;
}
return false;
};
export const getFreeTrialAfterFingerprint = async ({
db,
freeTrial,
productId,
fingerprint,
internalCustomerId,
multipleAllowed,
}: {
db: DrizzleCli;
freeTrial: FreeTrial | null | undefined;
productId: string;
fingerprint: string | null | undefined;
internalCustomerId: string;
multipleAllowed: boolean;
@@ -161,32 +114,20 @@ export const getFreeTrialAfterFingerprint = async ({
}
let uniqueFreeTrial: FreeTrial | null = freeTrial;
if (uniqueFreeTrial.unique_fingerprint && fingerprint) {
let exists = await trialFingerprintExists({
const data = await CusProductService.getByFingerprint({
db,
fingerprint,
freeTrialId: uniqueFreeTrial.id,
productId,
internalCustomerId,
fingerprint: uniqueFreeTrial.unique_fingerprint ? fingerprint! : undefined,
});
const exists = data && data.length > 0;
if (exists) {
console.log("Free trial fingerprint exists");
uniqueFreeTrial = null;
}
}
if (uniqueFreeTrial) {
// Check if same customer exists
let exists = await trialWithCustomerExists({
db,
internalCustomerId,
freeTrialId: uniqueFreeTrial.id,
});
if (exists) {
console.log("Free trial with customer exists");
uniqueFreeTrial = null;
}
}
return uniqueFreeTrial;
};

View File

@@ -41,6 +41,7 @@ export const verifySecretKey = async (req: any, res: any, next: any) => {
}
const apiKey = authHeader.split(" ")[1];
if (!apiKey.startsWith("am_")) {
return {
error: ErrCode.InvalidAuthHeader,

View File

@@ -8,9 +8,9 @@ if [ "$1" == "basic-parallel" ]; then
tests/basic/*.ts \
tests/basic/multi-feature/*.ts \
tests/basic/entities/*.ts \
# 'tests/basic/referrals/*.ts' 'tests/attach/**/*.ts' \
# && $MOCHA_CMD \
# 'tests/basic/referrals/*.ts' 'tests/attach/**/*.ts' \
# 'tests/basic/referrals/*.ts' 'tests/attach/**/*.ts' \
elif [ "$1" == "advanced-parallel" ]; then
MOCHA_PARALLEL=true \

View File

@@ -0,0 +1,25 @@
{
"ts-node": {
"esm": true,
"experimentalSpecifier": true
},
"compilerOptions": {
"target": "ES2020",
"moduleResolution": "NodeNext",
"module": "NodeNext",
"declaration": true,
"rootDir": ".",
"baseUrl": ".",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -9,7 +9,6 @@
"module": "NodeNext", // or "node16"/"nodenext"
"declaration": true,
// "rootDir": "../",
"rootDir": ".",
"baseUrl": ".",
"outDir": "./dist",
@@ -18,8 +17,6 @@
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
// "allowImportingTsExtensions": true,
// "noEmit": true,
"paths": {
"@/*": ["src/*"],
"@shared/*": ["../shared/*"],

View File

@@ -70,7 +70,10 @@ export * from "./models/cusModels/fullCusModel.js";
export * from "./models/cusModels/cusExpand.js";
export * from "./models/cusModels/invoiceModels/invoiceResponseModels.js";
export * from "./models/cusModels/invoiceModels/invoiceTable.js";
// Cus response
export * from "./models/cusModels/cusResponseModels.js";
export * from "./models/cusModels/cusResModels/cusProductResponse.js";
export * from "./models/cusModels/cusResModels/cusFeatureResponse.js";
export * from "./models/cusModels/entityModels/entityModels.js";
export * from "./models/cusModels/entityModels/entityTable.js";

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
import { EntInterval } from "../../productModels/entModels/entEnums.js";
export const CusEntResponseSchema = z.object({
feature_id: z.string(),
interval: z.nativeEnum(EntInterval).nullish(),
unlimited: z.boolean().nullish(),
balance: z.number().nullish(), //
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
});
export const CusEntResponseV2Schema = z.object({
id: z.string(),
name: z.string().nullish(),
interval: z.nativeEnum(EntInterval).or(z.literal("multiple")).nullish(),
unlimited: z.boolean().nullish(),
balance: z.number().nullish(),
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
breakdown: z
.array(
z.object({
interval: z.nativeEnum(EntInterval),
balance: z.number().nullish(),
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
}),
)
.nullish(),
});
export type CusEntResponse = z.infer<typeof CusEntResponseSchema>;
export type CusEntResponseV2 = z.infer<typeof CusEntResponseV2Schema>;

View File

@@ -0,0 +1,18 @@
import { z } from "zod";
import { CusProductStatus } from "../../cusProductModels/cusProductEnums.js";
export const CusProductResponseSchema = z.object({
id: z.string(),
name: z.string().nullable(),
group: z.string().nullable(),
status: z.nativeEnum(CusProductStatus),
// created_at: z.number(),
canceled_at: z.number().nullish(),
started_at: z.number(),
subscription_ids: z.array(z.string()).nullish(),
current_period_start: z.number().nullish(),
current_period_end: z.number().nullish(),
entity_id: z.string().nullish(),
});

View File

@@ -1,58 +1,9 @@
import { z } from "zod";
import { AppEnv } from "../genModels/genEnums.js";
import { EntInterval } from "../productModels/entModels/entEnums.js";
import { InvoiceResponseSchema } from "./invoiceModels/invoiceResponseModels.js";
import { CusProductStatus } from "../cusProductModels/cusProductEnums.js";
import { RewardResponseSchema } from "../rewardModels/rewardModels/rewardResponseModels.js";
export const CusProductResponseSchema = z.object({
id: z.string(),
name: z.string().nullable(),
group: z.string().nullable(),
status: z.nativeEnum(CusProductStatus),
// created_at: z.number(),
canceled_at: z.number().nullish(),
started_at: z.number(),
subscription_ids: z.array(z.string()).nullish(),
current_period_start: z.number().nullish(),
current_period_end: z.number().nullish(),
entity_id: z.string().nullish(),
});
export const CusEntResponseSchema = z.object({
feature_id: z.string(),
interval: z.nativeEnum(EntInterval).nullish(),
unlimited: z.boolean().nullish(),
balance: z.number().nullish(), //
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
});
export const CusEntResponseV2Schema = z.object({
id: z.string(),
name: z.string().nullish(),
interval: z.nativeEnum(EntInterval).or(z.literal("multiple")).nullish(),
unlimited: z.boolean().nullish(),
balance: z.number().nullish(),
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
breakdown: z
.array(
z.object({
interval: z.nativeEnum(EntInterval),
balance: z.number().nullish(),
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
}),
)
.nullish(),
});
import { EntityResponseSchema } from "./entityModels/entityResModels.js";
import { CusProductResponseSchema } from "./cusResModels/cusProductResponse.js";
export const TrialUsedResponseSchema = z.object({
product_id: z.string(),
@@ -79,10 +30,8 @@ export const CusResponseSchema = z.object({
trials_used: z.array(TrialUsedResponseSchema).optional(),
rewards: RewardResponseSchema.nullish(),
metadata: z.record(z.any()).default({}),
entities: z.array(EntityResponseSchema).optional(),
});
export type CusResponse = z.infer<typeof CusResponseSchema>;
export type CusEntResponse = z.infer<typeof CusEntResponseSchema>;
export type CusEntResponseV2 = z.infer<typeof CusEntResponseV2Schema>;
export type CusProductResponse = z.infer<typeof CusProductResponseSchema>;

View File

@@ -1,22 +1,20 @@
import { AppEnv } from "../../genModels/genEnums.js";
import {
CusProductResponseSchema,
CusEntResponseV2Schema,
} from "../cusResponseModels.js";
import { z } from "zod";
import { InvoiceResponseSchema } from "../invoiceModels/invoiceResponseModels.js";
import { CusProductResponseSchema } from "../cusResModels/cusProductResponse.js";
import { CusEntResponseV2Schema } from "../cusResModels/cusFeatureResponse.js";
export const EntityResponseSchema = z.object({
id: z.string().nullable(),
name: z.string().nullable(),
customer_id: z.string(),
feature_id: z.string().nullish(),
created_at: z.number(),
env: z.nativeEnum(AppEnv),
products: z.array(CusProductResponseSchema),
features: z.record(z.string(), CusEntResponseV2Schema),
products: z.array(CusProductResponseSchema).optional(),
features: z.record(z.string(), CusEntResponseV2Schema).optional(),
invoices: z.array(InvoiceResponseSchema).optional(),
});

View File

@@ -68,6 +68,7 @@ export const FullProductSchema = ProductSchema.extend({
),
free_trial: FreeTrialSchema.nullish(),
free_trials: z.array(FreeTrialSchema).nullish(),
free_trial_ids: z.array(z.string()).nullish(),
});
export type Product = z.infer<typeof ProductSchema>;

View File

@@ -5,6 +5,7 @@ import {
EntitlementWithFeature,
PriceType,
ProductItem,
ProductItemInterval,
} from "@autumn/shared";
import { FixedPriceConfig, Price, UsagePriceConfig } from "@autumn/shared";
@@ -101,8 +102,6 @@ export const isOneOffProduct = (
});
};
export const isFreeProduct = (prices: Price[]) => {
return prices.every((price) => {
return price.config?.interval == BillingInterval.OneOff;
});
export const isFreeProduct = (items: ProductItem[]) => {
return items.every((item) => isFeatureItem(item));
};

View File

@@ -124,46 +124,73 @@ function ProductView({ env }: { env: AppEnv }) {
: `Save changes to product: ${product?.name}`,
};
// Handle browser beforeunload event
// Replace the current useBlocker call with a fixed useEffect
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasChanges) {
e.preventDefault();
}
};
if (!hasChanges) return;
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [hasChanges]);
// Intercept in-app navigation
useEffect(() => {
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
let currentUrl = window.location.href;
let isRestoring = false; // Flag to prevent recursive popstate events
window.history.pushState = function (...args) {
if (hasChanges) {
const handleNavigation = () => {
const confirmed = window.confirm(
"You have unsaved changes. Click Update Product to save your changes.",
"Are you sure you want to leave without updating the product? Click cancel to stay and save your changes, or click OK to leave without saving.",
);
if (!confirmed) return;
}
return confirmed;
};
// Handle programmatic navigation (pushState/replaceState)
window.history.pushState = function (...args) {
if (handleNavigation()) {
currentUrl = window.location.href;
return originalPushState.apply(this, args);
}
};
window.history.replaceState = function (...args) {
if (hasChanges) {
const confirmed = window.confirm(
"You have unsaved changes. Click Update Product to save your changes.",
);
if (!confirmed) return;
}
if (handleNavigation()) {
currentUrl = window.location.href;
return originalReplaceState.apply(this, args);
}
};
// Handle back/forward button navigation
const handlePopState = (event: PopStateEvent) => {
if (isRestoring) return; // Prevent handling our own restore operation
const confirmed = window.confirm(
"Are you sure you want to leave without updating the product? Click cancel to stay and save your changes, or click OK to leave without saving.",
);
if (!confirmed) {
// User clicked Cancel (wants to stay) - go forward to undo the back navigation
isRestoring = true;
window.history.go(1); // Go forward to undo the back navigation
setTimeout(() => {
isRestoring = false;
}, 100); // Reset flag after navigation
} else {
currentUrl = window.location.href;
}
};
window.addEventListener("popstate", handlePopState);
// Optional: Handle page unload/refresh as well
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = ""; // Required for some browsers
return "";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.history.pushState = originalPushState;
window.history.replaceState = originalReplaceState;
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [hasChanges]);

View File

@@ -5,7 +5,13 @@ import { useEffect, useState } from "react";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { SelectTrigger, SelectValue } from "@/components/ui/select";
import { keyToTitle, slugify } from "@/utils/formatUtils/formatTextUtils";
import { Reward, RewardType, Product, FullProduct } from "@autumn/shared";
import {
Reward,
RewardType,
Product,
FullProduct,
ProductV2,
} from "@autumn/shared";
import { useProductsContext } from "../ProductsContext";
import { DiscountConfig } from "./DiscountConfig";
import { notNullish } from "@/utils/genUtils";
@@ -20,6 +26,7 @@ export const RewardConfig = ({
setReward: (reward: Reward) => void;
}) => {
const { products } = useProductsContext();
const [idChanged, setIdChanged] = useState(false);
useEffect(() => {
@@ -110,10 +117,8 @@ export const RewardConfig = ({
>
{(() => {
const freeAddOns = products
.filter((product: FullProduct) => product.is_add_on)
.filter((product: FullProduct) =>
isFreeProduct(product.prices),
);
.filter((product: ProductV2) => product.is_add_on)
.filter((product: ProductV2) => isFreeProduct(product.items));
const empty = freeAddOns.length === 0;
return (

View File

@@ -13,8 +13,6 @@ export const RewardsTable = () => {
const [selectedReward, setSelectedReward] = useState<Reward | null>(null);
const [open, setOpen] = useState(false);
console.log("products: ", products);
return (
<>
<UpdateReward