refactor: knip deletions

This commit is contained in:
Charlie Lamb
2026-01-28 10:45:56 +00:00
parent d1b3a281f1
commit 71dea99cb5
483 changed files with 94 additions and 47012 deletions

1428
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +0,0 @@
import * as Sentry from "@sentry/bun";
import { subDays } from "date-fns";
import { db } from "@/db/initDrizzle.js";
import { hatchet } from "@/external/hatchet/initHatchet.js";
import { logger } from "@/external/logtail/logtailUtils";
import { deleteOrg } from "../../internal/orgs/deleteOrg/deleteOrg";
import { OrgService } from "../../internal/orgs/OrgService";
export const cleanupPreviewOrgsWorkflow = hatchet?.workflow({
name: "cleanup-preview-orgs",
onCrons: ["0 8 * * *"], // Run daily at 3 AM UTC
});
cleanupPreviewOrgsWorkflow?.task({
name: "cleanup-preview-orgs-task",
executionTimeout: "300s",
fn: async () => {
// 1. Find all preview orgs with no memberships
const orgsToDelete = await OrgService.listPreviewOrgsForDeletion({ db });
logger.info(`Found ${orgsToDelete.length} preview orgs to delete`);
// SAFETY: Alert if we're trying to delete too many orgs (possible bug)
if (orgsToDelete.length > 20) {
Sentry.captureException(
`Found ${orgsToDelete.length} preview orgs with no members, too many to run cleanup.`,
);
return { deletedCount: 0, totalFound: orgsToDelete.length, errors: 0 };
}
let deletedCount = 0;
for (const previewOrg of orgsToDelete) {
// SAFETY: Double-check this is actually a preview org
if (!previewOrg.slug.startsWith("preview|")) {
logger.error(
`Attempted to delete non-preview org in cleanup: ${previewOrg.slug}`,
);
Sentry.captureException(
`Attempted to delete non-preview org in cleanup: ${previewOrg.slug}`,
);
continue;
}
// SAFETY: Don't delete orgs older than 10 days (might be important)
const tenDaysAgo = subDays(new Date(), 10);
if (new Date(previewOrg.createdAt).getTime() < tenDaysAgo.getTime()) {
logger.error(
`Preview org older than 10 days found in cleanup: ${previewOrg.slug} (created: ${previewOrg.createdAt})`,
);
Sentry.captureException(
`Preview org older than 10 days found in cleanup: ${previewOrg.slug} (created: ${previewOrg.createdAt})`,
);
continue;
}
logger.info(
`Deleting preview org: ${previewOrg.id} (${previewOrg.slug})`,
);
await deleteOrg({
org: previewOrg,
db,
logger,
deleteOrgFromDb: true,
});
deletedCount++;
logger.info(
`Successfully deleted preview org: ${previewOrg.id} (${previewOrg.slug})`,
);
}
return { deletedCount, totalFound: orgsToDelete.length };
},
});

View File

@@ -1,146 +0,0 @@
import { type SQL, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
export interface ArrayAggregationConfig {
table: PgTable;
alias?: string;
filter?: SQL;
orderBy?: SQL[];
limit?: number;
distinct?: boolean;
}
export interface RowSubqueryConfig {
table: PgTable;
alias?: string;
where?: SQL;
}
export interface JunctionJoinConfig {
junctionTable: PgTable;
fromField: string;
toField: string;
fromTable: PgTable;
toTable: PgTable;
fromId: SQL;
}
/**
* Generate json_agg SQL for array aggregation with COALESCE to empty array
* Example: COALESCE(json_agg(row_to_json(e) ORDER BY e.id) FILTER (WHERE e.id IS NOT NULL), '[]'::json)
*/
export function generateArrayAggSQL({
table,
alias,
filter,
orderBy,
distinct = false,
}: ArrayAggregationConfig): SQL {
const tableAlias = alias || getTableAlias(table);
const distinctKeyword = distinct ? sql`DISTINCT ` : sql``;
let aggExpression = sql`json_agg(${distinctKeyword}row_to_json(${sql.identifier(tableAlias)})`;
// Add ORDER BY if provided
if (orderBy && orderBy.length > 0) {
aggExpression = sql`${aggExpression} ORDER BY ${sql.join(orderBy, sql`, `)}`;
}
aggExpression = sql`${aggExpression})`;
// Add FILTER clause if provided
if (filter) {
aggExpression = sql`${aggExpression} FILTER (WHERE ${filter})`;
}
// Wrap with COALESCE to handle NULL → empty array
return sql`COALESCE(${aggExpression}, '[]'::json)`;
}
/**
* Generate row_to_json SQL for single row subquery
* Example: (SELECT row_to_json(p) FROM products p WHERE p.id = ${parentId})
*/
export function generateRowSubquerySQL({
table,
alias,
where,
}: RowSubqueryConfig): SQL {
const tableAlias = alias || getTableAlias(table);
const tableName = getTableName(table);
let query = sql`(SELECT row_to_json(${sql.identifier(tableAlias)}) FROM ${sql.identifier(tableName)} ${sql.identifier(tableAlias)}`;
if (where) {
query = sql`${query} WHERE ${where}`;
}
query = sql`${query})`;
return query;
}
// /**
// * Generate SQL for many-to-many join through junction table
// * Example:
// * SELECT json_agg(o)
// * FROM member m
// * INNER JOIN organizations o ON o.id = m.organization_id
// * WHERE m.user_id = ${userId}
// */
// export function generateJunctionJoinSQL({
// junctionTable,
// fromField,
// toField,
// toTable,
// fromId,
// }: JunctionJoinConfig): SQL {
// const junctionAlias = getTableAlias(junctionTable);
// const toAlias = getTableAlias(toTable);
// const junctionTableName = getTableName(junctionTable);
// const toTableName = getTableName(toTable);
// return sql`
// FROM ${sql.identifier(junctionTableName)} ${sql.identifier(junctionAlias)}
// INNER JOIN ${sql.identifier(toTableName)} ${sql.identifier(toAlias)}
// ON ${sql.identifier(toAlias)}.${sql.identifier("id")} = ${sql.identifier(junctionAlias)}.${sql.identifier(toField)}
// WHERE ${sql.identifier(junctionAlias)}.${sql.identifier(fromField)} = ${fromId}
// `;
// }
/**
* Generate SQL for limiting results per parent using window functions
* Example: row_number() OVER (PARTITION BY user_id ORDER BY created_at)
*/
export function generateRowNumberSQL({
partitionBy,
orderBy,
}: {
partitionBy: SQL;
orderBy?: SQL[];
}): SQL {
let windowSQL = sql`row_number() OVER (PARTITION BY ${partitionBy}`;
if (orderBy && orderBy.length > 0) {
windowSQL = sql`${windowSQL} ORDER BY ${sql.join(orderBy, sql`, `)}`;
}
windowSQL = sql`${windowSQL})`;
return windowSQL;
}
/**
* Extract table name from Drizzle table object
*/
function getTableName(table: PgTable): string {
return (table as any)[Symbol.for("drizzle:Name")] || String(table);
}
/**
* Generate a short alias for a table (first letter of table name)
*/
function getTableAlias(table: PgTable): string {
const tableName = getTableName(table);
return tableName.charAt(0);
}

View File

@@ -1,44 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { sql } from "drizzle-orm";
import { db } from "./initDrizzle.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Initialize all database functions (stored procedures)
* Loads SQL files in dependency order: helpers first, then main functions
*/
export const initializeDatabaseFunctions = async () => {
try {
console.log("Initializing database functions...");
const sqlPath = join(__dirname, "../internal/balances/utils/sql");
// Load SQL files in dependency order:
// 1. Helper functions (used by main functions)
// 2. Main functions (depend on helpers)
const sqlFiles = [
// Helper functions
"deductFromRollovers.sql",
"deductFromMainBalance.sql",
"getTotalBalance.sql",
"deductFromAdditionalBalance.sql",
"performDeduction.sql",
"syncBalances.sql",
"syncBalancesV2.sql",
];
for (const file of sqlFiles) {
const sqlContent = readFileSync(join(sqlPath, file), "utf-8");
await db.execute(sql.raw(sqlContent));
console.log(` ✓ Loaded ${file}`);
}
console.log("Database functions initialized successfully");
} catch (error) {
console.error("Failed to initialize database functions:", error);
throw error;
}
};

View File

@@ -1,65 +0,0 @@
import * as schema from "@autumn/shared";
import { is } from "drizzle-orm";
import { PgTable } from "drizzle-orm/pg-core";
import { logger } from "../external/logtail/logtailUtils";
import type { DrizzleCli } from "./initDrizzle";
const SKIP_TABLES = ["migrationErrors"];
export const validateDbSchema = async ({ db }: { db: DrizzleCli }) => {
// Dynamically get all tables from schema (exclude relations)
const tableEntries = Object.entries(schema)
.filter(([name, table]) => {
// Filter out relations and non-table exports
if (name.includes("Relations")) return false;
// Skip migrationErrors table (known issue)
if (SKIP_TABLES.includes(name)) return false;
return is(table, PgTable);
})
.map(([name, table]) => ({ name, table: table as PgTable }));
// Validate all tables by selecting all columns to ensure schema matches
// If schema mismatches, Drizzle will throw an error
const start = Date.now();
const results = await Promise.allSettled(
tableEntries.map(({ name, table }) =>
db
.select()
.from(table)
.limit(1)
.then(() => ({ name, success: true as const }))
.catch((err: Error) => ({
name,
success: false as const,
error: err.message,
})),
),
);
const elapsed = Date.now() - start;
// Check for any failures
const failures = results
.map((r) => (r.status === "fulfilled" ? r.value : null))
.filter(
(v): v is { name: string; success: false; error: string } =>
v !== null && !v.success,
);
if (failures.length > 0) {
const failureDetails = failures
.map((f) => `Table '${f.name}': ${f.error}`)
.join("; ");
logger.error(
`Health check failed - DB schema validation error: ${failureDetails}`,
);
throw new Error(
`Health check failed - DB schema validation error: ${failureDetails}`,
);
}
logger.info(
`Health check passed - DB schema validated for ${tableEntries.length} tables in ${elapsed}ms`,
);
return true;
};

View File

@@ -1,182 +0,0 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { sql } from "drizzle-orm";
import { logger } from "../external/logtail/logtailUtils";
import type { DrizzleCli } from "./initDrizzle";
type SqlFunction = {
name: string;
sourceFile: string;
content: string;
contentHash: string;
};
/**
* Dynamically discover SQL functions from the deductRpc folder
*/
const discoverSqlFunctions = (): SqlFunction[] => {
const __filename = fileURLToPath(import.meta.url);
const deductRpcPath = join(
__filename,
"../../internal/balances/track/trackUtils/deductRpc",
);
const sqlFiles = readdirSync(deductRpcPath).filter((file) =>
file.endsWith(".sql"),
);
return sqlFiles.map((file) => {
const filePath = join(deductRpcPath, file);
const content = readFileSync(filePath, "utf-8");
// Extract function name from CREATE FUNCTION statement
const functionNameMatch = content.match(
/CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(\w+)/i,
);
const functionName = functionNameMatch?.[1];
if (!functionName) {
throw new Error(`Could not extract function name from ${file}`);
}
// Create hash of normalized content (ignore whitespace differences)
const normalizedContent = content
.replace(/--.*$/gm, "") // Remove comments
.replace(/\s+/g, " ") // Normalize whitespace
.trim();
const contentHash = createHash("sha256")
.update(normalizedContent)
.digest("hex")
.substring(0, 16);
return {
name: functionName,
sourceFile: file,
content,
contentHash,
};
});
};
export const validateSqlFunctions = async ({
db,
validateContent = false,
}: {
db: DrizzleCli;
validateContent?: boolean;
}) => {
const start = Date.now();
// Dynamically discover SQL functions from source files
const requiredFunctions = discoverSqlFunctions();
logger.info(
`Discovered ${requiredFunctions.length} SQL functions from source files`,
);
// Query database for existing functions and their definitions
const result = await db.execute<{
function_name: string;
definition: string;
}>(sql`
SELECT
p.proname as function_name,
pg_get_functiondef(p.oid) as definition
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public'
AND p.prokind = 'f'
ORDER BY p.proname;
`);
const dbFunctions = new Map(
result.map((row) => [row.function_name, row.definition]),
);
// Check for missing functions
const missingFunctions = requiredFunctions.filter(
(fn) => !dbFunctions.has(fn.name),
);
if (missingFunctions.length > 0) {
const missingDetails = missingFunctions
.map((fn) => `'${fn.name}' (from ${fn.sourceFile})`)
.join(", ");
logger.error(
`SQL function validation failed: Missing functions: ${missingDetails}`,
);
throw new Error(`Missing SQL functions: ${missingDetails}`);
}
// Optionally validate function content
const mismatchedFunctions: Array<{
name: string;
sourceFile: string;
reason: string;
}> = [];
if (validateContent) {
for (const fn of requiredFunctions) {
const dbDefinition = dbFunctions.get(fn.name);
if (!dbDefinition) continue;
// Normalize both definitions for comparison
const normalizeFunc = (str: string) =>
str
.replace(/--.*$/gm, "") // Remove comments
.replace(/\s+/g, " ") // Normalize whitespace
.toLowerCase()
.trim();
const normalizedSource = normalizeFunc(fn.content);
const normalizedDb = normalizeFunc(dbDefinition);
// Create hashes for comparison
const sourceHash = createHash("sha256")
.update(normalizedSource)
.digest("hex")
.substring(0, 16);
const dbHash = createHash("sha256")
.update(normalizedDb)
.digest("hex")
.substring(0, 16);
if (sourceHash !== dbHash) {
mismatchedFunctions.push({
name: fn.name,
sourceFile: fn.sourceFile,
reason: `Source hash: ${sourceHash}, DB hash: ${dbHash}`,
});
}
}
if (mismatchedFunctions.length > 0) {
const mismatchDetails = mismatchedFunctions
.map((fn) => `'${fn.name}' (${fn.sourceFile}): ${fn.reason}`)
.join("; ");
logger.warn(`SQL function content mismatch detected: ${mismatchDetails}`);
logger.warn(
"Functions exist but their content differs from source files. Run migrations to update.",
);
}
}
const elapsed = Date.now() - start;
logger.info(
`SQL function validation passed - ${requiredFunctions.length} functions verified in ${elapsed}ms${validateContent ? " (content validated)" : ""}`,
);
if (mismatchedFunctions.length > 0) {
logger.info(
`⚠️ ${mismatchedFunctions.length} function(s) have content differences`,
);
}
return true;
};

View File

@@ -1,8 +0,0 @@
import { Redis } from "@upstash/redis";
const upstash = new Redis({
url: process.env.CLOUD_UPSTASH_REDIS_REST_URL,
token: process.env.CLOUD_UPSTASH_REDIS_REST_TOKEN,
});
export { upstash };

View File

@@ -1,167 +0,0 @@
import {
atmnToStripeAmountDecimal,
BillingInterval,
type EntitlementWithFeature,
InternalError,
type Organization,
type Price,
type Product,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "../../../db/initDrizzle.js";
import { orgToCurrency } from "../../../internal/orgs/orgUtils.js";
import { PriceService } from "../../../internal/products/prices/PriceService.js";
import { billingIntervalToStripe } from "../stripePriceUtils.js";
// 1. Product name
const prepaidToStripeTiers = ({
ent,
price,
org,
}: {
ent: EntitlementWithFeature;
price: Price;
org: Organization;
}) => {
const usageTiers = price.config.usage_tiers;
if (!usageTiers) {
throw new InternalError({
message:
"[Internal Error] Converting prepaid price to tiers, but `usage_tiers` field is missing",
});
}
// Create paid tiers first
const paidTiers: Stripe.PriceCreateParams.Tier[] = usageTiers.map(
(tier, index) => {
const atmnUnitAmount = new Decimal(tier.amount).div(
price.config.billing_units ?? 1,
);
const stripeUnitAmountDecimal = atmnToStripeAmountDecimal({
amount: atmnUnitAmount,
currency: orgToCurrency({ org }),
});
return {
unit_amount_decimal: stripeUnitAmountDecimal,
up_to: index === usageTiers.length - 1 ? "inf" : (tier.to ?? 0),
};
},
);
// 1. Get included usage
const includedUsage = ent.allowance;
if (includedUsage && includedUsage > 0) {
paidTiers.forEach((tier) => {
if (tier.up_to === "inf") {
return;
}
tier.up_to = new Decimal(tier.up_to as number)
.plus(includedUsage)
.toNumber();
});
paidTiers.unshift({
unit_amount_decimal: "0",
up_to: includedUsage,
});
}
return paidTiers;
};
export const createStripePrepaidPriceV2 = async ({
org,
stripeCli,
db,
price,
ent,
product,
curStripeProd,
}: {
org: Organization;
stripeCli: Stripe;
db: DrizzleCli;
price: Price;
ent: EntitlementWithFeature;
product: Product;
curStripeProd: Stripe.Product | null;
}) => {
let recurringData;
if (price.config!.interval !== BillingInterval.OneOff) {
recurringData = billingIntervalToStripe({
interval: price.config!.interval,
intervalCount: price.config!.interval_count,
});
}
const config = price.config as UsagePriceConfig;
const productName = `${product.name} - ${ent.feature.name}`;
const productData = curStripeProd
? { product: curStripeProd.id }
: {
product_data: {
name: productName,
},
};
// 2. If billing interval is one off
let stripePrice = null;
if (price.config!.interval === BillingInterval.OneOff) {
const amount = config.usage_tiers[0].amount;
const unitAmountDecimalStr = atmnToStripeAmountDecimal({
amount,
currency: orgToCurrency({ org }),
});
stripePrice = await stripeCli.prices.create({
...productData,
unit_amount_decimal: unitAmountDecimalStr,
currency: orgToCurrency({ org }),
});
config.stripe_product_id = stripePrice.product as string;
config.stripe_price_id = stripePrice.id;
} else {
const priceConfigTiers = price.config.usage_tiers;
let priceAmountData: Partial<Stripe.PriceCreateParams>;
if (priceConfigTiers?.length === 1) {
priceAmountData = {
unit_amount_decimal: atmnToStripeAmountDecimal({
amount: priceConfigTiers[0].amount,
currency: orgToCurrency({ org }),
}),
};
} else {
priceAmountData = {
billing_scheme: "tiered",
tiers_mode: "graduated",
tiers: prepaidToStripeTiers({ ent, price, org }),
};
}
stripePrice = await stripeCli.prices.create({
...productData,
currency: orgToCurrency({ org }),
...priceAmountData,
recurring: {
...(recurringData as any),
},
nickname: `Autumn Price (${ent.feature.name})`,
});
config.stripe_price_id = stripePrice.id;
config.stripe_product_id = stripePrice.product as string;
}
// New config
price.config = config;
await PriceService.update({
db,
id: price.id!,
update: { config },
});
};

View File

@@ -1,29 +0,0 @@
import type Stripe from "stripe";
import type { Logger } from "@/external/logtail/logtailUtils";
/**
* Logs core information from a Stripe invoice for debugging.
*/
export const logStripeInvoice = ({
logger,
stripeInvoice,
prefix,
}: {
logger: Logger;
stripeInvoice: Stripe.Invoice;
prefix?: string;
}) => {
const tag = prefix ? `[${prefix}]` : "";
logger.info(`${tag} Stripe Invoice`, {
id: stripeInvoice.id,
status: stripeInvoice.status,
total: stripeInvoice.total,
currency: stripeInvoice.currency,
hosted_invoice_url: stripeInvoice.hosted_invoice_url,
lines: stripeInvoice.lines.data.map((line) => ({
description: line.description,
amount: line.amount,
})),
});
};

View File

@@ -1,130 +0,0 @@
// import Stripe from "stripe";
// /**
// * Extends an existing coupon's duration by replacing it with a new coupon that has
// * the remaining duration + additional months. This avoids concurrent stacking issues.
// */
// export const extendCouponDuration = async ({
// stripeCli,
// sub,
// existingCouponId,
// additionalMonths,
// logger,
// }: {
// stripeCli: Stripe;
// sub: Stripe.Subscription;
// existingCouponId: string;
// additionalMonths: number;
// logger: any;
// }): Promise<{
// success: boolean;
// newCouponId?: string;
// error?: string;
// }> => {
// try {
// logger.info(
// `Extending coupon ${existingCouponId} by ${additionalMonths} months`
// );
// // Get current subscription and coupon details
// const existingCoupon = await stripeCli.coupons.retrieve(existingCouponId);
// const currentDiscounts = (sub.discounts as Stripe.Discount[]) || [];
// const existingDiscount = currentDiscounts.find((d: any) =>
// d.coupon?.id.startsWith(existingCouponId)
// );
// if (!existingDiscount) {
// return {
// success: false,
// error: "Original coupon not found on subscription",
// };
// }
// // Calculate remaining months using the discount's actual start and end times
// const originalDurationMonths = existingCoupon.duration_in_months || 0;
// // Use discount start and end times for accurate calculation
// const discountStart = new Date(existingDiscount.start * 1000);
// const discountEnd = existingDiscount.end
// ? new Date(existingDiscount.end * 1000)
// : null;
// const now = new Date();
// let remainingMonths: number;
// if (discountEnd) {
// // Calculate remaining time in months
// const remainingTimeMs = Math.max(
// 0,
// discountEnd.getTime() - now.getTime()
// );
// remainingMonths = Math.ceil(remainingTimeMs / (30 * 24 * 60 * 60 * 1000));
// } else {
// // If no end date (shouldn't happen for repeating coupons), fall back to original duration
// remainingMonths = originalDurationMonths;
// }
// const totalNewDurationMonths = remainingMonths + additionalMonths;
// logger.info(
// `Discount period: ${discountStart.toISOString()} to ${discountEnd?.toISOString() || "forever"}`
// );
// logger.info(
// `Original: ${originalDurationMonths}m, Remaining: ${remainingMonths}m, Adding: ${additionalMonths}m, Total: ${totalNewDurationMonths}m`
// );
// // Create a new coupon with the extended duration
// const extendedCouponId = `${existingCouponId}_${Date.now()}`;
// const couponCreateParams: Stripe.CouponCreateParams = {
// id: extendedCouponId,
// duration: "repeating",
// duration_in_months: totalNewDurationMonths,
// name: `Extended ${existingCoupon.name || "Coupon"}`,
// // metadata: {
// // original_coupon_id: existingCouponId,
// // original_duration: originalDurationMonths.toString(),
// // remaining_months: remainingMonths.toString(),
// // additional_months: additionalMonths.toString(),
// // total_duration: totalNewDurationMonths.toString(),
// // extended_at: Date.now().toString(),
// // },
// };
// // Copy discount value from the existing coupon
// if (existingCoupon.percent_off) {
// couponCreateParams.percent_off = existingCoupon.percent_off;
// } else if (existingCoupon.amount_off) {
// couponCreateParams.amount_off = existingCoupon.amount_off;
// if (existingCoupon.currency) {
// couponCreateParams.currency = existingCoupon.currency;
// }
// }
// // Copy applies_to if it exists
// if (existingCoupon.applies_to) {
// couponCreateParams.applies_to = existingCoupon.applies_to;
// }
// const extendedCoupon = await stripeCli.coupons.create(couponCreateParams);
// // Replace the existing coupon with the extended one
// const otherDiscounts = currentDiscounts
// .filter((d: any) => d.coupon?.id !== existingCouponId)
// .map((d: Stripe.Discount) => ({ discount: d.id }));
// await stripeCli.subscriptions.update(sub.id, {
// discounts: [...otherDiscounts, { coupon: extendedCoupon.id }],
// });
// logger.info(
// `Successfully extended coupon duration to ${totalNewDurationMonths} months. New coupon ID: ${extendedCouponId}`
// );
// await stripeCli.coupons.del(existingCouponId);
// return { success: true, newCouponId: extendedCouponId };
// } catch (error: any) {
// logger.error(`Failed to extend coupon duration: ${error.message}`, error);
// return { success: false, error: error.message };
// }
// };

View File

@@ -1,151 +0,0 @@
import {
AppEnv,
ErrCode,
type Organization,
type Product,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import RecaseError from "@/utils/errorUtils.js";
export const createStripeProduct = async (
org: Organization,
env: AppEnv,
product: Product,
) => {
try {
const stripe = createStripeCli({ org, env });
const stripeProduct = await stripe.products.create({
name: product.name,
metadata: {
autumn_id: product.id,
autumn_internal_id: product.internal_id,
},
});
return stripeProduct;
} catch (error: any) {
throw new RecaseError({
message: `Error creating product in Stripe. ${error.message}`,
code: ErrCode.CreateStripeProductFailed,
statusCode: 500,
});
}
};
export const deleteStripeProduct = async (
org: Organization,
env: AppEnv,
product: Product,
) => {
const stripe = createStripeCli({ org, env });
if (
!product.processor ||
!product.processor.id ||
product.env === AppEnv.Live
) {
// Don't delete live products
return;
}
try {
await stripe.products.del(product.processor.id);
} catch (_error) {
throw new RecaseError({
message: "Failed to delete stripe product",
code: ErrCode.DeleteStripeProductFailed,
statusCode: 500,
});
}
};
export const deactivateStripeMeters = async ({
org,
env,
}: {
org: Organization;
env: AppEnv;
}) => {
const stripeCli = createStripeCli({ org, env });
const allStripeMeters = [];
let hasMore = true;
let startingAfter;
while (hasMore) {
const response: any = await stripeCli.billing.meters.list({
limit: 100,
status: "active",
starting_after: startingAfter,
});
allStripeMeters.push(...response.data);
hasMore = response.has_more;
if (hasMore && response.data.length > 0) {
startingAfter = response.data[response.data.length - 1].id;
}
}
const batchSize = 20;
for (let i = 0; i < allStripeMeters.length; i += batchSize) {
const batch = allStripeMeters.slice(i, i + batchSize);
await Promise.all(
batch.map((meter) => stripeCli.billing.meters.deactivate(meter.id)),
);
console.log(
`Deactivated ${i + batch.length}/${allStripeMeters.length} meters`,
);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
};
export const deleteAllStripeProducts = async ({
org,
env,
}: {
org: Organization;
env: AppEnv;
}) => {
const stripeCli = createStripeCli({ org, env });
const stripeProducts = await stripeCli.products.list({
limit: 100,
active: true,
});
if (stripeProducts.data.length === 0) {
return;
}
const firstProduct = stripeProducts.data[0];
if (firstProduct.livemode) {
throw new RecaseError({
message: "Cannot delete livemode products",
code: ErrCode.DeleteStripeProductFailed,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
const batchSize = 50;
for (let i = 0; i < stripeProducts.data.length; i += batchSize) {
const batch = stripeProducts.data.slice(i, i + batchSize);
await Promise.all(
batch.map(async (p) => {
try {
await stripeCli.products.del(p.id);
} catch (_error) {
await stripeCli.products.update(p.id, {
active: false,
});
}
}),
);
console.log(
`Deleted ${i + batch.length}/${stripeProducts.data.length} products`,
);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
};

View File

@@ -1,43 +0,0 @@
import type { FullCusProduct, InsertCustomerProduct } from "@autumn/shared";
import type { StripeSubscriptionUpdatedContext } from "../../stripeSubscriptionUpdatedContext";
/**
* Tracks a customer product update for the subscription updated workflow.
* - Adds to updatedCustomerProducts list for logging/audit
* - Updates customerProducts array in place so subsequent tasks see the change
* - Updates fullCustomer.customer_products so actions can see the change
*/
export const trackCustomerProductUpdate = ({
eventContext,
customerProduct,
updates,
}: {
eventContext: StripeSubscriptionUpdatedContext;
customerProduct: FullCusProduct;
updates: Partial<InsertCustomerProduct>;
}): FullCusProduct => {
const { customerProducts, fullCustomer, updatedCustomerProducts } =
eventContext;
// Track the update for logging
updatedCustomerProducts.push({ customerProduct, updates });
// Create updated product
const updatedProduct = { ...customerProduct, ...updates } as FullCusProduct;
// Update in customerProducts array
const idx = customerProducts.findIndex((cp) => cp.id === customerProduct.id);
if (idx >= 0) {
customerProducts[idx] = updatedProduct;
}
// Also update in fullCustomer.customer_products so actions can see the change
const fullCustomerIdx = fullCustomer.customer_products.findIndex(
(cp) => cp.id === customerProduct.id,
);
if (fullCustomerIdx >= 0) {
fullCustomer.customer_products[fullCustomerIdx] = updatedProduct;
}
return updatedProduct;
};

View File

@@ -1,66 +0,0 @@
import { AttachBranch, type Metadata, ProrationBehavior } from "@autumn/shared";
import { createStripeCli } from "@server/external/connect/createStripeCli";
import { getCusPaymentMethod } from "@server/external/stripe/stripeCusUtils";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { resetUsageBalances } from "@server/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems";
import { handleUpgradeFlow } from "@server/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow";
import { attachParamsToCurCusProduct } from "@server/internal/customers/attach/attachUtils/convertAttachParams";
import { getDefaultAttachConfig } from "@server/internal/customers/attach/attachUtils/getAttachConfig";
import type { AttachParams } from "@server/internal/customers/cusProducts/AttachParams";
import { MetadataService } from "@server/internal/metadata/MetadataService";
import type Stripe from "stripe";
export const handleInvoiceActionRequiredCompleted = async ({
ctx,
invoice,
metadata,
}: {
ctx: AutumnContext;
invoice: Stripe.Invoice;
metadata: Metadata;
}) => {
const { logger, org, env } = ctx;
logger.info(`invoice.paid, handling action required`);
const stripeCli = createStripeCli({ org, env });
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: invoice.customer as string,
});
const attachParams = {
...(metadata.data as AttachParams),
stripeCli,
req: ctx,
paymentMethod,
} as AttachParams;
const attachConfig = {
...getDefaultAttachConfig(),
proration: ProrationBehavior.None,
};
ctx.logger.info(`handling upgrade flow for invoice ${invoice.id}`);
await handleUpgradeFlow({
ctx,
attachParams,
config: attachConfig,
branch: AttachBranch.Upgrade,
});
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
if (attachParams.cusEntIds && curCusProduct) {
await resetUsageBalances({
db: ctx.db,
cusEntIds: attachParams.cusEntIds,
cusProduct: curCusProduct,
});
}
await MetadataService.delete({
db: ctx.db,
id: metadata.id,
});
};

View File

@@ -1,73 +0,0 @@
import type { Metadata } from "@autumn/shared";
import { AttachScenario } from "@autumn/shared";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import { CusService } from "../../../../../internal/customers/CusService.js";
import { MetadataService } from "../../../../../internal/metadata/MetadataService.js";
export const handleInvoiceCheckoutPaid = async ({
ctx,
metadata,
}: {
ctx: AutumnContext;
metadata: Metadata;
}) => {
const { logger, org, env, db } = ctx;
logger.info(
`invoice.paid, handling invoice checkout paid for metadata: ${metadata.id}`,
);
const { subId, anchorToUnix, config, ...rest } =
metadata.data as AttachParams;
const attachParams = rest;
if (!attachParams) return;
const reqMatch =
attachParams.org.id === org.id && attachParams.customer.env === env;
if (!reqMatch) return;
const batchInsert = [];
for (const product of attachParams.products) {
batchInsert.push(
createFullCusProduct({
db,
attachParams: attachToInsertParams(attachParams, product),
subscriptionIds: subId ? [subId] : undefined,
anchorToUnix,
carryExistingUsages: config?.carryUsage,
scenario: AttachScenario.New,
logger: logger,
}),
);
}
await Promise.all(batchInsert);
logger.info(
`✅ invoice.paid, successfully inserted cus products: ${attachParams.products.map((p) => p.id).join(", ")}`,
);
await MetadataService.delete({
db: ctx.db,
id: metadata.id,
});
// Fetch customer by internal ID
let customerId = attachParams.customer.id;
if (!customerId) {
const customer = await CusService.get({
db,
idOrInternalId: attachParams.customer.internal_id,
orgId: org.id,
env,
});
customerId = customer?.id;
}
};

View File

@@ -1,280 +0,0 @@
import type {
AppEnv,
FullCusProduct,
FullCustomerPrice,
InvoiceStatus,
Organization,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { nullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import type { Logger } from "../../../../logtail/logtailUtils.js";
import { stripeInvoiceToStripeSubscriptionId } from "../../../invoices/utils/convertStripeInvoice.js";
import {
getFullStripeInvoice,
getInvoiceDiscounts,
updateInvoiceIfExists,
} from "../../../stripeInvoiceUtils.js";
import { lineItemInCusProduct } from "../../../stripeSubUtils/stripeSubItemUtils.js";
import { getStripeSubs } from "../../../stripeSubUtils.js";
import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js";
import { handleInvoicePaidMetadata } from "./handleInvoicePaidMetadata.js";
const handleOneOffInvoicePaid = async ({
db,
stripeInvoice,
}: {
db: DrizzleCli;
stripeInvoice: Stripe.Invoice;
event: Stripe.Event;
}) => {
// Search for invoice
const invoice = await InvoiceService.getByStripeId({
db,
stripeId: stripeInvoice.id!,
});
if (!invoice) return;
// Update invoice status
await InvoiceService.updateByStripeId({
db,
stripeId: stripeInvoice.id!,
updates: {
status: stripeInvoice.status as InvoiceStatus,
hosted_invoice_url: stripeInvoice.hosted_invoice_url,
discounts: getInvoiceDiscounts({
expandedInvoice: stripeInvoice,
}),
},
});
console.log(`Updated one off invoice status to ${stripeInvoice.status}`);
};
const convertToChargeAutomatically = async ({
org,
env,
invoice,
activeCusProducts,
logger,
}: {
org: Organization;
env: AppEnv;
invoice: Stripe.Invoice;
activeCusProducts: FullCusProduct[];
logger: Logger;
}) => {
const stripeCli = createStripeCli({ org, env });
const subs = await getStripeSubs({
stripeCli,
subIds: activeCusProducts.flatMap((p) => p.subscription_ids || []),
});
const payments = invoice.payments;
const firstPayment = payments?.data?.[0];
const paymentIntentId = firstPayment?.payment?.payment_intent as string;
if (
subs.every((s) => s.collection_method === "charge_automatically") ||
nullish(paymentIntentId)
) {
return;
}
// Get payment intent...
// Try to attach payment method to subscription
try {
logger.info(`Converting to charge automatically`);
// 1. Get payment intent
const paymentIntent =
await stripeCli.paymentIntents.retrieve(paymentIntentId);
// 2. Get payment method
const paymentMethod = await stripeCli.paymentMethods.retrieve(
paymentIntent.payment_method as string,
);
await stripeCli.paymentMethods.attach(paymentMethod.id, {
customer: invoice.customer as string,
});
const batchUpdateSubs = [];
const updateSub = async (sub: Stripe.Subscription) => {
try {
await stripeCli.subscriptions.update(sub.id, {
collection_method: "charge_automatically",
default_payment_method: paymentMethod.id,
});
} catch (error) {
logger.warn(
`Convert to charge automatically: error updating subscription ${sub.id}`,
);
logger.warn(error);
}
};
for (const sub of subs) {
batchUpdateSubs.push(updateSub(sub));
}
await Promise.all(batchUpdateSubs);
logger.info("Convert to charge automatically successful!");
} catch (error) {
logger.warn(`Convert to charge automatically failed: ${error}`);
}
};
export const handleInvoicePaid = async ({
ctx,
invoiceData,
event,
}: {
ctx: AutumnContext;
invoiceData: Stripe.Invoice;
event: Stripe.Event;
}) => {
const { logger, org, env, db } = ctx;
const stripeCli = createStripeCli({ org, env });
const invoice = await getFullStripeInvoice({
stripeCli,
stripeId: invoiceData.id!,
expand: ["payments"],
});
if (invoice.metadata?.autumn_metadata_id) {
await handleInvoicePaidMetadata({
ctx,
invoice,
});
}
await handleInvoicePaidDiscount({
db,
expandedInvoice: invoice,
org,
env,
logger,
});
const subId = stripeInvoiceToStripeSubscriptionId(invoice);
if (subId) {
// Get customer product
const activeCusProducts = await CusProductService.getByStripeSubId({
db,
stripeSubId: subId,
orgId: org.id,
env,
});
if (!activeCusProducts || activeCusProducts.length === 0) {
// TODO: Send alert
if (invoice.livemode) {
logger.warn(
`invoice.paid: customer product not found for invoice ${invoice.id}`,
);
}
return;
}
if (org.config.convert_to_charge_automatically) {
await convertToChargeAutomatically({
org,
env,
invoice,
activeCusProducts,
logger,
});
}
const updated = await updateInvoiceIfExists({
db,
invoice,
});
if (!updated) {
const invoiceItems = await getInvoiceItems({
stripeInvoice: invoice,
prices: activeCusProducts.flatMap((p) =>
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price),
),
logger,
});
const invoiceLines = invoice.lines.data;
let cusProducts: FullCusProduct[] = activeCusProducts;
try {
cusProducts = activeCusProducts.filter((cp) =>
invoiceLines.some((l) =>
lineItemInCusProduct({ cusProduct: cp, lineItem: l }),
),
);
if (cusProducts.length === 0) {
cusProducts = activeCusProducts;
}
} catch (error) {
logger.error("Failed to filter cus products for invoice");
logger.error({ error });
}
const internalEntityId = new Set(
cusProducts.map((cp) => cp.internal_entity_id),
);
await InvoiceService.createInvoiceFromStripe({
db,
stripeInvoice: invoice,
internalCustomerId: activeCusProducts[0].internal_customer_id,
internalEntityId:
internalEntityId.size > 1
? undefined
: internalEntityId.values().next().value,
productIds: [...new Set(cusProducts.map((p) => p.product_id))],
internalProductIds: [
...new Set(cusProducts.map((p) => p.internal_product_id)),
],
org: org,
items: invoiceItems,
});
}
for (const cusProd of activeCusProducts) {
try {
await addTaskToQueue({
jobName: JobName.TriggerCheckoutReward,
payload: {
// For createWorkerContext
orgId: org.id,
env: cusProd.customer!.env,
customerId: cusProd.customer!.id,
// For triggerCheckoutReward
customer: cusProd.customer,
product: cusProd.product,
subId: cusProd.subscription_ids?.[0],
},
});
} catch (error) {
logger.error(`invoice.paid: failed to trigger checkout reward check`);
logger.error(error);
}
}
} else {
await handleOneOffInvoicePaid({
db,
stripeInvoice: invoice,
event,
});
}
};

View File

@@ -1,173 +0,0 @@
import {
type AppEnv,
CouponDurationType,
type Organization,
type Reward,
RewardType,
} from "@autumn/shared";
import { addMonths } from "date-fns";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { stripeCustomerToNowMs } from "@/external/stripe/customers/index.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { generateId } from "@/utils/genUtils.js";
import { stripeInvoiceToStripeSubscriptionId } from "../../../invoices/utils/convertStripeInvoice.js";
import {
deleteCouponFromCus,
deleteCouponFromSub,
} from "../../../stripeCouponUtils/deleteCouponFromCus.js";
export const handleInvoicePaidDiscount = async ({
db,
expandedInvoice,
org,
env,
logger,
}: {
db: DrizzleCli;
expandedInvoice: Stripe.Invoice;
org: Organization;
env: AppEnv;
logger: any;
}) => {
// Handle coupon
const stripeCli = createStripeCli({ org, env, legacyVersion: true });
if (expandedInvoice.discounts.length === 0) return;
const stripeCus = await stripeCli.customers.retrieve(
expandedInvoice.customer as string,
);
const legacyInvoice = await stripeCli.invoices.retrieve(expandedInvoice.id, {
expand: ["total_discount_amounts", "discounts.coupon"],
});
try {
const totalDiscountAmounts = expandedInvoice.total_discount_amounts;
// Log coupon information for debugging
for (const discount of legacyInvoice.discounts) {
if (typeof discount === "string" || !("coupon" in discount)) continue;
const curCoupon = discount.coupon as Stripe.Coupon;
if (!curCoupon || typeof curCoupon === "string" || !curCoupon.amount_off)
continue;
const rollSuffixIndex = curCoupon.id.indexOf("_roll_");
const couponId =
rollSuffixIndex !== -1
? curCoupon.id.substring(0, rollSuffixIndex)
: curCoupon.id;
const autumnReward: Reward | null = await RewardService.get({
db,
idOrInternalId: couponId,
orgId: org.id,
env,
});
const shouldRollover =
autumnReward &&
(autumnReward.type === RewardType.InvoiceCredits ||
autumnReward.type === RewardType.FreeProduct);
if (!shouldRollover) continue;
// Get ID of coupon
const originalCoupon = await stripeCli.coupons.retrieve(couponId, {
expand: ["applies_to"],
});
const curAmount = curCoupon.amount_off;
const amountUsed = totalDiscountAmounts?.find(
(item) => item.discount === discount.id,
)?.amount;
const newAmount = new Decimal(curAmount!).sub(amountUsed!).toNumber();
const curExpiresAt = curCoupon.metadata?.expires_at
? Number(curCoupon.metadata.expires_at)
: null;
const discountFinished = newAmount <= 0;
const now = await stripeCustomerToNowMs({
stripeCli,
stripeCustomer: stripeCus as Stripe.Customer,
});
const expired = curExpiresAt && curExpiresAt < now;
const subId = stripeInvoiceToStripeSubscriptionId(expandedInvoice);
if (discountFinished || expired) {
logger.info(
`Coupon ${couponId}, stripeCus: ${stripeCus.id}: credits used up or expired. discountFinished: ${discountFinished}, expired: ${expired}`,
);
if (subId) {
await deleteCouponFromCus({
stripeCli,
stripeSubId: subId,
stripeCusId: expandedInvoice.customer as string,
discountId: discount.id,
logger,
});
}
continue;
}
logger.info(
`Coupon ${couponId}, stripeCus: ${stripeCus.id}, updating amount from ${curAmount} to ${newAmount}`,
);
// Set expiry date
let expiresAt = curCoupon.metadata?.expires_at || null;
const discountConfig = autumnReward?.discount_config;
if (discountConfig?.duration_type === CouponDurationType.Months) {
expiresAt = addMonths(new Date(), discountConfig.duration_value)
.getTime()
.toString();
}
const newCoupon = await stripeCli.coupons.create({
id: `${couponId}_${generateId("roll")}`,
name: curCoupon.name as string,
amount_off: newAmount,
currency: expandedInvoice.currency,
duration: "once",
applies_to: originalCoupon.applies_to,
metadata: {
expires_at: expiresAt,
},
});
await stripeCli.rawRequest(
"POST",
`/v1/customers/${expandedInvoice.customer}`,
{
coupon: newCoupon.id,
},
);
await stripeCli.coupons.del(newCoupon.id);
if (subId) {
await deleteCouponFromSub({
stripeCli,
stripeSubId: subId,
discountId: discount.id,
logger,
});
}
}
} catch (error) {
logger.error("invoice.paid: error updating coupon");
logger.error(error);
}
};

View File

@@ -1,55 +0,0 @@
import { MetadataType } from "@autumn/shared";
import type Stripe from "stripe";
import { executeDeferredBillingPlan } from "@/internal/billing/v2/execute/executeDeferredBillingPlan";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import type { AttachParams } from "../../../../../internal/customers/cusProducts/AttachParams.js";
import { MetadataService } from "../../../../../internal/metadata/MetadataService.js";
import { handleInvoiceActionRequiredCompleted } from "./handleInvoiceActionRequiredCompleted";
import { handleInvoiceCheckoutPaid } from "./handleInvoiceCheckoutPaid";
export const handleInvoicePaidMetadata = async ({
ctx,
invoice,
}: {
ctx: AutumnContext;
invoice: Stripe.Invoice;
}) => {
const metadataId = invoice.metadata?.autumn_metadata_id;
if (!metadataId) return;
const metadata = await MetadataService.get({
db: ctx.db,
id: metadataId,
});
if (!metadata) return;
// Handle deferred billing plan (v2 flow)
if (metadata.type === MetadataType.DeferredInvoice) {
await executeDeferredBillingPlan({ ctx, metadata });
return;
}
// Legacy v1 flows below
const data = metadata.data as unknown as AttachParams;
const reqMatch =
data.org?.id === ctx.org.id && data.customer?.env === ctx.env;
if (!reqMatch) return;
if (metadata.type === MetadataType.InvoiceActionRequired) {
await handleInvoiceActionRequiredCompleted({
ctx,
invoice,
metadata,
});
return;
}
await handleInvoiceCheckoutPaid({
ctx,
metadata,
});
};

View File

@@ -1,74 +0,0 @@
import type Stripe from "stripe";
import {
getFullStripeSub,
subIsPrematurelyCanceled,
} from "@/external/stripe/stripeSubUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { handleCusProductDeleted } from "./handleSubDeleted/handleCusProductDeleted.js";
export const handleSubDeleted = async ({
ctx,
stripeCli,
data,
}: {
ctx: AutumnContext;
stripeCli: Stripe;
data: Stripe.Subscription;
}) => {
const { db, org, env, logger } = ctx;
const activeCusProducts = await CusProductService.getByStripeSubId({
db,
stripeSubId: data.id,
orgId: org.id,
env,
});
if (activeCusProducts.length === 0) {
if (data.livemode) {
logger.warn(
`subscription.deleted: ${data.id} - no customer products found`,
);
return;
}
}
const subscription = await getFullStripeSub({
stripeCli,
stripeId: data.id,
});
const cancellationComment = subscription.cancellation_details?.comment;
if (
cancellationComment === "autumn_upgrade" ||
cancellationComment === "autumn_cancel"
) {
logger.info(
`sub.deleted: ${subscription.id} from ${cancellationComment}, skipping`,
);
return;
}
if (cancellationComment?.includes("trial_canceled")) {
logger.info(
`sub.deleted: ${subscription.id} from trial canceled, skipping`,
);
return;
}
// Prematurely canceled if cancel_at_period_end is false or cancel_at is more than 20 seconds apart from current_period_end
const prematurelyCanceled = subIsPrematurelyCanceled(subscription);
// const batchUpdate = [];
for (const cusProduct of activeCusProducts) {
await handleCusProductDeleted({
ctx,
db,
stripeCli,
cusProduct,
subscription,
prematurelyCanceled,
});
}
};

View File

@@ -1,158 +0,0 @@
import {
ApiVersion,
AttachScenario,
BillingType,
CusProductStatus,
cusProductToPrices,
customerProductHasActiveStatus,
type FullCusProduct,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { webhookToAttachParams } from "@/external/stripe/webhookUtils/webhookUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { createUsageInvoice } from "@/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoice.js";
import { CusService } from "@/internal/customers/CusService.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import {
activateDefaultProduct,
activateFutureProduct,
} from "@/internal/customers/cusProducts/cusProductUtils.js";
export const handleCusProductDeleted = async ({
ctx,
db,
stripeCli,
cusProduct,
subscription,
prematurelyCanceled,
}: {
ctx: AutumnContext;
db: DrizzleCli;
stripeCli: Stripe;
cusProduct: FullCusProduct;
subscription: Stripe.Subscription;
prematurelyCanceled: boolean;
}) => {
const { org, env, logger } = ctx;
const { scheduled_ids } = cusProduct;
const fullCus = await CusService.getFull({
db,
idOrInternalId: cusProduct.internal_customer_id,
orgId: org.id,
env,
withEntities: true,
});
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: fullCus.processor?.id,
});
const isV4Usage = cusProduct.api_semver === ApiVersion.V1_Beta;
// refer to handleUpgradeFlow.ts, when cancel immediately through API / dashboard, this happens...?
const isAutumnCancel =
subscription.cancellation_details?.comment === "autumn_cancel";
if (
(cusProduct.internal_entity_id || isV4Usage) &&
!isAutumnCancel &&
customerProductHasActiveStatus(cusProduct)
) {
const usagePrices = cusProductToPrices({
cusProduct,
billingType: BillingType.UsageInArrear,
});
if (usagePrices.length > 0) {
logger.info(
`sub.deleted, submitting usage for ${fullCus.id}, ${cusProduct.product.name}`,
);
await createUsageInvoice({
db,
attachParams: webhookToAttachParams({
ctx,
stripeCli,
paymentMethod,
cusProduct,
fullCus,
}),
cusProduct,
sub: subscription,
logger,
});
}
}
if (scheduled_ids && scheduled_ids.length > 0 && !prematurelyCanceled) {
logger.info(
`sub.deleted: removing sub_id from cus product ${cusProduct.id}`,
);
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: {
subscription_ids: cusProduct.subscription_ids?.filter(
(id) => id !== subscription.id,
),
},
});
return;
}
logger.info(`sub.deleted: expiring cus product ${cusProduct.id}`);
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,
ended_at: subscription.ended_at ? subscription.ended_at * 1000 : null,
},
});
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: cusProduct.internal_customer_id,
org,
env,
customerId: null,
scenario: AttachScenario.Expired,
cusProduct,
});
if (cusProduct.product.is_add_on) return;
const activatedFuture = await activateFutureProduct({
ctx,
cusProduct,
});
if (activatedFuture) {
logger.info(`✅ sub.deleted: activated scheduled product`);
return;
}
const cusProducts = await CusProductService.list({
db,
internalCustomerId: cusProduct.internal_customer_id,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
});
const { curMainProduct } = getExistingCusProducts({
product: cusProduct.product,
cusProducts,
});
await activateDefaultProduct({
ctx,
productGroup: cusProduct.product.group,
fullCus,
curCusProduct: curMainProduct || undefined,
});
};

View File

@@ -1,148 +0,0 @@
import {
AttachScenario,
CusProductStatus,
type FullCustomer,
formatMs,
hasCustomerProductEnded,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { stripeCustomerToNowMs } from "@/external/stripe/customers/index";
import { isStripeSubscriptionScheduleInLastPhase } from "@/external/stripe/subscriptionSchedules/utils/classifyStripeSubscriptionScheduleUtils";
import { stripeSubscriptionScheduleToPhaseIndex } from "@/external/stripe/subscriptionSchedules/utils/convertStripeSubscriptionScheduleUtils";
import type { ExpandedStripeSubscription } from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription.js";
import { getStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { notNullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
export const handleSchedulePhaseCompleted = async ({
ctx,
stripeSubscription,
prevAttributes,
fullCustomer,
}: {
ctx: AutumnContext;
fullCustomer: FullCustomer;
stripeSubscription: ExpandedStripeSubscription;
// biome-ignore lint/suspicious/noExplicitAny: Don't know the type of prevAttributes
prevAttributes: any;
}) => {
if (
await getStripeSubscriptionLock({
stripeSubscriptionId: stripeSubscription.id,
})
) {
ctx.logger.info(
`[handleSchedulePhaseCompleted] SKIP: subscription is locked`,
);
return;
}
const { db, org, env, logger } = ctx;
const phasePossiblyChanged =
notNullish(prevAttributes?.items) &&
notNullish(stripeSubscription.schedule);
if (!phasePossiblyChanged) return;
const stripeSubscriptionSchedule = stripeSubscription.schedule;
const stripeCli = createStripeCli({ org, env });
const customerProducts = fullCustomer.customer_products;
const nowMs = await stripeCustomerToNowMs({
stripeCli,
stripeCustomer: stripeSubscription.customer,
});
const currentPhaseIndex = stripeSubscriptionScheduleToPhaseIndex({
stripeSubscriptionSchedule,
nowMs,
});
logger.info(
`[handleSchedulePhaseCompleted] sub: ${stripeSubscription.id}, now: ${formatMs(nowMs)}, currentPhase: ${currentPhaseIndex + 1}/${stripeSubscriptionSchedule.phases.length}`,
);
for (const cusProduct of customerProducts) {
const shouldExpire = hasCustomerProductEnded(cusProduct, { nowMs });
if (shouldExpire) {
logger.info(
`[handleSchedulePhaseCompleted] ❌ expiring: ${cusProduct.product.name}${cusProduct.entity_id ? `@${cusProduct.entity_id}` : ""}`,
);
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: { status: CusProductStatus.Expired },
});
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: cusProduct.internal_customer_id,
org,
env,
customerId: null,
scenario: AttachScenario.Expired,
cusProduct: cusProduct,
});
}
const shouldActivateCustomerProduct = () => {
if (cusProduct.status !== CusProductStatus.Scheduled) return false;
return cusProduct.starts_at <= nowMs;
};
if (shouldActivateCustomerProduct()) {
logger.info(
`[handleSchedulePhaseCompleted] ✅ activating: ${cusProduct.product.name}${cusProduct.entity_id ? `@${cusProduct.entity_id}` : ""}`,
);
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Active,
subscription_ids: [stripeSubscription.id],
scheduled_ids: [stripeSubscriptionSchedule.id],
},
});
}
}
if (
isStripeSubscriptionScheduleInLastPhase({
stripeSubscriptionSchedule,
nowMs,
})
) {
logger.debug(
`[handleSchedulePhaseCompleted] releasing schedule (last phase reached)`,
);
try {
await stripeCli.subscriptionSchedules.release(
stripeSubscriptionSchedule.id,
);
await CusProductService.updateByStripeScheduledId({
db,
stripeScheduledId: stripeSubscriptionSchedule.id,
updates: {
scheduled_ids: [],
},
});
} catch (error: unknown) {
if (error instanceof Error) {
if (process.env.NODE_ENV === "development") {
logger.warn(
`[handleSchedulePhaseCompleted] failed to release schedule: ${error.message}`,
);
} else {
logger.error(
`[handleSchedulePhaseCompleted] failed to release schedule: ${error.message}`,
);
}
}
}
}
};

View File

@@ -1,219 +0,0 @@
import {
AttachScenario,
CusProductStatus,
type FullCusProduct,
type Organization,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { productToInsertParams } from "@/internal/customers/attach/attachUtils/attachParams/convertToParams.js";
import { CusService } from "@/internal/customers/CusService.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { formatUnixToDateTime, nullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import {
getLatestPeriodEnd,
subToPeriodStartEnd,
} from "../../../stripeSubUtils/convertSubUtils.js";
export const isSubCanceled = ({
previousAttributes,
sub,
}: {
previousAttributes: unknown;
sub: Stripe.Subscription;
}) => {
const prevAttrs = previousAttributes as Record<string, unknown>;
if (!sub.cancel_at && !sub.cancel_at_period_end) {
return {
canceled: false,
canceledAt: null,
};
}
const cancelAtPreviousEnd =
!prevAttrs.cancel_at_period_end && sub.cancel_at_period_end;
const cancelAt = nullish(prevAttrs.cancel_at) && sub.cancel_at;
const canceledAt = nullish(prevAttrs.canceled_at) && sub.canceled_at;
return {
canceled: cancelAtPreviousEnd || cancelAt || canceledAt,
canceledAt: sub.canceled_at ? sub.canceled_at * 1000 : Date.now(),
};
};
const updateCusProductCanceled = async ({
db,
sub,
canceledAt,
logger,
}: {
db: DrizzleCli;
sub: Stripe.Subscription;
canceledAt?: number | null;
logger: { info: (msg: string) => void };
}) => {
// 1. Check if sub has schedule
if (sub.schedule) {
return;
}
logger.info(
`sub.updated: updating cus products to canceled, stripeSubId=${sub.id}, canceledAt=${canceledAt}`,
);
const cancelsAt = sub.cancel_at ? sub.cancel_at * 1000 : undefined;
await CusProductService.updateByStripeSubId({
db,
stripeSubId: sub.id,
updates: {
canceled_at: canceledAt || Date.now(),
canceled: true,
ended_at: cancelsAt,
},
});
};
export const handleSubCanceled = async ({
ctx,
previousAttributes,
org,
sub,
updatedCusProducts,
}: {
ctx: AutumnContext;
previousAttributes: unknown;
sub: Stripe.Subscription;
org: Organization;
updatedCusProducts: FullCusProduct[];
}) => {
const { canceled, canceledAt } = isSubCanceled({
previousAttributes,
sub,
});
const isAutumnDowngrade =
sub.cancellation_details?.comment?.includes("autumn_downgrade") ||
sub.cancellation_details?.comment?.includes("autumn_cancel");
const { db, env, logger } = ctx;
if (!canceled) return;
if (isAutumnDowngrade) {
logger.info(`sub.canceled SKIP: isAutumnDowngrade`);
return;
}
if (updatedCusProducts.length === 0) {
logger.info(`sub.canceled SKIP: canceled but no updatedCusProducts`);
return;
}
await updateCusProductCanceled({
db,
sub,
canceledAt,
logger,
});
if (!org.config.sync_status) {
logger.info(`sub.canceled SKIP webhook: org.config.sync_status=false`);
return;
}
const allDefaultProducts = await ProductService.listDefault({
db,
orgId: org.id,
env,
});
const fullCus = await CusService.getFull({
db,
idOrInternalId: updatedCusProducts[0].customer!.id!,
orgId: org.id,
env,
withEntities: true,
inStatuses: [CusProductStatus.Scheduled],
});
const cusProducts = fullCus.customer_products;
const entities = fullCus.entities;
const defaultProducts = allDefaultProducts.filter((p) =>
updatedCusProducts.some(
(cp: FullCusProduct) =>
cp.product.group === p.group && nullish(cp.internal_entity_id),
),
);
if (defaultProducts.length === 0) return;
if (defaultProducts.length > 0) {
const { end } = subToPeriodStartEnd({ sub });
const productNames = defaultProducts.map((p) => p.name).join(", ");
const periodEnd = formatUnixToDateTime(end * 1000);
logger.info(
`subscription.updated: canceled -> attempting to schedule default products: ${productNames}, period end: ${periodEnd}`,
);
}
const scheduledCusProducts: FullCusProduct[] = [];
for (const product of defaultProducts) {
const alreadyScheduled = cusProducts.some(
(cp: FullCusProduct) => cp.product.group === product.group,
);
if (alreadyScheduled) {
continue;
}
const insertParams = productToInsertParams({
ctx,
fullCus,
newProduct: product,
entities,
});
const end = getLatestPeriodEnd({ sub });
const fullCusProduct = await createFullCusProduct({
db,
attachParams: insertParams,
startsAt: end * 1000,
sendWebhook: false,
logger,
});
if (fullCusProduct) {
scheduledCusProducts.push(fullCusProduct);
}
}
for (const cusProd of updatedCusProducts) {
try {
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: cusProd.internal_customer_id,
org,
env,
customerId: null,
scenario: AttachScenario.Cancel,
cusProduct: cusProd,
scheduledCusProduct: scheduledCusProducts.find(
(cp) => cp.product.group === cusProd.product.group,
),
});
logger.info(`sub.canceled ✅ SENT webhook for ${cusProd.product.name}`);
} catch (error) {
logger.error(
`sub.canceled ❌ FAILED webhook for ${cusProd.product.name}`,
error,
);
}
}
};

View File

@@ -1,72 +0,0 @@
import {
AttachScenario,
type FullCusProduct,
type Organization,
} from "@autumn/shared";
import type Stripe from "stripe";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
export const isSubPastDue = ({
previousAttributes,
sub,
}: {
// biome-ignore lint/suspicious/noExplicitAny: Don't know the type of previousAttributes
previousAttributes: any;
sub: Stripe.Subscription;
}) => {
const wasPastDue = previousAttributes.status === "past_due";
const isPastDue = sub.status === "past_due";
return {
pastDue: !wasPastDue && isPastDue,
};
};
export const handleSubPastDue = async ({
ctx,
previousAttributes,
org,
sub,
updatedCusProducts,
}: {
ctx: AutumnContext;
// biome-ignore lint/suspicious/noExplicitAny: Don't know the type of previousAttributes
previousAttributes: any;
sub: Stripe.Subscription;
org: Organization;
updatedCusProducts: FullCusProduct[];
}) => {
const { pastDue } = isSubPastDue({
previousAttributes,
sub,
});
const { env, logger } = ctx;
if (!pastDue || updatedCusProducts.length === 0) return;
logger.info(
`Subscription ${sub.id} is now past due, firing webhooks for ${updatedCusProducts.length} customer product(s)`,
);
if (!org.config.sync_status) return;
for (const cusProd of updatedCusProducts) {
try {
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: cusProd.internal_customer_id,
org,
env,
customerId: null,
scenario: AttachScenario.PastDue,
cusProduct: cusProd,
});
} catch (error) {
logger.error("Failed to add products updated webhook task to queue", {
error,
});
}
}
};

View File

@@ -1,142 +0,0 @@
import { AttachScenario, type FullCusProduct } from "@autumn/shared";
import type Stripe from "stripe";
import { getStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
const isSubRenewed = ({
previousAttributes,
sub,
}: {
previousAttributes: unknown;
sub: Stripe.Subscription;
}) => {
const prevAttrs = previousAttributes as Record<string, unknown>;
// 1. If previously canceled
const uncanceledAtPreviousEnd =
prevAttrs.cancel_at_period_end && !sub.cancel_at_period_end;
const uncancelAt = notNullish(prevAttrs.cancel_at) && nullish(sub.cancel_at);
const uncanceledAt = notNullish(prevAttrs.canceled_at) && sub.canceled_at;
return {
renewed: uncanceledAtPreviousEnd || uncancelAt || uncanceledAt,
renewedAt: Date.now(),
};
};
export const handleSubRenewed = async ({
ctx,
prevAttributes,
sub,
updatedCusProducts,
}: {
ctx: AutumnContext;
prevAttributes: unknown;
sub: Stripe.Subscription;
updatedCusProducts: FullCusProduct[];
}) => {
const { db, org, env, logger } = ctx;
const { renewed } = isSubRenewed({
previousAttributes: prevAttributes,
sub,
});
logger.info(`sub.renewed: renewed=${renewed}`);
if (!renewed) return;
if (updatedCusProducts.length === 0) {
logger.info(`sub.renewed SKIP: renewed but no updatedCusProducts`);
return;
}
const subLock = await getStripeSubscriptionLock({
stripeSubscriptionId: sub.id,
});
logger.info(`sub.renewed: renewed=${renewed}, subLock=${!!subLock}`);
if (subLock) {
logger.info(`sub.renewed SKIP: already handled by attach`);
return;
}
const customer = updatedCusProducts[0].customer;
const cusProducts = await CusProductService.list({
db,
internalCustomerId: customer!.internal_id,
});
logger.info(`handling sub.renewed!`);
await CusProductService.updateByStripeSubId({
db,
stripeSubId: sub.id,
updates: { canceled_at: null, canceled: false, ended_at: null },
});
if (!org.config.sync_status) {
logger.info(`sub.renewed SKIP webhook: org.config.sync_status=false`);
return;
}
const curScheduledProductsMap = new Map<string, FullCusProduct>();
for (const x of updatedCusProducts) {
const { curScheduledProduct } = getExistingCusProducts({
product: x.product,
cusProducts,
internalEntityId: x.internal_entity_id,
});
if (
curScheduledProduct &&
!curScheduledProductsMap.has(curScheduledProduct.id)
) {
curScheduledProductsMap.set(curScheduledProduct.id, curScheduledProduct);
}
}
const curScheduledProducts = Array.from(curScheduledProductsMap.values());
const deletedCusProducts: FullCusProduct[] = [];
for (const curScheduledProduct of curScheduledProducts) {
if (!curScheduledProduct) continue;
logger.info(
`sub.updated: renewed -> removing scheduled: ${curScheduledProduct.product.name}, main product: ${updatedCusProducts[0].product.name}`,
);
await CusProductService.delete({
db,
cusProductId: curScheduledProduct.id,
});
deletedCusProducts.push(curScheduledProduct);
}
for (const cusProd of updatedCusProducts) {
try {
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: cusProd.internal_customer_id,
org,
env,
customerId: null,
scenario: AttachScenario.Renew,
cusProduct: cusProd,
deletedCusProduct: deletedCusProducts.find(
(cp) => cp.product.group === cusProd.product.group,
),
});
logger.info(`sub.renewed ✅ SENT webhook for ${cusProd.product.name}`);
} catch (error) {
logger.error(
`sub.renewed ❌ FAILED webhook for ${cusProd.product.name}`,
error,
);
}
}
};

View File

@@ -1,188 +0,0 @@
import {
ACTIVE_STATUSES,
type CollectionMethod,
CusProductStatus,
formatMs,
InternalError,
} from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getExpandedStripeSubscription } from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription.js";
import { stripeSubscriptionToTrialEndsAtMs } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
import { CusService } from "@/internal/customers/CusService.js";
import {
CusProductService,
RELEVANT_STATUSES,
} from "@/internal/customers/cusProducts/CusProductService.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import { handleSchedulePhaseCompleted } from "./handleSchedulePhaseCompleted.js";
import { handleSubCanceled } from "./handleSubCanceled.js";
import { handleSubPastDue } from "./handleSubPastDue.js";
import { handleSubRenewed } from "./handleSubRenewed.js";
export const handleSubscriptionUpdated = async ({
ctx,
eventData,
}: {
ctx: AutumnContext;
eventData: Stripe.Event.Data;
}) => {
const previousAttributes = eventData.previous_attributes;
const { db, org, env, logger } = ctx;
const fullCustomer = await CusService.getFull({
db,
idOrInternalId: ctx.customerId ?? "",
orgId: org.id,
env,
inStatuses: RELEVANT_STATUSES,
});
const stripeSubscription = await getExpandedStripeSubscription({
ctx,
subscriptionId: (eventData.object as Stripe.Subscription).id,
});
// handle scheduled updated
await handleSchedulePhaseCompleted({
ctx,
stripeSubscription,
prevAttributes: previousAttributes,
fullCustomer,
});
// Get cus products by stripe sub id
const cusProducts = await CusProductService.getByStripeSubId({
db,
stripeSubId: stripeSubscription.id,
orgId: org.id,
env,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
});
if (cusProducts.length === 0) return;
const subStatusMap: {
[key: string]: CusProductStatus;
} = {
trialing: CusProductStatus.Active,
active: CusProductStatus.Active,
past_due: CusProductStatus.PastDue,
incomplete: CusProductStatus.PastDue, // temporary status for incomplete subscription
};
const trialEndsAtMs = stripeSubscriptionToTrialEndsAtMs({
stripeSubscription,
});
ctx.logger.info(
`SUB.UPDATED: Setting trial ends to: ${formatMs(trialEndsAtMs)}`,
);
const updatedCusProducts = await CusProductService.updateByStripeSubId({
db,
stripeSubId: stripeSubscription.id,
inStatuses: ACTIVE_STATUSES,
updates: {
status: subStatusMap[stripeSubscription.status] ?? undefined, // don't change status if it's unknown
collection_method:
stripeSubscription.collection_method as CollectionMethod,
trial_ends_at: trialEndsAtMs,
},
});
// 2. Update canceled & canceled_at IF sub has no schedule...?
if (updatedCusProducts.length > 0) {
logger.info(
`✅ sub.updated: updated ${updatedCusProducts.length} customer product(s)} (${updatedCusProducts[0].status})`,
);
}
await handleSubCanceled({
ctx,
previousAttributes,
sub: stripeSubscription,
updatedCusProducts: cusProducts,
org,
});
await handleSubPastDue({
ctx,
previousAttributes,
sub: stripeSubscription,
updatedCusProducts: cusProducts,
org,
});
await handleSubRenewed({
ctx,
prevAttributes: previousAttributes,
sub: stripeSubscription,
updatedCusProducts: cusProducts,
});
try {
await SubService.updateFromStripe({
db,
stripeSub: stripeSubscription,
});
} catch (error) {
logger.warn(
`Failed to update sub from stripe. Stripe sub ID: ${stripeSubscription.id}, org: ${org.slug}, env: ${env}`,
error,
);
}
// Cancel subscription immediately
if (
stripeSubscription.status === "past_due" &&
org.config.cancel_on_past_due
) {
if (
!stripeSubscription.latest_invoice ||
typeof stripeSubscription.latest_invoice !== "string"
) {
throw new InternalError({
message: "subscription.latest_invoice is not a string",
});
}
const stripeCli = createStripeCli({
org,
env,
});
const latestInvoice = await stripeCli.invoices.retrieve(
stripeSubscription.latest_invoice,
);
logger.info(
`Latest invoice billing reason: ${latestInvoice.billing_reason}`,
);
const validInvoiceReasons = ["subscription_cycle", "subscription_create"];
if (!validInvoiceReasons.includes(latestInvoice.billing_reason ?? "")) {
logger.info(
`sub.updated, latest invoice billing reason isn't subscription_cycle / subscription_create, past_due not forcing cancel`,
);
return;
}
try {
logger.info(
`sub.updated (past_due), cancelling subscription: ${stripeSubscription.id}`,
);
await stripeCli.subscriptions.cancel(stripeSubscription.id);
if (latestInvoice.status === "open") {
await stripeCli.invoices.voidInvoice(stripeSubscription.latest_invoice);
}
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
logger.error(
`subscription.updated: error cancelling / voiding: ${errMsg}`,
);
}
}
};

View File

@@ -1,50 +0,0 @@
import {
cusProductToEnts,
cusProductToPrices,
cusProductToProduct,
type Entity,
type FullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv";
export const webhookToAttachParams = ({
ctx,
stripeCli,
paymentMethod,
cusProduct,
fullCus,
entities,
}: {
ctx: AutumnContext;
stripeCli: Stripe;
paymentMethod?: Stripe.PaymentMethod | null;
cusProduct: FullCusProduct;
fullCus: FullCustomer;
entities?: Entity[];
}): AttachParams => {
const fullProduct = cusProductToProduct({ cusProduct });
const { org, features } = ctx;
const params: AttachParams = {
stripeCli,
paymentMethod,
customer: fullCus,
org,
products: [fullProduct],
prices: cusProductToPrices({ cusProduct }),
entitlements: cusProductToEnts({ cusProduct }),
features,
freeTrial: cusProduct.free_trial || null,
optionsList: cusProduct.options,
cusProducts: [cusProduct],
internalEntityId: cusProduct.internal_entity_id || undefined,
entities: entities || [],
replaceables: [],
};
return params;
};

View File

@@ -1,23 +0,0 @@
import { logger } from "../logtail/logtailUtils.js";
export function safeSb<T extends (...args: any[]) => any>({
fn,
action,
}: {
fn: T;
action: string;
}): (...args: Parameters<T>) => Promise<ReturnType<T> | undefined> {
return async (...args: Parameters<T>) => {
if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_KEY) {
logger.warn(
`SUPABASE_URL or SUPABASE_SERVICE_KEY is not set, skipping ${action}`,
);
return;
}
try {
return await fn(...args);
} catch (error) {
logger.error(`Error ${action}: ${error}`);
}
};
}

View File

@@ -1,23 +0,0 @@
import { client, type DrizzleCli } from "@/db/initDrizzle.js";
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
export const subscribeToOrgUpdates = async ({ db }: { db: DrizzleCli }) => {
try {
await client.listen("org_updates", async (payload) => {
try {
const data = JSON.parse(payload);
if (data.table === "organizations" && data.operation === "UPDATE") {
await clearOrgCache({ db, orgId: data.new.id });
}
} catch (error) {
console.warn("Error processing org update notification:", error);
}
});
console.log(
"Successfully subscribed to organization updates via PostgreSQL LISTEN/NOTIFY",
);
} catch (error) {
console.warn("Error subscribing to org updates:", error);
}
};

View File

@@ -1,5 +0,0 @@
/**
* Update an existing installation
*
* @see handleUpdateBillingPlan
*/

View File

@@ -17,8 +17,8 @@ import express from "express";
import { addRequestToLogs } from "@/utils/logging/addContextToLogs.js";
import { client, db } from "./db/initDrizzle.js";
import { ClickHouseManager } from "./external/clickhouse/ClickHouseManager.js";
import { warmupRegionalRedis } from "./external/redis/initRedis.js";
import { logger } from "./external/logtail/logtailUtils.js";
import { warmupRegionalRedis } from "./external/redis/initRedis.js";
import { redirectToHono } from "./initHono.js";
import { apiRouter } from "./internal/api/apiRouter.js";
import { auth } from "./utils/auth.js";
@@ -98,6 +98,8 @@ const init = async () => {
"app_env",
"x-api-version",
"x-client-type",
"x-request-id",
"x-visitor-id",
"Authorization",
"Content-Type",
"Accept",

View File

@@ -9,7 +9,6 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { autumnWebhookRouter } from "./external/autumn/autumnWebhookRouter.js";
import { cliRouter } from "./internal/dev/cli/cliRouter.js";
import { revenuecatWebhookRouter } from "./external/revenueCat/revenuecatWebhookRouter.js";
import { stripeWebhookRouter } from "./external/stripe/stripeWebhookRouter.js";
import { vercelWebhookRouter } from "./external/vercel/vercelWebhookRouter.js";
@@ -18,6 +17,7 @@ import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { handleHealthCheck } from "./honoUtils/handleHealthCheck.js";
import { cliRouter } from "./internal/dev/cli/cliRouter.js";
import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js";
import { apiRouter } from "./routers/apiRouter.js";
import { internalRouter } from "./routers/internalRouter.js";
@@ -39,6 +39,8 @@ const ALLOWED_HEADERS = [
"app_env",
"x-api-version",
"x-client-type",
"x-request-id",
"x-visitor-id",
"Authorization",
"Content-Type",
"Accept",

View File

@@ -1,48 +0,0 @@
import { oauthClient } from "@autumn/shared";
import { desc } from "drizzle-orm";
import { createRoute } from "../../honoMiddlewares/routeHandler";
export const handleListOAuthClients = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { db } = ctx;
const clients = await db
.select({
id: oauthClient.id,
clientId: oauthClient.clientId,
name: oauthClient.name,
redirectUris: oauthClient.redirectUris,
public: oauthClient.public,
disabled: oauthClient.disabled,
skipConsent: oauthClient.skipConsent,
scopes: oauthClient.scopes,
tokenEndpointAuthMethod: oauthClient.tokenEndpointAuthMethod,
grantTypes: oauthClient.grantTypes,
responseTypes: oauthClient.responseTypes,
createdAt: oauthClient.createdAt,
updatedAt: oauthClient.updatedAt,
})
.from(oauthClient)
.orderBy(desc(oauthClient.createdAt));
return c.json({
clients: clients.map((client) => ({
id: client.id,
client_id: client.clientId,
client_name: client.name,
redirect_uris: client.redirectUris,
public: client.public,
disabled: client.disabled,
skip_consent: client.skipConsent,
scope: client.scopes?.join(" "),
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
grant_types: client.grantTypes,
response_types: client.responseTypes,
client_id_issued_at: client.createdAt
? Math.floor(client.createdAt.getTime() / 1000)
: undefined,
})),
});
},
});

View File

@@ -1,32 +0,0 @@
import { ErrCode } from "@autumn/shared";
import type { NextFunction } from "express";
import { auth } from "@/utils/auth.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
export const withAdminAuth = async (req: any, res: any, next: NextFunction) => {
const { logger } = req as ExtendedRequest;
try {
const data = await auth.api.getSession({
headers: req.headers,
});
// Check if user has admin role
const isAdmin = data?.user?.role === "admin";
if (!isAdmin) {
return res.status(403).json({
error: {
code: ErrCode.InvalidRequest,
message: "Forbidden - Admin access required",
},
});
}
next();
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(`Admin req failed: ${errorMessage}`);
return res.status(400).json();
}
};

View File

@@ -1,219 +0,0 @@
// import { ApiVersion, ErrCode, type Feature, FeatureType } from "@autumn/shared";
// import { Router } from "express";
// import { StatusCodes } from "http-status-codes";
// import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
// import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
// import { notNullish } from "@/utils/genUtils.js";
// import { handleEventSent } from "../events/eventRouter.js";
// import { getCheckData } from "./checkUtils/getCheckData.js";
// import { getV1CheckResponse } from "./checkUtils/getV1CheckResponse.js";
// import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js";
// import { getBooleanEntitledResult } from "./checkUtils.js";
// import { getCheckPreview } from "./getCheckPreview.js";
// import { handleProductCheck } from "./handlers/handleProductCheck.js";
// export const checkRouter: Router = Router();
// checkRouter.post("", async (req: any, res: any) => {
// try {
// const {
// customer_id,
// feature_id,
// product_id,
// required_quantity,
// required_balance,
// customer_data,
// send_event,
// event_data,
// entity_id,
// } = req.body;
// const { logger, db } = req;
// if (!customer_id) {
// throw new RecaseError({
// message: "`customer_id` is required",
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
// if (!feature_id && !product_id) {
// throw new RecaseError({
// message: "`feature_id` or `product_id` is required",
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
// if (feature_id && product_id) {
// throw new RecaseError({
// message:
// "Provide either feature_id or product_id. Not allowed to provide both",
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
// if (product_id) {
// const result = await handleProductCheck({
// ctx: req as AutumnContext,
// body: req.body,
// });
// return res.status(200).json(result);
// }
// const requiredBalance = notNullish(required_balance)
// ? required_balance
// : notNullish(required_quantity)
// ? required_quantity
// : null;
// let quantity = 1;
// if (notNullish(requiredBalance)) {
// const floatQuantity = parseFloat(requiredBalance);
// if (Number.isNaN(floatQuantity)) {
// throw new RecaseError({
// message: "Invalid required_balance",
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
// quantity = floatQuantity;
// }
// const {
// fullCus,
// cusEnts,
// feature,
// creditSystems,
// org,
// cusProducts,
// allFeatures,
// } = await getCheckData({ req });
// // 2. If boolean, return true
// if (feature.type === FeatureType.Boolean) {
// return await getBooleanEntitledResult({
// db,
// fullCus,
// res,
// cusEnts,
// feature,
// apiVersion: req.apiVersion,
// withPreview: req.body.with_preview,
// cusProducts,
// allFeatures,
// });
// }
// const v1Response = getV1CheckResponse({
// originalFeature: feature,
// creditSystems,
// cusEnts: cusEnts!,
// quantity,
// entityId: entity_id,
// org,
// });
// const v2Response = await getV2CheckResponse({
// fullCus,
// cusEnts,
// feature,
// creditSystems,
// org,
// cusProducts,
// requiredBalance,
// apiVersion: req.apiVersion,
// });
// const { allowed, balance } = v2Response;
// const featureToUse = allFeatures.find(
// (f: Feature) => f.id === v2Response.feature_id,
// );
// if (allowed && req.isPublic !== true) {
// if (send_event) {
// await handleEventSent({
// req: {
// ...req,
// body: {
// ...req.body,
// value: quantity,
// },
// },
// customer_id: customer_id,
// customer_data: customer_data,
// event_data: {
// customer_id: customer_id,
// feature_id: feature_id,
// value: quantity,
// entity_id: entity_id,
// },
// });
// } else if (notNullish(event_data)) {
// await handleEventSent({
// req,
// customer_id: customer_id,
// customer_data: customer_data,
// event_data: {
// customer_id: customer_id,
// feature_id: feature_id,
// ...event_data,
// },
// });
// }
// }
// let preview;
// if (req.body.with_preview) {
// try {
// preview = await getCheckPreview({
// db,
// allowed,
// balance: notNullish(balance) ? balance : undefined,
// feature: featureToUse!,
// cusProducts,
// allFeatures,
// });
// } catch (error) {
// logger.error("Failed to get check preview", error);
// console.error(error);
// }
// }
// if (req.apiVersion.gte(ApiVersion.V1_1)) {
// res.status(200).json({
// ...v2Response,
// preview,
// });
// } else {
// res.status(200).json({
// ...v1Response,
// preview,
// });
// }
// return;
// } catch (error) {
// handleRequestError({ req, error, res, action: "Failed to GET entitled" });
// }
// });
// // let features = [feature, ...creditSystems];
// // let balanceObj: any, featureToUse: any;
// // try {
// // balanceObj = balances.length > 0 ? balances[0] : null;
// // featureToUse =
// // notNullish(balanceObj) && balanceObj.feature_id !== feature.id
// // ? features.find((f) => f.id === balanceObj.feature_id)
// // : creditSystems.length > 0
// // ? creditSystems[0]
// // : feature;
// // } catch (error) {
// // logger.error(`/check: failed to get balance & feature to use`, error);
// // }
// // 3. If with preview, get preview

View File

@@ -1,171 +0,0 @@
import {
type AppEnv,
BillingType,
type Customer,
type Entity,
EntityExpand,
ErrCode,
type Feature,
type FullCustomerEntitlement,
type FullCustomerPrice,
type Organization,
type UsagePriceConfig,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { submitUsageToStripe } from "@/external/stripe/stripeMeterUtils.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import {
getBillingType,
roundUsage,
} from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
export const getLinkedCusEnt = ({
linkedFeature,
cusEnts,
}: {
linkedFeature: any;
cusEnts: any;
}) => {
// Get linked cus ent...
const linkedCusEnt = cusEnts.find(
(e: any) => e.entitlement.feature.id === linkedFeature.id,
);
if (!linkedCusEnt) {
return null;
}
return linkedCusEnt;
};
export const entityFeatureIdExists = ({
cusEnt,
}: {
cusEnt: FullCustomerEntitlement;
}) => {
const ent = cusEnt.entitlement;
return notNullish(ent.entity_feature_id);
};
export const entityMatchesFeature = ({
feature,
entity,
}: {
feature: Feature;
entity: Entity;
}) => {
return feature.id === entity.feature_id;
};
export const isLinkedToEntity = ({
cusEnt,
entity,
}: {
cusEnt: FullCustomerEntitlement;
entity: Entity;
}) => {
return cusEnt.entitlement.entity_feature_id === entity.feature_id;
};
export const removeEntityFromCusEnt = async ({
db,
cusEnt,
entity,
logger,
cusPrice,
customer,
org,
env,
}: {
db: DrizzleCli;
cusEnt: FullCustomerEntitlement;
entity: Entity;
logger: any;
cusPrice?: FullCustomerPrice;
customer: Customer;
org: Organization;
env: AppEnv;
}) => {
// isLinked
const isLinked = isLinkedToEntity({
cusEnt,
entity,
});
if (!isLinked) {
return;
}
const entitlement = cusEnt.entitlement;
console.log(
`Linked cus ent: ${entitlement.feature.id}, isLinked: ${isLinked}`,
);
// Delete cus ent ids
const newEntities = structuredClone(cusEnt.entities!);
// TODO: Send usage to stripe if cus price exists
const stripeCli = createStripeCli({
org,
env,
});
if (cusPrice) {
const config = cusPrice.price.config as UsagePriceConfig;
const billingType = getBillingType(config);
if (billingType === BillingType.UsageInArrear) {
let usage = -newEntities[entity.id]?.balance;
usage = roundUsage({
usage,
billingUnits: config.billing_units!,
});
await submitUsageToStripe({
price: cusPrice.price,
usage,
customer,
feature: entitlement.feature,
logger,
stripeCli,
});
}
}
delete newEntities[entity.id];
await CusEntService.update({
db,
id: cusEnt.id,
updates: {
entities: newEntities,
},
});
logger.info(
`Feature: ${entitlement.feature.id}, customer: ${cusEnt.customer_id}, deleted entities from cus ent`,
);
};
export const parseEntityExpand = (expand: string): EntityExpand[] => {
if (expand) {
const options = expand.split(",");
const result: EntityExpand[] = [];
for (const option of options) {
if (!Object.values(EntityExpand).includes(option as EntityExpand)) {
throw new RecaseError({
message: `Invalid expand option: ${option}`,
code: ErrCode.InvalidExpand,
statusCode: StatusCodes.BAD_REQUEST,
});
}
result.push(option as EntityExpand);
}
return result;
} else {
return [];
}
};

View File

@@ -1,41 +0,0 @@
import { ErrCode } from "@autumn/shared";
import { differenceInMonths, differenceInYears } from "date-fns";
import { StatusCodes } from "http-status-codes";
import RecaseError from "@/utils/errorUtils.js";
export const getEventTimestamp = (timestamp?: number | null) => {
// 1. If timestamp is not provided, return now
if (!timestamp) {
return new Date();
}
try {
const date = new Date(timestamp);
if (differenceInYears(new Date(), date) >= 2) {
throw new RecaseError({
message: "Timestamp must be within the last 2 years",
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
} else if (differenceInMonths(new Date(), date) <= -1) {
throw new RecaseError({
message: "Timestamp can only be up to 1 month in the future",
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
return date;
} catch (error) {
if (error instanceof RecaseError) {
throw error;
}
throw new RecaseError({
message: "Invalid timestamp",
code: ErrCode.InvalidInputs,
data: error,
});
}
};

View File

@@ -1,63 +0,0 @@
// // =============================================================================
// // CASE 1: Deduct from ALL entities
// // =============================================================================
// const deductFromAllEntities = ({
// currentBalance,
// currentEntities,
// currentAdjustment,
// amountToDeduct,
// creditCost,
// allowNegative,
// alterGrantedBalance,
// }: {
// currentBalance: number;
// currentEntities: Record<string, EntityBalance> | null;
// currentAdjustment: number;
// amountToDeduct: number;
// creditCost: number;
// allowNegative: boolean;
// alterGrantedBalance: boolean;
// }): DeductFromMainBalanceResult => {
// let remaining = new Decimal(amountToDeduct).mul(creditCost).toNumber();
// let totalDeducted = 0;
// const newEntities: Record<string, EntityBalance> = structuredClone(
// currentEntities ?? {},
// );
// // Sort entity keys for consistency (matches SQL ORDER BY)
// const sortedEntityKeys = Object.keys(newEntities).sort();
// for (const entityKey of sortedEntityKeys) {
// if (remaining === 0) break;
// const entityBalance = newEntities[entityKey]?.balance ?? 0;
// const entityAdjustment = newEntities[entityKey]?.adjustment ?? 0;
// const { deducted, newBalance, newAdjustment } = calculateDeduction({
// currentBalance: entityBalance,
// currentAdjustment: entityAdjustment,
// amountToDeduct: remaining,
// allowNegative,
// alterGrantedBalance,
// });
// if (deducted !== 0) {
// newEntities[entityKey] = {
// ...newEntities[entityKey],
// balance: newBalance,
// adjustment: newAdjustment,
// };
// remaining = new Decimal(remaining).sub(deducted).toNumber();
// totalDeducted = new Decimal(totalDeducted).add(deducted).toNumber();
// }
// }
// return {
// deducted: totalDeducted,
// newBalance: currentBalance, // Top-level balance unchanged for entity-scoped
// newEntities,
// newAdjustment: currentAdjustment, // Top-level adjustment unchanged
// remaining: new Decimal(remaining).div(creditCost).toNumber(),
// };
// };

View File

@@ -1,225 +0,0 @@
import type { AppEnv } from "@autumn/shared";
import { currentRegion } from "@/external/redis/initRedis.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { logger } from "../../../../external/logtail/logtailUtils";
const hashPairKey = (key: string): string => {
return Bun.hash(key).toString(36);
};
interface SyncPairContext {
customerId: string;
featureId: string;
orgId: string;
env: AppEnv;
entityId?: string;
region: string;
timestamp: number;
breakdownIds: string[];
}
interface CustomerBatch {
pairs: Map<string, SyncPairContext>;
timer: NodeJS.Timeout | null;
}
/**
* Batching manager for syncing Redis balance deductions to PostgreSQL
* Collects sync items within a time window, then queues each item individually to SQS
*
* Benefits:
* - In-memory deduplication: Same pair only queued once per batch window (500ms)
* - SQS deduplication: MessageDeduplicationId prevents duplicate processing (5 min window)
* - Per-customer FIFO ordering: MessageGroupId ensures ordered processing per customer
* - Non-blocking: Track endpoint returns immediately
* - Simple: Each sync item is a separate SQS message, easy to retry and monitor
*/
export class SyncBatchingManager {
// Map of customerId -> batch
private customerBatches: Map<string, CustomerBatch> = new Map();
private readonly BATCH_WINDOW_MS =
process.env.NODE_ENV === "development" ? 500 : 1000; // 1000ms batching window
private readonly MAX_BATCH_SIZE_PER_CUSTOMER = 1000; // Max unique pairs per customer batch
/**
* Add a (customerId, featureId) pair to the sync batch
* Idempotent - multiple calls for same pair only result in one sync
*/
addSyncPair({
customerId,
featureId,
orgId,
env,
entityId,
region,
breakdownIds,
}: Omit<SyncPairContext, "timestamp">): void {
// Get or create batch for this customer
let customerBatch = this.customerBatches.get(customerId);
if (!customerBatch) {
customerBatch = {
pairs: new Map(),
timer: null,
};
this.customerBatches.set(customerId, customerBatch);
}
// Create unique key for this pair (within customer scope)
const rawKey = `${orgId}:${env}:${featureId}${entityId ? `:${entityId}` : ""}`;
const pairKey = hashPairKey(rawKey);
// If this is the first pair for this customer, schedule batch execution
if (customerBatch.pairs.size === 0) {
this.scheduleCustomerBatch({ customerId });
}
// Add or update pair (Map handles deduplication)
// Use the earliest timestamp if the pair already exists, otherwise use current time
const existingPair = customerBatch.pairs.get(pairKey);
customerBatch.pairs.set(pairKey, {
customerId,
featureId,
orgId,
env,
entityId,
region: region || currentRegion,
timestamp: existingPair?.timestamp ?? Date.now(),
breakdownIds: existingPair
? [...new Set([...existingPair.breakdownIds, ...breakdownIds])]
: breakdownIds,
});
// Force flush if batch is full
if (customerBatch.pairs.size >= this.MAX_BATCH_SIZE_PER_CUSTOMER) {
this.executeCustomerBatch({ customerId });
}
}
/**
* Schedule batch execution for a specific customer after window expires
*/
private scheduleCustomerBatch({ customerId }: { customerId: string }): void {
const customerBatch = this.customerBatches.get(customerId);
if (!customerBatch) return;
customerBatch.timer = setTimeout(() => {
this.executeCustomerBatch({ customerId });
}, this.BATCH_WINDOW_MS);
}
/**
* Execute the batch for a specific customer - flush to SQS
* Queues each sync item individually with deduplication
*/
private async executeCustomerBatch({
customerId,
}: {
customerId: string;
}): Promise<void> {
const customerBatch = this.customerBatches.get(customerId);
if (!customerBatch) return;
// Clear timer
if (customerBatch.timer) {
clearTimeout(customerBatch.timer);
customerBatch.timer = null;
}
// Snapshot current batch and remove from map
const currentPairs = customerBatch.pairs;
this.customerBatches.delete(customerId);
if (currentPairs.size === 0) {
return;
}
try {
// Queue each sync item individually with deduplication
const items = Array.from(currentPairs.values());
for (const item of items) {
// Create deterministic deduplication key from sync item fields
const dedupKey = item.entityId
? `${item.orgId}:${item.env}:${item.customerId}:${item.featureId}:${item.entityId}`
: `${item.orgId}:${item.env}:${item.customerId}:${item.featureId}`;
// Deduplicate messages in a 10ms window
const dedupTimestamp = Math.floor(Date.now() / 10);
// Hash to create AWS-friendly alphanumeric ID
const dedupHash = Bun.hash(`${dedupKey}:${dedupTimestamp}`).toString(
36,
);
await addTaskToQueue({
jobName: JobName.SyncBalanceBatchV2,
payload: {
orgId: item.orgId,
env: item.env,
customerId: item.customerId,
item, // Single item
},
messageGroupId: customerId, // FIFO ordering per customer
messageDeduplicationId: dedupHash, // SQS deduplication (1-second window)
});
logger.info(
`Queued sync item for customer ${customerId}, feature: ${item.featureId}${item.entityId ? `, entity: ${item.entityId}` : ""}`,
);
}
} catch (error) {
logger.error(
`❌ Failed to queue sync items for customer ${customerId}, error: ${error instanceof Error ? error.message : "unknown"}`,
{
error: error instanceof Error ? error : new Error(String(error)),
customerId,
},
);
console.error(
`❌ Failed to queue sync items for customer ${customerId}:`,
error,
);
// TODO: Consider retry logic or dead letter queue
}
}
/**
* Get current batch statistics (for monitoring)
*/
getStats(): {
totalCustomers: number;
totalPendingPairs: number;
activeTimers: number;
} {
let totalPairs = 0;
let activeTimers = 0;
for (const batch of this.customerBatches.values()) {
totalPairs += batch.pairs.size;
if (batch.timer !== null) activeTimers++;
}
return {
totalCustomers: this.customerBatches.size,
totalPendingPairs: totalPairs,
activeTimers,
};
}
/**
* Force flush all customer batches (useful for graceful shutdown)
*/
async flush(): Promise<void> {
const customerIds = Array.from(this.customerBatches.keys());
await Promise.all(
customerIds.map((customerId) =>
this.executeCustomerBatch({ customerId }),
),
);
}
}
// Singleton instance
export const globalSyncBatchingManager = new SyncBatchingManager();

View File

@@ -1,27 +0,0 @@
import type { FeatureOptions } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
/**
* Update customer product options with new feature quantities.
*
* Extracted from:
* - updateQuantityFlow.ts:55-59
*/
export const updateCustomerProductOptions = async ({
ctx,
customerProductId,
updatedFeatureOptions,
}: {
ctx: AutumnContext;
customerProductId: string;
updatedFeatureOptions: FeatureOptions[];
}) => {
const { db } = ctx;
await CusProductService.update({
db,
cusProductId: customerProductId,
updates: { options: updatedFeatureOptions },
});
};

View File

@@ -1,27 +0,0 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/billingResult";
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
export const logStripeExecution = ({
ctx,
result,
stage,
}: {
ctx: AutumnContext;
result: StripeBillingPlanResult;
stage: "invoice" | "subscription" | "complete";
}) => {
addToExtraLogs({
ctx,
extras: {
stripeExecution: {
stage,
stripeSubscriptionId: result.stripeSubscription?.id ?? "undefined",
stripeInvoiceId: result.stripeInvoice?.id ?? "undefined",
stripeInvoiceStatus: result.stripeInvoice?.status ?? "undefined",
requiredAction: result.requiredAction ?? "undefined",
deferred: result.deferred ?? false,
},
},
});
};

View File

@@ -1,78 +0,0 @@
import type {
AttachBodyV1,
FreeTrial,
FullCusProduct,
FullCustomer,
FullCustomerPrice,
FullProduct,
LineItem,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { StripeInvoiceAction } from "./types/billingPlan";
export type AttachContext = {
fullCus: FullCustomer;
products: FullProduct[];
freeTrial?: FreeTrial;
// Stripe context
stripeSub?: Stripe.Subscription;
stripeCus: Stripe.Customer;
paymentMethod?: Stripe.PaymentMethod;
testClockFrozenTime?: number;
body: AttachBodyV1;
};
export type StripeSubAction = {
type:
| "create"
| "update"
| "cancel_immediately"
| "cancel_at_period_end"
| "none";
subId?: string;
items?: Stripe.SubscriptionUpdateParams.Item[];
};
export type StripeCheckoutAction = {
shouldCreate: boolean;
reason?: string;
params: Stripe.Checkout.SessionCreateParams;
};
export type UpdateOneOffAction = {
targetCusProduct: FullCusProduct;
};
export type AttachPlan = {
lineItems: LineItem[];
// 1. Autumn actions
updateOneOffAction?: UpdateOneOffAction;
newCusProducts: FullCusProduct[];
// 2. Checkout session?
stripeCheckoutAction: StripeCheckoutAction;
stripeSubAction: StripeSubAction;
stripeInvoiceAction?: StripeInvoiceAction;
};
export type BillingPlan = {
intent: "attach" | "update_quantity" | "update_plan" | "cancel" | "one_off";
};
export type SubscriptionUpdateInvoiceAction = {
shouldCreateInvoice: boolean;
invoiceItems: {
description: string;
amountDollars: number;
stripePriceId: string;
periodStartEpochMs: number;
periodEndEpochMs: number;
}[];
shouldChargeImmediately: boolean;
paymentMethod?: Stripe.PaymentMethod;
customerPrices: FullCustomerPrice[];
};

View File

@@ -1,34 +0,0 @@
import { cp, isCustomerProductFree } from "@autumn/shared";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
export const computeOneOffLineItems = ({
billingContext,
autumnBillingPlan,
}: {
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const { insertCustomerProducts } = autumnBillingPlan;
const newCustomerProduct = insertCustomerProducts?.[0];
if (!newCustomerProduct) return [];
// Only allow one off items if going from free -> paid
const currentIsFree = isCustomerProductFree(billingContext.customerProduct);
const { valid: newIsPaidRecurring } = cp(newCustomerProduct)
.paid()
.recurring();
const includeOneOffItems = currentIsFree && newIsPaidRecurring;
if (!includeOneOffItems) return [];
// const newOneOffItems = cusProductToLineItems({
// cusProduct: newCustomerProduct,
// nowMs: billingContext.currentEpochMs,
// billingCycleAnchorMs: billingContext.billingCycleAnchorMs,
// direction: "charge",
// org: billingContext.org,
// logger: billingContext.logger,
// });
};

View File

@@ -1,43 +0,0 @@
import type { FullCustomer } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { initSubscriptionFromStripe } from "@/internal/subscriptions/utils/initSubscriptionFromStripe.js";
import type { CreateCustomerContext } from "../createCustomerContext.js";
/**
* Finalize customer creation after Stripe subscription is created.
* Links subscription_ids back to customer products and builds final customer.
*/
export const finalizeCreateCustomer = async ({
ctx,
context,
autumnBillingPlan,
stripeSubscription,
}: {
ctx: AutumnContext;
context: CreateCustomerContext;
autumnBillingPlan: AutumnBillingPlan;
stripeSubscription: Stripe.Subscription | undefined;
}): Promise<FullCustomer> => {
const { fullCustomer } = context;
if (!stripeSubscription) return fullCustomer;
// Link subscription_ids to customer products
for (const customerProduct of autumnBillingPlan.insertCustomerProducts) {
await CusProductService.update({
db: ctx.db,
cusProductId: customerProduct.id,
updates: { subscription_ids: customerProduct.subscription_ids },
});
}
// Build final customer with subscription and products
return {
...fullCustomer,
subscriptions: [initSubscriptionFromStripe({ ctx, stripeSubscription })],
customer_products: autumnBillingPlan.insertCustomerProducts,
};
};

View File

@@ -1,66 +0,0 @@
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { generateId } from "@/utils/genUtils.js";
import type { ItemSet } from "@/utils/models/ItemSet.js";
export const createSubSchedule = async ({
db,
attachParams,
itemSet,
endOfBillingPeriod,
}: {
db: DrizzleCli;
attachParams: AttachParams;
itemSet: ItemSet;
endOfBillingPeriod: number;
}) => {
const { org, customer, paymentMethod } = attachParams;
const { stripeCli } = attachParams;
// let subItems = items.filter(
// (item: any, index: number) =>
// index >= prices.length ||
// prices[index].config!.interval !== BillingInterval.OneOff
// );
// let oneOffItems = items.filter(
// (item: any, index: number) =>
// index < prices.length &&
// prices[index].config!.interval === BillingInterval.OneOff
// );
const { subItems, invoiceItems, usageFeatures } = itemSet;
const newSubscriptionSchedule = await stripeCli.subscriptionSchedules.create({
customer: customer.processor.id,
start_date: endOfBillingPeriod,
billing_mode: { type: "flexible" },
phases: [
{
items: sanitizeSubItems(subItems),
default_payment_method: paymentMethod?.id,
add_invoice_items:
invoiceItems as Stripe.SubscriptionScheduleCreateParams.Phase.AddInvoiceItem[],
},
],
});
await SubService.createSub({
db,
sub: {
id: generateId("sub"),
stripe_id: null,
stripe_schedule_id: newSubscriptionSchedule.id,
created_at: Date.now(),
usage_features: usageFeatures,
org_id: org.id,
env: customer.env,
current_period_start: null,
current_period_end: null,
},
});
return newSubscriptionSchedule;
};

View File

@@ -1,79 +0,0 @@
import type { FullCusProduct } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import {
createUsageInvoiceItems,
resetUsageBalances,
} from "./createUsageInvoiceItems.js";
export const createUsageInvoice = async ({
db,
attachParams,
cusProduct,
sub,
logger,
}: {
db: DrizzleCli;
attachParams: AttachParams;
cusProduct: FullCusProduct;
sub: Stripe.Subscription;
logger: any;
}) => {
const { stripeCli, paymentMethod } = attachParams;
const customer = cusProduct.customer!;
const invoice = await stripeCli.invoices.create({
customer: customer.processor.id,
auto_advance: false,
});
const { cusEntIds } = await createUsageInvoiceItems({
db,
attachParams,
cusProduct,
sub,
invoiceId: invoice.id,
logger,
});
await stripeCli.invoices.finalizeInvoice(invoice.id!, {
auto_advance: false,
});
const {
paid,
error,
invoice: latestInvoice,
} = await payForInvoice({
stripeCli,
paymentMethod,
invoiceId: invoice.id!,
logger,
errorOnFail: false,
});
if (latestInvoice) {
await resetUsageBalances({
db,
cusEntIds,
cusProduct,
});
await insertInvoiceFromAttach({
db,
invoiceId: latestInvoice.id,
attachParams,
logger,
});
}
if (!paid) {
logger.error(`sub.deleted, failed to pay invoice: ${invoice.id}`, {
error,
});
}
return invoice;
};

View File

@@ -1,8 +0,0 @@
import type Stripe from "stripe";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
export const getNextCycle = (stripeSubs: Stripe.Subscription[]) => {
const { end } = subToPeriodStartEnd({ sub: stripeSubs[0] });
const nextCycle = end * 1000;
return nextCycle;
};

View File

@@ -1,45 +0,0 @@
import type { EntitlementWithFeature, Price } from "@autumn/shared";
export const hasPriceIdsChanged = ({
oldPrices,
newPrices,
}: {
oldPrices: Price[];
newPrices: Price[];
}) => {
for (const price of oldPrices) {
if (!newPrices.some((p) => p.id === price.id)) {
return true;
}
}
for (const price of newPrices) {
if (!oldPrices.some((p) => p.id === price.id)) {
return true;
}
}
return false;
};
export const hasEntIdsChanged = ({
oldEntitlements,
newEntitlements,
}: {
oldEntitlements: EntitlementWithFeature[];
newEntitlements: EntitlementWithFeature[];
}) => {
for (const entitlement of oldEntitlements) {
if (!newEntitlements.some((e) => e.id === entitlement.id)) {
return true;
}
}
for (const entitlement of newEntitlements) {
if (!oldEntitlements.some((e) => e.id === entitlement.id)) {
return true;
}
}
return false;
};

View File

@@ -1,127 +0,0 @@
import {
type EntitlementWithFeature,
type FullProduct,
formatAmount,
isFixedPrice,
isOneOffPrice,
type Organization,
type Price,
priceToInvoiceAmount,
type Reward,
} from "@autumn/shared";
import type Stripe from "stripe";
import { newPriceToInvoiceDescription } from "@/internal/invoices/invoiceFormatUtils.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import {
formatReward,
getAmountAfterReward,
getAmountAfterStripeDiscounts,
} from "@/internal/rewards/rewardUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
export const priceToNewPreviewItem = ({
org,
price,
entitlements,
skipOneOff,
now,
anchor,
productQuantity = 1,
product,
onTrial,
rewards,
subDiscounts,
}: {
org: Organization;
price: Price;
entitlements: EntitlementWithFeature[];
skipOneOff?: boolean;
now?: number;
anchor?: number;
productQuantity?: number;
product: FullProduct;
onTrial?: boolean;
rewards?: Reward[];
subDiscounts?: Stripe.Discount[];
}) => {
if (skipOneOff && isOneOffPrice(price)) return;
now = now ?? Date.now();
const ent = getPriceEntitlement(price, entitlements);
const finalProration = getProration({
anchor,
now,
intervalConfig: {
interval: price.config.interval!,
intervalCount: price.config.interval_count || 1,
},
});
const applyRewards = rewards?.filter(
(r) =>
r.discount_config?.price_ids?.includes(price.id) ||
r.discount_config?.apply_to_all,
);
for (const reward of applyRewards ?? []) {
console.log("Apply Reward", formatReward({ reward }));
}
if (isFixedPrice(price)) {
let amount = priceToInvoiceAmount({
price,
quantity: 1,
proration: finalProration,
productQuantity,
now,
});
if (onTrial) {
amount = 0;
}
for (const reward of applyRewards ?? []) {
amount = getAmountAfterReward({
amount,
reward,
subDiscounts: subDiscounts ?? [],
currency: org.default_currency || undefined,
});
}
amount = getAmountAfterStripeDiscounts({
price,
amount,
product,
stripeDiscounts: subDiscounts ?? [],
currency: org.default_currency || undefined,
});
let description = newPriceToInvoiceDescription({
org,
price,
product,
});
if (productQuantity > 1) {
description = `${description} x ${productQuantity}`;
}
if (finalProration) {
description = `${description} (from ${formatUnixToDate(now)})`;
}
return {
price_id: price.id,
price: formatAmount({ org, amount }),
description,
amount,
usage_model: priceToUsageModel(price),
feature_id: ent?.feature_id,
};
}
};

View File

@@ -1,159 +0,0 @@
import {
type AttachBodyV0,
type AttachBranch,
type AttachConfig,
cusProductsToPrices,
type PreviewLineItem,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import { getAddAndRemoveProducts } from "../attachFunctions/multiAttach/getAddAndRemoveProducts.js";
import { priceToNewPreviewItem } from "../attachPreviewUtils/priceToNewPreviewItem.js";
import { priceToUnusedPreviewItem } from "../attachPreviewUtils/priceToUnusedPreviewItem.js";
import { getCustomerSub } from "../attachUtils/convertAttachParams.js";
import { handleMultiAttachErrors } from "../attachUtils/handleAttachErrors/handleMultiAttachErrors.js";
export const getMultiAttachPreview = async ({
// biome-ignore lint/correctness/noUnusedFunctionParameters: might be used in the future
ctx,
attachBody,
attachParams,
config,
branch,
}: {
ctx: AutumnContext;
attachBody: AttachBodyV0;
attachParams: AttachParams;
config: AttachConfig;
branch: AttachBranch;
}) => {
await handleMultiAttachErrors({ attachParams, attachBody, branch });
const { customer } = attachParams;
const cusProducts = customer.customer_products;
const { sub } = await getCustomerSub({ attachParams });
const items: PreviewLineItem[] = [];
const subItems = sub?.items.data || [];
// 1. Get remove cus products...
const { expireCusProducts } = await getAddAndRemoveProducts({
attachParams,
config,
});
const prices = cusProductsToPrices({ cusProducts: expireCusProducts });
for (const price of prices) {
const cusProduct = cusProducts.find(
(cp) => cp.internal_product_id === price.internal_product_id,
)!;
const previewLineItem = priceToUnusedPreviewItem({
customer,
price,
stripeItems: subItems,
cusProduct,
now: attachParams.now!,
org: attachParams.org,
latestInvoice: sub?.latest_invoice as Stripe.Invoice,
subDiscounts: (sub?.discounts ?? []) as Stripe.Discount[],
});
if (!previewLineItem) continue;
items.push(previewLineItem);
}
const productList = attachParams.productsList!;
const newItems: PreviewLineItem[] = [];
const itemsWithoutTrial: PreviewLineItem[] = [];
for (const productOptions of productList) {
const product = attachParams.products.find(
(p) => p.id === productOptions.product_id,
)!;
// Anchor to unix...
const anchor = sub ? sub.billing_cycle_anchor * 1000 : undefined;
if (config.disableTrial) {
attachParams.freeTrial = null;
}
const onTrial =
notNullish(attachParams?.freeTrial) || sub?.status === "trialing";
for (const price of product.prices) {
const newItem = priceToNewPreviewItem({
org: attachParams.org,
price,
entitlements: product.entitlements,
skipOneOff: false,
now: attachParams.now!,
anchor,
productQuantity: productOptions.quantity ?? 1,
product,
onTrial,
rewards: attachParams.rewards,
subDiscounts: (sub?.discounts ?? []) as Stripe.Discount[],
});
const noTrialItem = priceToNewPreviewItem({
org: attachParams.org,
price,
entitlements: product.entitlements,
skipOneOff: false,
now: attachParams.now!,
// anchorToUnix,
productQuantity: productOptions.quantity ?? 1,
product,
onTrial: false,
rewards: attachParams.rewards,
subDiscounts: (sub?.discounts ?? []) as Stripe.Discount[],
});
if (newItem) {
newItems.push(newItem);
}
if (noTrialItem) {
itemsWithoutTrial.push(noTrialItem);
}
}
}
const totalDueToday = newItems.reduce(
(acc, item) => acc + (item.amount ?? 0),
0,
);
const freeTrial = attachParams.freeTrial;
let dueNextCycle;
if (freeTrial || sub?.status === "trialing") {
const nextCycleAt = freeTrial
? freeTrialToStripeTimestamp({ freeTrial, now: attachParams.now })! * 1000
: sub
? getEarliestPeriodEnd({ sub }) * 1000
: undefined;
if (nextCycleAt) {
dueNextCycle = {
line_items: itemsWithoutTrial,
due_at: nextCycleAt,
};
}
}
return {
// items,
due_today: {
line_items: [...items, ...newItems],
total: new Decimal(totalDueToday).toNumber(),
},
due_next_cycle: dueNextCycle,
};
};

View File

@@ -1,37 +0,0 @@
// export enum AttachBranch {
// MultiProduct = "multi_product",
// OneOff = "one_off",
// New = "new",
// AddOn = "add_on",
// // Same product
// NewVersion = "new_version",
// SameCustomEnts = "same_custom_ents",
// SameCustom = "same_custom",
// UpdatePrepaidQuantity = "update_prepaid_quantity",
// Renew = "renew",
// // Handle upgrades / downgrades
// MainIsFree = "main_is_free",
// MainIsTrial = "main_is_trial",
// Upgrade = "upgrade",
// Downgrade = "downgrade",
// }
export enum AttachFunction {
CreateCheckout = "create_checkout",
AddProduct = "add_product",
UpdateEnts = "update_ents", // only update entitlements
UpdateProduct = "update_product", // update product
ScheduleProduct = "schedule_product",
UpdatePrepaidQuantity = "update_prepaid_quantity",
Renew = "renew",
}
/* Handle checkout / public error:
1. New version, same custom, same custom ents, renew, update prepaid quantity
2.
*/

View File

@@ -1,19 +0,0 @@
// import { ExtendedRequest } from "@/utils/models/Request.js";
// import {
// AppEnv,
// Customer,
// Feature,
// FullProduct,
// Organization,
// } from "@autumn/shared";
// import Stripe from "stripe";
// export type AttachContext = {
// req: ExtendedRequest;
// customer: Customer;
// products: FullProduct[];
// paymentMethod: Stripe.PaymentMethod;
// stripeCli: Stripe;
// // More...?
// };

View File

@@ -1,28 +0,0 @@
import { type AttachConfig, CusProductStatus } from "@autumn/shared";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
export const handleUnifiedAttach = async ({
req,
res,
attachParams,
config,
}: {
req: any;
res: any;
attachParams: AttachParams;
config: AttachConfig;
}) => {
// 1.
const cusProducts = attachParams.customer.customer_products;
const scheduledCusProducts = cusProducts.filter(
(cp) => cp.status === CusProductStatus.Scheduled,
);
res.status(200).json({
success: true,
message: "Unified attach",
});
return;
};

View File

@@ -1,46 +0,0 @@
import {
CusProductStatus,
type FullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { cusProductToSub } from "../cusProducts/cusProductUtils/convertCusProduct.js";
export const cancelEndOfCycle = async ({
req,
cusProduct,
fullCus,
}: {
req: ExtendedRequest;
cusProduct: FullCusProduct;
fullCus: FullCustomer;
}) => {
const { db, org, env, logger } = req;
const stripeCli = createStripeCli({ org, env });
const sub = await cusProductToSub({ cusProduct, stripeCli });
if (sub) {
const latestPeriodEnd = getLatestPeriodEnd({ sub });
await stripeCli.subscriptions.update(sub.id, {
cancel_at: latestPeriodEnd,
});
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: { canceled_at: Date.now() },
});
} else {
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,
ended_at: Date.now(),
},
});
}
};

View File

@@ -1,93 +0,0 @@
import {
AttachScenario,
CusProductStatus,
cusProductToProduct,
type FullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { setStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { cusProductToSub } from "../cusProducts/cusProductUtils/convertCusProduct.js";
import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { activateDefaultProduct } from "../cusProducts/cusProductUtils.js";
export const cancelImmediately = async ({
ctx,
cusProduct,
fullCus,
prorate,
}: {
ctx: AutumnContext;
cusProduct: FullCusProduct;
fullCus: FullCustomer;
prorate: boolean;
}) => {
const { db, org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
const { curScheduledProduct } = getExistingCusProducts({
product: cusProduct.product,
cusProducts: fullCus.customer_products,
internalEntityId: cusProduct.internal_entity_id,
});
const sub = await cusProductToSub({ cusProduct, stripeCli });
if (sub) {
// Set lock to prevent webhook handler from processing this cancellation
await setStripeSubscriptionLock({
stripeSubscriptionId: sub.id,
lockedAtMs: Date.now(),
});
await stripeCli.subscriptions.cancel(sub.id, {
prorate: prorate,
cancellation_details: {
comment: "autumn_cancel",
},
});
}
const isMain = !cusProduct.product.is_add_on;
const product = cusProductToProduct({ cusProduct });
if (isMain && !isOneOff(product.prices)) {
// So it doesn't duplicate
if (curScheduledProduct) {
await CusProductService.delete({
db,
cusProductId: curScheduledProduct.id,
});
}
await activateDefaultProduct({
ctx,
productGroup: cusProduct.product.group,
fullCus,
});
}
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,
ended_at: Date.now(),
},
});
console.log("Sending webhook for expired product");
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: fullCus.internal_id,
org,
env,
customerId: fullCus.id || null,
cusProduct,
scenario: AttachScenario.Expired,
});
};

View File

@@ -1,61 +0,0 @@
import {
CusProductStatus,
type FullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { cusProductToSchedule } from "../cusProducts/cusProductUtils/convertCusProduct.js";
export const cancelScheduledProduct = async ({
req,
curScheduledProduct,
fullCus,
curMainProduct,
}: {
req: ExtendedRequest;
curScheduledProduct?: FullCusProduct;
fullCus: FullCustomer;
curMainProduct?: FullCusProduct;
}) => {
const { org, env, db, logger } = req;
const stripeCli = createStripeCli({ org, env });
// 1. Delete subscription schedule if exists
if (curScheduledProduct) {
const schedule = await cusProductToSchedule({
cusProduct: curScheduledProduct,
stripeCli,
});
if (schedule) {
await stripeCli.subscriptionSchedules.cancel(schedule.id);
}
logger.info(`Deleting scheduled prod (${curScheduledProduct.product.id})`);
await CusProductService.delete({
db,
cusProductId: curScheduledProduct.id,
});
}
// 2. Uncancel current main product
const subId = curMainProduct?.subscription_ids?.[0];
if (subId) {
await stripeCli.subscriptions.update(subId, { cancel_at: null });
}
if (curMainProduct) {
logger.info(`Updating main prod (${curMainProduct!.product.id}) to active`);
logger.info(`Cus product ID: ${curMainProduct!.id}`);
await CusProductService.update({
db,
cusProductId: curMainProduct!.id,
updates: {
status: CusProductStatus.Active,
canceled_at: null,
},
});
}
};

View File

@@ -1,92 +0,0 @@
import {
CusProductNotFoundError,
cusProductToProcessorType,
EntityNotFoundError,
type FullCusProduct,
notNullish,
nullish,
ProcessorType,
RELEVANT_STATUSES,
RecaseError,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { handleCancelProduct } from "@/internal/customers/cancel/handleCancelProduct.js";
import { CusService } from "../CusService.js";
export const handleCancel = createRoute({
// body: CancelBodySchema,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const {
customer_id,
product_id,
entity_id,
cancel_immediately,
prorate: bodyProrate,
customer_product_id,
} = await c.req.json();
const expireImmediately = cancel_immediately || false;
const prorate = notNullish(bodyProrate) ? bodyProrate : true;
const fullCus = await CusService.getFull({
db,
orgId: org.id,
idOrInternalId: customer_id,
env,
withEntities: true,
entityId: entity_id,
inStatuses: RELEVANT_STATUSES,
allowNotFound: false,
});
if (entity_id && !fullCus.entity) {
throw new EntityNotFoundError({ entityId: entity_id });
}
const cusProducts = fullCus.customer_products;
const entity = fullCus.entity;
const cusProduct = cusProducts.find((cusProduct: FullCusProduct) => {
const productIdMatch = cusProduct.product.id === product_id;
const entityMatch = entity
? cusProduct.internal_entity_id === entity.internal_id
: nullish(cusProduct.internal_entity_id);
const cusProductIdMatch = customer_product_id
? cusProduct.id === customer_product_id
: true;
return productIdMatch && entityMatch && cusProductIdMatch;
});
if (!cusProduct) {
throw new CusProductNotFoundError({
customerId: customer_id,
productId: product_id,
entityId: entity_id,
});
}
if (cusProductToProcessorType(cusProduct) === ProcessorType.RevenueCat) {
throw new RecaseError({
message: `Cannot cancel '${cusProduct.product.name}' because it is managed by RevenueCat.`,
});
}
await handleCancelProduct({
ctx,
cusProduct,
fullCus,
expireImmediately,
prorate,
});
return c.json({
success: true,
customer_id: customer_id,
product_id: product_id,
});
},
});

View File

@@ -1,208 +0,0 @@
import {
AttachBranch,
CusProductStatus,
cusProductToProduct,
type EntitlementWithFeature,
type FullCusProduct,
type FullCustomer,
type Price,
ProrationBehavior,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { getCusPaymentMethod } from "../../../external/stripe/stripeCusUtils.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { handleRenewProduct } from "../attach/attachFunctions/handleRenewProduct.js";
import { handleScheduleFunction2 } from "../attach/attachFunctions/scheduleFlow/handleScheduleFlow2.js";
import { handleUpgradeFlow } from "../attach/attachFunctions/upgradeFlow/handleUpgradeFlow.js";
import { getDefaultAttachConfig } from "../attach/attachUtils/getAttachConfig.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js";
import {
activateDefaultProduct,
getFreeDefaultProductByGroup,
} from "../cusProducts/cusProductUtils.js";
export const handleCancelProduct = async ({
ctx,
cusProduct, // cus product to expire
fullCus,
expireImmediately = true,
prorate,
}: {
ctx: AutumnContext;
cusProduct: FullCusProduct;
fullCus: FullCustomer;
expireImmediately: boolean;
prorate: boolean;
}) => {
const { org, env, logger, features } = ctx;
logger.info("--------------------------------");
logger.info(
`🔔 Expiring cutomer product (${
expireImmediately ? "immediately" : "end of cycle"
})`,
);
logger.info(
`Customer: ${fullCus.id || fullCus.internal_id} (${env}), Org: ${org.id}`,
);
logger.info(
`Product: ${cusProduct.product.name}, Status: ${cusProduct.status}`,
);
const stripeCli = createStripeCli({ org, env });
// 1. Build attach params
if (cusProduct.status === CusProductStatus.Scheduled) {
const { curMainProduct } = getExistingCusProducts({
product: cusProduct.product,
cusProducts: fullCus.customer_products,
internalEntityId: cusProduct.internal_entity_id,
});
const product = cusProductToProduct({ cusProduct: curMainProduct! });
await handleRenewProduct({
ctx,
attachParams: {
stripeCli,
customer: fullCus,
org,
cusProducts: fullCus.customer_products,
products: [product],
internalEntityId: cusProduct.internal_entity_id || undefined,
paymentMethod: null,
prices: product.prices,
entitlements: product.entitlements,
freeTrial: product.free_trial || null,
optionsList: curMainProduct?.options || [],
replaceables: [],
entities: fullCus.entities,
features,
},
config: getDefaultAttachConfig(),
});
return;
}
// 2. If there's a scheduled product, throw error?
const isMain = !cusProduct.product.is_add_on;
const product = cusProductToProduct({ cusProduct });
const isFree = isFreeProduct(product.prices || []);
if (isMain) {
// Delete scheduled product
const { curScheduledProduct } = getExistingCusProducts({
product: product,
cusProducts: fullCus.customer_products,
internalEntityId: cusProduct.internal_entity_id,
});
console.log(
`Current scheduled product: ${curScheduledProduct?.product.name}`,
);
// Delete scheduled product.
if (curScheduledProduct) {
await CusProductService.delete({
db: ctx.db,
cusProductId: curScheduledProduct?.id,
});
}
}
// 2. If expire at cycle end, just cancel subscriptions
if (!expireImmediately && !isFree) {
const defaultProduct = await getFreeDefaultProductByGroup({
ctx,
productGroup: product.group,
});
let products = [product];
let prices: Price[] = [];
let entitlements: EntitlementWithFeature[] = [];
let skipInsertCusProduct = true;
if (
!isFreeProduct(product.prices) &&
!product.is_add_on &&
defaultProduct
) {
products = [defaultProduct];
prices = defaultProduct.prices;
entitlements = defaultProduct.entitlements;
skipInsertCusProduct = false;
}
await handleScheduleFunction2({
ctx,
attachParams: {
stripeCli,
customer: fullCus,
org,
cusProducts: fullCus.customer_products,
products,
internalEntityId: cusProduct.internal_entity_id || undefined,
paymentMethod: null,
prices,
entitlements,
freeTrial: null,
optionsList: [],
replaceables: [],
entities: fullCus.entities,
features,
fromCancel: true,
},
config: getDefaultAttachConfig(),
skipInsertCusProduct,
});
// Schedule default product...
return;
}
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: fullCus.processor?.id,
});
// Cancel product immediately
await handleUpgradeFlow({
ctx,
attachParams: {
stripeCli,
customer: fullCus,
org,
cusProduct,
cusProducts: fullCus.customer_products,
products: [],
internalEntityId: cusProduct.internal_entity_id || undefined,
paymentMethod,
prices: [],
entitlements: [],
freeTrial: null,
optionsList: [],
replaceables: [],
entities: fullCus.entities,
features,
fromCancel: true,
},
config: {
...getDefaultAttachConfig(),
proration: prorate
? ProrationBehavior.Immediately
: ProrationBehavior.None,
requirePaymentMethod: false,
},
branch: AttachBranch.Cancel,
});
// Activate default product
if (!product.is_add_on && !isOneOff(product.prices)) {
await activateDefaultProduct({
ctx,
productGroup: cusProduct.product.group,
fullCus,
curCusProduct: cusProduct,
});
}
};

View File

@@ -1,19 +0,0 @@
// 1. Get min next reset at cus ent
import type { Feature, FullCustomerEntitlement } from "@autumn/shared";
export const getMinNextResetAtCusEnt = ({
cusEnts,
feature,
}: {
cusEnts: FullCustomerEntitlement[];
feature: Feature;
}) => {
return cusEnts
.filter(
(cusEnt) => cusEnt.entitlement.internal_feature_id == feature.internal_id,
)
.reduce((min, cusEnt) => {
return Math.min(min, cusEnt.next_reset_at || Infinity);
}, Infinity);
};

View File

@@ -1,142 +0,0 @@
import {
type ApiEntityV1,
addToExpand,
CusExpand,
type EntityLegacyData,
type FullCustomer,
filterEntityLevelCustomerEntitlementsFromFullCustomer,
filterOutEntitiesFromFullCustomer,
} from "@autumn/shared";
import { redis } from "../../../../external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js";
import { getApiEntityBase } from "../../../entities/entityUtils/apiEntityUtils/getApiEntityBase.js";
import { getApiCustomerBase } from "../apiCusUtils/getApiCustomerBase.js";
/**
* Set customer cache in Redis with all entities
* This function builds the master customer cache (customer-level features only)
* and individual entity caches (entity-level features only)
*/
export const setCachedApiCustomer = async ({
ctx,
fullCus,
customerId,
source,
fetchTimeMs,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
customerId: string;
source?: string;
fetchTimeMs: number; // Timestamp when data was fetched from Postgres (for stale write prevention)
}) => {
const { org, env, logger } = ctx;
const ctxWithExpand = addToExpand({
ctx,
add: [
CusExpand.BalancesFeature,
CusExpand.SubscriptionsPlan,
CusExpand.Invoices,
CusExpand.ScheduledSubscriptionsPlan,
],
});
// Build master api customer (customer-level features only)
const { apiCustomer: masterApiCustomer, legacyData } =
await getApiCustomerBase({
ctx: ctxWithExpand,
fullCus: filterOutEntitiesFromFullCustomer({ fullCus }),
withAutumnId: true,
});
// Build entity api customers (entity-level features only)
const filteredFullCus = filterEntityLevelCustomerEntitlementsFromFullCustomer(
{
fullCustomer: fullCus,
},
);
// Build entities first
const entityBatch: {
entityId: string;
entityData: ApiEntityV1 & { legacyData: EntityLegacyData };
}[] = [];
const entityFullCus = {
...filteredFullCus,
customer_products: filteredFullCus.customer_products,
};
for (const entity of fullCus.entities) {
const { apiEntity, legacyData: entityLegacyData } = await getApiEntityBase({
ctx: ctxWithExpand,
fullCus: entityFullCus,
entity,
withAutumnId: true,
});
entityBatch.push({
entityId: entity.id,
entityData: {
...apiEntity,
legacyData: entityLegacyData,
},
});
}
// Then write to Redis
const masterApiCustomerData = {
...masterApiCustomer,
entities: fullCus.entities.filter((e) => e.id !== null),
legacyData,
};
if (masterApiCustomerData.id === null) return;
// console.log(
// `Setting cached api customer ${customerId}, masterApiCustomerData: `,
// masterApiCustomerData,
// );
const result = await tryRedisWrite(async () => {
return redis.setCustomer(
JSON.stringify(masterApiCustomerData),
org.id,
env,
customerId,
fetchTimeMs.toString(),
);
});
if (result === "CACHE_EXISTS") {
logger.info(
`Cache already exists for customer ${customerId}, source: ${source}`,
);
return;
}
if (result === "STALE_WRITE") {
logger.info(
`Stale write blocked for customer ${customerId}, source: ${source}`,
);
return;
}
// Write entity caches (only if customer cache was written)
const filteredEntityBatch = entityBatch.filter(
(e) => e.entityData.id !== null,
);
if (filteredEntityBatch.length > 0) {
await tryRedisWrite(async () => {
return redis.setEntitiesBatch(
JSON.stringify(filteredEntityBatch),
org.id,
env,
);
});
}
logger.info(`Set cached api customer ${customerId}, source: ${source}`);
};

View File

@@ -1,50 +0,0 @@
import type { FullCustomer } from "@autumn/shared";
import { redis } from "../../../../external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js";
import { InvoiceService } from "../../../invoices/InvoiceService.js";
import { invoicesToResponse } from "../../../invoices/invoiceUtils.js";
/**
* Set customer invoices cache in Redis with all entities
* This function updates only the invoices array in the customer cache (customer-level invoices only)
* and individual entity caches (entity-level invoices only)
*/
export const setCachedApiInvoices = async ({
ctx,
fullCus,
customerId,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
customerId: string;
}) => {
const { org, env, logger, db } = ctx;
// Get customer-level invoices (no entity or null entity)
const invoices = fullCus.invoices
? fullCus.invoices
: await InvoiceService.list({
db,
internalCustomerId: fullCus.internal_id,
limit: 10,
});
// Build master api customer invoices (customer-level only)
const masterApiInvoices = invoicesToResponse({
invoices,
});
// Then write to Redis
await tryRedisWrite(async () => {
await redis.setInvoices(
JSON.stringify(masterApiInvoices),
org.id,
env,
customerId,
);
logger.info(
`Updated customer invoices cache for customer ${customerId} (${masterApiInvoices.length} invoices)`,
);
});
};

View File

@@ -1,102 +0,0 @@
import {
addToExpand,
CusExpand,
type FullCustomer,
filterCusProductsByEntity,
filterOutEntitiesFromFullCustomer,
} from "@autumn/shared";
import { redis } from "../../../../external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js";
import { getApiSubscriptions } from "../apiCusUtils/getApiSubscription/getApiSubscriptions.js";
/**
* Set customer subscriptions cache in Redis with all entities
* This function updates subscriptions and scheduled_subscriptions arrays in the customer cache (customer-level only)
* and individual entity caches (entity-level only)
*/
export const setCachedApiSubs = async ({
ctx,
fullCus,
customerId,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
customerId: string;
}) => {
const { org, env, logger } = ctx;
// Build master api customer subscriptions (customer-level products only)
const ctxWithExpand = addToExpand({
ctx,
add: [CusExpand.SubscriptionsPlan, CusExpand.ScheduledSubscriptionsPlan],
});
const { data: masterApiSubs } = await getApiSubscriptions({
ctx: ctxWithExpand,
fullCus: filterOutEntitiesFromFullCustomer({ fullCus }),
});
// Split subscriptions by status
const activeSubscriptions = masterApiSubs.filter(
(s) => s.status === "active",
);
const scheduledSubscriptions = masterApiSubs.filter(
(s) => s.status === "scheduled",
);
// console.log(`Updating api subs for customer ${customerId}`, masterApiSubs);
// Then write to Redis
await tryRedisWrite(async () => {
// Update customer subscriptions and scheduled_subscriptions
await redis.setSubscriptions(
JSON.stringify(activeSubscriptions),
JSON.stringify(scheduledSubscriptions),
org.id,
env,
customerId,
);
logger.info(
`Updated customer subscriptions cache for customer ${customerId} (${activeSubscriptions.length} active, ${scheduledSubscriptions.length} scheduled)`,
);
// Update entity subscriptions
for (const entity of fullCus.entities) {
// Filter customer products for this specific entity
const entityCusProducts = filterCusProductsByEntity({
cusProducts: fullCus.customer_products,
entity,
org,
});
const { data: entitySubscriptions } = await getApiSubscriptions({
ctx: ctxWithExpand,
fullCus: {
...fullCus,
customer_products: entityCusProducts,
entity, // Set entity for entity-specific balance calculations
},
});
// Split entity subscriptions by status
const entityActiveSubscriptions = entitySubscriptions.filter(
(s) => s.status === "active",
);
const entityScheduledSubscriptions = entitySubscriptions.filter(
(s) => s.status === "scheduled",
);
await redis.setEntityProducts(
JSON.stringify(entityActiveSubscriptions),
JSON.stringify(entityScheduledSubscriptions),
org.id,
env,
customerId,
entity.id,
);
logger.info(
`Updated entity subscriptions cache for entity ${entity.id} (${entityActiveSubscriptions.length} active, ${entityScheduledSubscriptions.length} scheduled)`,
);
}
});
};

View File

@@ -1,130 +0,0 @@
import {
type FullCustomer,
filterEntityLevelCustomerEntitlementsFromFullCustomer,
filterOutEntitiesFromFullCustomer,
} from "@autumn/shared";
import { redis } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { getApiBalances } from "../apiCusUtils/getApiBalance/getApiBalances.js";
type BalancesPayload = Record<
string,
{
granted_balance: number;
breakdown?: Array<{ id: string; granted_balance: number }>;
}
>;
type EntityBatchItem = {
entityId: string;
balances: BalancesPayload;
};
/**
* Update granted_balance in Redis cache for all features
* This is used after updateGrantedBalance to keep Redis in sync without clearing the entire cache
*
* Updates both:
* 1. Customer-level cache (customer-level products only)
* 2. Entity caches in batch (entity-level products only)
*/
export const setCachedGrantedBalance = async ({
ctx,
fullCus,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
}) => {
const { org, env, logger } = ctx;
const customerId = fullCus.id;
if (!customerId) {
logger.debug("[setCachedGrantedBalance] No customer ID, skipping");
return;
}
// ============================================================================
// 1. Build customer-level balances payload (customer-level products only)
// ============================================================================
const filteredFullCus = filterOutEntitiesFromFullCustomer({
fullCus,
});
const customerLevelCusProducts = filteredFullCus.customer_products;
const { data: customerBalances } = await getApiBalances({
ctx,
fullCus: {
...fullCus,
customer_products: customerLevelCusProducts,
},
});
const customerBalancesPayload: BalancesPayload = {};
for (const [featureId, balance] of Object.entries(customerBalances)) {
customerBalancesPayload[featureId] = {
granted_balance: balance.granted_balance,
breakdown: balance.breakdown?.map((bd) => ({
id: bd.id,
granted_balance: bd.granted_balance,
})),
};
}
// ============================================================================
// 2. Build entity balances batch (entity-level products only)
// ============================================================================
const entityLevelCusProducts =
filterEntityLevelCustomerEntitlementsFromFullCustomer({
fullCustomer: fullCus,
}).customer_products;
const entityBatch: EntityBatchItem[] = [];
for (const entity of fullCus.entities) {
const { data: entityBalances } = await getApiBalances({
ctx,
fullCus: {
...fullCus,
customer_products: entityLevelCusProducts,
entity,
},
});
const entityBalancesPayload: BalancesPayload = {};
for (const [featureId, balance] of Object.entries(entityBalances)) {
entityBalancesPayload[featureId] = {
granted_balance: balance.granted_balance,
breakdown: balance.breakdown?.map((bd) => ({
id: bd.id,
granted_balance: bd.granted_balance,
})),
};
}
if (Object.keys(entityBalancesPayload).length > 0) {
entityBatch.push({
entityId: entity.id,
balances: entityBalancesPayload,
});
}
}
// ============================================================================
// 3. Write to Redis in a single call
// ============================================================================
await tryRedisWrite(async () => {
return redis.setGrantedBalance(
org.id,
env,
customerId,
JSON.stringify(customerBalancesPayload),
JSON.stringify(entityBatch),
);
});
logger.debug(
`[setCachedGrantedBalance] Updated granted_balance for customer (${Object.keys(customerBalancesPayload).length} features) and ${entityBatch.length} entities`,
);
};

View File

@@ -1,67 +0,0 @@
import { logger } from "@/external/logtail/logtailUtils.js";
import { redis } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
/**
* Builds the test cache delete guard key (matches Lua function)
*/
const buildTestCacheDeleteGuardKey = ({
orgId,
env,
customerId,
}: {
orgId: string;
env: string;
customerId: string;
}) => `{${orgId}}:${env}:test_cache_delete_guard:${customerId}`;
/**
* Sets a test cache delete guard to prevent cache deletion during testing.
* When this guard exists, deleteCustomer.lua will skip deletion.
*/
export const setTestCacheDeleteGuard = async ({
ctx,
customerId,
ttlMs = 60000, // Default 60 seconds
}: {
ctx: AutumnContext;
customerId: string;
ttlMs?: number;
}): Promise<boolean> => {
const key = buildTestCacheDeleteGuardKey({
orgId: ctx.org.id,
env: ctx.env,
customerId,
});
try {
await redis.set(key, "1", "PX", ttlMs);
return true;
} catch (error) {
logger.error(`Failed to set test cache delete guard: ${error}`);
return false;
}
};
/**
* Removes the test cache delete guard.
*/
export const removeTestCacheDeleteGuard = async ({
ctx,
customerId,
}: {
ctx: AutumnContext;
customerId: string;
}): Promise<boolean> => {
const key = buildTestCacheDeleteGuardKey({
orgId: ctx.org.id,
env: ctx.env,
customerId,
});
try {
await redis.del(key);
return true;
} catch (error) {
logger.error(`Failed to remove test cache delete guard: ${error}`);
return false;
}
};

View File

@@ -1,72 +0,0 @@
import {
type FullCusEntWithFullCusProduct,
type FullCustomer,
filterEntityProductCusEnts,
filterOutEntityCusEnts,
filterPerEntityCusEnts,
getCusEntBalance,
sumValues,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { RequestContext } from "../../../../../honoUtils/HonoEnv.js";
export const cusEntsToEntityBreakdown = ({
ctx,
fullCus,
cusEnts,
}: {
ctx: RequestContext;
cusEnts: FullCusEntWithFullCusProduct[];
fullCus: FullCustomer;
}) => {
if (fullCus.entity) return undefined; // We don't need to show entity breakdown for a single entity.
// Entity breakdown.
const masterBalance = sumValues(
filterOutEntityCusEnts({ cusEnts }).map((ce) => {
const { balance } = getCusEntBalance({
cusEnt: ce,
});
return balance;
}),
);
const entityBalances: Record<string, number> = {};
const perEntityCusEnts = filterPerEntityCusEnts({ cusEnts });
for (const cusEnt of perEntityCusEnts) {
for (const entityId in cusEnt.entities) {
if (!entityBalances[entityId]) {
entityBalances[entityId] = 0;
}
entityBalances[entityId] = new Decimal(entityBalances[entityId])
.add(cusEnt.entities[entityId].balance)
.toNumber();
}
}
const entityProductCusEnts = filterEntityProductCusEnts({ cusEnts });
for (const cusEnt of entityProductCusEnts) {
const entityId =
fullCus.entities.find(
(e) => e.internal_id === cusEnt.customer_product?.internal_entity_id,
)?.id || cusEnt.customer_product?.entity_id;
if (!entityId) continue;
if (!entityBalances[entityId]) {
entityBalances[entityId] = 0;
}
entityBalances[entityId] = new Decimal(entityBalances[entityId])
.add(cusEnt.balance ?? 0)
.toNumber();
}
if (Object.keys(entityBalances).length === 0) return undefined;
return {
master: masterBalance,
entities: sumValues(Object.values(entityBalances)),
};
};

View File

@@ -1,111 +0,0 @@
import type { ApiCusUpcomingInvoice } from "@autumn/shared";
import {
type AppEnv,
CusExpand,
type FullCustomer,
type Organization,
stripeToAtmnAmount,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { lineItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { stripeDiscountToResponse } from "./stripeDiscountToResponse.js";
export const getCusUpcomingInvoice = async ({
db,
org,
env,
fullCus,
expand,
}: {
db: DrizzleCli;
org: Organization;
env: AppEnv;
fullCus: FullCustomer;
expand: CusExpand[];
}) => {
if (!expand.includes(CusExpand.UpcomingInvoice)) return undefined;
const subIds = fullCus.customer_products.flatMap(
(cp) => cp.subscription_ids || [],
);
if (subIds.length === 0) return null;
const stripeCli = createStripeCli({ org, env });
const subs = await getStripeSubs({
stripeCli,
subIds,
});
const sub = subs.reduce((acc, sub) => {
const curSubPeriodEnd = getEarliestPeriodEnd({ sub });
const nextSubPeriodEnd = getEarliestPeriodEnd({ sub });
return nextSubPeriodEnd < curSubPeriodEnd ? sub : acc;
}, subs[0]);
const upcomingInvoice = await stripeCli.invoices.createPreview({
customer: fullCus.processor?.id,
subscription: sub.id,
expand: ["discounts.source.coupon"],
});
const lines = [];
for (const line of upcomingInvoice.lines.data) {
const cusProd = fullCus.customer_products.find((cp) =>
lineItemInCusProduct({ cusProduct: cp, lineItem: line }),
);
const atmnLineAmount = stripeToAtmnAmount({
amount: line.amount,
currency: line.currency,
});
lines.push({
product_id: cusProd?.product.id || null,
description: line.description || "",
amount: atmnLineAmount,
});
}
const stripeDiscounts = upcomingInvoice.discounts.filter(
(d): d is Stripe.Discount =>
typeof d === "object" && d !== null && "coupon" in d,
) as Stripe.Discount[];
// Get reward in IDs
const discounts = stripeDiscounts
.map((d) =>
stripeDiscountToResponse({
discount: d,
totalDiscountAmounts:
upcomingInvoice.total_discount_amounts || undefined,
}),
)
.filter((d) => d !== null);
const atmnSubtotal = stripeToAtmnAmount({
amount: upcomingInvoice.subtotal,
currency: upcomingInvoice.currency,
});
const atmnTotal = stripeToAtmnAmount({
amount: upcomingInvoice.total,
currency: upcomingInvoice.currency,
});
const res: ApiCusUpcomingInvoice = {
lines,
discounts,
subtotal: atmnSubtotal,
total: atmnTotal,
currency: upcomingInvoice.currency,
};
return res;
};

View File

@@ -1,72 +0,0 @@
import {
CouponDurationType,
RewardType,
stripeToAtmnAmount,
} from "@autumn/shared";
import type Stripe from "stripe";
const parseStripeCouponDuration = (coupon: Stripe.Coupon) => {
let duration_type: CouponDurationType = CouponDurationType.OneOff;
let duration_value: number = 0;
if (coupon.duration === "forever") {
duration_type = CouponDurationType.Forever;
} else if (coupon.duration === "once") {
duration_type = CouponDurationType.OneOff;
} else if (coupon.duration === "repeating") {
duration_type = CouponDurationType.Months;
duration_value = coupon.duration_in_months || 0;
} else {
duration_type = CouponDurationType.OneOff;
}
return {
duration_type,
duration_value,
};
};
export const stripeDiscountToResponse = ({
discount,
totalDiscountAmounts,
}: {
discount: Stripe.Discount;
totalDiscountAmounts?: Stripe.Invoice.TotalDiscountAmount[];
}) => {
const d = discount;
const coupon = d.source.coupon;
if (!coupon || typeof coupon === "string") {
return null;
}
const { duration_type, duration_value } = parseStripeCouponDuration(coupon);
const totalDiscountAmount = totalDiscountAmounts?.find(
(t) => t.discount === d.id,
);
const totalAtmnDiscountAmount = stripeToAtmnAmount({
amount: totalDiscountAmount?.amount || 0,
currency: coupon.currency ?? undefined,
});
return {
id: coupon.id,
name: coupon.name ?? "",
type: coupon.amount_off
? RewardType.FixedDiscount
: RewardType.PercentageDiscount,
discount_value: coupon.amount_off || coupon.percent_off || 0,
currency: coupon.currency ?? null,
start: d.start ?? null,
end: d.end ?? null,
// subscription_id: d.subscription ?? null,
duration_type,
duration_value,
total_discount_amount: totalDiscountAmount?.amount
? totalAtmnDiscountAmount
: null,
};
};

View File

@@ -1,105 +0,0 @@
import type {
Feature,
Organization,
ProductItem,
ProductV2,
} from "@autumn/shared";
import { getFeatureNameWithCapital } from "@/internal/features/utils/displayUtils.js";
import {
featurePricetoPricecnItem,
getPriceText,
} from "@/internal/products/pricecn/pricecnUtils.js";
import {
isFeatureItem,
isFeaturePriceItem,
isPriceItem,
} from "@/internal/products/product-items/productItemUtils/getItemType.js";
import { notNullish } from "@/utils/genUtils.js";
import { formatCurrency, formatTiers } from "./previewUtils.js";
export const getProductChargeText = ({
product,
org,
features,
}: {
product: ProductV2;
org: Organization;
features: Feature[];
}) => {
const basePrices = product.items.filter((i) => isPriceItem(i));
const total = basePrices.reduce((acc, curr) => acc + curr.price!, 0);
const itemStrs = [];
if (total > 0) {
itemStrs.push(
formatCurrency({
amount: total,
defaultCurrency: org.default_currency!,
}),
);
}
const prepaidPrices = product.items.filter(
(i) => isFeaturePriceItem(i) && i.usage_model == "prepaid",
);
const prepaidStrings = prepaidPrices.map((i) => {
const feature = features.find((f) => f.id === i.feature_id);
const priceStr = formatTiers({
tiers: i.tiers!,
org,
});
const featureStr =
i.billing_units && i.billing_units > 1
? `${i.billing_units} ${feature?.name}`
: feature?.name;
return `${priceStr} / ${featureStr}`;
});
return [...itemStrs, ...prepaidStrings];
};
export const getItemDescription = ({
item,
features,
product,
org,
}: {
item: ProductItem;
features: Feature[];
product: ProductV2;
org: Organization;
}) => {
const prices = product.items.filter((i) => !isFeatureItem(i));
const priceStr = getPriceText({
item,
org,
});
if (isPriceItem(item)) {
const baseName =
prices.length == 1
? product.name
: notNullish(item.interval)
? "Subscription"
: "One-time";
return baseName;
} else {
const feature = features.find((f) => f.id === item.feature_id);
// let pricecnItem = featurePricetoPricecnItem({
// feature,
// item,
// org,
// });
// // let combinedStr = pricecnItem.primaryText + " " + pricecnItem.secondaryText;
// // combinedStr = `${feature?.name} - ${combinedStr}`;
// // if (item.usage_model == "pay_per_use") {
// // combinedStr = `${combinedStr}`;
// // }
return `${getFeatureNameWithCapital({ feature: feature! })}`;
}
};

View File

@@ -1,89 +0,0 @@
import type { Organization, PriceTier } from "@autumn/shared";
export const formatCurrency = ({
amount,
defaultCurrency,
}: {
amount: number;
defaultCurrency?: string;
}) => {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: defaultCurrency || "usd",
});
return formatter.format(amount);
};
export const formatTiers = ({
tiers,
org,
}: {
tiers: PriceTier[];
org: Organization;
}) => {
if (tiers.length == 1) {
return formatCurrency({
amount: tiers[0].amount,
defaultCurrency: org.default_currency!,
});
}
const tiersStart = formatCurrency({
amount: tiers[0].amount,
defaultCurrency: org.default_currency!,
});
const tiersEnd = formatCurrency({
amount: tiers[tiers.length - 1].amount,
defaultCurrency: org.default_currency!,
});
return `${tiersStart} - ${tiersEnd}`;
};
export const getItemsHtml = ({
items,
org,
}: {
items: any[];
org: Organization;
}) => {
let html = "";
const pricedItems = items.filter((item) => item.amount != 0);
const totalAmount = pricedItems.reduce((acc: number, item: any) => {
return acc + item.amount;
}, 0);
if (pricedItems.length == 1) {
html += `<br/><p style="font-size: 1.1em;"><strong>${formatCurrency({
amount: totalAmount,
defaultCurrency: org.default_currency!,
})}</strong></p>`;
} else {
html += `<br/><ul>${itemsToHtml({ items: pricedItems })}</ul>`;
html += `<br/><p style="font-size: 1.1em;">Total: ${formatCurrency({
amount: totalAmount,
defaultCurrency: org.default_currency!,
})}</p>`;
}
return html;
};
export const itemsToHtml = ({ items }: { items: any[] }) => {
let html = "";
for (const item of items) {
if (item.amount == 0) {
continue;
}
html += `<li>- ${item.name || item.description}: ${formatCurrency({
amount: item.amount,
defaultCurrency: item.currency,
})}</li>`;
}
return html;
};

View File

@@ -1,51 +0,0 @@
import { MigrationJobStep, type Organization } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { sendTextEmail } from "@/external/resend/resendUtils.js";
import { safeResend } from "@/external/resend/safeResend.js";
import { MigrationService } from "../migrations/MigrationService.js";
import { FROM_AUTUMN } from "./constants.js";
export const sendMigrationEmail = safeResend({
fn: async ({
db,
migrationJobId,
org,
}: {
db: DrizzleCli;
migrationJobId: string;
org: Organization;
}) => {
const migrationJob = await MigrationService.getJob({
db,
id: migrationJobId,
});
// Send email
const getCustomersStep =
migrationJob.step_details[MigrationJobStep.GetCustomers];
const migrateStep =
migrationJob.step_details[MigrationJobStep.MigrateCustomers];
console.log("Sending migration email");
await sendTextEmail({
from: FROM_AUTUMN,
to: "johnyeocx@gmail.com",
subject: `Migration Job Finished -- ${migrationJob.id}`,
body: `
ORG: ${org.id}, ${org.slug}
Step: Get migration customers
1. Total customers: ${getCustomersStep?.total_customers}
2. Canceled customers: ${getCustomersStep?.canceled_customers}
Step: Migrate customers
1. Number of errors: ${migrateStep?.num_errors}
2. Failed customers:
${migrateStep?.failed_customers}
`,
});
},
action: "send migration email",
});

View File

@@ -1,4 +1,4 @@
import OTPEmail from "@emails/OTPEmail.js";
import OTPEmail from "@emails/otpemail.js";
import { logger } from "@/external/logtail/logtailUtils.js";
import { createResendCli } from "@/external/resend/resendUtils.js";
import { FROM_AUTUMN } from "./constants.js";

View File

@@ -1,108 +0,0 @@
import {
type ApiEntityV1,
ApiEntityV1Schema,
type EntityLegacyData,
EntityNotFoundError,
type FullCustomer,
filterEntityLevelCustomerEntitlementsFromFullCustomer,
filterPlanAndFeatureExpand,
} from "@autumn/shared";
import { CACHE_CUSTOMER_VERSION } from "@lua/cacheConfig.js";
import type { Redis } from "ioredis";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getOrSetCachedFullCustomer } from "../../../customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js";
import { getApiEntityBase } from "../apiEntityUtils/getApiEntityBase.js";
export const buildCachedApiEntityKey = ({
entityId,
customerId,
orgId,
env,
}: {
entityId: string;
customerId: string;
orgId: string;
env: string;
}) => {
return `{${orgId}}:${env}:customer:${CACHE_CUSTOMER_VERSION}:${customerId}:entity:${entityId}`;
};
/**
* Get ApiEntity from Redis cache
* If not found, fetch from DB, cache it, and return
* If skipCache is true, always fetch from DB
*/
export const getCachedApiEntity = async ({
ctx,
customerId,
entityId,
skipCustomerMerge = false,
fullCus,
redisInstance: _redisInstance,
cacheVersion: _cacheVersion,
}: {
ctx: AutumnContext;
customerId: string;
entityId: string;
skipCustomerMerge?: boolean; // If true, returns only entity's own features (no customer merging)
fullCus?: FullCustomer;
redisInstance?: Redis; // Kept for backwards compatibility
cacheVersion?: string; // Kept for backwards compatibility
}): Promise<{ apiEntity: ApiEntityV1; legacyData: EntityLegacyData }> => {
const getExpandedApiEntity = async () => {
// Get FullCustomer from cache (reads from same cache that track Lua script updates)
// Falls back to DB if cache miss
if (!fullCus) {
fullCus = await getOrSetCachedFullCustomer({
ctx,
customerId,
entityId,
source: "getCachedApiEntity",
});
fullCus.entity = fullCus.entities.find((e) => e.id === entityId);
}
const entity = fullCus.entity;
if (!entity) {
throw new EntityNotFoundError({ entityId });
}
// Build ApiEntity with full products for return
const { apiEntity, legacyData } = await getApiEntityBase({
ctx,
entity,
fullCus: fullCus,
withAutumnId: true,
});
const { apiEntity: pureApiEntity } = await getApiEntityBase({
ctx,
entity,
fullCus: filterEntityLevelCustomerEntitlementsFromFullCustomer({
fullCustomer: fullCus,
}),
withAutumnId: true,
});
return {
apiEntity: ApiEntityV1Schema.parse(
skipCustomerMerge ? pureApiEntity : apiEntity,
),
legacyData,
};
};
const { apiEntity, legacyData } = await getExpandedApiEntity();
const filteredApiEntity = filterPlanAndFeatureExpand<ApiEntityV1>({
expand: ctx.expand,
target: apiEntity,
});
return {
apiEntity: filteredApiEntity,
legacyData,
};
};

View File

@@ -1,48 +0,0 @@
import { type Feature, getFeatureName } from "@autumn/shared";
import { AppEnv, Entity } from "autumn-js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { EntityService } from "@/internal/api/entities/EntityService.js";
export const getEntityInvoiceDescription = async ({
db,
internalEntityId,
features,
logger,
}: {
db: DrizzleCli;
internalEntityId: string;
features: Feature[];
logger: any;
}) => {
try {
const entity = await EntityService.getByInternalId({
db,
internalId: internalEntityId,
});
const feature = features.find(
(f) => f.internal_id == entity?.internal_feature_id,
);
let entDetails = "";
if (entity.name) {
entDetails = `${entity.name}${entity.id ? ` (ID: ${entity.id})` : ""}`;
} else if (entity.id) {
entDetails = `${entity.id}`;
}
if (feature && entDetails) {
const featureName = getFeatureName({
feature,
plural: false,
capitalize: true,
});
return `${featureName}: ${entDetails}`;
}
return "";
} catch (error) {
logger.error(`Failed to get entity invoice description`, { error });
return "";
}
};

View File

@@ -1,101 +0,0 @@
import type { Entity } from "@autumn/shared";
import { notNullish, nullish } from "@/utils/genUtils.js";
export const logEntityToAction = ({
entityToAction,
logger,
}: {
entityToAction: any;
logger: any;
}) => {
for (const id in entityToAction) {
logger.info(
`${id} - ${entityToAction[id].action}${
entityToAction[id].replace
? ` (replace ${
entityToAction[id].replace.id ||
entityToAction[id].replace.internal_id
})`
: ""
}`,
);
}
};
// Probably going to change
export const getEntityToAction = ({
inputEntities,
existingEntities,
feature,
logger,
}: {
inputEntities: any[];
existingEntities: Entity[];
feature: any;
logger: any;
}) => {
const entityToAction: any = {};
let createCount = 0;
const replacedEntities: string[] = [];
for (const inputEntity of inputEntities) {
const curEntity = existingEntities.find(
(e: any) => e.id === inputEntity.id,
);
if (curEntity && curEntity.deleted) {
entityToAction[inputEntity.id] = {
action: "replace",
replace: curEntity,
entity: inputEntity,
};
replacedEntities.push(curEntity.id);
continue;
}
let replaced = false;
for (const entity of existingEntities) {
if (entity.deleted && !replacedEntities.includes(entity.id)) {
replaced = true;
replacedEntities.push(entity.id);
entityToAction[inputEntity.id] = {
action: "replace",
replace: entity,
entity: inputEntity,
};
break;
}
// If there's an entity with null ID and cur input entity has an ID, fill it up!
if (
nullish(entity.id) &&
notNullish(inputEntity.id) &&
!replacedEntities.includes(entity.internal_id) &&
entity.feature_id === feature.id
) {
entityToAction[inputEntity.id] = {
action: "replace",
replace: entity,
entity: inputEntity,
};
replacedEntities.push(entity.internal_id);
replaced = true;
break;
}
}
if (!replaced) {
entityToAction[inputEntity.id] = {
action: "create",
entity: inputEntity,
};
createCount++;
}
}
logEntityToAction({ entityToAction, logger });
return entityToAction;
};

View File

@@ -1,66 +0,0 @@
import {
ApiFeatureType,
type ApiFeatureV0,
type AppEnv,
FeatureType,
type FeatureUsageType,
} from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js";
import {
constructBooleanFeature,
constructCreditSystem,
constructMeteredFeature,
} from "./constructFeatureUtils.js";
export const fromApiFeature = ({
apiFeature,
orgId,
env,
}: {
apiFeature: ApiFeatureV0;
orgId: string;
env: AppEnv;
}) => {
const isMetered =
apiFeature.type === ApiFeatureType.SingleUsage ||
apiFeature.type === ApiFeatureType.ContinuousUse;
const featureType: FeatureType = isMetered
? FeatureType.Metered
: (apiFeature.type as unknown as FeatureType);
if (isMetered) {
return constructMeteredFeature({
featureId: apiFeature.id,
name: apiFeature.name || "",
usageType: apiFeature.type as unknown as FeatureUsageType,
orgId,
env,
});
}
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",
statusCode: 400,
});
}
return constructCreditSystem({
featureId: apiFeature.id,
name: apiFeature.name || "",
orgId,
env,
schema: apiFeature.credit_schema!,
});
}
return constructBooleanFeature({
featureId: apiFeature.id,
name: apiFeature.name || "",
orgId,
env,
});
};

View File

@@ -1,159 +0,0 @@
import {
AggregateType,
AllowanceType,
BillingInterval,
EntInterval,
// DB Models
entitlements,
FeatureType,
FeatureUsageType,
features,
PriceType,
prices,
products,
} from "@autumn/shared";
import { AppEnv } from "autumn-js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { generateId, keyToTitle } from "@/utils/genUtils.js";
const defaultFeatures = [
{
internal_id: "",
id: "pro_analytics",
type: FeatureType.Boolean,
display: {
singular: "pro analytics",
plural: "pro analytics",
},
},
{
internal_id: "",
id: "chat_messages",
type: FeatureType.Metered,
config: {
filters: [
{
value: ["chat_messages"],
property: "",
operator: "",
},
],
aggregate: {
type: AggregateType.Count,
},
usage_type: FeatureUsageType.Single,
display: {
singular: "chat message",
plural: "chat messages",
},
},
},
];
export const createOnboardingProducts = async ({
db,
orgId,
}: {
db: DrizzleCli;
orgId: string;
}) => {
const env = AppEnv.Sandbox;
const insertedFeatures = defaultFeatures.map((f) => ({
...f,
org_id: orgId,
env,
internal_id: generateId("fe"),
name: keyToTitle(f.id),
created_at: Date.now(),
}));
await db.insert(features).values(insertedFeatures as any);
const defaultProducts = [
{
id: "free_example",
name: "Free (Example)",
env: AppEnv.Sandbox,
is_default: true,
entitlements: [
{
internal_feature_id: insertedFeatures[1].internal_id,
feature_id: insertedFeatures[1].id,
allowance: 10,
interval: EntInterval.Month,
allowance_type: AllowanceType.Fixed,
},
],
prices: [],
},
{
id: "pro_example",
name: "Pro (Example)",
env: AppEnv.Sandbox,
is_default: false,
entitlements: [
{
internal_feature_id: insertedFeatures[0].internal_id,
feature_id: insertedFeatures[0].id,
},
{
internal_feature_id: insertedFeatures[1].internal_id,
feature_id: insertedFeatures[1].id,
allowance_type: AllowanceType.Unlimited,
},
],
prices: [
{
name: "Monthly",
config: {
type: PriceType.Fixed,
amount: 20.5,
interval: BillingInterval.Month,
},
},
],
},
];
const batchInsert = [];
for (const product of defaultProducts) {
const insertProduct = async (product: any) => {
const internalProductId = generateId("pr");
await db.insert(products).values({
...product,
internal_id: internalProductId,
org_id: orgId,
env,
group: "",
is_add_on: false,
created_at: Date.now(),
version: 1,
});
for (const entitlement of product.entitlements) {
await db.insert(entitlements).values({
...entitlement,
id: generateId("en"),
org_id: orgId,
env,
created_at: Date.now(),
});
}
for (const price of product.prices) {
await db.insert(prices).values({
...price,
id: generateId("pr"),
internal_product_id: internalProductId,
created_at: Date.now(),
org_id: orgId,
});
}
};
batchInsert.push(insertProduct(product));
}
await Promise.all(batchInsert);
};

View File

@@ -1,104 +0,0 @@
import {
AggregateType,
AppEnv,
ChatFeatureCreditSchema,
type ChatResultFeature,
type CreditSystemConfig,
FeatureType,
FeatureUsageType,
type MeteredConfig,
} from "@autumn/shared";
import { validateMeteredConfig } from "@/internal/features/featureUtils.js";
import { constructFeature } from "@/internal/features/utils/constructFeatureUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { keyToTitle } from "@/utils/genUtils.js";
const validateFeatures = (features: ChatResultFeature[]) => {
features.forEach((feature) => {
if (feature.type == "credit_system") {
if (!feature.credit_schema) {
throw new RecaseError({
message: "Credit schema is required for credit system",
code: "invalid_chat_feature",
statusCode: 400,
});
} else {
feature.credit_schema.forEach((item) => {
if (!ChatFeatureCreditSchema.safeParse(item).success) {
throw new RecaseError({
message: "Invalid credit schema",
code: "invalid_chat_feature",
statusCode: 400,
});
}
const meteredFeature = features.some(
(m) => m.id == item.metered_feature_id && m.id != feature.id,
);
if (!meteredFeature) {
throw new RecaseError({
message: `Metered feature ${item.metered_feature_id} not found`,
code: "invalid_chat_feature",
statusCode: 400,
});
}
});
}
}
});
};
export const parseChatResultFeatures = ({
features,
orgId,
}: {
features: ChatResultFeature[];
orgId: string;
}) => {
validateFeatures(features);
return features.map((feature) => {
const type =
feature.type == "boolean"
? FeatureType.Boolean
: feature.type == "credit_system"
? FeatureType.CreditSystem
: FeatureType.Metered;
let config: CreditSystemConfig | MeteredConfig | undefined;
if (type == FeatureType.CreditSystem) {
config = {
schema: feature.credit_schema!.map((item) => ({
feature_amount: 1,
metered_feature_id: item.metered_feature_id,
credit_amount: item.credit_cost,
})),
usage_type: FeatureUsageType.Single,
};
} else if (type == FeatureType.Metered) {
config = validateMeteredConfig({
usage_type: feature.type as FeatureUsageType,
filters: [
{
property: "",
operator: "",
value: [],
},
],
aggregate: { type: AggregateType.Sum, property: "value" },
});
}
const backendFeat = constructFeature({
id: feature.id,
name: keyToTitle(feature.id),
type,
env: AppEnv.Sandbox,
config,
orgId: orgId,
display: feature.display,
});
return backendFeat;
});
};

View File

@@ -1,70 +0,0 @@
import {
AppEnv,
CreateProductV2ParamsSchema,
type Entitlement,
type Feature,
type Price,
type Product,
type ProductV2,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { constructProduct } from "@/internal/products/productUtils.js";
export const parseChatProducts = async ({
db,
logger,
features,
orgId,
chatProducts,
}: {
db: DrizzleCli;
logger: any;
features: Feature[];
orgId: string;
chatProducts: ProductV2[];
}) => {
const products: Product[] = [];
const allPrices: Price[] = [];
const allEnts: Entitlement[] = [];
let currentFeatures = features;
for (const product of chatProducts) {
const backendProduct: Product = constructProduct({
productData: CreateProductV2ParamsSchema.parse({
...product,
}),
orgId,
env: AppEnv.Sandbox,
});
const {
prices,
entitlements,
features: updatedFeatures,
} = await handleNewProductItems({
db,
curPrices: [],
curEnts: [],
newItems: product.items,
product: backendProduct,
features: currentFeatures,
saveToDb: false,
isCustom: false,
logger,
});
currentFeatures = updatedFeatures;
products.push(backendProduct);
allPrices.push(...prices);
allEnts.push(
...entitlements.map((ent) => {
return ent;
}),
);
}
return { products, prices: allPrices, ents: allEnts };
};

View File

@@ -1,322 +0,0 @@
import {
AppEnv,
member,
type Organization,
organizations,
type StripeConfig,
user as userTable,
} from "@autumn/shared";
import { Autumn } from "autumn-js";
import { generateId } from "better-auth";
import { and, eq } from "drizzle-orm";
import { type NextFunction, Router } from "express";
import { z } from "zod";
import { createKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
import { handleStripeSecretKey } from "@/internal/orgs/orgUtils/handleStripeSecretKey";
import { shouldReconnectStripe } from "@/internal/orgs/orgUtils.js";
import { afterOrgCreated } from "@/utils/authUtils/afterOrgCreated.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
const platformRouter = Router();
const platformAuthMiddleware = async (
req: any,
res: any,
next: NextFunction,
) => {
if (!process.env.AUTUMN_SECRET_KEY) next();
try {
const autumn = new Autumn();
const { data, error } = await autumn.check({
customer_id: req.org.id,
feature_id: "platform",
});
if (error) {
throw error;
}
if (!data?.allowed) {
res.status(403).json({
message:
"You're not allowed to access the platform API. Please contact hey@useautumn.com to request access!",
code: "not_allowed",
});
return;
}
next();
} catch (error) {
req.logger.error(`Failed to check if org is allowed to access platform`, {
error,
});
res.status(500).json({
message: "Failed to check if org is allowed to access platform",
code: "internal_error",
});
}
};
platformRouter.use(platformAuthMiddleware);
const ExchangeSchema = z.object({
// organization_name: z.string().nullish(),
// organization_slug: z.string().nullish(),
organization: z
.object({
name: z.string().nonempty(),
slug: z.string().nonempty(),
})
.nullish(),
email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
stripe_test_key: z.string().nonempty().optional(),
stripe_live_key: z.string().nonempty().optional(),
});
platformRouter.post("/exchange", (req: any, res: any) =>
routeHandler({
req,
res,
action: "exchange",
handler: async (req: ExtendedRequest, res: any) => {
const { organization, email, stripe_test_key, stripe_live_key } =
req.body;
const { db, logger } = req;
ExchangeSchema.parse({
organization,
email,
stripe_test_key,
stripe_live_key,
});
if (!stripe_test_key && !stripe_live_key) {
res.status(400).json({
message: "Either stripe_test_key or stripe_live_key is required",
code: "invalid_request",
});
return;
}
// 1. Check if user with this email already exists
let user = await db.query.user.findFirst({
where: eq(userTable.email, email),
});
if (!user) {
[user] = await db
.insert(userTable)
.values({
id: generateId(),
name: "",
email,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
role: "user",
banned: false,
banReason: null,
banExpires: null,
createdBy: req.org.id,
})
.returning();
}
logger.info(`User found / created: ${user.id} (${email})`);
let org: Organization;
// let membership = await db.query.member.findFirst({
// with: {
// organization: true,
// },
// where: and(
// eq(member.userId, user.id!),
// eq(member.role, "owner"),
// organization_slug
// ? eq(organizations.slug, organization_slug)
// : undefined,
// eq(organizations.created_by, req.org.id)
// ),
// });
const orgSlug = organization?.slug
? `${organization.slug}_${req.org.id}`
: undefined;
const data = await db
.select()
.from(member)
.innerJoin(organizations, eq(member.organizationId, organizations.id))
.where(
and(
eq(member.userId, user.id!),
eq(member.role, "owner"),
orgSlug ? eq(organizations.slug, orgSlug) : undefined,
eq(organizations.created_by, req.org.id),
),
);
const membership = data.length > 0 ? data[0] : null;
if (!membership) {
logger.info(`Connected to Stripe`);
// 2. Create org
const orgId = generateId();
[org] = (await db
.insert(organizations)
.values({
id: orgId,
slug: orgSlug
? orgSlug
: `platform_org_${Math.floor(10000000 + Math.random() * 90000000)}`,
name: organization?.name || `Platform Org (${req.org.id})`,
logo: "",
createdAt: new Date(),
metadata: "",
created_by: req.org.id,
})
.returning()) as [Organization];
await db.insert(member).values({
id: generateId(),
organizationId: orgId,
userId: user.id!,
role: "owner",
createdAt: new Date(),
});
await afterOrgCreated({ org, user, createStripeAccount: false });
} else {
// org = (await db.query.organizations.findFirst({
// where: eq(organizations.id, membership.organizationId),
// })) as Organization;
org = membership.organizations as Organization;
}
let sandboxKey: string | undefined;
let prodKey: string | undefined;
let finalStripeConfig: StripeConfig = {};
let defaultCurrency = org.default_currency || "usd";
// Connect stripe if not exists...
if (stripe_test_key) {
const reconnectStripe = await shouldReconnectStripe({
org,
env: AppEnv.Sandbox,
stripeKey: stripe_test_key,
logger: req.logger,
});
if (reconnectStripe) {
const {
test_api_key,
test_webhook_secret,
defaultCurrency: newDefaultCurrency,
} = await handleStripeSecretKey({
orgId: org.id,
secretKey: stripe_test_key,
env: AppEnv.Sandbox,
});
finalStripeConfig = {
...finalStripeConfig,
test_api_key,
test_webhook_secret,
};
if (!defaultCurrency) {
defaultCurrency = newDefaultCurrency || "usd";
}
}
sandboxKey = await createKey({
db,
orgId: org.id,
env: AppEnv.Sandbox,
name: "Platform API Key",
prefix: "am_sk_test",
meta: {},
});
}
if (stripe_live_key) {
const reconnectStripe = await shouldReconnectStripe({
org,
env: AppEnv.Live,
stripeKey: stripe_live_key,
logger: req.logger,
});
if (reconnectStripe) {
console.log("Reconnecting stripe live");
const {
live_api_key,
live_webhook_secret,
defaultCurrency: newDefaultCurrency,
} = await handleStripeSecretKey({
orgId: org.id,
secretKey: stripe_live_key,
env: AppEnv.Live,
});
finalStripeConfig = {
...finalStripeConfig,
live_api_key,
live_webhook_secret,
};
if (!defaultCurrency) {
defaultCurrency = newDefaultCurrency || "usd";
}
}
prodKey = await createKey({
db,
orgId: org.id,
env: AppEnv.Live,
name: "Platform API Key",
prefix: "am_sk_live",
meta: {},
});
}
// if (!org.stripe_config?.success_url) {
// finalStripeConfig.success_url = `https://useautumn.com`;
// }
await db
.update(organizations)
.set({
default_currency: defaultCurrency,
stripe_connected: true,
stripe_config: {
...org.stripe_config,
...finalStripeConfig,
} as StripeConfig,
})
.where(eq(organizations.id, org.id));
res.status(200).json({
// org: {
// id: org.id,
// slug: org.slug,
// name: org.name,
// },
// user: {
// id: user.id!,
// email,
// },
api_keys: {
sandbox: sandboxKey,
production: prodKey,
},
});
},
}),
);
export { platformRouter };

View File

@@ -1,33 +0,0 @@
import type { Entitlement } from "@autumn/shared";
import { entitlements } from "@models/productModels/entModels/entTable";
import { eq } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle";
import { generateId } from "../../../utils/genUtils";
export const copyEnt = async ({
db,
entId,
isCustom = true,
internalProductId,
}: {
db: DrizzleCli;
entId: string;
isCustom?: boolean;
internalProductId?: string;
}) => {
const ent = await db.query.entitlements.findFirst({
where: eq(entitlements.id, entId),
});
let newEnt = structuredClone(ent!) as Entitlement;
newEnt = {
...newEnt,
id: generateId("ent"),
created_at: Date.now(),
is_custom: isCustom ?? newEnt.is_custom,
internal_product_id: internalProductId || newEnt.internal_product_id,
};
return newEnt;
};

View File

@@ -1,75 +0,0 @@
// import { ProductService } from "@/internal/products/ProductService.js";
// import type {
// ExtendedRequest,
// ExtendedResponse,
// } from "@/utils/models/Request.js";
// import { routeHandler } from "@/utils/routerUtils.js";
// import { CusService } from "../../customers/CusService.js";
// import { getProductResponse } from "../productUtils/productResponseUtils/getProductResponse.js";
// import { sortFullProducts } from "../productUtils/sortProductUtils.js";
// // biome-ignore lint/suspicious/noExplicitAny: alright buddy WRAP it up 👉🚪
// export const handleListProductsBeta = async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "list products v2 (beta)",
// handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
// const { org, features, env, db } = req;
// const customerId = req.query.customer_id;
// const entityId = req.query.entity_id as string | undefined;
// const includeAll = req.query.include_archived as unknown as boolean;
// const [products, customer] = await Promise.all([
// ProductService.listFull({
// db,
// orgId: org.id,
// env,
// archived: includeAll ? undefined : false,
// }),
// (async () => {
// if (!customerId) {
// return undefined;
// }
// return await CusService.getFull({
// db,
// idOrInternalId: customerId as string,
// orgId: org.id,
// env,
// entityId: entityId as string,
// withEntities: true,
// withSubs: true,
// });
// })(),
// ]);
// if (req.query.v1_schema === "true") {
// res.status(200).json({
// list: products,
// });
// return;
// }
// sortFullProducts({ products });
// const batchResponse = [];
// for (const p of products) {
// batchResponse.push(
// getProductResponse({
// product: p,
// features,
// currency: org.default_currency || undefined,
// db,
// fullCus: customer ? customer : undefined,
// }),
// );
// }
// const productResponse = await Promise.all(batchResponse);
// res.status(200).json({
// list: productResponse,
// });
// },
// });

View File

@@ -1,46 +0,0 @@
import { ProductNotFoundError, productsAreSame } from "@autumn/shared";
import { routeHandler } from "../../../utils/routerUtils.js";
import { CusProductService } from "../../customers/cusProducts/CusProductService.js";
import { ProductService } from "../ProductService.js";
export const handlePlanHasCustomers = (req: any, res: any) =>
routeHandler({
req,
res,
action: "Get product has customers",
handler: async (req: any, res: any) => {
const { product_id } = req.params;
const { db, features } = req;
const product = await ProductService.getFull({
db,
idOrInternalId: product_id,
orgId: req.orgId,
env: req.env,
});
if (!product) {
throw new ProductNotFoundError({ productId: product_id });
}
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: product.internal_id,
});
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: req.body,
curProductV1: product,
features,
});
const productSame = itemsSame && freeTrialsSame;
res.status(200).json({
current_version: product.version,
will_version: !productSame && cusProductsCurVersion.length > 0,
archived: product.archived,
});
},
});

View File

@@ -1,20 +0,0 @@
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
/**
* GET /products/has_entity_feature_id
* Used by: vite/src/views/products/plan/hooks/useHasEntityFeatureId.ts
*/
export const handleHasEntityFeatureId = createRoute({
handler: async (c) => {
const { db, org, env } = c.get("ctx");
const hasEntityFeatureId = await EntitlementService.hasEntityFeatureId({
db,
orgId: org.id,
env,
});
return c.json({ hasEntityFeatureId });
},
});

View File

@@ -1,72 +0,0 @@
import {
type FullEntitlement,
getFeatureInvoiceDescription,
type Organization,
type Price,
type Product,
shouldProrate,
type UsagePriceConfig,
usageToFeatureName,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { constructPreviewItem } from "@/internal/invoices/previewItemUtils/constructPreviewItem.js";
export const getUsageDiffLineItem = ({
prevBalance,
newBalance,
org,
price,
newUsageAmount,
ent,
product,
}: {
prevBalance: number;
newBalance: number;
org: Organization;
price: Price;
newUsageAmount: number;
ent: FullEntitlement;
product: Product;
}) => {
const usageDiff = new Decimal(prevBalance).sub(newBalance).abs().toNumber();
const isIncrease = newBalance <= prevBalance;
const willProrate = isIncrease
? shouldProrate(price.proration_config?.on_increase)
: shouldProrate(price.proration_config?.on_decrease);
let description = getFeatureInvoiceDescription({
feature: ent.feature,
usage: usageDiff,
billingUnits: (price.config as UsagePriceConfig).billing_units,
});
if (isIncrease) {
description = `${product.name} - Additional ${description}`;
} else {
description = `Unused ${product.name} - ${description}`;
}
let previewLineItem = constructPreviewItem({
price,
org,
amount: newUsageAmount,
description,
});
if (!isIncrease && !willProrate) {
const featureName = usageToFeatureName({
usage: usageDiff,
feature: ent.feature,
});
previewLineItem = constructPreviewItem({
priceStr: `${usageDiff} free ${featureName}`,
price,
org,
// description: `${product.name} - ${usageDiff} free ${featureName}`,
description: `${product.name} - ${featureName}`,
});
}
return previewLineItem;
};

View File

@@ -1,44 +0,0 @@
import {
type FullProduct,
PriceType,
stripeToAtmnAmount,
} from "@autumn/shared";
import type Stripe from "stripe";
import { subItemToAutumnInterval } from "@/external/stripe/utils.js";
import { constructPrice } from "../priceUtils.js";
export const subItemToFixedPrice = ({
subItem,
product,
basePrice,
}: {
subItem: Stripe.SubscriptionItem;
product: FullProduct;
basePrice?: number;
}) => {
const { price } = subItem;
const { interval, intervalCount } = subItemToAutumnInterval(subItem);
const atmnAmount = stripeToAtmnAmount({
amount: price.unit_amount || 0,
currency: price.currency,
});
return constructPrice({
internalProductId: product.internal_id,
isCustom: true,
orgId: product.org_id,
fixedConfig: {
type: PriceType.Fixed,
amount: basePrice || atmnAmount,
interval,
interval_count: intervalCount,
stripe_price_id: price.id,
stripe_product_id: undefined,
feature_id: undefined,
internal_feature_id: undefined,
},
});
};

View File

@@ -1,64 +0,0 @@
import {
type FixedPriceConfig,
type Price,
PriceType,
prices,
type UsagePriceConfig,
} from "@autumn/shared";
import { eq } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { generateId } from "@/utils/genUtils.js";
import { PriceService } from "../PriceService.js";
export const copyPrice = async ({
db,
priceId,
usagePriceConfig,
fixedPriceConfig,
isCustom,
withPrevConfig = true,
}: {
db: DrizzleCli;
priceId: string;
usagePriceConfig?: Partial<UsagePriceConfig>;
fixedPriceConfig?: Partial<FixedPriceConfig>;
isCustom?: boolean;
withPrevConfig?: boolean;
}) => {
const price = (await db.query.prices.findFirst({
where: eq(prices.id, priceId),
})) as Price;
let newPrice = structuredClone(price);
newPrice = {
...newPrice,
id: generateId("pr"),
created_at: Date.now(),
is_custom: isCustom || newPrice.is_custom,
};
if (fixedPriceConfig) {
newPrice = {
...newPrice,
entitlement_id: null,
config: {
...(withPrevConfig ? (newPrice.config as FixedPriceConfig) : {}),
type: PriceType.Fixed,
...fixedPriceConfig,
} as FixedPriceConfig,
};
}
if (usagePriceConfig) {
newPrice = {
...newPrice,
config: {
...(newPrice.config as UsagePriceConfig),
...usagePriceConfig,
},
};
}
return newPrice;
};

View File

@@ -115,13 +115,3 @@ export const priceToProduct = ({
(p: Product) => p.internal_id == price.internal_product_id,
);
};
const filterByBillingType = ({
prices,
billingType,
}: {
prices: Price[];
billingType: BillingType;
}) => {
return prices.filter((p) => getBillingType(p.config) == billingType);
};

View File

@@ -1,132 +0,0 @@
import {
BillingType,
type EntInterval,
type EntitlementWithFeature,
entIntervalToValue,
entToPrice,
getBillingType,
isFixedPrice,
itemToEntInterval,
type Price,
type ProductItem,
UsageModel,
} from "@autumn/shared";
import { isFeatureItem, isFeaturePriceItem } from "./getItemType.js";
export const addIdsToProductItems = ({
items,
curPrices,
curEnts,
}: {
items: ProductItem[];
curPrices: Price[];
curEnts: EntitlementWithFeature[];
}) => {
// 1. Handle base price
const entIds = new Set<string>();
const priceIds = new Set<string>();
const basePriceItem = items.find((item) => item.price_id === null);
const baseCurPrice = curPrices.find((price) => isFixedPrice(price));
if (basePriceItem && baseCurPrice) {
basePriceItem.price_id = baseCurPrice.id;
basePriceItem.price_config = baseCurPrice.config;
priceIds.add(baseCurPrice.id);
}
// Feature items
const featureIdToItems: Record<string, ProductItem[]> = {};
const featureIdToCurEnts: Record<string, EntitlementWithFeature[]> = {};
for (const item of items) {
if (!item.feature_id) continue;
if (!featureIdToItems[item.feature_id]) {
featureIdToItems[item.feature_id] = [item];
} else {
featureIdToItems[item.feature_id].push(item);
}
featureIdToItems[item.feature_id].sort((a, b) => {
if (isFeatureItem(a) && isFeatureItem(b)) return 0;
// If there's a price, go first,
if (isFeaturePriceItem(a) && !isFeaturePriceItem(b)) return -1;
if (!isFeaturePriceItem(a) && isFeaturePriceItem(b)) return 1;
// Sort by interval
const aIntervalValue = entIntervalToValue(
itemToEntInterval({ item: a }) as EntInterval,
);
const bIntervalValue = entIntervalToValue(
itemToEntInterval({ item: b }) as EntInterval,
);
if (!aIntervalValue.eq(bIntervalValue)) {
return aIntervalValue.sub(bIntervalValue).toNumber();
}
// If it's pay per use usage model, go first,
if (
a.usage_model === UsageModel.PayPerUse &&
b.usage_model !== UsageModel.PayPerUse
)
return -1;
if (
a.usage_model !== UsageModel.PayPerUse &&
b.usage_model === UsageModel.PayPerUse
)
return 1;
return 0;
});
}
for (const curEnt of curEnts) {
if (!curEnt.feature_id) continue;
if (!featureIdToCurEnts[curEnt.feature_id]) {
featureIdToCurEnts[curEnt.feature_id] = [curEnt];
} else {
featureIdToCurEnts[curEnt.feature_id].push(curEnt);
}
featureIdToCurEnts[curEnt.feature_id].sort((a, b) => {
// 1. a hasprice
const aPrice = entToPrice({ ent: a, prices: curPrices });
const bPrice = entToPrice({ ent: b, prices: curPrices });
if (!aPrice && !bPrice) return 0;
if (aPrice && !bPrice) return -1;
if (!aPrice && bPrice) return 1;
const aIntervalValue = entIntervalToValue(a.interval, a.interval_count);
const bIntervalValue = entIntervalToValue(b.interval, b.interval_count);
if (!aIntervalValue.eq(bIntervalValue)) {
return aIntervalValue.sub(bIntervalValue).toNumber();
}
const isPrepaid =
getBillingType(aPrice!.config) === BillingType.UsageInAdvance;
const isPrepaidB =
getBillingType(bPrice!.config) === BillingType.UsageInAdvance;
if (isPrepaid && !isPrepaidB) return -1;
if (!isPrepaid && isPrepaidB) return 1;
return 0;
});
}
for (const featureId in featureIdToItems) {
for (let i = 0; i < featureIdToItems[featureId].length; i++) {
// featureIdToItems[featureId][i].id = generateId("item");
const entLength = featureIdToCurEnts[featureId]?.length ?? 0;
if (entLength > i) {
const ent = featureIdToCurEnts[featureId]?.[i];
if (ent && !entIds.has(ent.id)) {
entIds.add(ent.id);
featureIdToItems[featureId][i].entitlement_id = ent.id;
}
}
}
}
};

View File

@@ -1,15 +0,0 @@
import type { Entitlement, Price, ProductItem } from "@autumn/shared";
export const matchItemToPriceAndEnt = ({
item,
curPrices,
curEnts,
}: {
item: ProductItem;
curPrices: Price[];
curEnts: Entitlement[];
}) => {
// return items.find(
// (i) => i.feature_id === item.feature_id && i.interval === item.interval,
// );
};

View File

@@ -1,7 +0,0 @@
import { Router } from "express";
export const userRouter: Router = Router();
userRouter.get("", async (req: any, res) => {
res.status(200).json({ userId: req.userId });
});

View File

View File

@@ -1,9 +0,0 @@
export const parseAuthHeader = (req: any) => {
const authHeader =
req.headers["Authorization"] || req.headers["authorization"];
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return null;
}
const bearerToken = authHeader.split(" ")[1];
return bearerToken;
};

View File

@@ -1,189 +0,0 @@
import type { z } from "zod/v4";
/**
* Normalize data from Redis cache to fix cjson quirks.
*
* Different Redis providers handle cjson.encode differently:
* - Empty objects {} may become empty arrays []
* - null values may become undefined
* - Empty arrays [] may become empty objects {}
*
* This function dynamically normalizes data based on a Zod schema structure.
*/
/**
* Get the type string from a Zod schema's _def
*/
const getSchemaType = (schema: z.ZodTypeAny): string | undefined => {
return (schema as any)._def?.type;
};
/**
* Unwrap optional/nullable/effects to get the inner schema
*/
const unwrapSchema = (schema: z.ZodTypeAny): z.ZodTypeAny => {
const type = getSchemaType(schema);
const def = (schema as any)._def;
// Unwrap optional → innerType
if (type === "optional") {
return unwrapSchema(def.innerType);
}
// Unwrap nullable → innerType
if (type === "nullable") {
return unwrapSchema(def.innerType);
}
// Unwrap effects (transform, refine, etc.) → schema
if (type === "effects") {
return unwrapSchema(def.schema);
}
// Unwrap default → innerType
if (type === "default") {
return unwrapSchema(def.innerType);
}
return schema;
};
/**
* Check if schema allows null values (has .nullable() wrapper)
*/
const isNullable = (schema: z.ZodTypeAny): boolean => {
const type = getSchemaType(schema);
const def = (schema as any)._def;
if (type === "nullable") {
return true;
}
// Check wrapped types
if (type === "optional" || type === "effects" || type === "default") {
const innerSchema = def.innerType || def.schema;
return isNullable(innerSchema);
}
return false;
};
/**
* Check if a value is an empty object (not an array)
*/
const isEmptyObject = (value: unknown): boolean => {
return (
value !== null &&
value !== undefined &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).length === 0
);
};
/**
* Normalize Lua empty table to array
* Lua cjson encodes empty tables as {} (object) instead of [] (array)
*/
export const normalizeToArray = (value: unknown): string[] => {
if (Array.isArray(value)) return value;
if (value && typeof value === "object" && Object.keys(value).length === 0)
return [];
return [];
};
/**
* Dynamically normalize data based on Zod schema structure.
* Handles Redis cjson quirks by walking the schema and fixing data accordingly.
*/
export const normalizeFromSchema = <T>({
schema,
data,
}: {
schema: z.ZodTypeAny;
data: unknown;
}): T => {
// Step 1: Check if data is undefined and field is nullable → convert to null
if (data === undefined && isNullable(schema)) {
return null as T;
}
// Step 2: Unwrap schema to get the base type
const unwrapped = unwrapSchema(schema);
const type = getSchemaType(unwrapped);
// Step 3: Handle each Zod type
// RECORD (object with dynamic keys)
if (type === "record") {
// If data is an empty array, convert to empty object
if (Array.isArray(data) && data.length === 0) {
return {} as T;
}
// If data is a valid object, recursively normalize values
if (data && typeof data === "object" && !Array.isArray(data)) {
const valueSchema = (unwrapped as any)._def.valueType;
const normalized: Record<string, unknown> = {};
for (const key in data as Record<string, unknown>) {
normalized[key] = normalizeFromSchema({
schema: valueSchema,
data: (data as Record<string, unknown>)[key],
});
}
return normalized as T;
}
}
// ARRAY
if (type === "array") {
// If data is an empty object, convert to empty array
if (isEmptyObject(data)) {
return [] as T;
}
// If data is a valid array, recursively normalize items
if (Array.isArray(data)) {
const itemSchema = (unwrapped as any)._def.element;
return data.map((item) =>
normalizeFromSchema({ schema: itemSchema, data: item }),
) as T;
}
}
// OBJECT (with defined shape)
if (type === "object") {
// If data is not an object, return as-is
if (!data || typeof data !== "object" || Array.isArray(data)) {
return data as T;
}
// Recursively normalize all fields in the object
const shape = (unwrapped as any)._def.shape;
const normalized: Record<string, unknown> = {
...(data as Record<string, unknown>),
};
for (const key in shape) {
// Hardcoded fix: scheduled_subscriptions should be an array, never null/undefined
if (
key === "scheduled_subscriptions" &&
(normalized[key] === undefined || normalized[key] === null)
) {
normalized[key] = [];
} else {
normalized[key] = normalizeFromSchema({
schema: shape[key],
data: normalized[key],
});
}
}
return normalized as T;
}
// For all other types (string, number, boolean, etc.), return as-is
return data as T;
};

View File

@@ -1,28 +0,0 @@
import {
cusProductToPrices,
type FullCustomer,
isFreeProduct,
} from "@autumn/shared";
export const allCusProductsOnSubFree = ({
fullCus,
subId,
}: {
fullCus: FullCustomer;
subId: string;
}) => {
const isFree = fullCus.customer_products.every((cp) => {
const hasSubId = cp.subscription_ids?.includes(subId);
if (!hasSubId) return true;
const prices = cusProductToPrices({ cusProduct: cp });
if (isFreeProduct({ prices })) {
return true;
}
return false;
});
if (isFree) {
return true;
}
return false;
};

Some files were not shown because too many files have changed in this diff Show More