refactor: knip deletions
This commit is contained in:
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
8
server/src/external/redis/initUpstash.ts
vendored
8
server/src/external/redis/initUpstash.ts
vendored
@@ -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 };
|
||||
@@ -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 },
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
// }
|
||||
// };
|
||||
151
server/src/external/stripe/stripeProductUtils.ts
vendored
151
server/src/external/stripe/stripeProductUtils.ts
vendored
@@ -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));
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
23
server/src/external/supabase/safeSb.ts
vendored
23
server/src/external/supabase/safeSb.ts
vendored
@@ -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}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* Update an existing installation
|
||||
*
|
||||
* @see handleUpdateBillingPlan
|
||||
*/
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
// };
|
||||
// };
|
||||
@@ -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();
|
||||
@@ -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 },
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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,
|
||||
// });
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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...?
|
||||
// };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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)`,
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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`,
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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)),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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! })}`;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 "";
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
// });
|
||||
// },
|
||||
// });
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
// );
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user