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({
entitlement: ent,
options: getEntOptions(cusProduct.options, ent),
relatedPrice: getRelatedCusPrice(cusEnt, cusPrices)?.price,
productQuantity: cusProduct.quantity || 1,
}) || 0) * count;
const resetBalance = getResetBalance({
entitlement: ent,
options: getEntOptions(cusProduct.options, ent),
relatedPrice: getRelatedCusPrice(cusEnt, cusPrices)?.price,
productQuantity: cusProduct.quantity || 1,
});
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,31 +114,19 @@ export const getFreeTrialAfterFingerprint = async ({
}
let uniqueFreeTrial: FreeTrial | null = freeTrial;
if (uniqueFreeTrial.unique_fingerprint && fingerprint) {
let exists = await trialFingerprintExists({
db,
fingerprint,
freeTrialId: uniqueFreeTrial.id,
});
if (exists) {
console.log("Free trial fingerprint exists");
uniqueFreeTrial = null;
}
}
const data = await CusProductService.getByFingerprint({
db,
productId,
internalCustomerId,
fingerprint: uniqueFreeTrial.unique_fingerprint ? fingerprint! : undefined,
});
if (uniqueFreeTrial) {
// Check if same customer exists
let exists = await trialWithCustomerExists({
db,
internalCustomerId,
freeTrialId: uniqueFreeTrial.id,
});
const exists = data && data.length > 0;
if (exists) {
console.log("Free trial with customer exists");
uniqueFreeTrial = null;
}
if (exists) {
console.log("Free trial fingerprint 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
const handleNavigation = () => {
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.",
);
return confirmed;
};
// Handle programmatic navigation (pushState/replaceState)
window.history.pushState = 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 originalPushState.apply(this, args);
}
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);
}
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