feat: atomic update usage
This commit is contained in:
@@ -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' \
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
|
||||
for (const cusEnt of batch) {
|
||||
batchResets.push(
|
||||
resetCustomerEntitlement({
|
||||
db,
|
||||
ctx,
|
||||
cusEnt: cusEnt,
|
||||
updatedCusEnts,
|
||||
}),
|
||||
|
||||
14
server/src/db/repoContext.ts
Normal file
14
server/src/db/repoContext.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const handleCancellation = async ({
|
||||
}
|
||||
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: curSameProduct.id,
|
||||
updates: {
|
||||
canceled_at: Date.now(),
|
||||
|
||||
@@ -37,7 +37,7 @@ export const handleExpiration = async ({
|
||||
|
||||
// Expire the cus_product
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: curSameProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
|
||||
@@ -73,7 +73,7 @@ export const handleInitialPurchase = async ({
|
||||
|
||||
// Expire old cus_product
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: curMainProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
|
||||
@@ -33,7 +33,7 @@ export const handleUncancellation = async ({
|
||||
|
||||
if (cusProduct) {
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: cusProduct.id,
|
||||
updates: {
|
||||
canceled_at: null,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ export const handleStripeSubscriptionCanceled = async ({
|
||||
};
|
||||
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: customerProduct.id,
|
||||
updates,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ export const handleSubCreated = async ({
|
||||
|
||||
const updateCusProd = async () => {
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: cusProd.id,
|
||||
updates: {
|
||||
subscription_ids: subIds,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : [],
|
||||
|
||||
@@ -157,7 +157,7 @@ export const updateStripeSub2 = async ({
|
||||
if (curCusProduct) {
|
||||
if (!url) {
|
||||
await resetUsageBalances({
|
||||
db,
|
||||
ctx,
|
||||
cusEntIds,
|
||||
cusProduct: curCusProduct,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -64,7 +64,7 @@ export const activateScheduledCustomerProduct = async ({
|
||||
};
|
||||
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: customerProduct.id,
|
||||
updates,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -47,7 +47,7 @@ export const deleteScheduledCustomerProduct = async ({
|
||||
);
|
||||
|
||||
await CusProductService.delete({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: scheduledCustomerProduct.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export const expireCustomerProductAndActivateDefault = async ({
|
||||
};
|
||||
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: customerProduct.id,
|
||||
updates,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -118,7 +118,7 @@ export const handleTransferProductV2 = createRoute({
|
||||
});
|
||||
} else {
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: cusProduct.id,
|
||||
updates: {
|
||||
entity_id: toEntity?.id || null,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? ""),
|
||||
},
|
||||
}
|
||||
: {
|
||||
|
||||
@@ -46,7 +46,7 @@ export const migrateRevenueCatCustomer = async ({
|
||||
});
|
||||
|
||||
await CusProductService.update({
|
||||
db,
|
||||
ctx,
|
||||
cusProductId: cusProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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 }[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1462,7 +1462,7 @@ export async function initScenario({
|
||||
const productGroup = productPrefix;
|
||||
|
||||
await resetAndGetCusEnt({
|
||||
db: ctx.db,
|
||||
ctx,
|
||||
customer,
|
||||
productGroup,
|
||||
featureId: action.featureId,
|
||||
|
||||
Reference in New Issue
Block a user