fix: default at the entity level, no_billing_changes flag in attach
This commit is contained in:
@@ -21,8 +21,8 @@ const main = async () => {
|
||||
logger,
|
||||
};
|
||||
await Promise.all([
|
||||
runProductCron({ ctx }),
|
||||
runResetCron({ ctx }),
|
||||
runProductCron(),
|
||||
runInvoiceCron({ ctx }),
|
||||
runOneOffCleanup({ ctx }),
|
||||
]);
|
||||
|
||||
110
server/src/cron/productCron/fetchExpiredTrialProducts.ts
Normal file
110
server/src/cron/productCron/fetchExpiredTrialProducts.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
type AppEnv,
|
||||
customerPrices,
|
||||
customerProducts,
|
||||
customers,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
isNotNull,
|
||||
lt,
|
||||
notExists,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { generateId } from "@/utils/genUtils";
|
||||
import { createWorkerAutumnContext } from "@/utils/workerUtils/createAutumnContext";
|
||||
import type { CronContext } from "../utils/CronContext";
|
||||
|
||||
export type ExpiredTrialRow = {
|
||||
customerProduct: InferSelectModel<typeof customerProducts>;
|
||||
customer: InferSelectModel<typeof customers>;
|
||||
};
|
||||
|
||||
export type OrgEnvExpiredTrials = {
|
||||
ctx: AutumnContext;
|
||||
rows: ExpiredTrialRow[];
|
||||
};
|
||||
|
||||
export const fetchExpiredTrialProducts = async ({
|
||||
batchSize,
|
||||
db,
|
||||
}: {
|
||||
batchSize: number;
|
||||
db: DrizzleCli;
|
||||
}) => {
|
||||
return db
|
||||
.select({
|
||||
customerProduct: customerProducts,
|
||||
customer: customers,
|
||||
})
|
||||
.from(customerProducts)
|
||||
.innerJoin(
|
||||
customers,
|
||||
eq(customerProducts.internal_customer_id, customers.internal_id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
notExists(
|
||||
db
|
||||
.select()
|
||||
.from(customerPrices)
|
||||
.where(eq(customerPrices.customer_product_id, customerProducts.id)),
|
||||
),
|
||||
inArray(customerProducts.status, ACTIVE_STATUSES),
|
||||
isNotNull(customerProducts.trial_ends_at),
|
||||
lt(
|
||||
customerProducts.trial_ends_at,
|
||||
sql`(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(batchSize);
|
||||
};
|
||||
|
||||
export const groupByOrgEnv = async ({
|
||||
results,
|
||||
cronContext,
|
||||
}: {
|
||||
results: ExpiredTrialRow[];
|
||||
cronContext: CronContext;
|
||||
}): Promise<OrgEnvExpiredTrials[]> => {
|
||||
const byOrgEnv = new Map<
|
||||
string,
|
||||
{ orgId: string; env: AppEnv; rows: ExpiredTrialRow[] }
|
||||
>();
|
||||
|
||||
for (const row of results) {
|
||||
const key = `${row.customer.org_id}:${row.customer.env}`;
|
||||
const existing = byOrgEnv.get(key);
|
||||
if (existing) {
|
||||
existing.rows.push(row);
|
||||
} else {
|
||||
byOrgEnv.set(key, {
|
||||
orgId: row.customer.org_id,
|
||||
env: row.customer.env as AppEnv,
|
||||
rows: [row],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const groups: OrgEnvExpiredTrials[] = [];
|
||||
for (const { orgId, env, rows } of byOrgEnv.values()) {
|
||||
const ctx = await createWorkerAutumnContext({
|
||||
db: cronContext.db,
|
||||
orgId,
|
||||
env,
|
||||
logger: cronContext.logger,
|
||||
workerId: generateId("product-cron"),
|
||||
});
|
||||
|
||||
groups.push({ ctx, rows });
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
66
server/src/cron/productCron/processExpiredTrialRow.ts
Normal file
66
server/src/cron/productCron/processExpiredTrialRow.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
CusProductStatus,
|
||||
type customerProducts,
|
||||
type customers,
|
||||
type FullProduct,
|
||||
} from "@autumn/shared";
|
||||
import { customerProductToDefaultProduct } from "@utils/cusProductUtils/convertCusProduct/customerProductToDefaultProduct";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { activateFreeDefaultProduct } from "@/internal/customers/cusProducts/actions/activateFreeDefaultProduct";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
|
||||
|
||||
export const processExpiredTrialRow = async ({
|
||||
ctx,
|
||||
customerProduct,
|
||||
customer,
|
||||
defaultProducts,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerProduct: InferSelectModel<typeof customerProducts>;
|
||||
customer: InferSelectModel<typeof customers>;
|
||||
defaultProducts: FullProduct[];
|
||||
}) => {
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customer.internal_id,
|
||||
withEntities: true,
|
||||
withSubs: true,
|
||||
});
|
||||
|
||||
const fullCustomerProduct = fullCustomer.customer_products.find(
|
||||
(cp) => cp.id === customerProduct.id,
|
||||
);
|
||||
|
||||
if (!fullCustomerProduct) return;
|
||||
|
||||
const defaultProduct = customerProductToDefaultProduct({
|
||||
ctx,
|
||||
customerProduct: fullCustomerProduct,
|
||||
defaultProducts,
|
||||
});
|
||||
|
||||
if (defaultProduct) {
|
||||
await activateFreeDefaultProduct({
|
||||
ctx,
|
||||
customerProduct: fullCustomerProduct,
|
||||
fullCustomer,
|
||||
defaultProduct,
|
||||
});
|
||||
}
|
||||
await CusProductService.update({
|
||||
ctx,
|
||||
cusProductId: fullCustomerProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteCachedFullCustomer({
|
||||
ctx,
|
||||
customerId: fullCustomer.id ?? "",
|
||||
source: "productCron",
|
||||
});
|
||||
};
|
||||
@@ -1,84 +1,96 @@
|
||||
import { type AppEnv, CusProductStatus, ms } from "@autumn/shared";
|
||||
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
|
||||
import { batchDeleteCachedFullCustomers } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
import type { CronContext } from "../utils/CronContext";
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
CusProductStatus,
|
||||
customerPrices,
|
||||
customerProducts,
|
||||
customers,
|
||||
notNullish,
|
||||
} from "@autumn/shared";
|
||||
import { and, eq, inArray, isNotNull, lt, notExists, sql } from "drizzle-orm";
|
||||
import { db } from "@/db/initDrizzle.js";
|
||||
import { batchDeleteCachedCustomers } from "../../internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers";
|
||||
fetchExpiredTrialProducts,
|
||||
groupByOrgEnv,
|
||||
} from "./fetchExpiredTrialProducts";
|
||||
import { processExpiredTrialRow } from "./processExpiredTrialRow";
|
||||
|
||||
export const runProductCron = async () => {
|
||||
export const runProductCron = async ({
|
||||
ctx: cronContext,
|
||||
}: {
|
||||
ctx: CronContext;
|
||||
}) => {
|
||||
console.log("Running product cron");
|
||||
|
||||
const { db } = cronContext;
|
||||
const maxIterations = 10;
|
||||
const timeoutMs = ms.minutes(1);
|
||||
const startTime = Date.now();
|
||||
const batchSize = 1000;
|
||||
let totalExpired = 0;
|
||||
|
||||
try {
|
||||
// Get customer_products that have 0 customer_prices, and trial_ends_at is not null, and trial_ends_at > now
|
||||
const results = await db
|
||||
.select()
|
||||
.from(customerProducts)
|
||||
.innerJoin(
|
||||
customers,
|
||||
eq(customerProducts.internal_customer_id, customers.internal_id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
// No customer_prices exist for this customer_product
|
||||
notExists(
|
||||
db
|
||||
.select()
|
||||
.from(customerPrices)
|
||||
.where(
|
||||
eq(customerPrices.customer_product_id, customerProducts.id),
|
||||
),
|
||||
),
|
||||
// status is not expired
|
||||
inArray(customerProducts.status, ACTIVE_STATUSES),
|
||||
let iteration = 0;
|
||||
|
||||
// trial_ends_at is not null
|
||||
isNotNull(customerProducts.trial_ends_at),
|
||||
while (iteration < maxIterations && Date.now() - startTime < timeoutMs) {
|
||||
iteration++;
|
||||
|
||||
// is already expired
|
||||
lt(
|
||||
customerProducts.trial_ends_at,
|
||||
sql`(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const results = await fetchExpiredTrialProducts({ batchSize, db });
|
||||
|
||||
console.log(
|
||||
`Found ${results.length} customer products with no prices and active trials`,
|
||||
);
|
||||
if (results.length === 0) break;
|
||||
|
||||
const expireCusProducts = async (ids: string[]) => {
|
||||
await db
|
||||
.update(customerProducts)
|
||||
.set({
|
||||
status: CusProductStatus.Expired,
|
||||
})
|
||||
.where(inArray(customerProducts.id, ids));
|
||||
};
|
||||
|
||||
const batchSize = 250;
|
||||
|
||||
for (let i = 0; i < results.length; i += batchSize) {
|
||||
const batch = results.slice(i, i + batchSize);
|
||||
await expireCusProducts(batch.map((r) => r.customer_products.id));
|
||||
console.log(
|
||||
`Expired batch of ${i + batch.length}/${results.length} customer products`,
|
||||
`Product cron iteration ${iteration}: processing ${results.length} expired trials`,
|
||||
);
|
||||
|
||||
await batchDeleteCachedCustomers({
|
||||
customers: batch
|
||||
.filter((r) => notNullish(r.customers.id))
|
||||
.map((r) => ({
|
||||
orgId: r.customers.org_id,
|
||||
env: r.customers.env,
|
||||
customerId: r.customers.id!,
|
||||
})),
|
||||
});
|
||||
const resultsByOrgEnv = await groupByOrgEnv({ results, cronContext });
|
||||
|
||||
for (const { ctx, rows } of resultsByOrgEnv) {
|
||||
const defaultProducts = await ProductService.listDefault({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
onlyFree: true,
|
||||
});
|
||||
|
||||
if (defaultProducts.length === 0) {
|
||||
await customerProductRepo.batchUpdate({
|
||||
ctx,
|
||||
updates: rows.map((row) => ({
|
||||
id: row.customerProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
},
|
||||
})),
|
||||
});
|
||||
await batchDeleteCachedFullCustomers({
|
||||
customers: rows.map((row) => ({
|
||||
orgId: row.customer.org_id,
|
||||
env: row.customer.env as AppEnv,
|
||||
customerId: row.customer.id ?? "",
|
||||
})),
|
||||
});
|
||||
console.log(`Expired ${rows.length} customer products`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const processBatchSize = 250;
|
||||
for (let i = 0; i < rows.length; i += processBatchSize) {
|
||||
const batch = rows.slice(i, i + processBatchSize);
|
||||
await Promise.all(
|
||||
batch.map((row) =>
|
||||
processExpiredTrialRow({
|
||||
ctx,
|
||||
customerProduct: row.customerProduct,
|
||||
customer: row.customer,
|
||||
defaultProducts,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
totalExpired += results.length;
|
||||
console.log(`Expired ${totalExpired} customer products so far`);
|
||||
|
||||
if (results.length < batchSize) break;
|
||||
}
|
||||
return results;
|
||||
|
||||
console.log(`Product cron finished: expired ${totalExpired} total`);
|
||||
} catch (error) {
|
||||
console.log("Error running product cron:", error);
|
||||
}
|
||||
|
||||
@@ -83,8 +83,9 @@ export const processConsumablePricesForInvoiceCreated = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
if (lineItems.length > 0) {
|
||||
const invoiceItems = await createStripeInvoiceItems({
|
||||
const skipOverageSubmission = ctx.org.config.skip_overage_submission;
|
||||
if (lineItems.length > 0 && !skipOverageSubmission) {
|
||||
await createStripeInvoiceItems({
|
||||
ctx,
|
||||
invoiceItems: lineItemsToCreateInvoiceItemsParams({
|
||||
stripeCustomerId: eventContext.stripeCustomer.id,
|
||||
|
||||
@@ -82,7 +82,8 @@ export const processConsumablePricesForSubscriptionDeleted = async ({
|
||||
// No cusEntFilter - bill all consumable entitlements on cancellation
|
||||
});
|
||||
|
||||
if (lineItems.length > 0) {
|
||||
const skipOverageSubmission = ctx.org.config.skip_overage_submission;
|
||||
if (lineItems.length > 0 && !skipOverageSubmission) {
|
||||
// 2. Create, finalize, and pay a single invoice with all line items
|
||||
const invoiceLines = lineItemsToInvoiceAddLinesParams({ lineItems });
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { FullCusProduct } from "@autumn/shared";
|
||||
import {
|
||||
customerProductEligibleForDefaultProduct,
|
||||
enrichFullCustomerWithEntity,
|
||||
type FullCusProduct,
|
||||
} from "@autumn/shared";
|
||||
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { scheduleDefaultProduct } from "@/internal/customers/cusProducts/cusProductUtils/scheduleDefaultProduct";
|
||||
@@ -31,12 +35,19 @@ export const scheduleDefaultProducts = async ({
|
||||
|
||||
// Schedule default for each canceled non-entity product group
|
||||
for (const canceledProduct of canceledCustomerProducts) {
|
||||
if (canceledProduct.internal_entity_id) continue;
|
||||
const eligibleForDefaultProduct = customerProductEligibleForDefaultProduct({
|
||||
ctx,
|
||||
customerProduct: canceledProduct,
|
||||
});
|
||||
if (!eligibleForDefaultProduct) continue;
|
||||
|
||||
await scheduleDefaultProduct({
|
||||
ctx,
|
||||
productGroup: canceledProduct.product.group,
|
||||
fullCustomer,
|
||||
fullCustomer: enrichFullCustomerWithEntity({
|
||||
fullCustomer,
|
||||
internalEntityId: canceledProduct.internal_entity_id ?? null,
|
||||
}),
|
||||
scheduleAtMs,
|
||||
defaultProducts,
|
||||
});
|
||||
|
||||
@@ -116,8 +116,8 @@ export const setupAttachBillingContext = async ({
|
||||
product: attachProduct,
|
||||
targetCustomerProduct: currentCustomerProduct,
|
||||
contextOverride,
|
||||
paramDiscounts: params.discounts,
|
||||
newBillingSubscription: shouldForceNewSubscription || undefined,
|
||||
params,
|
||||
// paramDiscounts: params.discounts,
|
||||
});
|
||||
|
||||
const featureQuantities = setupFeatureQuantitiesContext({
|
||||
@@ -229,5 +229,9 @@ export const setupAttachBillingContext = async ({
|
||||
params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }),
|
||||
|
||||
externalId: params.subscription_id,
|
||||
|
||||
skipBillingChanges:
|
||||
params.no_billing_changes ??
|
||||
params.processor_subscription_id !== undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -92,8 +92,9 @@ export const setupMultiAttachBillingContext = async ({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
targetCustomerProduct: undefined,
|
||||
paramDiscounts: params.discounts,
|
||||
newBillingSubscription: params.new_billing_subscription || undefined,
|
||||
params,
|
||||
// paramDiscounts: params.discounts,
|
||||
// newBillingSubscription: params.new_billing_subscription || undefined,
|
||||
});
|
||||
|
||||
const invoiceMode = setupInvoiceModeContext({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { CusProductStatus, type FullCusProduct } from "@autumn/shared";
|
||||
import {
|
||||
CusProductStatus,
|
||||
enrichFullCustomerWithEntity,
|
||||
type FullCusProduct,
|
||||
type UpdateSubscriptionBillingContext,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
|
||||
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
|
||||
|
||||
/**
|
||||
@@ -44,7 +48,10 @@ export const computeDefaultCustomerProduct = ({
|
||||
const newDefaultProduct = initFullCustomerProduct({
|
||||
ctx,
|
||||
initContext: {
|
||||
fullCustomer,
|
||||
fullCustomer: enrichFullCustomerWithEntity({
|
||||
fullCustomer,
|
||||
internalEntityId: customerProduct.internal_entity_id ?? null,
|
||||
}),
|
||||
fullProduct: defaultProduct,
|
||||
featureQuantities: [],
|
||||
resetCycleAnchor: startsAt,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
AutumnBillingPlan,
|
||||
UpdateSubscriptionV1Params,
|
||||
} from "@autumn/shared";
|
||||
|
||||
type CusProductFieldUpdates = NonNullable<
|
||||
NonNullable<AutumnBillingPlan["updateCustomerProduct"]>["updates"]
|
||||
>;
|
||||
|
||||
export const computeFieldUpdates = ({
|
||||
params,
|
||||
}: {
|
||||
params: UpdateSubscriptionV1Params;
|
||||
}) => {
|
||||
const updates: CusProductFieldUpdates = {};
|
||||
|
||||
if (params.processor_subscription_id !== undefined) {
|
||||
// unsets processor subscription id if it is set to a new value
|
||||
updates.subscription_ids = params.processor_subscription_id
|
||||
? [params.processor_subscription_id]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (params.status !== undefined) {
|
||||
updates.status = params.status;
|
||||
}
|
||||
|
||||
return updates;
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { computeCustomPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan";
|
||||
import { finalizeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan";
|
||||
import { computeUpdateQuantityPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan";
|
||||
import { computeFieldUpdates } from "./computeFieldUpdates";
|
||||
|
||||
/**
|
||||
* Compute the subscription update plan
|
||||
@@ -40,6 +41,7 @@ export const computeUpdateSubscriptionPlan = async ({
|
||||
case UpdateSubscriptionIntent.UpdatePlan:
|
||||
plan = await computeCustomPlan({
|
||||
ctx,
|
||||
params,
|
||||
updateSubscriptionContext: billingContext,
|
||||
});
|
||||
break;
|
||||
@@ -61,6 +63,17 @@ export const computeUpdateSubscriptionPlan = async ({
|
||||
break;
|
||||
}
|
||||
|
||||
const fieldUpdates = computeFieldUpdates({ params });
|
||||
if (Object.keys(fieldUpdates).length > 0) {
|
||||
plan.updateCustomerProduct = {
|
||||
customerProduct: billingContext.customerProduct,
|
||||
updates: {
|
||||
...plan.updateCustomerProduct?.updates,
|
||||
...fieldUpdates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Apply cancel plan if cancelAction is set in context
|
||||
plan = computeCancelPlan({ ctx, billingContext, plan });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AutumnBillingPlan,
|
||||
UpdateSubscriptionBillingContext,
|
||||
UpdateSubscriptionV1Params,
|
||||
} from "@autumn/shared";
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
@@ -10,9 +11,11 @@ import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutum
|
||||
|
||||
export const computeCustomPlan = async ({
|
||||
ctx,
|
||||
params,
|
||||
updateSubscriptionContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: UpdateSubscriptionV1Params;
|
||||
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||
}) => {
|
||||
const {
|
||||
@@ -28,6 +31,7 @@ export const computeCustomPlan = async ({
|
||||
// Compute the new customer product
|
||||
const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({
|
||||
ctx,
|
||||
params,
|
||||
updateSubscriptionContext,
|
||||
fullProduct: customFullProduct,
|
||||
currentCustomerProduct: customerProduct,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
FullCusProduct,
|
||||
FullProduct,
|
||||
UpdateSubscriptionBillingContext,
|
||||
UpdateSubscriptionV1Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { computeCancelFields } from "@/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelFields";
|
||||
@@ -9,11 +10,13 @@ import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCus
|
||||
|
||||
export const computeCustomPlanNewCustomerProduct = ({
|
||||
ctx,
|
||||
params,
|
||||
updateSubscriptionContext,
|
||||
fullProduct,
|
||||
currentCustomerProduct,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: UpdateSubscriptionV1Params;
|
||||
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||
fullProduct: FullProduct;
|
||||
currentCustomerProduct: FullCusProduct;
|
||||
@@ -78,6 +81,12 @@ export const computeCustomPlanNewCustomerProduct = ({
|
||||
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
|
||||
startsAt: currentCustomerProduct.starts_at ?? undefined,
|
||||
...cancelFields,
|
||||
|
||||
...(params.processor_subscription_id
|
||||
? { subscriptionId: params.processor_subscription_id }
|
||||
: {}),
|
||||
|
||||
...(params.status ? { status: params.status } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
|
||||
import { formatMs } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
|
||||
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
|
||||
|
||||
export const logUpdateSubscriptionContext = ({
|
||||
@@ -55,6 +55,7 @@ export const logUpdateSubscriptionContext = ({
|
||||
|
||||
defaultProduct: billingContext.defaultProduct?.name ?? "undefined",
|
||||
cancelAction: cancelAction ? cancelAction : "no cancel operation",
|
||||
skipBillingChanges: billingContext.skipBillingChanges,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
cp,
|
||||
customerProductEligibleForDefaultProduct,
|
||||
type FullCusProduct,
|
||||
type FullProduct,
|
||||
nullish,
|
||||
@@ -25,12 +25,18 @@ export const setupDefaultProductContext = async ({
|
||||
if (nullish(params.cancel_action)) return undefined;
|
||||
|
||||
// Add-ons don't trigger default products
|
||||
const { valid: isMainCustomerScopedAndRecurring } = cp(customerProduct)
|
||||
.main()
|
||||
.recurring()
|
||||
.customerScoped();
|
||||
const valid = customerProductEligibleForDefaultProduct({
|
||||
ctx,
|
||||
customerProduct,
|
||||
});
|
||||
|
||||
if (!isMainCustomerScopedAndRecurring) return undefined;
|
||||
if (!valid) return undefined;
|
||||
// const { valid: isMainCustomerScopedAndRecurring } = cp(customerProduct)
|
||||
// .main()
|
||||
// .recurring()
|
||||
// .customerScoped();
|
||||
|
||||
// if (!isMainCustomerScopedAndRecurring) return undefined;
|
||||
|
||||
const defaultProduct = await getFreeDefaultProductByGroup({
|
||||
ctx,
|
||||
|
||||
@@ -17,6 +17,13 @@ import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoic
|
||||
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
|
||||
import { setupUpdateSubscriptionTrialContext } from "./setupUpdateSubscriptionTrialContext";
|
||||
|
||||
const FIELDS_WITH_BILLING_CHANGES = [
|
||||
"feature_quantities",
|
||||
"version",
|
||||
"customize",
|
||||
"cancel_action",
|
||||
] as const satisfies (keyof UpdateSubscriptionV1Params)[];
|
||||
|
||||
/**
|
||||
* Fetch the context for updating a subscription
|
||||
* @param ctx - The context
|
||||
@@ -109,6 +116,12 @@ export const setupUpdateSubscriptionBillingContext = async ({
|
||||
|
||||
const cancelAction = setupCancelAction({ params });
|
||||
|
||||
const billingRelatedFields = Object.keys(params).filter((key) =>
|
||||
FIELDS_WITH_BILLING_CHANGES.includes(
|
||||
key as (typeof FIELDS_WITH_BILLING_CHANGES)[number],
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
fullCustomer,
|
||||
fullProducts: [fullProduct],
|
||||
@@ -136,5 +149,10 @@ export const setupUpdateSubscriptionBillingContext = async ({
|
||||
billingVersion: contextOverride.billingVersion
|
||||
? contextOverride.billingVersion
|
||||
: (customerProduct.billing_version ?? BillingVersion.V2),
|
||||
|
||||
skipBillingChanges:
|
||||
params.no_billing_changes === true ||
|
||||
params.processor_subscription_id !== undefined ||
|
||||
billingRelatedFields.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BillingContext,
|
||||
BillingPlan,
|
||||
BillingResult,
|
||||
StripeBillingPlanResult,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
||||
@@ -18,11 +19,14 @@ export const executeBillingPlan = async ({
|
||||
billingContext: BillingContext;
|
||||
billingPlan: BillingPlan;
|
||||
}): Promise<BillingResult> => {
|
||||
const stripeBillingResult = await executeStripeBillingPlan({
|
||||
ctx,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
});
|
||||
const stripeBillingResult: StripeBillingPlanResult =
|
||||
billingContext.skipBillingChanges
|
||||
? {}
|
||||
: await executeStripeBillingPlan({
|
||||
ctx,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
});
|
||||
|
||||
if (stripeBillingResult.deferred) {
|
||||
// Store line items even when deferred — invoice already exists in DB
|
||||
|
||||
@@ -29,6 +29,10 @@ export const evaluateStripeBillingPlan = async ({
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
checkoutMode?: CheckoutMode;
|
||||
}): Promise<StripeBillingPlan> => {
|
||||
if (billingContext.skipBillingChanges) {
|
||||
return {};
|
||||
}
|
||||
|
||||
await initStripeResourcesForBillingPlan({
|
||||
ctx,
|
||||
autumnBillingPlan,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
type AttachParamsV1,
|
||||
type FullCustomer,
|
||||
getTargetSubscriptionCusProduct,
|
||||
InternalError,
|
||||
type MultiAttachParamsV0,
|
||||
type Product,
|
||||
type UpdateSubscriptionV1Params,
|
||||
} from "@autumn/shared";
|
||||
import { createStripeCli } from "@server/external/connect/createStripeCli";
|
||||
import type { StripeSubscriptionWithDiscounts } from "@server/external/stripe/subscriptions";
|
||||
@@ -18,18 +21,29 @@ export const fetchStripeSubscriptionForBilling = async ({
|
||||
fullCus,
|
||||
product,
|
||||
targetCusProductId,
|
||||
newBillingSubscription,
|
||||
params,
|
||||
// newBillingSubscription,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCus: FullCustomer;
|
||||
product?: Product;
|
||||
targetCusProductId?: string;
|
||||
newBillingSubscription?: boolean;
|
||||
// newBillingSubscription?: boolean;
|
||||
params?: AttachParamsV1 | MultiAttachParamsV0 | UpdateSubscriptionV1Params;
|
||||
}): Promise<StripeSubscriptionWithDiscounts | undefined> => {
|
||||
if (newBillingSubscription) {
|
||||
if (
|
||||
params &&
|
||||
"new_billing_subscription" in params &&
|
||||
params.new_billing_subscription
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const processorSubscriptionId =
|
||||
params && "processor_subscription_id" in params
|
||||
? params.processor_subscription_id
|
||||
: undefined;
|
||||
|
||||
const { org, env } = ctx;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
@@ -40,7 +54,8 @@ export const fetchStripeSubscriptionForBilling = async ({
|
||||
cusProductId: targetCusProductId,
|
||||
});
|
||||
|
||||
const subId = cusProductWithSub?.subscription_ids?.[0];
|
||||
const subId =
|
||||
processorSubscriptionId ?? cusProductWithSub?.subscription_ids?.[0];
|
||||
|
||||
if (!subId) return undefined;
|
||||
|
||||
@@ -63,5 +78,11 @@ export const fetchStripeSubscriptionForBilling = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (sub.customer !== fullCus.processor.id) {
|
||||
throw new InternalError({
|
||||
message: `[Stripe Subscription] Subscription is not for the current customer: ${subId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return sub as StripeSubscriptionWithDiscounts;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type {
|
||||
AttachDiscount,
|
||||
AttachParamsV1,
|
||||
BillingContextOverride,
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
MultiAttachParamsV0,
|
||||
Product,
|
||||
UpdateSubscriptionV1Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { fetchStripeCustomerForBilling } from "./fetchStripeCustomerForBilling";
|
||||
@@ -17,28 +19,39 @@ export const setupStripeBillingContext = async ({
|
||||
product,
|
||||
targetCustomerProduct,
|
||||
contextOverride = {},
|
||||
paramDiscounts,
|
||||
newBillingSubscription,
|
||||
// paramDiscounts,
|
||||
params,
|
||||
// newBillingSubscription,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCustomer: FullCustomer;
|
||||
product?: Product;
|
||||
targetCustomerProduct?: FullCusProduct;
|
||||
contextOverride?: BillingContextOverride;
|
||||
paramDiscounts?: AttachDiscount[];
|
||||
newBillingSubscription?: boolean;
|
||||
// paramDiscounts?: AttachDiscount[];
|
||||
params?: AttachParamsV1 | MultiAttachParamsV0 | UpdateSubscriptionV1Params;
|
||||
// newBillingSubscription?: boolean;
|
||||
}) => {
|
||||
const { stripeBillingContext } = contextOverride;
|
||||
|
||||
if (stripeBillingContext) return stripeBillingContext;
|
||||
|
||||
const {
|
||||
stripeCus: stripeCustomer,
|
||||
paymentMethod,
|
||||
testClockFrozenTime,
|
||||
} = await fetchStripeCustomerForBilling({
|
||||
ctx,
|
||||
fullCus: fullCustomer,
|
||||
});
|
||||
|
||||
// If no target customer product, skip subscription/schedule fetching
|
||||
const stripeSubscription = await fetchStripeSubscriptionForBilling({
|
||||
ctx,
|
||||
fullCus: fullCustomer,
|
||||
product,
|
||||
targetCusProductId: targetCustomerProduct?.id,
|
||||
newBillingSubscription,
|
||||
params,
|
||||
});
|
||||
|
||||
const stripeSubscriptionSchedule = targetCustomerProduct
|
||||
@@ -54,20 +67,12 @@ export const setupStripeBillingContext = async ({
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
stripeCus: stripeCustomer,
|
||||
paymentMethod,
|
||||
testClockFrozenTime,
|
||||
} = await fetchStripeCustomerForBilling({
|
||||
ctx,
|
||||
fullCus: fullCustomer,
|
||||
});
|
||||
|
||||
const stripeDiscounts = await fetchStripeDiscountsForBilling({
|
||||
ctx,
|
||||
stripeSubscription,
|
||||
stripeCustomer,
|
||||
paramDiscounts,
|
||||
paramDiscounts:
|
||||
params && "discounts" in params ? params.discounts : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -81,7 +81,8 @@ export const productToInsertParams = ({
|
||||
cusProducts: fullCus.customer_products,
|
||||
freeTrial: null,
|
||||
optionsList: [],
|
||||
internalEntityId: undefined,
|
||||
internalEntityId: fullCus.entity?.internal_id ?? undefined,
|
||||
entityId: fullCus.entity?.id ?? undefined,
|
||||
entities: entities || [],
|
||||
replaceables: [],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
customerProductEligibleForDefaultProduct,
|
||||
enrichFullCustomerWithEntity,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
isCustomerProductCustomerScoped,
|
||||
type FullProduct,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
||||
@@ -12,13 +14,22 @@ export const activateFreeDefaultProduct = async ({
|
||||
ctx,
|
||||
customerProduct,
|
||||
fullCustomer,
|
||||
defaultProduct,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerProduct: FullCusProduct;
|
||||
fullCustomer: FullCustomer;
|
||||
defaultProduct?: FullProduct;
|
||||
}): Promise<FullCusProduct | undefined> => {
|
||||
const { logger } = ctx;
|
||||
if (!isCustomerProductCustomerScoped(customerProduct)) {
|
||||
|
||||
// customerProduct eligible for default product
|
||||
const eligibleForDefaultProduct = customerProductEligibleForDefaultProduct({
|
||||
ctx,
|
||||
customerProduct,
|
||||
});
|
||||
|
||||
if (!eligibleForDefaultProduct) {
|
||||
logger.debug(
|
||||
`[activateFreeDefaultProduct] Skipping - product is not main recurring customer scoped: ${customerProduct.product.name}`,
|
||||
);
|
||||
@@ -26,10 +37,12 @@ export const activateFreeDefaultProduct = async ({
|
||||
}
|
||||
|
||||
// 1. Get free default product for group
|
||||
const freeDefaultProduct = await productActions.getFreeDefaultByGroup({
|
||||
ctx,
|
||||
productGroup: customerProduct.product.group,
|
||||
});
|
||||
const freeDefaultProduct =
|
||||
defaultProduct ??
|
||||
(await productActions.getFreeDefaultByGroup({
|
||||
ctx,
|
||||
productGroup: customerProduct.product.group,
|
||||
}));
|
||||
|
||||
if (!freeDefaultProduct) return;
|
||||
|
||||
@@ -37,7 +50,10 @@ export const activateFreeDefaultProduct = async ({
|
||||
const newCustomerProduct = initFullCustomerProductFromProduct({
|
||||
ctx,
|
||||
initContext: {
|
||||
fullCustomer,
|
||||
fullCustomer: enrichFullCustomerWithEntity({
|
||||
fullCustomer,
|
||||
internalEntityId: customerProduct.internal_entity_id ?? null,
|
||||
}),
|
||||
fullProduct: freeDefaultProduct,
|
||||
currentEpochMs: Date.now(),
|
||||
featureQuantities: [],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getApiEntity } from "../entityUtils/apiEntityUtils/getApiEntity";
|
||||
import { constructEntity } from "../entityUtils/entityUtils";
|
||||
import { createEntityForCusProduct } from "../handlers/handleCreateEntity/createEntityForCusProduct";
|
||||
import { validateAndGetInputEntities } from "../handlers/handleCreateEntity/getInputEntities";
|
||||
import { attachDefaultProductsToEntities } from "./batchCreateEntities/attachDefaultProductsToEntities";
|
||||
|
||||
export const batchCreateEntities = async ({
|
||||
ctx,
|
||||
@@ -86,11 +87,16 @@ export const batchCreateEntities = async ({
|
||||
|
||||
newEntities.push(...insertedEntities);
|
||||
|
||||
await attachDefaultProductsToEntities({
|
||||
ctx,
|
||||
fullCustomer: fullCus,
|
||||
entities: newEntities,
|
||||
customerData,
|
||||
});
|
||||
|
||||
// Get api entity for each entity...
|
||||
const apiEntities = [];
|
||||
for (const entity of newEntities) {
|
||||
// Cloned fullCus
|
||||
|
||||
const clonedFullCus = structuredClone(fullCus);
|
||||
clonedFullCus.entity = entity;
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
type CustomerData,
|
||||
type Entity,
|
||||
type FullCustomer,
|
||||
isFreeProduct,
|
||||
orgDefaultAppliesToEntities,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
||||
import { initFullCustomerProductFromProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProductFromProduct";
|
||||
import { setupDefaultProductsContext } from "@/internal/customers/actions/createWithDefaults/setup/setupDefaultProductsContext";
|
||||
|
||||
export const attachDefaultProductsToEntities = async ({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
entities,
|
||||
customerData,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCustomer: FullCustomer;
|
||||
entities: Entity[];
|
||||
customerData?: CustomerData;
|
||||
}) => {
|
||||
if (!orgDefaultAppliesToEntities({ ctx })) return;
|
||||
|
||||
const defaultProducts = await setupDefaultProductsContext({
|
||||
ctx,
|
||||
customerData,
|
||||
});
|
||||
|
||||
const freeDefaultProducts = defaultProducts.fullProducts.filter((product) =>
|
||||
isFreeProduct({ prices: product.prices }),
|
||||
);
|
||||
|
||||
const currentEpochMs = Date.now();
|
||||
for (const entity of entities) {
|
||||
const insertCustomerProducts = freeDefaultProducts.map((product) =>
|
||||
initFullCustomerProductFromProduct({
|
||||
ctx,
|
||||
initContext: {
|
||||
fullCustomer: {
|
||||
...fullCustomer,
|
||||
entity: entity,
|
||||
},
|
||||
fullProduct: product,
|
||||
currentEpochMs,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await executeAutumnBillingPlan({
|
||||
ctx,
|
||||
autumnBillingPlan: {
|
||||
customerId: fullCustomer.id ?? "",
|
||||
insertCustomerProducts,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { queryWithCache } from "@/utils/cacheUtils/queryWithCache";
|
||||
import { buildProductsCacheKey, PRODUCTS_CACHE_TTL } from "./productCacheUtils";
|
||||
import { getLatestProducts } from "./productUtils";
|
||||
import { getLatestProducts, isFreeProduct } from "./productUtils";
|
||||
import { sortFullProducts } from "./productUtils/sortProductUtils";
|
||||
|
||||
const parseFreeTrials = ({
|
||||
@@ -131,12 +131,14 @@ export class ProductService {
|
||||
env,
|
||||
group,
|
||||
inIds,
|
||||
onlyFree = false,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
group?: string;
|
||||
inIds?: string[];
|
||||
onlyFree?: boolean;
|
||||
}) {
|
||||
const prods = (await db.query.products.findMany({
|
||||
where: and(
|
||||
@@ -167,6 +169,12 @@ export class ProductService {
|
||||
|
||||
const latestProducts = getLatestProducts(prods);
|
||||
|
||||
if (onlyFree) {
|
||||
return latestProducts.filter((p) =>
|
||||
isFreeProduct(p.prices),
|
||||
) as FullProduct[];
|
||||
}
|
||||
|
||||
return latestProducts as FullProduct[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { TestGroup } from "../types";
|
||||
|
||||
export const temp: TestGroup = {
|
||||
name: "temp",
|
||||
description: "Tests created in this current session",
|
||||
tier: "domain",
|
||||
paths: ["auto-topup"],
|
||||
};
|
||||
@@ -50,7 +50,6 @@ const allGroups: TestGroup[] = [
|
||||
webhooks,
|
||||
advanced,
|
||||
misc,
|
||||
temp,
|
||||
];
|
||||
|
||||
export const getAllGroups = (): TestGroup[] => allGroups;
|
||||
|
||||
@@ -2,16 +2,37 @@ import type { TestGroup } from "./types";
|
||||
|
||||
export const temp: TestGroup = {
|
||||
name: "temp",
|
||||
description: "Failed tests from billing V2 run",
|
||||
description: "Default product behavior verification tests",
|
||||
tier: "domain",
|
||||
paths: [
|
||||
"integration/billing/multi-attach/customize/multi-attach-customize-addons.test.ts",
|
||||
"integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts",
|
||||
"integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts",
|
||||
"integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice-discounts.test.ts",
|
||||
"integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-discounts.test.ts",
|
||||
"integration/crud/customers/create-customer.test.ts",
|
||||
"integration/crud/customers/update-customer.test.ts",
|
||||
"integration/crud/customers/cross-version-list-customers.test.ts",
|
||||
// Cancel immediately with default tests
|
||||
"integration/billing/update-subscription/cancel/immediately/cancel-immediately.test.ts",
|
||||
|
||||
// Cancel end of cycle tests
|
||||
"integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle.test.ts",
|
||||
|
||||
// Uncancel tests
|
||||
"integration/billing/update-subscription/cancel/uncancel/uncancel-basic.test.ts",
|
||||
|
||||
// Create customer with defaults
|
||||
"integration/crud/customers/create-customer-defaults.test.ts",
|
||||
|
||||
// Default applies to entities (the new behavior)
|
||||
"integration/org-config/default-applies-to-entity.test.ts",
|
||||
|
||||
// Stripe webhook: subscription deleted (activates defaults)
|
||||
"integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts",
|
||||
|
||||
// Stripe webhook: subscription updated uncancel
|
||||
"integration/billing/stripe-webhooks/subscription-updated/subscription-updated-uncancel.test.ts",
|
||||
|
||||
// Scheduled switch basic (pro to free downgrade flows)
|
||||
"integration/billing/attach/scheduled-switch/scheduled-switch-basic.test.ts",
|
||||
|
||||
// Scheduled switch with entities
|
||||
"integration/billing/attach/scheduled-switch/scheduled-switch-entities-basic.test.ts",
|
||||
|
||||
// Invoice created consumable (usage-in-arrear)
|
||||
"integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable.test.ts",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
type AttachParamsV0Input,
|
||||
secondsToMs,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { createCustomStripeSubscription } from "@tests/integration/billing/utils/stripe/createCustomStripeSubscription";
|
||||
import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { advanceTestClock } from "@tests/utils/stripeUtils";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { addMonths } from "date-fns";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { expectCustomerInvoiceCorrect } from "../../utils/expectCustomerInvoiceCorrect";
|
||||
|
||||
test(`${chalk.yellowBright("processor_subscription_id: attach with existing stripe subscription anchors reset cycle")}`, async () => {
|
||||
const customerId = "processor-sub-id-anchor";
|
||||
|
||||
const monthlyMessages = items.monthlyMessages({ includedUsage: 100 });
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [monthlyMessages],
|
||||
});
|
||||
|
||||
const { autumnV1, ctx, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: true, paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const stripeSubscription = await createCustomStripeSubscription({
|
||||
ctx,
|
||||
customerId,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
const billingCycleAnchorMs = secondsToMs(
|
||||
stripeSubscription.billing_cycle_anchor,
|
||||
);
|
||||
|
||||
expect(testClockId).toBeDefined();
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
numberOfWeeks: 2,
|
||||
});
|
||||
|
||||
await autumnV1.billing.attach<AttachParamsV0Input>({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
processor_subscription_id: stripeSubscription.id,
|
||||
});
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer: customerAfter,
|
||||
active: [pro.id],
|
||||
});
|
||||
|
||||
const fullCustomerAfter = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
|
||||
const cusProduct = fullCustomerAfter.customer_products.find(
|
||||
(cp) => cp.product_id === pro.id,
|
||||
);
|
||||
|
||||
expect(cusProduct).toBeDefined();
|
||||
expect(cusProduct!.subscription_ids).toContain(stripeSubscription.id);
|
||||
|
||||
const messagesResetAt =
|
||||
customerAfter.features[TestFeature.Messages]?.next_reset_at;
|
||||
expect(messagesResetAt).toBeDefined();
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
resetsAt: addMonths(billingCycleAnchorMs, 1).getTime(),
|
||||
});
|
||||
|
||||
const stripeSubscriptionAfter = await ctx.stripeCli.subscriptions.retrieve(
|
||||
stripeSubscription.id,
|
||||
);
|
||||
expect(stripeSubscriptionAfter.status).toEqual("active");
|
||||
expectStripeSubscriptionUnchanged({
|
||||
before: stripeSubscription,
|
||||
after: stripeSubscriptionAfter,
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customerId,
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("processor_subscription_id: upgrade with no_billing_changes preserves anchor and subscription")}`, async () => {
|
||||
const customerId = "processor-sub-id-upgrade";
|
||||
|
||||
const monthlyMessages = items.monthlyMessages({ includedUsage: 100 });
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [monthlyMessages],
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const stripeSubscription = await createCustomStripeSubscription({
|
||||
ctx,
|
||||
customerId,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
const billingCycleAnchorMs = secondsToMs(
|
||||
stripeSubscription.billing_cycle_anchor,
|
||||
);
|
||||
|
||||
await autumnV1.billing.attach<AttachParamsV0Input>({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
processor_subscription_id: stripeSubscription.id,
|
||||
});
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
active: [pro.id],
|
||||
});
|
||||
|
||||
await autumnV1.billing.attach<AttachParamsV0Input>({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
processor_subscription_id: stripeSubscription.id,
|
||||
no_billing_changes: true,
|
||||
});
|
||||
|
||||
const customerAfterUpgrade =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer: customerAfterUpgrade,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfterUpgrade,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
balance: 500,
|
||||
resetsAt: addMonths(billingCycleAnchorMs, 1).getTime(),
|
||||
});
|
||||
|
||||
const stripeSubscriptionAfterUpgrade =
|
||||
await ctx.stripeCli.subscriptions.retrieve(stripeSubscription.id);
|
||||
|
||||
expect(stripeSubscriptionAfterUpgrade.status).toEqual("active");
|
||||
expectStripeSubscriptionUnchanged({
|
||||
before: stripeSubscription,
|
||||
after: stripeSubscriptionAfterUpgrade,
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customerId,
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,7 @@ test(`${chalk.yellowBright("sub.deleted: cancel active subscription via Stripe (
|
||||
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
|
||||
|
||||
// Wait for webhook to process
|
||||
await timeout(8000);
|
||||
await timeout(12000);
|
||||
|
||||
// Verify pro is gone and free is active
|
||||
const customerAfterCancel =
|
||||
@@ -189,7 +189,7 @@ test(`${chalk.yellowBright("sub.deleted: cancel after end_of_cycle via Stripe")}
|
||||
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
|
||||
|
||||
// Wait for webhook to process
|
||||
await timeout(8000);
|
||||
await timeout(12000);
|
||||
|
||||
// Verify pro is gone and free is active
|
||||
const customerAfterCancel =
|
||||
@@ -284,7 +284,7 @@ test(`${chalk.yellowBright("sub.deleted: cancel with scheduled downgrade via Str
|
||||
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
|
||||
|
||||
// Wait for webhook to process
|
||||
await timeout(8000);
|
||||
await timeout(12000);
|
||||
|
||||
// Verify premium and pro are gone, free is active
|
||||
const customerAfterCancel =
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { test } from "bun:test";
|
||||
import chalk from "chalk";
|
||||
|
||||
test(`${chalk.yellowBright("processor_subscription_id: attach with existing stripe subscription anchors reset cycle")}`, async () => {});
|
||||
|
||||
test(`${chalk.yellowBright("processor_subscription_id: upgrade with no_billing_changes preserves anchor and subscription")}`, async () => {});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { test } from "bun:test";
|
||||
import chalk from "chalk";
|
||||
|
||||
test(`${chalk.yellowBright("processor_subscription_id: attach with existing stripe subscription anchors reset cycle")}`, async () => {});
|
||||
|
||||
test(`${chalk.yellowBright("processor_subscription_id: upgrade with no_billing_changes preserves anchor and subscription")}`, async () => {});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type {
|
||||
ApiCustomerV3,
|
||||
ApiCustomerV5,
|
||||
CustomerExpand,
|
||||
UpdateSubscriptionV1ParamsInput,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { createCustomStripeSubscription } from "@tests/integration/billing/utils/stripe/createCustomStripeSubscription";
|
||||
import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
|
||||
// ─── Test 1: Update processor_subscription_id to null clears subscription_ids ───
|
||||
|
||||
test(`${chalk.yellowBright("update processor_subscription_id: setting null clears subscription_ids")}`, async () => {
|
||||
const customerId = "update-proc-sub-id-null";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({ id: "pro", items: [messagesItem] });
|
||||
|
||||
const { autumnV1, autumnV2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
|
||||
customer_id: customerId,
|
||||
plan_id: pro.id,
|
||||
processor_subscription_id: null,
|
||||
});
|
||||
|
||||
const fullCustomerAfter = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
|
||||
const cusProductAfter = fullCustomerAfter.customer_products.find(
|
||||
(cp) => cp.product_id === pro.id,
|
||||
);
|
||||
expect(cusProductAfter).toBeDefined();
|
||||
expect(cusProductAfter?.subscription_ids).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── Test 2: Update processor_subscription_id to a new stripe subscription ───
|
||||
|
||||
test(`${chalk.yellowBright("update processor_subscription_id: set to new stripe subscription links correctly")}`, async () => {
|
||||
const customerId = "update-proc-sub-id-set";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({ id: "pro", items: [messagesItem] });
|
||||
|
||||
const { autumnV1, autumnV2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const newStripeSubscription = await createCustomStripeSubscription({
|
||||
ctx,
|
||||
customerId,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
|
||||
customer_id: customerId,
|
||||
plan_id: pro.id,
|
||||
processor_subscription_id: newStripeSubscription.id,
|
||||
});
|
||||
|
||||
const fullCustomerAfter = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
|
||||
const cusProductAfter = fullCustomerAfter.customer_products.find(
|
||||
(cp) => cp.product_id === pro.id,
|
||||
);
|
||||
expect(cusProductAfter).toBeDefined();
|
||||
expect(cusProductAfter?.subscription_ids).toContain(newStripeSubscription.id);
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerProducts({ customer: customerAfter, active: [pro.id] });
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
});
|
||||
|
||||
const stripeSubscriptionAfter = await ctx.stripeCli.subscriptions.retrieve(
|
||||
newStripeSubscription.id,
|
||||
);
|
||||
expectStripeSubscriptionUnchanged({
|
||||
before: newStripeSubscription,
|
||||
after: stripeSubscriptionAfter,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 3: Update processor_subscription_id + customize simultaneously ───
|
||||
// Stripe subscription should remain unchanged, only Autumn plan is updated.
|
||||
|
||||
test(`${chalk.yellowBright("update processor_subscription_id: with customize leaves stripe subscription unchanged")}`, async () => {
|
||||
const customerId = "update-proc-sub-id-customize";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({ id: "pro", items: [messagesItem] });
|
||||
|
||||
const { autumnV1, autumnV2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const newStripeSubscription = await createCustomStripeSubscription({
|
||||
ctx,
|
||||
customerId,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
|
||||
customer_id: customerId,
|
||||
plan_id: pro.id,
|
||||
processor_subscription_id: newStripeSubscription.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 50 }),
|
||||
items: [itemsV2.monthlyMessages({ included: 250 })],
|
||||
},
|
||||
});
|
||||
|
||||
const fullCustomerAfter = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
|
||||
const cusProductAfter = fullCustomerAfter.customer_products.find(
|
||||
(cp) => cp.product_id === pro.id,
|
||||
);
|
||||
expect(cusProductAfter).toBeDefined();
|
||||
expect(cusProductAfter?.subscription_ids).toContain(newStripeSubscription.id);
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerProducts({ customer: customerAfter, active: [pro.id] });
|
||||
|
||||
const customerV2After = await autumnV2.customers.get<ApiCustomerV5>(
|
||||
customerId,
|
||||
{
|
||||
expand: ["subscriptions.plan" as CustomerExpand],
|
||||
},
|
||||
);
|
||||
expect(customerV2After.subscriptions[0].plan?.price?.amount).toBe(50);
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 250,
|
||||
});
|
||||
|
||||
const stripeSubscriptionAfter = await ctx.stripeCli.subscriptions.retrieve(
|
||||
newStripeSubscription.id,
|
||||
);
|
||||
expectStripeSubscriptionUnchanged({
|
||||
before: newStripeSubscription,
|
||||
after: stripeSubscriptionAfter,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type Stripe from "stripe";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
|
||||
/**
|
||||
* Creates a Stripe subscription for a customer using an inline price
|
||||
* derived from the Autumn product's Stripe processor ID.
|
||||
*/
|
||||
export const createCustomStripeSubscription = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
productId,
|
||||
unitAmount = 2000,
|
||||
interval = "month",
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
customerId: string;
|
||||
productId: string;
|
||||
unitAmount?: number;
|
||||
interval?: Stripe.PriceCreateParams.Recurring.Interval;
|
||||
}): Promise<Stripe.Subscription> => {
|
||||
const [fullCustomer, fullProduct] = await Promise.all([
|
||||
CusService.getFull({ ctx, idOrInternalId: customerId }),
|
||||
ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: productId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
}),
|
||||
]);
|
||||
|
||||
const stripeCustomerId = fullCustomer.processor.id ?? "";
|
||||
const stripeProductId = fullProduct.processor?.id ?? "";
|
||||
|
||||
return ctx.stripeCli.subscriptions.create({
|
||||
customer: stripeCustomerId,
|
||||
items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product: stripeProductId,
|
||||
unit_amount: unitAmount,
|
||||
recurring: { interval },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect } from "bun:test";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Asserts that two Stripe subscriptions are equivalent --
|
||||
* same items (price IDs + quantities) and billing_cycle_anchor.
|
||||
*/
|
||||
export const expectStripeSubscriptionUnchanged = ({
|
||||
before,
|
||||
after,
|
||||
}: {
|
||||
before: Stripe.Subscription;
|
||||
after: Stripe.Subscription;
|
||||
}) => {
|
||||
expect(
|
||||
after.billing_cycle_anchor,
|
||||
"billing_cycle_anchor should be unchanged",
|
||||
).toEqual(before.billing_cycle_anchor);
|
||||
|
||||
expect(
|
||||
after.items.data.length,
|
||||
"subscription should have the same number of items",
|
||||
).toEqual(before.items.data.length);
|
||||
|
||||
for (const beforeItem of before.items.data) {
|
||||
const afterItem = after.items.data.find(
|
||||
(i) => i.price.id === beforeItem.price.id,
|
||||
);
|
||||
expect(
|
||||
afterItem,
|
||||
`subscription item with price ${beforeItem.price.id} should still exist`,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
afterItem!.quantity,
|
||||
`quantity for price ${beforeItem.price.id} should be unchanged`,
|
||||
).toEqual(beforeItem.quantity);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Tests for default_applies_to_entities org config behavior.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
customerProducts,
|
||||
FreeTrialDuration,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { getEntitySubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import defaultCtx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { runProductCron } from "@/cron/productCron/runProductCron";
|
||||
import { db } from "@/db/initDrizzle";
|
||||
import { logger } from "@/external/logtail/logtailUtils";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { OrgService } from "@/internal/orgs/OrgService";
|
||||
import { timeout } from "@/utils/genUtils";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Default applies to entities
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("default applies to entities", () => {
|
||||
beforeAll(async () => {
|
||||
await OrgService.update({
|
||||
db: db,
|
||||
orgId: defaultCtx.org.id,
|
||||
updates: {
|
||||
config: {
|
||||
...defaultCtx.org.config,
|
||||
default_applies_to_entities: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await OrgService.update({
|
||||
db: db,
|
||||
orgId: defaultCtx.org.id,
|
||||
updates: {
|
||||
config: {
|
||||
...defaultCtx.org.config,
|
||||
default_applies_to_entities: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("default applies to entities 1")}`, async () => {
|
||||
const customerId = "default-applies-to-entities";
|
||||
|
||||
const consumableMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 100,
|
||||
});
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
isDefault: true,
|
||||
items: [consumableMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
notPresent: [free.id],
|
||||
});
|
||||
|
||||
await autumnV1.entities.create(customerId, {
|
||||
id: "user1",
|
||||
name: "User 1",
|
||||
feature_id: TestFeature.Users,
|
||||
customer_data: {
|
||||
internal_options: {
|
||||
default_group: customerId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const entity = await autumnV1.entities.get(customerId, "user1");
|
||||
await expectCustomerProducts({
|
||||
customer: entity,
|
||||
active: [free.id],
|
||||
});
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("default applies to entities 2: product cron expires trial and activates default")}`, async () => {
|
||||
const customerId = "cron-default-entity";
|
||||
|
||||
const monthlyMessages = items.monthlyMessages({
|
||||
includedUsage: 100,
|
||||
});
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
isDefault: true,
|
||||
items: [monthlyMessages],
|
||||
});
|
||||
|
||||
const proTrial = products.base({
|
||||
id: "pro-trial",
|
||||
items: [monthlyMessages],
|
||||
freeTrial: {
|
||||
length: 7,
|
||||
duration: FreeTrialDuration.Day,
|
||||
},
|
||||
});
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, proTrial] }),
|
||||
s.entities({
|
||||
count: 1,
|
||||
featureId: TestFeature.Users,
|
||||
defaultGroup: customerId,
|
||||
}),
|
||||
],
|
||||
actions: [s.attach({ productId: proTrial.id, entityIndex: 0 })],
|
||||
});
|
||||
|
||||
const entityBefore = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: entityBefore,
|
||||
active: [proTrial.id],
|
||||
});
|
||||
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx: defaultCtx,
|
||||
idOrInternalId: customerId,
|
||||
withEntities: true,
|
||||
});
|
||||
|
||||
const trialCusProduct = fullCustomer.customer_products.find(
|
||||
(cp) => cp.product_id === proTrial.id && cp.entity_id === entities[0].id,
|
||||
);
|
||||
expect(trialCusProduct).toBeDefined();
|
||||
|
||||
const pastTrialEnd = Date.now() - 60_000;
|
||||
await db
|
||||
.update(customerProducts)
|
||||
.set({ trial_ends_at: pastTrialEnd })
|
||||
.where(eq(customerProducts.id, trialCusProduct!.id));
|
||||
|
||||
await runProductCron({ ctx: { db, logger } });
|
||||
|
||||
const entityAfter = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
await expectCustomerProducts({
|
||||
customer: entityAfter,
|
||||
active: [free.id],
|
||||
notPresent: [proTrial.id],
|
||||
});
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("default applies to entities 3: cancel end of cycle schedules free on entity")}`, async () => {
|
||||
const customerId = "cancel-eoc-entity-default";
|
||||
|
||||
const monthlyMessages = items.monthlyMessages({
|
||||
includedUsage: 100,
|
||||
});
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
isDefault: true,
|
||||
items: [monthlyMessages],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [monthlyMessages],
|
||||
});
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro] }),
|
||||
s.entities({
|
||||
count: 2,
|
||||
featureId: TestFeature.Users,
|
||||
defaultGroup: customerId,
|
||||
}),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entity1Before = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: entity1Before,
|
||||
active: [pro.id],
|
||||
});
|
||||
|
||||
await autumnV1.subscriptions.update({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
cancel_action: "cancel_end_of_cycle",
|
||||
});
|
||||
|
||||
const entity1After = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: entity1After,
|
||||
canceling: [pro.id],
|
||||
scheduled: [free.id],
|
||||
});
|
||||
|
||||
const entity2After = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: entity2After,
|
||||
active: [pro.id],
|
||||
});
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("default applies to entities 4: stripe cancel schedules free on entity")}`, async () => {
|
||||
const customerId = "stripe-cancel-entity-default";
|
||||
|
||||
const monthlyMessages = items.monthlyMessages({
|
||||
includedUsage: 100,
|
||||
});
|
||||
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
isDefault: true,
|
||||
items: [monthlyMessages],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [monthlyMessages],
|
||||
});
|
||||
|
||||
const { autumnV1, ctx, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro] }),
|
||||
s.entities({
|
||||
count: 1,
|
||||
featureId: TestFeature.Users,
|
||||
defaultGroup: customerId,
|
||||
}),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id, entityIndex: 0 })],
|
||||
});
|
||||
|
||||
const entityBefore = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: entityBefore,
|
||||
active: [pro.id],
|
||||
});
|
||||
|
||||
const subscriptionId = await getEntitySubscriptionId({
|
||||
ctx,
|
||||
customerId,
|
||||
entityId: entities[0].id,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
|
||||
|
||||
await timeout(12000);
|
||||
|
||||
const entityAfter = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
await expectCustomerProducts({
|
||||
customer: entityAfter,
|
||||
active: [free.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Invoice Created Webhook Tests - Consumable Edge Cases
|
||||
*
|
||||
* Tests for edge case scenarios involving consumable (usage-in-arrear) prices
|
||||
* during downgrades, multiple subscriptions, and complex billing scenarios.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { timeout } from "@tests/utils/genUtils";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { db } from "@/db/initDrizzle";
|
||||
import { OrgService } from "@/internal/orgs/OrgService";
|
||||
import { expectCustomerFeatureCorrect } from "../billing/utils/expectCustomerFeatureCorrect";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Addon with separate subscription + consumable
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Pro ($20/mo) with consumable messages (100 included, $0.10/unit)
|
||||
* - Recurring Addon ($20/mo) with consumable words (50 included, $0.05/unit)
|
||||
* - Addon attached with new_billing_subscription: true (separate Stripe subscription)
|
||||
* - Track 200 messages (100 overage) and 150 words (100 overage)
|
||||
* - Advance to next billing cycle
|
||||
*
|
||||
* Expected Result:
|
||||
* - Pro invoice: $20 base + $10 message overage = $30
|
||||
* - Addon invoice: $20 base + $5 word overage = $25
|
||||
* - Each subscription's invoice has its own product's overage
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("skip overage submission 1")}`, async () => {
|
||||
const customerId = "skip-overage-submission";
|
||||
|
||||
const consumableMessagesItem = items.consumableMessages({
|
||||
includedUsage: 100,
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [consumableMessagesItem],
|
||||
});
|
||||
|
||||
// Save original org config and enable void_invoices_on_subscription_deletion
|
||||
// This must be set in the database because webhooks read config from DB, not request headers
|
||||
|
||||
const { ctx, autumnV1, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
// 1. Attach Pro
|
||||
s.attach({ productId: pro.id }),
|
||||
],
|
||||
});
|
||||
|
||||
await OrgService.update({
|
||||
db: db,
|
||||
orgId: ctx.org.id,
|
||||
updates: {
|
||||
config: {
|
||||
...ctx.org.config,
|
||||
skip_overage_submission: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Verify final state
|
||||
const customerAfterAdvance =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Both products should be active
|
||||
await expectProductActive({
|
||||
customer: customerAfterAdvance,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 200,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
withPause: true,
|
||||
});
|
||||
|
||||
// Should have 4 invoices:
|
||||
// 1. Initial Pro ($20)
|
||||
// 3. Pro renewal: $20 base + $10 overage = $30
|
||||
const customerAfterAdvance2 =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: customerAfterAdvance2,
|
||||
count: 2,
|
||||
latestTotal: 20,
|
||||
latestInvoiceProductId: pro.id,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfterAdvance2,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify both balances are reset correctly
|
||||
expect(customerAfterAdvance2.features[TestFeature.Messages].balance).toBe(
|
||||
100,
|
||||
);
|
||||
|
||||
await OrgService.update({
|
||||
db: db,
|
||||
orgId: ctx.org.id,
|
||||
updates: {
|
||||
config: {
|
||||
...ctx.org.config,
|
||||
skip_overage_submission: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ type FeatureOption = {
|
||||
type EntityConfig = {
|
||||
count: number;
|
||||
featureId: string;
|
||||
defaultGroup?: string;
|
||||
};
|
||||
|
||||
type GeneratedEntity = {
|
||||
@@ -314,16 +315,20 @@ const products = ({
|
||||
* Entities are auto-generated with ids "ent-1", "ent-2", etc.
|
||||
* @param count - Number of entities to create
|
||||
* @param featureId - Feature ID for all entities (e.g., TestFeature.Users)
|
||||
* @param defaultGroup - Optional default_group passed via customer_data.internal_options
|
||||
* @example s.entities({ count: 2, featureId: TestFeature.Users })
|
||||
* @example s.entities({ count: 1, featureId: TestFeature.Users, defaultGroup: "my-customer" })
|
||||
*/
|
||||
const entities = ({
|
||||
count,
|
||||
featureId,
|
||||
defaultGroup,
|
||||
}: {
|
||||
count: number;
|
||||
featureId: string;
|
||||
defaultGroup?: string;
|
||||
}): ConfigFn => {
|
||||
return (config) => ({ ...config, entityConfig: { count, featureId } });
|
||||
return (config) => ({ ...config, entityConfig: { count, featureId, defaultGroup } });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1234,10 +1239,18 @@ export async function initScenario({
|
||||
"Cannot create entities: customerId is required when using s.entities()",
|
||||
);
|
||||
}
|
||||
const defaultGroup = config.entityConfig?.defaultGroup;
|
||||
const entityDefs = generatedEntities.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
feature_id: e.featureId,
|
||||
...(defaultGroup && {
|
||||
customer_data: {
|
||||
internal_options: {
|
||||
default_group: defaultGroup,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
await autumnV1.entities.create(customerId, entityDefs);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseV0Schema.extend({
|
||||
// - 'prorate_immediately' (default): Invoice line items are charged immediately
|
||||
// - 'next_cycle_only': Do NOT create any charges due to the attach
|
||||
billing_behavior: BillingBehaviorSchema.optional(),
|
||||
|
||||
// For importing an existing subscription...?
|
||||
processor_subscription_id: z.string().optional(),
|
||||
no_billing_changes: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const AttachParamsV0Schema = ExtAttachParamsV0Schema.extend({
|
||||
|
||||
@@ -39,6 +39,13 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({
|
||||
description:
|
||||
"Custom line items that override the auto-generated proration invoice. Only valid for immediate plan changes (eg. upgrades or one off plans).",
|
||||
}),
|
||||
|
||||
processor_subscription_id: z.string().optional().meta({
|
||||
internal: true,
|
||||
}),
|
||||
no_billing_changes: z.boolean().optional().meta({
|
||||
internal: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export type AttachParamsV1 = z.infer<typeof AttachParamsV1Schema>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CusProductStatus } from "@models/cusProductModels/cusProductEnums";
|
||||
import { nullish } from "@utils/utils";
|
||||
import { z } from "zod/v4";
|
||||
import { BillingBehaviorSchema } from "../common/billingBehavior";
|
||||
@@ -22,6 +23,16 @@ export const ExtUpdateSubscriptionV0ParamsSchema =
|
||||
// - 'prorate_immediately' (default): Invoice line items are charged immediately
|
||||
// - 'next_cycle_only': Do NOT create any charges due to the update
|
||||
billing_behavior: BillingBehaviorSchema.optional(),
|
||||
|
||||
processor_subscription_id: z.string().nullable().optional(),
|
||||
no_billing_changes: z.boolean().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
CusProductStatus.Active,
|
||||
CusProductStatus.PastDue,
|
||||
CusProductStatus.Expired,
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const UpdateSubscriptionV0ParamsSchema =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CusProductStatus } from "@models/cusProductModels/cusProductEnums";
|
||||
import { z } from "zod/v4";
|
||||
import { BillingParamsBaseV1Schema } from "../common/billingParamsBase/billingParamsBaseV1";
|
||||
import { CancelActionSchema } from "../common/cancelAction";
|
||||
@@ -12,23 +13,48 @@ export const ExtUpdateSubscriptionV1ParamsSchema =
|
||||
description:
|
||||
"Action to perform for cancellation. 'cancel_immediately' cancels now with prorated refund, 'cancel_end_of_cycle' cancels at period end, 'uncancel' reverses a pending cancellation.",
|
||||
}),
|
||||
|
||||
processor_subscription_id: z.string().nullable().optional().meta({
|
||||
internal: true,
|
||||
}),
|
||||
|
||||
no_billing_changes: z.boolean().optional().meta({
|
||||
internal: true,
|
||||
}),
|
||||
|
||||
status: z
|
||||
.enum([
|
||||
CusProductStatus.Active,
|
||||
CusProductStatus.PastDue,
|
||||
CusProductStatus.Expired,
|
||||
])
|
||||
.optional()
|
||||
.meta({
|
||||
internal: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const UPDATE_FIELDS = [
|
||||
"feature_quantities",
|
||||
"version",
|
||||
"customize",
|
||||
"cancel_action",
|
||||
"processor_subscription_id",
|
||||
"no_billing_changes",
|
||||
"status",
|
||||
] as const satisfies (keyof z.input<
|
||||
typeof ExtUpdateSubscriptionV1ParamsSchema
|
||||
>)[];
|
||||
|
||||
export const UpdateSubscriptionV1ParamsSchema =
|
||||
ExtUpdateSubscriptionV1ParamsSchema.extend({
|
||||
customer_product_id: z.string().optional().meta({
|
||||
internal: true,
|
||||
}),
|
||||
}).refine(
|
||||
(data) =>
|
||||
data.feature_quantities !== undefined ||
|
||||
data.version !== undefined ||
|
||||
data.customize !== undefined ||
|
||||
data.cancel_action !== undefined,
|
||||
{
|
||||
message:
|
||||
"At least one update parameter must be provided (feature_quantities, version, customize, or cancel_action)",
|
||||
},
|
||||
);
|
||||
}).refine((data) => UPDATE_FIELDS.some((key) => data[key] !== undefined), {
|
||||
message:
|
||||
"At least one update parameter must be provided (feature_quantities, version, customize, or cancel_action)",
|
||||
});
|
||||
|
||||
export type UpdateSubscriptionV1Params = z.infer<
|
||||
typeof UpdateSubscriptionV1ParamsSchema
|
||||
|
||||
@@ -68,4 +68,6 @@ export interface BillingContext {
|
||||
|
||||
billingVersion: BillingVersion;
|
||||
successUrl?: string;
|
||||
|
||||
skipBillingChanges?: boolean;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export const AutumnBillingPlanSchema = z.object({
|
||||
ended_at: z.number().nullish(),
|
||||
|
||||
scheduled_ids: z.array(z.string()).optional(),
|
||||
|
||||
subscription_ids: z.array(z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -21,6 +21,12 @@ export const OrgConfigSchema = z.object({
|
||||
invoice_memos: z.boolean().default(false),
|
||||
entity_product: z.boolean().default(false),
|
||||
void_invoices_on_subscription_deletion: z.boolean().default(false),
|
||||
|
||||
// default
|
||||
default_applies_to_entities: z.boolean().default(false),
|
||||
|
||||
// skip_overage_submission
|
||||
skip_overage_submission: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type OrgConfig = z.infer<typeof OrgConfigSchema>;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { CusProductStatus } from "@models/cusProductModels/cusProductEnums.js";
|
||||
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels.js";
|
||||
import type {
|
||||
CusProduct,
|
||||
FullCusProduct,
|
||||
} from "@models/cusProductModels/cusProductModels.js";
|
||||
import type { Product } from "@models/productModels/productModels";
|
||||
import { orgDefaultAppliesToEntities } from "../../..";
|
||||
import type { SharedContext } from "../../../types/sharedContext";
|
||||
import {
|
||||
isFreeProduct,
|
||||
isOneOffProduct,
|
||||
@@ -12,7 +18,9 @@ import { cusProductToPrices } from "../convertCusProduct";
|
||||
// PRODUCT TYPE CHECKS
|
||||
// ============================================================================
|
||||
|
||||
export const isCustomerProductMain = (customerProduct?: FullCusProduct) => {
|
||||
export const isCustomerProductMain = (
|
||||
customerProduct?: CusProduct & { product: Product },
|
||||
) => {
|
||||
if (!customerProduct) return false;
|
||||
return !customerProduct.product.is_add_on;
|
||||
};
|
||||
@@ -232,16 +240,32 @@ export const customerProductsHaveDuplicateProductId = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const isCustomerProductEntityScoped = (
|
||||
customerProduct?: FullCusProduct,
|
||||
) => {
|
||||
export const isCustomerProductEntityScoped = (customerProduct?: CusProduct) => {
|
||||
if (!customerProduct) return false;
|
||||
return notNullish(customerProduct.internal_entity_id);
|
||||
};
|
||||
|
||||
export const isCustomerProductCustomerScoped = (
|
||||
customerProduct?: FullCusProduct,
|
||||
customerProduct?: CusProduct,
|
||||
) => {
|
||||
if (!customerProduct) return false;
|
||||
return nullish(customerProduct.internal_entity_id);
|
||||
};
|
||||
|
||||
export const customerProductEligibleForDefaultProduct = ({
|
||||
ctx,
|
||||
customerProduct,
|
||||
}: {
|
||||
ctx: SharedContext;
|
||||
customerProduct: FullCusProduct;
|
||||
}) => {
|
||||
const orgDefaultScopeMatches = orgDefaultAppliesToEntities({ ctx })
|
||||
? isCustomerProductEntityScoped(customerProduct)
|
||||
: isCustomerProductCustomerScoped(customerProduct);
|
||||
|
||||
if (!orgDefaultScopeMatches) return false;
|
||||
if (!isCustomerProductMain(customerProduct)) return false;
|
||||
if (!isCustomerProductRecurring(customerProduct)) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels";
|
||||
import type { FullProduct } from "@models/productModels/productModels";
|
||||
import type { SharedContext } from "../../../types";
|
||||
import { customerProductEligibleForDefaultProduct } from "../classifyCustomerProduct/classifyCustomerProduct";
|
||||
|
||||
export const customerProductToDefaultProduct = ({
|
||||
ctx,
|
||||
customerProduct,
|
||||
defaultProducts,
|
||||
}: {
|
||||
ctx: SharedContext;
|
||||
customerProduct: FullCusProduct;
|
||||
defaultProducts: FullProduct[];
|
||||
}) => {
|
||||
const eligibleForDefaultProduct = customerProductEligibleForDefaultProduct({
|
||||
ctx,
|
||||
customerProduct,
|
||||
});
|
||||
|
||||
if (!eligibleForDefaultProduct) return undefined;
|
||||
|
||||
return defaultProducts.find(
|
||||
(p) =>
|
||||
p.group === customerProduct.product.group &&
|
||||
p.id !== customerProduct.product.id,
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppEnv } from "../../index.js";
|
||||
import { AppEnv, type SharedContext } from "../../index.js";
|
||||
import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js";
|
||||
import type { Organization } from "../../models/orgModels/orgTable.js";
|
||||
|
||||
@@ -26,3 +26,11 @@ export const orgToReturnUrl = ({
|
||||
return org.stripe_config?.success_url || "https://useautumn.com";
|
||||
}
|
||||
};
|
||||
|
||||
export const orgDefaultAppliesToEntities = ({
|
||||
ctx,
|
||||
}: {
|
||||
ctx: SharedContext;
|
||||
}) => {
|
||||
return ctx.org.config.default_applies_to_entities;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user