feat: atomic update usage

This commit is contained in:
John Yeo
2026-02-24 13:34:34 +00:00
parent 0cbd018aab
commit d5a226e8fd
85 changed files with 484 additions and 490 deletions

View File

@@ -2,26 +2,26 @@
source "$(dirname "$0")/config.sh"
export TEST_FILE_CONCURRENCY=6
export TEST_FILE_CONCURRENCY=3
BUN_PARALLEL_V2 \
'billing/legacy/attach'
BUN_PARALLEL_V2 \
'attach/basic' \
'attach/upgrade' \
'attach/downgrade' \
'attach/free' \
'attach/addOn' \
'attach/checkout' \
'attach/others' \
'attach/upgradeOld' \
'attach/response' \
'interval/upgrade' \
'interval/multiSub' \
'server/tests/attach/entities' \
--max=6
# BUN_PARALLEL_V2 \
# 'attach/basic' \
# 'attach/upgrade' \
# 'attach/downgrade' \
# 'attach/free' \
# 'attach/addOn' \
# 'attach/checkout' \
# 'attach/others' \
# 'attach/upgradeOld' \
# 'attach/response' \
# 'interval/upgrade' \
# 'interval/multiSub' \
# 'server/tests/attach/entities' \
# --max=6
# # 'billing/invoice-action-required' \

View File

@@ -1,4 +1,5 @@
import { CusProductStatus } from "@autumn/shared";
import { type AppEnv, CusProductStatus } from "@autumn/shared";
import type { RepoContext } from "@/db/repoContext.js";
import { getOneOffCustomerProductsToCleanup } from "@/internal/customers/cusProducts/actions/cleanupOneOff/getOneOffToCleanup.js";
import { batchUpdateCustomerProducts } from "@/internal/customers/cusProducts/repos/batchUpdateCustomerProducts.js";
import type { CronContext } from "../utils/CronContext.js";
@@ -33,13 +34,44 @@ export const runOneOffCleanup = async ({ ctx }: { ctx: CronContext }) => {
);
}
await batchUpdateCustomerProducts({
db: ctx.db,
updates: toCleanup.map((result) => ({
id: result.customer_product.id,
updates: { status: CusProductStatus.Expired },
})),
});
// Group customer products by org and env
const groupedByOrgEnv = new Map<
string,
{ orgId: string; env: AppEnv; customerProductIds: string[] }
>();
for (const result of toCleanup) {
const key = `${result.org.id}:${result.customer.env}`;
if (!groupedByOrgEnv.has(key)) {
groupedByOrgEnv.set(key, {
orgId: result.org.id,
env: result.customer.env,
customerProductIds: [],
});
}
groupedByOrgEnv
.get(key)!
.customerProductIds.push(result.customer_product.id);
}
// Process each org/env group
for (const [_key, group] of groupedByOrgEnv) {
const repoContext: RepoContext = {
db: ctx.db,
org: {
id: group.orgId,
},
env: group.env,
logger: logger,
};
await batchUpdateCustomerProducts({
ctx: repoContext,
updates: group.customerProductIds.map((id) => ({
id,
updates: { status: CusProductStatus.Expired },
})),
});
}
logger.info(`Expired ${toCleanup.length} customer products`);
console.log(`Expired ${toCleanup.length} customer products`);

View File

@@ -7,7 +7,7 @@ import {
} from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { format } from "date-fns";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService";
@@ -16,20 +16,31 @@ import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts
import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { getNextResetAt } from "@/utils/timeUtils.js";
import type { CronContext } from "../utils/CronContext";
import { getStripeSubscriptionAnchor } from "./getStripeSubscriptionAnchor";
import { resetShortDurationCustomerEntitlement } from "./resetShortDurationCustomerEntitlement";
const shortDurations = [EntInterval.Minute, EntInterval.Hour, EntInterval.Day];
export const resetCustomerEntitlement = async ({
db,
ctx,
cusEnt,
updatedCusEnts,
}: {
db: DrizzleCli;
ctx: CronContext;
cusEnt: ResetCusEnt;
updatedCusEnts: ResetCusEnt[];
}) => {
const repoContext: RepoContext = {
db: ctx.db,
logger: ctx.logger,
org: {
id: cusEnt.customer.org_id,
},
env: cusEnt.customer.env,
customerId: cusEnt.customer_id ?? "",
};
try {
const ent = cusEnt.entitlement as FullEntitlement;
@@ -38,7 +49,7 @@ export const resetCustomerEntitlement = async ({
shortDurations.includes(ent.interval as EntInterval)
) {
return await resetShortDurationCustomerEntitlement({
db,
ctx: repoContext,
cusEnt,
updatedCusEnts,
});
@@ -48,7 +59,7 @@ export const resetCustomerEntitlement = async ({
let relatedCusPrice = null;
if (cusEnt.customer_product_id) {
const cusPrices = await CusPriceService.getByCustomerProductId({
db,
db: ctx.db,
customerProductId: cusEnt.customer_product_id,
});
relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices);
@@ -66,7 +77,7 @@ export const resetCustomerEntitlement = async ({
const entitlement = cusEnt.entitlement;
if (entitlement.allowance_type === AllowanceType.Unlimited) {
await CusEntService.update({
db,
ctx: repoContext,
id: cusEnt.id,
updates: {
unlimited: true,
@@ -82,7 +93,7 @@ export const resetCustomerEntitlement = async ({
if (entitlement.interval === EntInterval.Lifetime) {
await CusEntService.update({
db,
ctx: repoContext,
id: cusEnt.id,
updates: {
next_reset_at: null,
@@ -125,7 +136,7 @@ export const resetCustomerEntitlement = async ({
if (cusEnt.customer_product) {
try {
nextResetAt = await getStripeSubscriptionAnchor({
db,
db: ctx.db,
cusEnt,
nextResetAt,
});
@@ -138,7 +149,7 @@ export const resetCustomerEntitlement = async ({
}
await CusEntService.update({
db,
ctx: repoContext,
id: cusEnt.id,
updates: {
...resetBalanceUpdate,
@@ -149,7 +160,7 @@ export const resetCustomerEntitlement = async ({
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {
await RolloverService.insert({
db,
ctx: repoContext,
rows: rolloverUpdate.toInsert,
fullCusEnt: cusEnt,
});

View File

@@ -1,18 +1,18 @@
import type { EntInterval, FullEntitlement, ResetCusEnt } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { Decimal } from "decimal.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext";
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService";
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils";
import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils";
import { getNextResetAt } from "@/utils/timeUtils.js";
export const resetShortDurationCustomerEntitlement = async ({
db,
ctx,
cusEnt,
updatedCusEnts,
}: {
db: DrizzleCli;
ctx: RepoContext;
cusEnt: ResetCusEnt;
updatedCusEnts: ResetCusEnt[];
}) => {
@@ -44,7 +44,7 @@ export const resetShortDurationCustomerEntitlement = async ({
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {
await RolloverService.insert({
db,
ctx,
rows: rolloverUpdate.toInsert,
fullCusEnt: cusEnt,
});

View File

@@ -48,7 +48,7 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
for (const cusEnt of batch) {
batchResets.push(
resetCustomerEntitlement({
db,
ctx,
cusEnt: cusEnt,
updatedCusEnts,
}),

View File

@@ -0,0 +1,14 @@
import type { AppEnv } from "@autumn/shared";
import type { Logger } from "@/external/logtail/logtailUtils";
import type { DrizzleCli } from "./initDrizzle.js";
export interface RepoContext {
org: {
id: string;
};
env: AppEnv;
db: DrizzleCli;
logger: Logger;
customerId?: string;
}

View File

@@ -55,7 +55,7 @@ export const handleRenewal = async ({
`Renewal for existing past due product ${product.id}, marking as active`,
);
await CusProductService.update({
db,
ctx,
cusProductId: curSameProduct.id,
updates: {
status: CusProductStatus.Active,
@@ -86,7 +86,7 @@ export const handleRenewal = async ({
// Expire old cus_product
await CusProductService.update({
db,
ctx,
cusProductId: curMainProduct.id,
updates: {
status: CusProductStatus.Expired,
@@ -98,7 +98,7 @@ export const handleRenewal = async ({
} else if (curSameProduct) {
// Reactivate the same product if it was expired/cancelled
await CusProductService.update({
db,
ctx,
cusProductId: curSameProduct.id,
updates: {
status: CusProductStatus.Active,

View File

@@ -48,7 +48,7 @@ export const handleBillingIssue = async ({
if (ACTIVE_STATUSES.includes(curSameProduct.status)) {
await CusProductService.update({
db,
ctx,
cusProductId: curSameProduct.id,
updates: {
status: CusProductStatus.PastDue,

View File

@@ -36,7 +36,7 @@ export const handleCancellation = async ({
}
await CusProductService.update({
db,
ctx,
cusProductId: curSameProduct.id,
updates: {
canceled_at: Date.now(),

View File

@@ -37,7 +37,7 @@ export const handleExpiration = async ({
// Expire the cus_product
await CusProductService.update({
db,
ctx,
cusProductId: curSameProduct.id,
updates: {
status: CusProductStatus.Expired,

View File

@@ -73,7 +73,7 @@ export const handleInitialPurchase = async ({
// Expire old cus_product
await CusProductService.update({
db,
ctx,
cusProductId: curMainProduct.id,
updates: {
status: CusProductStatus.Expired,

View File

@@ -33,7 +33,7 @@ export const handleUncancellation = async ({
if (cusProduct) {
await CusProductService.update({
db,
ctx,
cusProductId: cusProduct.id,
updates: {
canceled_at: null,

View File

@@ -4,7 +4,7 @@ import type {
} from "@autumn/shared";
import { customerPriceToCustomerEntitlement } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { findLinkedCusEnts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js";
import { removeReplaceablesFromCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/linkedCusEntUtils.js";
@@ -12,22 +12,21 @@ import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
export const handleContUsePrices = async ({
db,
ctx,
cusEnts,
cusPrice,
invoice,
usageSub,
logger,
resetBalance = true,
}: {
db: DrizzleCli;
ctx: AutumnContext;
cusEnts: FullCustomerEntitlement[];
cusPrice: FullCustomerPrice;
invoice: Stripe.Invoice;
usageSub: Stripe.Subscription;
logger: any;
resetBalance?: boolean;
}): Promise<boolean> => {
const { logger } = ctx;
const cusEnt = customerPriceToCustomerEntitlement({
customerPrice: cusPrice,
customerEntitlements: cusEnts,
@@ -74,7 +73,7 @@ export const handleContUsePrices = async ({
});
await CusEntService.update({
db,
ctx,
id: linkedCusEnt.id,
updates: {
entities: newEntities,
@@ -83,13 +82,13 @@ export const handleContUsePrices = async ({
}
await CusEntService.increment({
db,
ctx,
id: cusEnt.id,
amount: replaceables.length,
});
await RepService.deleteInIds({
db,
ctx,
ids: replaceables.map((r) => r.id),
});

View File

@@ -1,23 +1,16 @@
import {
type AppEnv,
BillingType,
CusProductStatus,
customerPriceToCustomerEntitlement,
type FullCusProduct,
isFixedPrice,
type 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 type { AutumnContext } from "@/honoUtils/HonoEnv";
import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { stripeInvoiceToStripeSubscriptionId } from "../../invoices/utils/convertStripeInvoice";
import { getFullStripeInvoice } from "../../stripeInvoiceUtils.js";
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
import { getStripeSubs } from "../../stripeSubUtils.js";
import { handleContUsePrices } from "./handleContUsePrices.js";
import { handlePrepaidPrices } from "./handlePrepaidPrices.js";
import { handleUsagePrices } from "./handleUsagePrices.js";
@@ -28,24 +21,19 @@ import { handleUsagePrices } from "./handleUsagePrices.js";
// For upgrade, bill_immediately: invoice period start = sub period start (cur cycle), invoice period end cancel immediately date
export const sendUsageAndReset = async ({
db,
ctx,
activeProduct,
org,
env,
invoice,
logger,
submitUsage = true,
resetBalance = true,
}: {
db: DrizzleCli;
ctx: AutumnContext;
activeProduct: FullCusProduct;
org: Organization;
env: AppEnv;
invoice: Stripe.Invoice;
logger: any;
submitUsage?: boolean;
resetBalance?: boolean;
}) => {
const { org, env, logger } = ctx;
const stripeCli = createStripeCli({ org, env });
const cusEnts = activeProduct.customer_entitlements;
@@ -85,15 +73,13 @@ export const sendUsageAndReset = async ({
if (billingType === BillingType.UsageInArrear) {
const handledUsage = await handleUsagePrices({
db,
org,
ctx,
invoice,
customer,
relatedCusEnt,
stripeCli,
price,
usageSub: usageBasedSub,
logger,
activeProduct,
submitUsage,
resetBalance,
@@ -104,12 +90,11 @@ export const sendUsageAndReset = async ({
if (billingType === BillingType.InArrearProrated) {
const handledContUse = await handleContUsePrices({
db,
ctx,
cusEnts,
cusPrice,
invoice,
usageSub: usageBasedSub,
logger,
resetBalance,
});
@@ -118,12 +103,11 @@ export const sendUsageAndReset = async ({
if (billingType === BillingType.UsageInAdvance) {
const handledPrepaid = await handlePrepaidPrices({
db,
ctx,
cusPrice,
cusProduct: activeProduct,
usageSub: usageBasedSub,
invoice,
logger,
resetBalance,
});
@@ -131,91 +115,3 @@ export const sendUsageAndReset = async ({
}
}
};
const handleInvoiceCreated = async ({
db,
org,
data,
env,
logger,
}: {
db: DrizzleCli;
org: Organization;
data: Stripe.Invoice;
env: AppEnv;
logger: any;
}) => {
const stripeCli = createStripeCli({ org, env });
const invoice = await getFullStripeInvoice({
stripeCli,
stripeId: data.id!,
});
const subId = stripeInvoiceToStripeSubscriptionId(invoice);
if (subId) {
const activeProducts = await CusProductService.getByStripeSubId({
db,
stripeSubId: subId,
orgId: org.id,
env,
inStatuses: [
CusProductStatus.Active,
// CusProductStatus.Expired,
CusProductStatus.PastDue,
],
});
if (activeProducts.length === 0) {
logger.warn(
`Stripe invoice.created -- no active products found (${org.slug})`,
);
return;
}
await FeatureService.list({
db,
orgId: org.id,
env,
});
const stripeSubs = await getStripeSubs({
stripeCli: createStripeCli({ org, env }),
subIds: activeProducts.flatMap((p) => p.subscription_ids || []),
});
for (const activeProduct of activeProducts) {
const subId = stripeInvoiceToStripeSubscriptionId(invoice);
const subscription = stripeSubs.find((s) => s.id === subId);
await sendUsageAndReset({
db,
activeProduct,
org,
env,
invoice,
logger,
submitUsage: true, // Always submit usage during invoice.created
resetBalance: validateProductShouldReset({
subscription,
_invoice: invoice,
}), // Skip balance reset for Vercel (wait for payment confirmation)
});
}
}
};
const validateProductShouldReset = ({
subscription,
_invoice,
}: {
subscription?: Stripe.Subscription;
_invoice: Stripe.Invoice;
}) => {
/**
* This was separated so as to give us headroom to add further Custom Payment Methods in the future.
* e.g RevenueCat etc...
*/
if (subscription?.metadata?.vercel_installation_id) return false;
return true;
};

View File

@@ -7,7 +7,7 @@ import {
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js";
@@ -18,22 +18,22 @@ import { notNullish } from "@/utils/genUtils.js";
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
export const handlePrepaidPrices = async ({
db,
ctx,
cusProduct,
cusPrice,
usageSub,
invoice,
logger,
resetBalance = true,
}: {
db: DrizzleCli;
ctx: AutumnContext;
cusProduct: FullCusProduct;
cusPrice: FullCustomerPrice;
usageSub: Stripe.Subscription;
invoice: Stripe.Invoice;
logger: any;
resetBalance?: boolean;
}): Promise<boolean> => {
const { logger } = ctx;
const { org } = ctx;
const { start, end } = subToPeriodStartEnd({ sub: usageSub });
const isNewPeriod = invoice.period_start !== start;
@@ -93,7 +93,7 @@ export const handlePrepaidPrices = async ({
});
await CusProductService.update({
db,
ctx,
cusProductId: cusProduct.id,
updates: {
options: newOptions as FeatureOptions[],
@@ -104,7 +104,7 @@ export const handlePrepaidPrices = async ({
const difference =
(options?.quantity ?? 0) - (options?.upcoming_quantity ?? 0);
await CusEntService.decrement({
db,
ctx,
id: cusEnt.id,
amount: difference,
});
@@ -118,14 +118,14 @@ export const handlePrepaidPrices = async ({
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {
await RolloverService.insert({
db,
ctx,
rows: rolloverUpdate.toInsert,
fullCusEnt: cusEnt,
});
}
await CusEntService.update({
db,
ctx,
id: cusEnt.id,
updates: {
...resetUpdate,

View File

@@ -4,13 +4,12 @@ import {
EntInterval,
type FullCusProduct,
type FullCustomerEntitlement,
type Organization,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import { differenceInMinutes, subDays } from "date-fns";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js";
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js";
@@ -21,32 +20,31 @@ import { getInvoiceItemForUsage } from "../../stripePriceUtils.js";
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
export const handleUsagePrices = async ({
db,
org,
ctx,
invoice,
customer,
relatedCusEnt,
stripeCli,
price,
usageSub,
logger,
activeProduct,
submitUsage = true,
resetBalance = true,
}: {
db: DrizzleCli;
org: Organization;
ctx: AutumnContext;
invoice: Stripe.Invoice;
customer: Customer;
relatedCusEnt: FullCustomerEntitlement;
stripeCli: Stripe;
price: Price;
usageSub: Stripe.Subscription;
logger: any;
activeProduct: FullCusProduct;
submitUsage?: boolean;
resetBalance?: boolean;
}): Promise<boolean> => {
const { logger } = ctx;
const { org } = ctx;
const invoiceCreatedRecently =
Math.abs(
differenceInMinutes(
@@ -153,7 +151,7 @@ export const handleUsagePrices = async ({
const { end } = subToPeriodStartEnd({ sub: usageSub });
await CusEntService.update({
db,
ctx,
id: relatedCusEnt.id,
updates: {
...resetBalancesUpdate,
@@ -169,7 +167,7 @@ export const handleUsagePrices = async ({
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {
await RolloverService.insert({
db,
ctx,
rows: rolloverUpdate.toInsert,
fullCusEnt: relatedCusEnt,
});

View File

@@ -27,7 +27,7 @@ const processAllocatedPrice = async ({
customerEntitlement: FullCusEntWithFullCusProduct;
}) => {
const { db } = ctx;
const { stripeInvoice } = eventContext;
const { stripeInvoice, fullCustomer } = eventContext;
const customerProduct = customerEntitlement.customer_product;
const customerEntitlements = customerProduct?.customer_entitlements ?? [];
@@ -54,7 +54,7 @@ const processAllocatedPrice = async ({
});
await CusEntService.update({
db,
ctx,
id: linkedCusEnt.id,
updates: {
entities: newEntities,
@@ -63,13 +63,13 @@ const processAllocatedPrice = async ({
}
await CusEntService.increment({
db,
ctx,
id: customerEntitlement.id,
amount: replaceables.length,
});
await RepService.deleteInIds({
db,
ctx,
ids: replaceables.map((r) => r.id),
});

View File

@@ -88,7 +88,7 @@ export const processConsumablePricesForInvoiceCreated = async ({
}
await CusEntService.batchUpdate({
db: ctx.db,
ctx,
data: updateCustomerEntitlements,
});
@@ -100,7 +100,7 @@ export const processConsumablePricesForInvoiceCreated = async ({
});
await RolloverService.insert({
db: ctx.db,
ctx,
rows: rolloverUpdates.toInsert,
fullCusEnt: update.customerEntitlement,
});

View File

@@ -41,7 +41,7 @@ const processPrepaidPrice = async ({
const customerProduct = customerEntitlement.customer_product;
const { stripeSubscription } = eventContext;
const { stripeSubscription, fullCustomer } = eventContext;
const { db } = ctx;
if (!options) return;
@@ -80,7 +80,7 @@ const processPrepaidPrice = async ({
});
await CusProductService.update({
db,
ctx,
cusProductId: customerProduct.id,
updates: {
options: newOptions,
@@ -91,7 +91,7 @@ const processPrepaidPrice = async ({
const difference =
(options?.quantity ?? 0) - (options?.upcoming_quantity ?? 0);
await CusEntService.decrement({
db,
ctx,
id: customerEntitlement.id,
amount: difference,
});
@@ -105,14 +105,14 @@ const processPrepaidPrice = async ({
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {
await RolloverService.insert({
db,
ctx,
rows: rolloverUpdate.toInsert,
fullCusEnt: customerEntitlement,
});
}
await CusEntService.update({
db,
ctx,
id: customerEntitlement.id,
updates: {
...resetUpdate,

View File

@@ -53,7 +53,7 @@ export const handleInvoiceActionRequiredCompleted = async ({
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
if (attachParams.cusEntIds && curCusProduct) {
await resetUsageBalances({
db: ctx.db,
ctx,
cusEntIds: attachParams.cusEntIds,
cusProduct: curCusProduct,
});

View File

@@ -47,7 +47,6 @@ export const processConsumablePricesForSubscriptionDeleted = async ({
ctx: StripeWebhookContext;
eventContext: StripeSubscriptionDeletedContext;
}): Promise<void> => {
const { db } = ctx;
const { stripeSubscription, fullCustomer, customerProducts } = eventContext;
// Skip if subscription has metered items - Stripe handles metered billing automatically
@@ -106,7 +105,7 @@ export const processConsumablePricesForSubscriptionDeleted = async ({
// 4. Reset usage balances for all affected customer entitlements (only if payment succeeded)
await CusEntService.batchUpdate({
db,
ctx,
data: updateCustomerEntitlements,
});
};

View File

@@ -75,7 +75,7 @@ export const handleStripeSubscriptionCanceled = async ({
};
await CusProductService.update({
db,
ctx,
cusProductId: customerProduct.id,
updates,
});

View File

@@ -32,7 +32,7 @@ export const handleStripeSubscriptionRenewed = async ({
ctx: StripeWebhookContext;
subscriptionUpdatedContext: StripeSubscriptionUpdatedContext;
}): Promise<void> => {
const { db, org, env, logger } = ctx;
const { org, env, logger } = ctx;
const {
stripeSubscription,
previousAttributes,
@@ -80,7 +80,7 @@ export const handleStripeSubscriptionRenewed = async ({
};
await CusProductService.update({
db,
ctx,
cusProductId: customerProduct.id,
updates,
});
@@ -112,7 +112,7 @@ export const handleStripeSubscriptionRenewed = async ({
if (scheduledProduct) {
await CusProductService.delete({
db,
ctx,
cusProductId: scheduledProduct.id,
});

View File

@@ -3,7 +3,6 @@ import {
type CollectionMethod,
CusProductStatus,
cp,
type FullCusProduct,
type InsertCustomerProduct,
} from "@autumn/shared";
import {
@@ -99,7 +98,7 @@ export const syncCustomerProductStatus = async ({
);
await CusProductService.update({
db,
ctx,
cusProductId: customerProduct.id,
updates,
});

View File

@@ -106,7 +106,7 @@ export const handleSubCreated = async ({
const updateCusProd = async () => {
await CusProductService.update({
db,
ctx,
cusProductId: cusProd.id,
updates: {
subscription_ids: subIds,

View File

@@ -1,12 +1,5 @@
import {
type AppEnv,
AttachScenario,
type FeatureOptions,
type Organization,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AttachScenario, type FeatureOptions } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import { sendUsageAndReset } from "@/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js";
import { parseVercelPrepaidQuantities } from "@/external/vercel/misc/vercelInvoicing.js";
import { VercelResourceService } from "@/external/vercel/services/VercelResourceService.js";
@@ -138,10 +131,11 @@ export const handleMarketplaceInvoicePaid = async ({
status: "ready",
},
});
} catch (error: any) {
logger.warn("Could not fetch or parse resource metadata", {
error: error.message,
resourceId: vercelResourceId,
} catch (error) {
logger.warn(`Could not fetch or parse resource metadata ${error}`, {
data: {
resourceId: vercelResourceId,
},
});
// Continue with empty optionsList
}
@@ -151,12 +145,9 @@ export const handleMarketplaceInvoicePaid = async ({
const activeProduct = existingCusProducts[0];
await sendUsageAndReset({
db,
ctx,
activeProduct,
org,
env,
invoice,
logger,
submitUsage: false, // Usage already submitted in invoice.created
resetBalance: true, // Payment confirmed - now safe to reset balance
});

View File

@@ -1,6 +1,5 @@
import { CreateBalanceParamsV0Schema } from "@autumn/shared";
import { FeatureNotFoundError } from "@shared/index";
import type { DrizzleCli } from "@/db/initDrizzle";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { prepareNewBalanceForInsertion } from "@/internal/balances/createBalance/prepareNewBalanceForInsertion";
import { validateCreateBalanceParams } from "@/internal/balances/createBalance/validateCreateBalance";
@@ -44,16 +43,14 @@ export const handleCreateBalance = createRoute({
params: createBalanceParams,
});
await ctx.db.transaction(async (tx) => {
await EntitlementService.insert({
db: tx as unknown as DrizzleCli,
data: [newEntitlement],
});
await EntitlementService.insert({
db: ctx.db,
data: [newEntitlement],
});
await CusEntService.insert({
db: tx as unknown as DrizzleCli,
data: [newCustomerEntitlement],
});
await CusEntService.insert({
ctx,
data: [newCustomerEntitlement],
});
return c.json({ success: true });

View File

@@ -66,7 +66,7 @@ export const handleUpdateBalance = createRoute({
if (notNullish(params.next_reset_at) && params.customer_entitlement_id) {
await CusEntService.update({
db: ctx.db,
ctx,
id: params.customer_entitlement_id,
updates: {
next_reset_at: params.next_reset_at,

View File

@@ -88,7 +88,7 @@ export const updateGrantedBalance = async ({
};
await CusEntService.update({
db: ctx.db,
ctx,
id: targetCusEnt.id,
updates: { entities: newEntities },
});
@@ -97,7 +97,7 @@ export const updateGrantedBalance = async ({
targetCusEnt.entities = newEntities;
} else {
await CusEntService.update({
db: ctx.db,
ctx,
id: targetCusEnt.id,
updates: { adjustment: requiredAdjustment },
});

View File

@@ -1,5 +1,4 @@
import {
type AppEnv,
BillingType,
type Customer,
type Entitlement,
@@ -7,16 +6,15 @@ import {
type Feature,
type FullCusEntWithFullCusProduct,
type FullCustomerPrice,
type Organization,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
@@ -59,33 +57,25 @@ export const getUsageFromBalance = ({
};
export const adjustAllowance = async ({
db,
env,
org,
ctx,
affectedFeature,
cusEnt,
cusPrices,
customer,
originalBalance,
newBalance,
logger,
errorIfIncomplete = false,
// deduction,
// product,
// fromEntities = false,
}: {
db: DrizzleCli;
env: AppEnv;
ctx: AutumnContext;
affectedFeature: Feature;
org: Organization;
cusEnt: FullCusEntWithFullCusProduct;
cusPrices: FullCustomerPrice[];
customer: Customer;
originalBalance: number;
newBalance: number;
logger: any;
errorIfIncomplete?: boolean;
}) => {
const { logger, org, env } = ctx;
const cusPrice = getRelatedCusPrice(cusEnt, cusPrices);
const billingType = cusPrice ? getBillingType(cusPrice.price.config!) : null;
const cusProduct = cusEnt.customer_product;
@@ -139,7 +129,7 @@ export const adjustAllowance = async ({
if (isUpgrade) {
return await handleProratedUpgrade({
db,
ctx,
stripeCli,
cusEnt,
cusPrice,
@@ -147,13 +137,10 @@ export const adjustAllowance = async ({
subItem: subItem as Stripe.SubscriptionItem,
newBalance,
prevBalance: originalBalance,
org,
logger,
});
} else {
return await handleProratedDowngrade({
db,
org,
ctx,
stripeCli,
cusEnt,
cusPrice,
@@ -161,7 +148,6 @@ export const adjustAllowance = async ({
subItem: subItem as Stripe.SubscriptionItem,
newBalance,
prevBalance: originalBalance,
logger,
});
}
};

View File

@@ -3,7 +3,6 @@ import {
type FullCustomerPrice,
getFeatureInvoiceDescription,
type OnIncrease,
type Organization,
type Price,
type Product,
shouldBillNow,
@@ -12,41 +11,41 @@ import {
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { stripeSubscriptionToNowMs } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
const getUpgradeProrationInvoiceItem = ({
ctx,
prevPrice,
newPrice,
now,
feature,
newRoundedUsage,
price,
org,
onIncrease,
product,
stripeSub,
subItem,
logger,
}: {
ctx: AutumnContext;
prevPrice: number;
newPrice: number;
now: number;
feature: Feature;
newRoundedUsage: number;
price: Price;
org: Organization;
onIncrease: OnIncrease;
product: Product;
stripeSub: Stripe.Subscription;
subItem: Stripe.SubscriptionItem;
logger: Logger;
}) => {
const { logger, org } = ctx;
const billingUnits = (price.config as UsagePriceConfig).billing_units;
let invoiceAmount = new Decimal(newPrice).minus(prevPrice).toNumber();
let invoiceDescription = getFeatureInvoiceDescription({
@@ -76,7 +75,7 @@ const getUpgradeProrationInvoiceItem = ({
const invoiceItem = constructStripeInvoiceItem({
product,
amount: invoiceAmount,
org,
ctx,
price: price,
description: invoiceDescription,
stripeSubId: stripeSub.id,
@@ -93,7 +92,7 @@ const getUpgradeProrationInvoiceItem = ({
};
export const createUpgradeProrationInvoice = async ({
org,
ctx,
cusPrice,
stripeCli,
sub,
@@ -105,9 +104,8 @@ export const createUpgradeProrationInvoice = async ({
product,
config,
onIncrease,
logger,
}: {
org: Organization;
ctx: AutumnContext;
cusPrice: FullCustomerPrice;
stripeCli: Stripe;
sub: Stripe.Subscription;
@@ -119,8 +117,8 @@ export const createUpgradeProrationInvoice = async ({
product: Product;
config: UsagePriceConfig;
onIncrease: OnIncrease;
logger: Logger;
}) => {
const { logger } = ctx;
const now = await stripeSubscriptionToNowMs({
stripeSubscription: sub,
stripeCli,
@@ -132,18 +130,17 @@ export const createUpgradeProrationInvoice = async ({
});
const invoiceItem = getUpgradeProrationInvoiceItem({
ctx,
prevPrice,
newPrice,
now,
feature,
newRoundedUsage,
price: cusPrice.price,
org,
onIncrease,
product,
stripeSub: sub,
subItem,
logger,
});
const invoiceAmount =

View File

@@ -7,7 +7,6 @@ import {
InternalError,
OnDecrease,
OnIncrease,
type Organization,
type Product,
priceToInvoiceAmount,
shouldProrate,
@@ -16,9 +15,8 @@ import {
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import { stripeSubscriptionToNowMs } from "@/external/stripe/subscriptions/index.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
@@ -29,7 +27,7 @@ import { formatUnixToDate } from "@/utils/genUtils.js";
import { getUsageFromBalance } from "../adjustAllowance.js";
const createDowngradeProrationInvoice = async ({
org,
ctx,
cusPrice,
stripeCli,
sub,
@@ -41,9 +39,8 @@ const createDowngradeProrationInvoice = async ({
product,
onIncrease,
onDecrease,
logger,
}: {
org: Organization;
ctx: AutumnContext;
cusPrice: FullCustomerPrice;
stripeCli: Stripe;
sub: Stripe.Subscription;
@@ -55,8 +52,8 @@ const createDowngradeProrationInvoice = async ({
product: Product;
onIncrease: OnIncrease;
onDecrease: OnDecrease;
logger: any;
}) => {
const { logger } = ctx;
const config = cusPrice.price.config as UsagePriceConfig;
const now = await stripeSubscriptionToNowMs({
@@ -94,9 +91,9 @@ const createDowngradeProrationInvoice = async ({
);
const invoiceItem = constructStripeInvoiceItem({
ctx,
product,
amount: invoiceAmount,
org,
price: cusPrice.price,
description: invoiceDescription,
stripeSubId: sub.id,
@@ -129,8 +126,7 @@ const createDowngradeProrationInvoice = async ({
};
export const handleProratedDowngrade = async ({
db,
org,
ctx,
stripeCli,
cusEnt,
cusPrice,
@@ -138,10 +134,8 @@ export const handleProratedDowngrade = async ({
subItem,
newBalance,
prevBalance,
logger,
}: {
db: DrizzleCli;
org: Organization;
ctx: AutumnContext;
stripeCli: Stripe;
cusEnt: FullCusEntWithFullCusProduct;
cusPrice: FullCustomerPrice;
@@ -149,8 +143,8 @@ export const handleProratedDowngrade = async ({
subItem: Stripe.SubscriptionItem;
newBalance: number;
prevBalance: number;
logger: Logger;
}) => {
const { logger } = ctx;
logger.info(`Handling quantity decrease`);
if (!cusEnt.customer_product) {
@@ -200,7 +194,7 @@ export const handleProratedDowngrade = async ({
});
invoice = await createDowngradeProrationInvoice({
org,
ctx,
cusPrice,
stripeCli,
sub,
@@ -217,7 +211,6 @@ export const handleProratedDowngrade = async ({
cusPrice.price.proration_config?.on_increase ||
OnIncrease.ProrateImmediately,
onDecrease,
logger,
});
} else {
if (prevOverage > 0) {
@@ -228,7 +221,7 @@ export const handleProratedDowngrade = async ({
});
await RepService.insert({
db,
ctx,
data: newReplaceables,
});
}

View File

@@ -4,14 +4,13 @@ import {
type FullCustomerPrice,
InternalError,
OnIncrease,
type Organization,
type Price,
priceToInvoiceAmount,
shouldCreateInvoiceItem,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js";
import { roundUsage } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getUsageFromBalance } from "../adjustAllowance.js";
@@ -94,28 +93,25 @@ export function getReps({
}
export const handleProratedUpgrade = async ({
db,
ctx,
stripeCli,
cusEnt,
org,
cusPrice,
sub,
subItem,
newBalance,
prevBalance,
logger,
}: {
db: DrizzleCli;
ctx: AutumnContext;
stripeCli: Stripe;
org: Organization;
cusEnt: FullCusEntWithFullCusProduct;
cusPrice: FullCustomerPrice;
sub: Stripe.Subscription;
subItem: Stripe.SubscriptionItem;
newBalance: number;
prevBalance: number;
logger: any;
}) => {
const { logger } = ctx;
logger.info(`Handling quantity increase`);
if (!cusEnt.customer_product) {
@@ -156,7 +152,7 @@ export const handleProratedUpgrade = async ({
let invoice = null;
if (shouldCreateInvoiceItem(onIncrease) && sub.status !== "trialing") {
invoice = await createUpgradeProrationInvoice({
org,
ctx,
cusPrice,
stripeCli,
sub,
@@ -168,12 +164,11 @@ export const handleProratedUpgrade = async ({
product,
config,
onIncrease,
logger,
});
}
const deleted = await RepService.deleteInIds({
db,
ctx,
ids: reps.map((r) => r.id),
});

View File

@@ -43,16 +43,13 @@ export const handlePaidAllocatedCusEnt = async ({
});
const { newReplaceables, deletedReplaceables } = await adjustAllowance({
db,
env,
org,
ctx,
cusPrices: cusPrices,
customer: fullCus,
affectedFeature: cusEnt.entitlement.feature,
cusEnt: cusEnt,
originalBalance: originalGrpBalance,
newBalance: newGrpBalance,
logger: ctx.logger,
});
// Adjust balance based on replaceables
@@ -70,7 +67,7 @@ export const handlePaidAllocatedCusEnt = async ({
);
await CusEntService.update({
db,
ctx,
id: cusEnt.id,
updates: {
balance: reUpdatedBalance,

View File

@@ -40,7 +40,7 @@ export const rollbackDeduction = async ({
// Restore the entitlement to original values
await CusEntService.update({
db,
ctx,
id: cusEntId,
updates: {
balance: originalCusEnt.balance ?? 0,

View File

@@ -30,7 +30,7 @@ export const insertNewCusProducts = async ({
// 2. Insert cusEnts
await CusEntService.insert({
db: ctx.db,
ctx,
data: cusEnts,
});
@@ -51,7 +51,7 @@ export const insertNewCusProducts = async ({
if (cusEnt.rollovers.length > 0) {
await RolloverService.insert({
db: ctx.db,
ctx,
rows: cusEnt.rollovers,
fullCusEnt: cusEnt,
});

View File

@@ -25,7 +25,7 @@ export const updateCustomerEntitlements = async ({
if (updates) {
await CusEntService.update({
db,
ctx,
id: customerEntitlement.id,
updates,
});
@@ -34,7 +34,7 @@ export const updateCustomerEntitlements = async ({
if (balanceChange > 0) {
await CusEntService.increment({
db,
ctx,
id: customerEntitlement.id,
amount: balanceChange,
});
@@ -42,7 +42,7 @@ export const updateCustomerEntitlements = async ({
const absoluteDecrement = Math.abs(balanceChange);
await CusEntService.decrement({
db,
ctx,
id: customerEntitlement.id,
amount: absoluteDecrement,
});

View File

@@ -61,7 +61,7 @@ export const executeAutumnBillingPlan = async ({
const { customerProduct, updates } = updateCustomerProduct;
await CusProductService.update({
db,
ctx,
cusProductId: customerProduct.id,
updates,
});
@@ -73,7 +73,7 @@ export const executeAutumnBillingPlan = async ({
`[executeAutumnBillingPlan] deleting scheduled customer product: ${deleteCustomerProduct.product.id}`,
);
await CusProductService.delete({
db,
ctx,
cusProductId: deleteCustomerProduct.id,
});
}

View File

@@ -44,11 +44,10 @@ export const reapplyExistingRolloversToCustomerProduct = async ({
existingRollovers: currentRollovers,
});
try {
for (const cusEnt of customerProduct.customer_entitlements) {
await RolloverService.insert({
db,
ctx,
rows: cusEnt.rollovers,
fullCusEnt: cusEnt,
});

View File

@@ -74,7 +74,7 @@ export const reapplyExistingUsagesToCustomerProduct = async ({
for (const cusEnt of customerProduct.customer_entitlements) {
await CusEntService.update({
db,
ctx,
id: cusEnt.id,
updates: {
balance: cusEnt.balance ?? 0,

View File

@@ -1,7 +1,6 @@
import type { FullCustomer } from "@autumn/shared";
import type { AutumnBillingPlan, FullCustomer } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { AutumnBillingPlan } from "@autumn/shared";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { initSubscriptionFromStripe } from "@/internal/subscriptions/utils/initSubscriptionFromStripe.js";
import type { CreateCustomerContext } from "./createCustomerContext.js";
@@ -28,7 +27,7 @@ export const finalizeCreateCustomer = async ({
// Link subscription_ids to customer products
for (const customerProduct of autumnBillingPlan.insertCustomerProducts) {
await CusProductService.update({
db: ctx.db,
ctx,
cusProductId: customerProduct.id,
updates: { subscription_ids: customerProduct.subscription_ids },
});

View File

@@ -59,7 +59,7 @@ export const applyResetResults = async ({
// Winner: we inserted the rollover into DB. Clear excess and
// update the in-memory array to include the new rollovers.
const clearedRollovers = await RolloverService.clearExcessRollovers({
db,
ctx,
newRows: result.rolloverInsert.rows,
fullCusEnt: original,
});
@@ -68,7 +68,7 @@ export const applyResetResults = async ({
// Loser: the winning request already inserted the rollover and
// cleared excess. Re-read from DB to get the authoritative state.
original.rollovers = await RolloverService.getCurrentRollovers({
db,
ctx,
cusEntID: cusEntId,
});
}

View File

@@ -20,6 +20,7 @@ import {
type ProductOptions,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { triggerVerifyCacheConsistency } from "@/internal/billing/v2/workflows/verifyCacheConsistency/triggerVerifyCacheConsistency.js";
import { searchCusProducts } from "@/internal/customers/cusProducts/cusProductUtils.js";
@@ -157,25 +158,26 @@ const initCusProduct = ({
};
const insertFullCusProduct = async ({
db,
ctx,
cusProd,
cusEnts,
cusPrices,
replaceables,
}: {
db: DrizzleCli;
ctx: RepoContext;
cusProd: CusProduct;
cusEnts: CustomerEntitlement[];
cusPrices: CustomerPrice[];
replaceables: InsertReplaceable[];
}) => {
const { db } = ctx;
await CusProductService.insert({
db,
data: cusProd,
});
await CusEntService.insert({
db,
ctx,
data: cusEnts as InsertCustomerEntitlement[],
});
@@ -185,19 +187,19 @@ const insertFullCusProduct = async ({
});
await RepService.insert({
db,
ctx,
data: replaceables,
});
};
const expireOrDeleteCusProduct = async ({
db,
ctx,
startsAt,
product,
cusProducts,
internalEntityId,
}: {
db: DrizzleCli;
ctx: RepoContext;
startsAt?: number;
product: FullProduct;
cusProducts?: FullCusProduct[];
@@ -216,7 +218,7 @@ const expireOrDeleteCusProduct = async ({
if (curScheduledProduct) {
await CusProductService.delete({
db,
ctx,
cusProductId: curScheduledProduct.id,
});
}
@@ -229,7 +231,7 @@ const expireOrDeleteCusProduct = async ({
if (curMainProduct) {
await CusProductService.update({
db,
ctx,
cusProductId: curMainProduct.id,
updates: {
status: CusProductStatus.Expired,
@@ -352,6 +354,15 @@ export const createFullCusProduct = async ({
internalEntityId: attachParams.internalEntityId,
});
const repoContext: RepoContext = {
db,
org: {
id: customer.org_id,
},
env: customer.env,
logger,
};
if (
(isOneOff(prices) || (isFreeProduct(prices) && product.is_add_on)) &&
notNullish(existingCusProduct) &&
@@ -361,9 +372,8 @@ export const createFullCusProduct = async ({
ACTIVE_STATUSES.includes(existingCusProduct.status)
) {
await updateOneTimeCusProduct({
db,
ctx: repoContext,
attachParams,
logger,
});
return;
@@ -476,7 +486,7 @@ export const createFullCusProduct = async ({
if (!isOneOff(prices) && !product.is_add_on) {
await expireOrDeleteCusProduct({
db,
ctx: repoContext,
startsAt,
product,
cusProducts: attachParams.cusProducts,
@@ -485,7 +495,7 @@ export const createFullCusProduct = async ({
}
await insertFullCusProduct({
db,
ctx: repoContext,
cusProd,
cusEnts: deductedCusEnts,
cusPrices,
@@ -497,7 +507,7 @@ export const createFullCusProduct = async ({
for (const operation of rolloverOps) {
rolloverInserts.push(
RolloverService.insert({
db,
ctx: repoContext,
rows: operation.toInsert,
fullCusEnt: operation.cusEnt,
}),

View File

@@ -11,39 +11,35 @@ import {
type Organization,
type Price,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { triggerVerifyCacheConsistency } from "@/internal/billing/v2/workflows/verifyCacheConsistency/triggerVerifyCacheConsistency.js";
import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { nullish } from "@/utils/genUtils.js";
import type { Logger } from "../../../external/logtail/logtailUtils.js";
import type { InsertCusProductParams } from "../cusProducts/AttachParams.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js";
import { initCusEntitlement } from "./initCusEnt.js";
const updateOneOffExistingEntitlement = async ({
db,
ctx,
cusEnt,
entitlement,
org,
env,
options,
relatedPrice,
logger,
attachParams,
}: {
db: DrizzleCli;
ctx: RepoContext;
cusEnt: FullCustomerEntitlement;
entitlement: EntitlementWithFeature;
org: Organization;
env: AppEnv;
options?: FeatureOptions;
relatedPrice?: Price;
logger: any;
attachParams: InsertCusProductParams;
}) => {
const { db, logger } = ctx;
if (entitlement.allowance_type === AllowanceType.Unlimited) {
return;
}
@@ -71,7 +67,7 @@ const updateOneOffExistingEntitlement = async ({
}
await CusEntService.update({
db,
ctx,
id: updatedCusEnt.id,
updates: {
balance: updatedCusEnt.balance! + resetBalance!,
@@ -82,13 +78,11 @@ const updateOneOffExistingEntitlement = async ({
};
export const updateOneTimeCusProduct = async ({
db,
ctx,
attachParams,
logger,
}: {
db: DrizzleCli;
ctx: RepoContext;
attachParams: InsertCusProductParams;
logger: Logger;
}) => {
// 1. Sort cus products by created_at
attachParams.cusProducts?.sort((a, b) => b.created_at - a.created_at);
@@ -122,15 +116,13 @@ export const updateOneTimeCusProduct = async ({
if (existingCusEnt) {
await updateOneOffExistingEntitlement({
db,
ctx,
cusEnt: existingCusEnt,
entitlement,
org: attachParams.org,
env: attachParams.customer.env,
options: options || undefined,
relatedPrice,
logger,
attachParams,
});
} else {
const newCusEnt = initCusEntitlement({
@@ -147,7 +139,7 @@ export const updateOneTimeCusProduct = async ({
console.log("Inserting new cus ent");
await CusEntService.insert({
db,
ctx,
data: [newCusEnt as any],
});
}
@@ -173,7 +165,7 @@ export const updateOneTimeCusProduct = async ({
}
await CusProductService.update({
db,
ctx,
cusProductId: existingCusProduct.id,
updates: {
options: newOptionsList,
@@ -197,7 +189,7 @@ export const updateOneTimeCusProduct = async ({
await triggerVerifyCacheConsistency({
newCustomerProduct: existingCusProduct,
previousFullCustomer: attachParams.customer as FullCustomer,
logger,
logger: ctx.logger,
source: "updateOneTimeCusProduct",
});
};

View File

@@ -130,7 +130,7 @@ export const handleMultiAttachFlow = async ({
for (const cusProduct of removeCusProducts) {
if (cusProduct.status === CusProductStatus.Scheduled) {
await CusProductService.delete({
db,
ctx,
cusProductId: cusProduct.id,
});
}
@@ -138,7 +138,7 @@ export const handleMultiAttachFlow = async ({
for (const cusProduct of expireCusProducts) {
await CusProductService.update({
db,
ctx,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,

View File

@@ -101,9 +101,9 @@ export const handleQuantityDowngrade = async ({
(f: Feature) => f.internal_id === newOptions.internal_feature_id,
)!;
const invoiceItem = constructStripeInvoiceItem({
ctx,
product,
amount: amount,
org: org,
price: cusPrice.price,
description: getFeatureInvoiceDescription({
feature: feature,
@@ -189,7 +189,7 @@ export const handleQuantityDowngrade = async ({
.toNumber();
await CusEntService.decrement({
db,
ctx,
id: cusEnt.id,
amount: decrementBy,
});

View File

@@ -103,9 +103,9 @@ export const handleQuantityUpgrade = async ({
const product = cusProductToProduct({ cusProduct });
const invoiceItem = constructStripeInvoiceItem({
ctx,
product,
amount: amount,
org: org,
price: cusPrice.price,
description: getFeatureInvoiceDescription({
feature: feature,
@@ -197,7 +197,7 @@ export const handleQuantityUpgrade = async ({
`🔥 Incrementing feature ${cusEnt.entitlement.feature.id} balance by ${incrementBy}`,
);
await CusEntService.increment({
db,
ctx,
id: cusEnt.id,
amount: incrementBy,
});

View File

@@ -10,6 +10,7 @@ import {
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext";
import type { Logger } from "@/external/logtail/logtailUtils";
import { subToAutumnInterval } from "@/external/stripe/utils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
@@ -168,17 +169,17 @@ export const createUsageInvoiceItems = async ({
};
export const resetUsageBalances = async ({
db,
ctx,
cusEntIds,
cusProduct,
}: {
db: DrizzleCli;
ctx: RepoContext;
cusEntIds: string[];
cusProduct: FullCusProduct;
}) => {
for (const cusEntId of cusEntIds) {
await CusEntService.update({
db,
ctx,
id: cusEntId,
updates: {
balance: 0,

View File

@@ -99,7 +99,7 @@ export const handleLegacyUpgradeFlow = async ({
if (curScheduledProduct) {
await CusProductService.delete({
db,
ctx,
cusProductId: curScheduledProduct.id,
});
}
@@ -219,7 +219,7 @@ export const handleLegacyUpgradeFlow = async ({
) {
logger.info(`UPGRADE FLOW: expiring previous cus product`);
await CusProductService.update({
db,
ctx,
cusProductId: curCusProduct.id,
updates: {
subscription_ids: canceled ? undefined : [],

View File

@@ -157,7 +157,7 @@ export const updateStripeSub2 = async ({
if (curCusProduct) {
if (!url) {
await resetUsageBalances({
db,
ctx,
cusEntIds,
cusProduct: curCusProduct,
});

View File

@@ -180,7 +180,7 @@ export const runAttachFunction = async ({
// 2. If main is trial, cancel it...
if (branch === AttachBranch.MainIsTrial) {
await CusProductService.update({
db,
ctx,
cusProductId: curMainProduct!.id,
updates: {
ended_at: attachParams.now,

View File

@@ -21,6 +21,7 @@ import {
or,
} from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext";
export const ACTIVE_STATUSES = [
CusProductStatus.Active,
@@ -397,14 +398,15 @@ export class CusProductService {
}
static async update({
db,
ctx,
cusProductId,
updates,
}: {
db: DrizzleCli;
ctx: RepoContext;
cusProductId: string;
updates: Partial<InsertCustomerProduct>;
}) {
const { db } = ctx;
return await db
.update(customerProducts)
.set(updates)
@@ -497,13 +499,13 @@ export class CusProductService {
}
static async delete({
db,
ctx,
cusProductId,
}: {
db: DrizzleCli;
ctx: RepoContext;
cusProductId: string;
}) {
return await db
return await ctx.db
.delete(customerProducts)
.where(eq(customerProducts.id, cusProductId))
.returning();

View File

@@ -64,7 +64,7 @@ export const activateScheduledCustomerProduct = async ({
};
await CusProductService.update({
db,
ctx,
cusProductId: customerProduct.id,
updates,
});

View File

@@ -27,7 +27,7 @@ export const cleanupOneOffCustomerProducts = async ({
}: {
ctx: AutumnContext;
}): Promise<CleanupOneOffResult> => {
const { logger, db } = ctx;
const { logger } = ctx;
// 1. Get customer products eligible for cleanup
const toCleanup = await getOneOffCustomerProductsToCleanup({ ctx });
@@ -47,7 +47,7 @@ export const cleanupOneOffCustomerProducts = async ({
);
await batchUpdateCustomerProducts({
db,
ctx,
updates: uniqueIds.map((id) => ({
id,
updates: { status: CusProductStatus.Expired },

View File

@@ -47,7 +47,7 @@ export const deleteScheduledCustomerProduct = async ({
);
await CusProductService.delete({
db,
ctx,
cusProductId: scheduledCustomerProduct.id,
});

View File

@@ -43,7 +43,7 @@ export const expireCustomerProductAndActivateDefault = async ({
};
await CusProductService.update({
db,
ctx,
cusProductId: customerProduct.id,
updates,
});

View File

@@ -20,6 +20,10 @@ import { and, eq, gt, inArray, isNull, lt, or, sql } from "drizzle-orm";
import { StatusCodes } from "http-status-codes";
import { buildConflictUpdateColumns } from "@/db/dbUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext";
import { redis } from "@/external/redis/initRedis.js";
import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import RecaseError from "@/utils/errorUtils.js";
export class CusEntService {
@@ -81,12 +85,13 @@ export class CusEntService {
}
static async insert({
db,
ctx,
data,
}: {
db: DrizzleCli;
ctx: RepoContext;
data: InsertCustomerEntitlement[] | FullCustomerEntitlement[];
}) {
const { db } = ctx;
if (Array.isArray(data) && data.length === 0) {
return;
}
@@ -176,14 +181,16 @@ export class CusEntService {
}
static async update({
db,
ctx,
id,
updates,
}: {
db: DrizzleCli;
ctx: RepoContext;
id: string;
updates: Partial<InsertCustomerEntitlement>;
}) {
const { db } = ctx;
const data = await db
.update(customerEntitlements)
.set({
@@ -196,11 +203,60 @@ export class CusEntService {
return data;
}
static async syncUpdateToCache({
ctx,
cusEntId,
updates,
}: {
ctx: RepoContext;
cusEntId: string;
updates: Partial<InsertCustomerEntitlement>;
}) {
const { org, env, customerId } = ctx;
if (!customerId) {
ctx.logger.warn(
`skipping cusEnt sync update to cache, customerId not known`,
);
return;
}
const cacheKey = buildFullCustomerCacheKey({
orgId: org.id,
env,
customerId: customerId ?? "",
});
const cacheUpdates = [
{
cus_ent_id: cusEntId,
balance: updates.balance ?? null,
additional_balance: updates.additional_balance ?? null,
adjustment: updates.adjustment ?? null,
entities: updates.entities ?? null,
next_reset_at: updates.next_reset_at ?? null,
expected_next_reset_at: null,
rollover_insert: null,
rollover_overwrites: null,
rollover_delete_ids: null,
new_replaceables: null,
deleted_replaceable_ids: null,
},
];
await tryRedisWrite(() =>
redis.updateCustomerEntitlements(
cacheKey,
JSON.stringify({ updates: cacheUpdates }),
),
);
}
static async batchUpdate({
db,
ctx,
data,
}: {
db: DrizzleCli;
ctx: RepoContext;
data: UpdateCustomerEntitlement[];
}) {
if (Array.isArray(data) && data.length === 0) {
@@ -215,7 +271,7 @@ export class CusEntService {
updatePromises.push(
CusEntService.update({
db,
ctx,
id: customerEntitlement.id,
updates: updates as Partial<InsertCustomerEntitlement>,
}),
@@ -273,14 +329,15 @@ export class CusEntService {
}
static async increment({
db,
ctx,
id,
amount,
}: {
db: DrizzleCli;
ctx: RepoContext;
id: string;
amount: number;
}) {
const { db } = ctx;
const data = await db
.update(customerEntitlements)
.set({
@@ -294,14 +351,16 @@ export class CusEntService {
}
static async decrement({
db,
ctx,
id,
amount,
}: {
db: DrizzleCli;
ctx: RepoContext;
id: string;
amount: number;
}) {
const { db } = ctx;
const data = await db
.update(customerEntitlements)
.set({

View File

@@ -5,17 +5,19 @@ import {
} from "@autumn/shared";
import { eq, inArray } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
export class RepService {
static async insert({
db,
ctx,
data,
}: {
db: DrizzleCli;
ctx: RepoContext;
data: InsertReplaceable[];
}) {
if (data.length === 0) return [];
const inserted = await db.insert(replaceables).values(data).returning();
const inserted = await ctx.db.insert(replaceables).values(data).returning();
return inserted as Replaceable[];
}
@@ -36,7 +38,14 @@ export class RepService {
return updated as Replaceable[];
}
static async deleteInIds({ db, ids }: { db: DrizzleCli; ids: string[] }) {
static async deleteInIds({
ctx,
ids,
}: {
ctx: AutumnContext;
ids: string[];
}) {
const { db } = ctx;
if (ids.length === 0) return [];
const deleted = await db
.delete(replaceables)

View File

@@ -4,8 +4,10 @@ import {
rollovers,
} from "@autumn/shared";
import { and, eq, gte, inArray } from "drizzle-orm";
import type { CronContext } from "@/cron/utils/CronContext.js";
import { buildConflictUpdateColumns } from "@/db/dbUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext.js";
import { performMaximumClearing } from "./rolloverUtils.js";
export class RolloverService {
@@ -58,12 +60,15 @@ export class RolloverService {
// }
static async getCurrentRollovers({
db,
// db,
ctx,
cusEntID,
}: {
db: DrizzleCli;
// db: DrizzleCli;
ctx: CronContext;
cusEntID: string;
}) {
const { db } = ctx;
return await db
.select()
.from(rollovers)
@@ -76,20 +81,21 @@ export class RolloverService {
}
static async insert({
db,
ctx,
rows,
fullCusEnt,
}: {
db: DrizzleCli;
ctx: RepoContext;
rows: Rollover[];
fullCusEnt: FullCustomerEntitlement;
}) {
const { db } = ctx;
if (rows.length === 0) return {};
await db.insert(rollovers).values(rows).returning();
return RolloverService.clearExcessRollovers({
db,
ctx,
newRows: rows,
fullCusEnt,
});
@@ -97,14 +103,15 @@ export class RolloverService {
/** Enforces the rollover max cap after new rollovers have been inserted into the DB. */
static async clearExcessRollovers({
db,
ctx,
newRows,
fullCusEnt,
}: {
db: DrizzleCli;
ctx: RepoContext;
newRows: Rollover[];
fullCusEnt: FullCustomerEntitlement;
}): Promise<Rollover[]> {
const { db } = ctx;
const curRollovers = [...fullCusEnt.rollovers, ...newRows];
const { toDelete, toUpdate } = performMaximumClearing({

View File

@@ -1,16 +1,18 @@
import type { InsertCustomerProduct } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusProductService } from "../CusProductService.js";
import { type InsertCustomerProduct } from "@autumn/shared";
import type { RepoContext } from "@/db/repoContext.js";
import { CusProductService } from "../CusProductService";
/**
* Batch update customer products by their IDs.
* Each update is executed in parallel using Promise.all.
*/
export const batchUpdateCustomerProducts = async ({
db,
ctx,
updates,
}: {
db: DrizzleCli;
ctx: RepoContext;
updates: {
id: string;
updates: Partial<InsertCustomerProduct>;
@@ -26,7 +28,7 @@ export const batchUpdateCustomerProducts = async ({
}
return CusProductService.update({
db,
ctx,
cusProductId: id,
updates: updateData,
});

View File

@@ -50,7 +50,7 @@ export const handleDecreaseAndTransfer = async ({
batchDecrement.push(
CusEntService.decrement({
db,
ctx,
id: cusEnt.id,
amount: resetBalance,
}),
@@ -60,7 +60,7 @@ export const handleDecreaseAndTransfer = async ({
await Promise.all(batchDecrement);
await CusProductService.update({
db,
ctx,
cusProductId: cusProduct.id,
updates: {
quantity: cusProduct.quantity - 1,

View File

@@ -118,7 +118,7 @@ export const handleTransferProductV2 = createRoute({
});
} else {
await CusProductService.update({
db,
ctx,
cusProductId: cusProduct.id,
updates: {
entity_id: toEntity?.id || null,

View File

@@ -62,16 +62,13 @@ export const deleteEntity = async ({
if (!mainCusEnt) continue;
const { newReplaceables } = await adjustAllowance({
db,
env,
org,
ctx,
cusPrices: cusProduct.customer_prices,
customer: fullCus,
affectedFeature: mainCusEnt.entitlement.feature,
cusEnt: { ...mainCusEnt, customer_product: cusProduct },
originalBalance: mainCusEnt.balance!,
newBalance: mainCusEnt.balance! + 1,
logger,
});
const linkedCusEnts = findLinkedCusEnts({
@@ -113,7 +110,7 @@ export const deleteEntity = async ({
}
await CusEntService.update({
db,
ctx,
id: linkedCusEnt.id,
updates: {
entities: newEntities,
@@ -123,7 +120,7 @@ export const deleteEntity = async ({
if (!replaceable) {
await CusEntService.increment({
db,
ctx,
id: mainCusEnt.id,
amount: 1,
});

View File

@@ -11,7 +11,6 @@ import {
findFeatureById,
type Replaceable,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { acquireLock, clearLock } from "@/external/redis/redisUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { adjustAllowance } from "@/internal/balances/utils/paidAllocatedFeature/adjustAllowance.js";
@@ -22,12 +21,12 @@ import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
const updateLinkedCusEnt = async ({
db,
ctx,
linkedCusEnt,
inputEntities,
entityToReplacement,
}: {
db: DrizzleCli;
ctx: AutumnContext;
linkedCusEnt: FullCustomerEntitlement;
inputEntities: CreateEntityParams[];
entityToReplacement: Record<string, string>;
@@ -57,7 +56,7 @@ const updateLinkedCusEnt = async ({
}
await CusEntService.update({
db,
ctx,
id: linkedCusEnt.id,
updates: {
entities: newEntities,
@@ -161,23 +160,20 @@ export const createEntityForCusProduct = async ({
const { deletedReplaceables: deletedReplaceables_ } =
await adjustAllowance({
db,
env,
org,
ctx,
cusPrices,
customer,
affectedFeature: feature,
affectedFeature: feature!,
cusEnt: mainCusEntWithCusProduct,
originalBalance,
newBalance,
logger,
newBalance: innerNewBalance,
errorIfIncomplete: true,
});
deletedReplaceables = deletedReplaceables_ || [];
await CusEntService.decrement({
db,
ctx,
id: mainCusEntWithCusProduct.id,
amount: inputEntities.length - deletedReplaceables.length,
});
@@ -203,7 +199,7 @@ export const createEntityForCusProduct = async ({
for (const linkedCusEnt of linkedCusEnts) {
await updateLinkedCusEnt({
db,
ctx,
linkedCusEnt,
inputEntities,
entityToReplacement,

View File

@@ -15,7 +15,7 @@ export const cancelSubsForEntity = async ({
cusProducts: FullCusProduct[];
entity: Entity;
}) => {
const { db, logger } = ctx;
const { logger } = ctx;
try {
for (const cusProduct of cusProducts) {
if (cusProduct.internal_entity_id !== entity.internal_id) {
@@ -24,7 +24,7 @@ export const cancelSubsForEntity = async ({
if (cusProduct.status === CusProductStatus.Scheduled) {
await CusProductService.delete({
db,
ctx,
cusProductId: cusProduct.id,
});
}

View File

@@ -55,16 +55,13 @@ export const handleDeleteEntity = createRoute({
if (!mainCusEnt) continue;
const { newReplaceables } = await adjustAllowance({
db,
env,
org,
ctx,
cusPrices: cusProduct.customer_prices,
customer: fullCus,
affectedFeature: mainCusEnt.entitlement.feature,
affectedFeature: mainCusEnt.entitlement.feature!,
cusEnt: { ...mainCusEnt, customer_product: cusProduct },
originalBalance: mainCusEnt.balance!,
newBalance: mainCusEnt.balance! + 1,
logger,
});
const linkedCusEnts = findLinkedCusEnts({
@@ -107,22 +104,34 @@ export const handleDeleteEntity = createRoute({
newEntities = newEntities_;
}
await CusEntService.update({
await CusEntService.update({
ctx: {
db,
id: linkedCusEnt.id,
updates: {
entities: newEntities,
},
});
logger,
org,
env,
customerId: customer_id,
},
id: linkedCusEnt.id,
updates: {
entities: newEntities,
},
});
}
if (!replaceable) {
await CusEntService.increment({
if (!replaceable) {
await CusEntService.increment({
ctx: {
db,
id: mainCusEnt.id,
amount: 1,
});
}
logger,
org,
env,
customerId: customer_id,
},
id: mainCusEnt.id,
amount: 1,
});
}
}
// Cancel any subs

View File

@@ -1,16 +1,12 @@
import type {
Organization,
Price,
Product,
UsagePriceConfig,
} from "@autumn/shared";
import type { Price, Product, UsagePriceConfig } from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
export const constructStripeInvoiceItem = ({
ctx,
product,
amount,
org,
price,
description,
stripeSubId,
@@ -18,9 +14,9 @@ export const constructStripeInvoiceItem = ({
periodStart,
periodEnd,
}: {
ctx: AutumnContext;
product: Product;
amount: number;
org: Organization;
price: Price;
description: string;
stripeSubId: string;
@@ -28,6 +24,7 @@ export const constructStripeInvoiceItem = ({
periodStart: number;
periodEnd: number;
}) => {
const { org } = ctx;
const config = price.config as UsagePriceConfig;
const amountInCents = Math.floor(
@@ -40,7 +37,7 @@ export const constructStripeInvoiceItem = ({
price_data: {
unit_amount: amountInCents,
currency: org.default_currency || "usd",
product: config.stripe_product_id || product.processor?.id!,
product: config.stripe_product_id || (product.processor?.id ?? ""),
},
}
: {

View File

@@ -46,7 +46,7 @@ export const migrateRevenueCatCustomer = async ({
});
await CusProductService.update({
db,
ctx,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,

View File

@@ -2,20 +2,20 @@ import {
type FullCustomer,
fullCustomerToCustomerEntitlements,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
export const updateUsages = async ({
ctx,
featureId,
usage,
fullCus,
db,
}: {
ctx: AutumnContext;
featureId: string;
usage: number;
fullCus: FullCustomer;
db: DrizzleCli;
}) => {
const cusEnts = fullCustomerToCustomerEntitlements({
fullCustomer: fullCus,
@@ -30,7 +30,7 @@ export const updateUsages = async ({
const newBalance = cusEnt.balance! - usage;
await CusEntService.update({
db,
ctx,
id: cusEnt.id,
updates: {
balance: newBalance,

View File

@@ -96,7 +96,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom reset intervals`)}`,
await timeout(3000);
await resetAndGetCusEnt({
db,
ctx,
customer,
productGroup: free.group || "",
featureId: TestFeature.Messages,
@@ -113,7 +113,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom reset intervals`)}`,
test("should reset words feature and have correct next reset at", async () => {
await resetAndGetCusEnt({
db,
ctx,
customer,
productGroup: free.group || "",
featureId: TestFeature.Words,

View File

@@ -104,7 +104,7 @@ describe(`${chalk.yellowBright("loose-reset: test getActiveResetPassed for loose
// 3. Call resetCustomerEntitlement
const updatedCusEnt = await resetCustomerEntitlement({
db: ctx.db,
ctx,
cusEnt: resetCusEnt,
updatedCusEnts: [],
});

View File

@@ -124,7 +124,7 @@ describe(`${chalk.yellowBright("track-race-condition3: track runs when credits a
chalk.yellow("\nStep 3: Resetting credits (simulates cron reset)..."),
);
await resetAndGetCusEnt({
db: ctx.db,
ctx,
customer: fullCustomer,
productGroup: pro.group!,
featureId: TestFeature.Messages,

View File

@@ -1,24 +1,25 @@
import type { Customer } from "@autumn/shared";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
import { clearCusEntsFromCache } from "@/cron/resetCron/clearCusEntsFromCache";
import { resetCustomerEntitlement } from "@/cron/resetCron/resetCustomerEntitlement.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
export const resetAndGetCusEnt = async ({
db,
ctx,
customer,
productGroup,
featureId,
skipCacheDeletion = false,
}: {
db: DrizzleCli;
ctx: TestContext;
customer: Customer;
productGroup: string;
featureId: string;
skipCacheDeletion?: boolean;
}) => {
const { db } = ctx;
// Run reset cusEnt on ...
let mainCusProduct = await getMainCusProduct({
db,
@@ -37,7 +38,7 @@ export const resetAndGetCusEnt = async ({
};
const updatedCusEnt = await resetCustomerEntitlement({
db,
ctx,
cusEnt: resetCusEnt,
updatedCusEnts: [],
});

View File

@@ -82,7 +82,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
await timeout(3000);
await resetAndGetCusEnt({
db: ctx.db,
ctx,
customer,
productGroup: free.group!,
featureId: TestFeature.Messages,
@@ -119,7 +119,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
// let usage2 = 50;
test("should reset again and have correct rollover", async () => {
await resetAndGetCusEnt({
db: ctx.db,
ctx,
customer,
productGroup: free.group!,
featureId: TestFeature.Messages,

View File

@@ -117,7 +117,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
// Run reset cusEnt on ...
await resetAndGetCusEnt({
db: ctx.db,
ctx,
customer,
productGroup: free.group!,
featureId: TestFeature.Messages,
@@ -157,7 +157,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
test("should reset again and have correct rollovers", async () => {
await resetAndGetCusEnt({
db: ctx.db,
ctx,
customer,
productGroup: free.group!,
featureId: TestFeature.Messages,

View File

@@ -85,14 +85,14 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for upgrade`)}`,
test("should create rollovers", async () => {
await resetAndGetCusEnt({
ctx,
customer,
db: ctx.db,
productGroup: testCase,
featureId: TestFeature.Messages,
});
await resetAndGetCusEnt({
ctx,
customer,
db: ctx.db,
productGroup: testCase,
featureId: TestFeature.Messages,
});

View File

@@ -92,14 +92,14 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for upgrade`)}`,
test("should create rollovers", async () => {
await resetAndGetCusEnt({
ctx,
customer,
db: ctx.db,
productGroup: testCase,
featureId: TestFeature.Messages,
});
await resetAndGetCusEnt({
ctx,
customer,
db: ctx.db,
productGroup: testCase,
featureId: TestFeature.Messages,
});

View File

@@ -398,7 +398,7 @@ test.concurrent(`${chalk.yellowBright("legacy-set-usage-prepaid1: set usage with
s.attach({ productId: freeProd.id }),
s.billing.attach({
productId: prepaidAddOn.id,
options: { quantity: 2 },
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});

View File

@@ -141,7 +141,7 @@ test.concurrent(`${chalk.yellowBright("legacy-addon 2: attach pro then free add-
// Attach the same free add-on a second time
await autumnV1.attach({
customer_id: customerId,
product_id: `${addOn.id}_${customerId}`,
product_id: addOn.id,
});
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
@@ -216,7 +216,7 @@ test.concurrent(`${chalk.yellowBright("legacy-addon 3: attach pro then monthly p
});
// Use AutumnCli.getCustomer for V1 response format (entitlements, add_ons)
const cusRes = await AutumnCli.getCustomer(customerId) as ApiCustomerV1;
const cusRes = (await AutumnCli.getCustomer(customerId)) as ApiCustomerV1;
// Pro gives 10 Messages, add-on gives 500
const expectedBalance = 10 + monthlyQuantity;
@@ -233,7 +233,10 @@ test.concurrent(`${chalk.yellowBright("legacy-addon 3: attach pro then monthly p
expect(cusRes.invoices.length).toBe(2);
// Verify /entitled returns correct balance (V0 endpoint with balances array)
const entitledRes = await AutumnCli.entitled(customerId, TestFeature.Messages) as {
const entitledRes = (await AutumnCli.entitled(
customerId,
TestFeature.Messages,
)) as {
allowed: boolean;
balances: { feature_id: string; balance: number }[];
};

View File

@@ -213,7 +213,8 @@ test.concurrent(`${chalk.yellowBright("legacy-checkout-adv 3: force checkout upg
product_id: pro.id,
});
const customerAfterPro = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const customerAfterPro =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterPro,
@@ -231,9 +232,11 @@ test.concurrent(`${chalk.yellowBright("legacy-checkout-adv 3: force checkout upg
customer_id: customerId,
product_id: pro.id,
cancel_immediately: true,
prorate: false,
});
const customerAfterCancel = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCancel,
@@ -249,7 +252,8 @@ test.concurrent(`${chalk.yellowBright("legacy-checkout-adv 3: force checkout upg
await completeStripeCheckoutFormV2({ url: res.checkout_url });
const customerAfterPremium = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const customerAfterPremium =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterPremium,

View File

@@ -148,7 +148,7 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: annual + monthly, adv
status: CusProductStatus.Active,
});
expect(
entity1.products.filter((p: any) => p.group === premiumAnnualProduct.group)
entity1.products.filter((p) => p.group === premiumAnnualProduct.group)
.length,
).toBe(1);
@@ -159,9 +159,9 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: annual + monthly, adv
productId: pro.id,
status: CusProductStatus.Active,
});
expect(
entity2.products.filter((p: any) => p.group === premium.group).length,
).toBe(1);
expect(entity2.products.filter((p) => p.group === premium.group).length).toBe(
1,
);
});
// ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -29,6 +29,7 @@ import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addWeeks } from "date-fns";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils";
@@ -89,7 +90,7 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 1: premium -> pro, advan
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfMonths: 1,
waitForSeconds: 10,
waitForSeconds: 30,
});
// Verify: Pro is active, Premium is gone
@@ -164,7 +165,7 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 2: premium -> free, adva
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfMonths: 1,
waitForSeconds: 10,
waitForSeconds: 30,
});
// Verify: Free is active
@@ -330,17 +331,17 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: pro-quarter -> premiu
await expectProductScheduled({ customer, productId: pro.id });
// Advance clock 3 months (end of quarter) - advance 1.5 months twice
await advanceTestClock({
const advancedTo = await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfWeeks: 6,
waitForSeconds: 10,
waitForSeconds: 30,
});
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfWeeks: 6,
waitForSeconds: 10,
advanceTo: addWeeks(advancedTo, 7).getTime(),
waitForSeconds: 30,
});
// Verify: Pro (monthly) is active
@@ -443,7 +444,7 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 5: premium -> pro schedu
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfMonths: 1,
waitForSeconds: 10,
waitForSeconds: 30,
});
// Verify: Pro is active with correct features

View File

@@ -243,7 +243,7 @@ test.concurrent(`${chalk.yellowBright("legacy-trial-merged 3: upgrade entities f
entity_id: entities[1].id,
});
await timeout(4000);
await timeout(5000);
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
expectProductAttached({
customer: entity2,

View File

@@ -501,9 +501,11 @@ test.concurrent(`${chalk.yellowBright("legacy-trial 5: paid to trial upgrade")}`
// 2 invoices: Pro paid ($20) + Premium trial ($0)
await expectCustomerInvoiceCorrect({
customer: customerAfterPremium,
count: 2,
count: 3,
latestTotal: 0, // Premium trial - $0
});
expect(customerAfterPremium.invoices?.[1].total).toBe(-20); // refund for pro
});
// ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -1462,7 +1462,7 @@ export async function initScenario({
const productGroup = productPrefix;
await resetAndGetCusEnt({
db: ctx.db,
ctx,
customer,
productGroup,
featureId: action.featureId,