fix: billing interval

This commit is contained in:
John Yeo
2025-09-30 16:01:15 +01:00
parent 2de9101331
commit 2a732450fd
12 changed files with 602 additions and 207 deletions

View File

@@ -1,14 +1,18 @@
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import {
type AttachBranch,
type AttachConfig,
UsageModel,
} from "@autumn/shared";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
attachParamsToProduct,
attachParamToCusProducts,
paramsToCurSub,
} from "../attachUtils/convertAttachParams.js";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { AttachBranch, AttachConfig, UsageModel } from "@autumn/shared";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
export const getDowngradeProductPreview = async ({
attachParams,
@@ -31,13 +35,12 @@ export const getDowngradeProductPreview = async ({
let items = await getItemsForNewProduct({
newProduct,
attachParams,
now,
logger,
});
items = items.filter((item) => item.usage_model !== UsageModel.Prepaid);
let options = getOptions({
const options = getOptions({
prodItems: mapToProductItems({
prices: newProduct.prices,
entitlements: newProduct.entitlements,
@@ -48,7 +51,7 @@ export const getDowngradeProductPreview = async ({
});
const latestPeriodEnd = sub ? getLatestPeriodEnd({ sub }) * 1000 : undefined;
let nextCycleAt = curCusProduct?.trial_ends_at
const nextCycleAt = curCusProduct?.trial_ends_at
? curCusProduct.trial_ends_at
: latestPeriodEnd;

View File

@@ -66,7 +66,6 @@ const getNextCycleItems = async ({
const items = await getItemsForNewProduct({
newProduct,
attachParams,
now: attachParams.now,
logger,
withPrepaid,
// anchor,
@@ -123,7 +122,6 @@ export const getNewProductPreview = async ({
const items = await getItemsForNewProduct({
newProduct,
attachParams,
now: attachParams.now,
freeTrial: attachParams.freeTrial,
anchor,
logger,

View File

@@ -179,10 +179,10 @@ export const getUpgradeProductPreview = async ({
freeTrial = curCusProduct.free_trial;
}
// console.log("Anchor")
const newPreviewItems = await getItemsForNewProduct({
newProduct,
attachParams,
now,
freeTrial,
sub: sub!,
logger,

View File

@@ -122,7 +122,6 @@ export const getProration = ({
export const getItemsForNewProduct = async ({
newProduct,
attachParams,
now,
proration,
anchor,
freeTrial,
@@ -133,7 +132,6 @@ export const getItemsForNewProduct = async ({
}: {
newProduct: FullProduct;
attachParams: AttachParams;
now?: number;
proration?: {
start: number;
end: number;
@@ -146,7 +144,7 @@ export const getItemsForNewProduct = async ({
skipOneOff?: boolean;
}) => {
const { org, features } = attachParams;
now = now || Date.now();
const now = attachParams.now || Date.now();
const items: PreviewLineItem[] = [];
@@ -163,6 +161,7 @@ export const getItemsForNewProduct = async ({
if (printLogs) {
console.log("price", formatPrice({ price }));
console.log("now:", formatUnixToDate(now));
console.log("anchor", formatUnixToDate(anchor));
}
const finalProration = getProration({

View File

@@ -1,8 +1,7 @@
import { BillingInterval, IntervalConfig } from "@autumn/shared";
import { BillingInterval, type IntervalConfig } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import {
addMinutes,
addMonths,
addSeconds,
addWeeks,
addYears,
differenceInSeconds,
@@ -20,7 +19,6 @@ import {
subWeeks,
subYears,
} from "date-fns";
import { UTCDate } from "@date-fns/utc";
import { formatUnixToDateTime } from "@/utils/genUtils.js";
export const subtractBillingIntervalUnix = ({
@@ -192,13 +190,13 @@ export const getAlignedIntervalUnix = ({
console.log("--------------------------------");
}
let anchorAndNaturalDiff = differenceInSeconds(
const anchorAndNaturalDiff = differenceInSeconds(
naturalBillingDate,
nextCycleAnchorUnix,
);
// For insurance, also means you can't set billing cycle anchor to a minute in the future...
let anchorAndNowDiff = Math.abs(
const anchorAndNowDiff = Math.abs(
differenceInSeconds(now, nextCycleAnchorUnix),
);
@@ -254,8 +252,9 @@ export const subtractFromUnixTillAligned = ({
return getTime(alignedDate);
};
// Subtracts an interval from a period end, preserving end-of-month anchoring
// e.g. 30 Sep -> 31 Aug (not 30 Aug)
// Subtracts an interval from a period end, preserving anchor-based end-of-month behavior
// Uses the anchor date (unixTimestamp) to determine if end-of-month logic should apply
// e.g. Sep 30 anchor -> Aug 30, Jul 30, Feb 28/29 (follows Stripe behavior)
export const subtractIntervalForProration = ({
unixTimestamp,
interval,
@@ -265,68 +264,64 @@ export const subtractIntervalForProration = ({
interval: BillingInterval;
intervalCount?: number;
}) => {
const endDate = new UTCDate(unixTimestamp);
const isEndOfMonth = () => {
const lastDay = new UTCDate(
endDate.getFullYear(),
endDate.getMonth() + 1,
0,
).getDate();
return getDate(endDate) === lastDay;
};
const anchorDate = new UTCDate(unixTimestamp);
const anchorDay = getDate(anchorDate);
const preserveTime = (d: UTCDate) => {
let preserved = new UTCDate(d.getTime());
preserved = new UTCDate(setHours(preserved, getHours(endDate)).getTime());
preserved = new UTCDate(
setMinutes(preserved, getMinutes(endDate)).getTime(),
setHours(preserved, getHours(anchorDate)).getTime(),
);
preserved = new UTCDate(
setSeconds(preserved, getSeconds(endDate)).getTime(),
setMinutes(preserved, getMinutes(anchorDate)).getTime(),
);
preserved = new UTCDate(
setSeconds(preserved, getSeconds(anchorDate)).getTime(),
);
return preserved;
};
const setToLastDayOfMonth = (d: UTCDate) => {
const last = new UTCDate(d.getFullYear(), d.getMonth() + 1, 0).getDate();
return new UTCDate(setDate(d, last).getTime());
const setToAnchorDayOrEndOfMonth = (d: UTCDate) => {
const lastDayOfTargetMonth = new UTCDate(
d.getFullYear(),
d.getMonth() + 1,
0,
).getDate();
// If the anchor day doesn't exist in the target month, go to the last day of that month
// This follows Stripe's behavior: Sep 30 -> Feb 28, Jul 31 -> Feb 28, etc.
const targetDay = Math.min(anchorDay, lastDayOfTargetMonth);
return new UTCDate(setDate(d, targetDay).getTime());
};
switch (interval) {
case BillingInterval.Week: {
const sub = new UTCDate(subWeeks(endDate, 1 * intervalCount).getTime());
const sub = new UTCDate(
subWeeks(anchorDate, 1 * intervalCount).getTime(),
);
return getTime(sub);
}
case BillingInterval.Month: {
let sub = new UTCDate(subMonths(endDate, 1 * intervalCount).getTime());
if (isEndOfMonth()) {
sub = setToLastDayOfMonth(sub);
}
let sub = new UTCDate(subMonths(anchorDate, 1 * intervalCount).getTime());
sub = setToAnchorDayOrEndOfMonth(sub);
sub = preserveTime(sub);
return getTime(sub);
}
case BillingInterval.Quarter: {
let sub = new UTCDate(subMonths(endDate, 3 * intervalCount).getTime());
if (isEndOfMonth()) {
sub = setToLastDayOfMonth(sub);
}
let sub = new UTCDate(subMonths(anchorDate, 3 * intervalCount).getTime());
sub = setToAnchorDayOrEndOfMonth(sub);
sub = preserveTime(sub);
return getTime(sub);
}
case BillingInterval.SemiAnnual: {
let sub = new UTCDate(subMonths(endDate, 6 * intervalCount).getTime());
if (isEndOfMonth()) {
sub = setToLastDayOfMonth(sub);
}
let sub = new UTCDate(subMonths(anchorDate, 6 * intervalCount).getTime());
sub = setToAnchorDayOrEndOfMonth(sub);
sub = preserveTime(sub);
return getTime(sub);
}
case BillingInterval.Year: {
let sub = new UTCDate(subYears(endDate, 1 * intervalCount).getTime());
if (isEndOfMonth()) {
sub = setToLastDayOfMonth(sub);
}
let sub = new UTCDate(subYears(anchorDate, 1 * intervalCount).getTime());
sub = setToAnchorDayOrEndOfMonth(sub);
sub = preserveTime(sub);
return getTime(sub);
}
@@ -335,8 +330,9 @@ export const subtractIntervalForProration = ({
}
};
// Adds an interval to a period start, preserving end-of-month anchoring
// e.g. 30 Sep -> 31 Oct (not 30 Oct)
// Adds an interval to a period start, preserving anchor-based end-of-month behavior
// Uses the anchor date (unixTimestamp) to determine if end-of-month logic should apply
// e.g. Sep 30 anchor -> Oct 30, Nov 30, Feb 28/29 (follows Stripe behavior)
export const addIntervalForProration = ({
unixTimestamp,
intervalConfig,
@@ -347,68 +343,64 @@ export const addIntervalForProration = ({
if (!intervalConfig) return unixTimestamp;
let { interval, intervalCount } = intervalConfig;
intervalCount = intervalCount ?? 1;
const startDate = new UTCDate(unixTimestamp);
const isEndOfMonth = () => {
const lastDay = new UTCDate(
startDate.getFullYear(),
startDate.getMonth() + 1,
0,
).getDate();
return getDate(startDate) === lastDay;
};
const anchorDate = new UTCDate(unixTimestamp);
const anchorDay = getDate(anchorDate);
const preserveTime = (d: UTCDate) => {
let preserved = new UTCDate(d.getTime());
preserved = new UTCDate(setHours(preserved, getHours(startDate)).getTime());
preserved = new UTCDate(
setMinutes(preserved, getMinutes(startDate)).getTime(),
setHours(preserved, getHours(anchorDate)).getTime(),
);
preserved = new UTCDate(
setSeconds(preserved, getSeconds(startDate)).getTime(),
setMinutes(preserved, getMinutes(anchorDate)).getTime(),
);
preserved = new UTCDate(
setSeconds(preserved, getSeconds(anchorDate)).getTime(),
);
return preserved;
};
const setToLastDayOfMonth = (d: UTCDate) => {
const last = new UTCDate(d.getFullYear(), d.getMonth() + 1, 0).getDate();
return new UTCDate(setDate(d, last).getTime());
const setToAnchorDayOrEndOfMonth = (d: UTCDate) => {
const lastDayOfTargetMonth = new UTCDate(
d.getFullYear(),
d.getMonth() + 1,
0,
).getDate();
// If the anchor day doesn't exist in the target month, go to the last day of that month
// This follows Stripe's behavior: Sep 30 -> Feb 28, Jul 31 -> Feb 28, etc.
const targetDay = Math.min(anchorDay, lastDayOfTargetMonth);
return new UTCDate(setDate(d, targetDay).getTime());
};
switch (interval) {
case BillingInterval.Week: {
const add = new UTCDate(addWeeks(startDate, 1 * intervalCount).getTime());
const add = new UTCDate(
addWeeks(anchorDate, 1 * intervalCount).getTime(),
);
return getTime(add);
}
case BillingInterval.Month: {
let add = new UTCDate(addMonths(startDate, 1 * intervalCount).getTime());
if (isEndOfMonth()) {
add = setToLastDayOfMonth(add);
}
let add = new UTCDate(addMonths(anchorDate, 1 * intervalCount).getTime());
add = setToAnchorDayOrEndOfMonth(add);
add = preserveTime(add);
return getTime(add);
}
case BillingInterval.Quarter: {
let add = new UTCDate(addMonths(startDate, 3 * intervalCount).getTime());
if (isEndOfMonth()) {
add = setToLastDayOfMonth(add);
}
let add = new UTCDate(addMonths(anchorDate, 3 * intervalCount).getTime());
add = setToAnchorDayOrEndOfMonth(add);
add = preserveTime(add);
return getTime(add);
}
case BillingInterval.SemiAnnual: {
let add = new UTCDate(addMonths(startDate, 6 * intervalCount).getTime());
if (isEndOfMonth()) {
add = setToLastDayOfMonth(add);
}
let add = new UTCDate(addMonths(anchorDate, 6 * intervalCount).getTime());
add = setToAnchorDayOrEndOfMonth(add);
add = preserveTime(add);
return getTime(add);
}
case BillingInterval.Year: {
let add = new UTCDate(addYears(startDate, 1 * intervalCount).getTime());
if (isEndOfMonth()) {
add = setToLastDayOfMonth(add);
}
let add = new UTCDate(addYears(anchorDate, 1 * intervalCount).getTime());
add = setToAnchorDayOrEndOfMonth(add);
add = preserveTime(add);
return getTime(add);
}

View File

@@ -1,9 +1,8 @@
import { IntervalConfig } from "@autumn/shared";
import type { IntervalConfig } from "@autumn/shared";
import {
addIntervalForProration,
subtractIntervalForProration,
} from "./billingIntervalUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
export const addIntervalToAnchor = ({
intervalConfig,
@@ -45,7 +44,7 @@ export const subtractIntervalFromAnchor = ({
now = now || Date.now();
for (let i = 0; i < 50; i++) {
let newAnchor = subtractIntervalForProration({
const newAnchor = subtractIntervalForProration({
unixTimestamp: anchor,
interval: intervalConfig.interval,
intervalCount: intervalConfig.intervalCount ?? 1,

View File

@@ -1,27 +1,25 @@
import {
Feature,
FullCustomerPrice,
OnIncrease,
Organization,
Price,
Product,
UsagePriceConfig,
type Feature,
type FullCustomerPrice,
getFeatureInvoiceDescription,
type OnIncrease,
type Organization,
type Price,
type Product,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { getFeatureInvoiceDescription } from "@autumn/shared";
import Stripe from "stripe";
import type Stripe from "stripe";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js";
import {
shouldProrate,
shouldBillNow,
shouldProrate,
} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
export const getUpgradeProrationInvoiceItem = ({
prevPrice,
@@ -68,12 +66,12 @@ export const getUpgradeProrationInvoiceItem = ({
amount: invoiceAmount,
});
let start = formatUnixToDate(now);
let end = formatUnixToDate(subItem.current_period_end * 1000);
const start = formatUnixToDate(now);
const end = formatUnixToDate(subItem.current_period_end * 1000);
invoiceDescription = `${invoiceDescription} (from ${start} to ${end})`;
}
let invoiceItem = constructStripeInvoiceItem({
const invoiceItem = constructStripeInvoiceItem({
product,
amount: invoiceAmount,
org,
@@ -117,14 +115,14 @@ export const createUpgradeProrationInvoice = async ({
onIncrease: OnIncrease;
logger: any;
}) => {
let now = await getStripeNow({ stripeCli, stripeSub: sub });
const now = await getStripeNow({ stripeCli, stripeSub: sub });
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: sub.customer as string,
});
let invoiceItem = getUpgradeProrationInvoiceItem({
const invoiceItem = getUpgradeProrationInvoiceItem({
prevPrice,
newPrice,
now,
@@ -138,12 +136,12 @@ export const createUpgradeProrationInvoice = async ({
subItem,
});
let invoiceAmount =
const invoiceAmount =
invoiceItem?.amount || invoiceItem?.price_data?.unit_amount || 0;
let invoiceDescription = invoiceItem?.description || "";
const invoiceDescription = invoiceItem?.description || "";
if (invoiceAmount == 0) return;
if (invoiceAmount === 0) return;
logger.info(
`🚀 Creating invoice item: ${invoiceDescription} - ${invoiceAmount.toFixed(2)}`,

View File

@@ -1,34 +1,31 @@
import {
FullCusEntWithFullCusProduct,
FullCustomerPrice,
InsertReplaceable,
type Feature,
type FullCusEntWithFullCusProduct,
type FullCustomerPrice,
getFeatureInvoiceDescription,
type InsertReplaceable,
OnDecrease,
} from "@autumn/shared";
import Stripe from "stripe";
import {
Feature,
Organization,
Product,
UsagePriceConfig,
type Organization,
type Product,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { getFeatureInvoiceDescription } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.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";
import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getUsageFromBalance } from "../adjustAllowance.js";
import { roundUsage } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getReplaceables } from "@/internal/products/prices/priceUtils/arrearProratedUtils/getContUsageDowngradeItem.js";
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import {
shouldBillNow,
shouldProrate,
} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
import { roundUsage } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { getUsageFromBalance } from "../adjustAllowance.js";
export const createDowngradeProrationInvoice = async ({
org,
@@ -59,7 +56,7 @@ export const createDowngradeProrationInvoice = async ({
}) => {
const config = cusPrice.price.config as UsagePriceConfig;
let now = await getStripeNow({ stripeCli, stripeSub: sub });
const now = await getStripeNow({ stripeCli, stripeSub: sub });
let invoiceAmount = new Decimal(newPrice).minus(prevPrice).toNumber();
logger.info(`Prev price: ${prevPrice}, New price: ${newPrice}`);
@@ -80,11 +77,11 @@ export const createDowngradeProrationInvoice = async ({
allowNegative: true,
});
let start = formatUnixToDate(now);
let end = formatUnixToDate(subItem.current_period_end * 1000);
const start = formatUnixToDate(now);
const end = formatUnixToDate(subItem.current_period_end * 1000);
invoiceDescription = `${invoiceDescription} (from ${start} to ${end})`;
if (invoiceAmount == 0) return;
if (invoiceAmount === 0) return;
logger.info(
`🚀 Creating invoice item: ${invoiceDescription} - ${invoiceAmount.toFixed(2)}`,
@@ -157,7 +154,7 @@ export const handleProratedDowngrade = async ({
balance: newBalance,
});
let onDecrease =
const onDecrease =
cusPrice.price.proration_config?.on_decrease ||
OnDecrease.ProrateImmediately;
@@ -167,9 +164,9 @@ export const handleProratedDowngrade = async ({
let invoice = null;
let newReplaceables: InsertReplaceable[] = [];
if (onDecrease == OnDecrease.NoProrations) {
if (onDecrease === OnDecrease.NoProrations || sub.status === "trialing") {
} else if (shouldProrate(onDecrease)) {
let prevPrice = priceToInvoiceAmount({
const prevPrice = priceToInvoiceAmount({
price: cusPrice.price,
overage: roundUsage({
usage: prevOverage,
@@ -177,7 +174,7 @@ export const handleProratedDowngrade = async ({
}),
});
let newPrice = priceToInvoiceAmount({
const newPrice = priceToInvoiceAmount({
price: cusPrice.price,
overage: roundUsage({
usage: newOverage,
@@ -217,10 +214,10 @@ export const handleProratedDowngrade = async ({
}
}
let numDeletedReplaceables = cusEnt.replaceables.filter(
const numDeletedReplaceables = cusEnt.replaceables.filter(
(r) => r.delete_next_cycle,
).length;
let newQuantity = newUsage - numDeletedReplaceables;
const newQuantity = newUsage - numDeletedReplaceables;
await stripeCli.subscriptionItems.update(subItem.id, {
quantity: roundUsage({

View File

@@ -1,23 +1,20 @@
import {
type Entitlement,
type FullCusEntWithFullCusProduct,
type FullCustomerPrice,
OnIncrease,
type Organization,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { shouldCreateInvoiceItem } from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
import {
Entitlement,
FullCusEntWithFullCusProduct,
FullCusEntWithProduct,
FullCustomerPrice,
OnIncrease,
Organization,
Price,
UsagePriceConfig,
} from "@autumn/shared";
import Stripe from "stripe";
import { createUpgradeProrationInvoice } from "./createUpgradeProrationInvoice.js";
import { getUsageFromBalance } from "../adjustAllowance.js";
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { roundUsage } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getUsageFromBalance } from "../adjustAllowance.js";
import { createUpgradeProrationInvoice } from "./createUpgradeProrationInvoice.js";
interface UsageValues {
prevRoundedUsage: number;
@@ -53,7 +50,7 @@ export const getPrevAndNewPriceForUpgrade = ({
balance: newBalance,
});
let prevPrice = priceToInvoiceAmount({
const prevPrice = priceToInvoiceAmount({
price,
overage: roundUsage({
// usage: prevUsage,
@@ -62,7 +59,7 @@ export const getPrevAndNewPriceForUpgrade = ({
}),
});
let newPrice = priceToInvoiceAmount({
const newPrice = priceToInvoiceAmount({
price,
overage: roundUsage({
// usage: newUsage,
@@ -90,8 +87,8 @@ export function getReps({
prevBalance: number;
newBalance: number;
}) {
let usageDiff = prevBalance - newBalance;
let reps = cusEnt.replaceables.slice(0, usageDiff);
const usageDiff = prevBalance - newBalance;
const reps = cusEnt.replaceables.slice(0, usageDiff);
return reps;
}
@@ -121,14 +118,14 @@ export const handleProratedUpgrade = async ({
logger.info(`Handling quantity increase`);
// 1. Get num reps to use
let reps = getReps({
const reps = getReps({
cusEnt,
prevBalance,
newBalance,
});
newBalance = newBalance + reps.length; // Increase new balance by number of reps
let { prevPrice, newPrice, newUsage } = getPrevAndNewPriceForUpgrade({
const { prevPrice, newPrice, newUsage } = getPrevAndNewPriceForUpgrade({
ent: cusEnt.entitlement,
price: cusPrice.price,
newBalance,
@@ -140,7 +137,7 @@ export const handleProratedUpgrade = async ({
const product = cusEnt.customer_product.product;
const feature = cusEnt.entitlement.feature;
let onIncrease =
const onIncrease =
cusPrice.price.proration_config?.on_increase ||
OnIncrease.ProrateImmediately;
@@ -150,7 +147,7 @@ export const handleProratedUpgrade = async ({
});
let invoice = null;
if (shouldCreateInvoiceItem(onIncrease)) {
if (shouldCreateInvoiceItem(onIncrease) && sub.status !== "trialing") {
invoice = await createUpgradeProrationInvoice({
org,
cusPrice,
@@ -168,7 +165,7 @@ export const handleProratedUpgrade = async ({
});
}
let deleted = await RepService.deleteInIds({
const deleted = await RepService.deleteInIds({
db,
ids: reps.map((r) => r.id),
});

View File

@@ -1,39 +1,39 @@
import { APIVersion, type AppEnv, type Organization } from "@autumn/shared";
import chalk from "chalk";
import { setupBefore } from "tests/before.js";
import { Stripe } from "stripe";
import { createProducts } from "tests/utils/productUtils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { APIVersion, AppEnv, Organization } from "@autumn/shared";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addWeeks } from "date-fns";
import { DrizzleCli } from "@/db/initDrizzle.js";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { createProducts } from "tests/utils/productUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
// UNCOMMENT FROM HERE
let pro = constructProduct({
const pro = constructProduct({
id: "pro",
items: [constructArrearItem({ featureId: TestFeature.Words })],
type: "pro",
});
let premium = constructProduct({
const premium = constructProduct({
id: "premium",
items: [constructArrearItem({ featureId: TestFeature.Words })],
type: "premium",
});
let growth = constructProduct({
const growth = constructProduct({
id: "growth",
items: [constructArrearItem({ featureId: TestFeature.Words })],
type: "growth",
});
describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => {
let customerId = "upgrade1";
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
const customerId = "upgrade1";
const autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let stripeCli: Stripe;
let testClockId: string;
@@ -77,7 +77,7 @@ describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => {
testClockId = testClockId1!;
});
it("should attach pro product", async function () {
it("should attach pro product", async () => {
await attachAndExpectCorrect({
autumn,
customerId,
@@ -89,7 +89,7 @@ describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => {
});
});
it("should attach premium product", async function () {
it("should attach premium product", async () => {
const wordsUsage = 100000;
await autumn.track({
customer_id: customerId,
@@ -115,7 +115,7 @@ describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => {
});
});
it("should attach growth product", async function () {
it("should attach growth product", async () => {
const wordsUsage = 200000;
await autumn.track({
customer_id: customerId,

View File

@@ -1,31 +1,31 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
APIVersion,
AppEnv,
type AppEnv,
OnDecrease,
OnIncrease,
Organization,
type Organization,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { addWeeks } from "date-fns";
import type Stripe from "stripe";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "../../attach/utils.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import {
calcProrationAndExpectInvoice,
expectSubQuantityCorrect,
} from "tests/utils/expectUtils/expectContUseUtils.js";
import { addWeeks } from "date-fns";
import { createProducts } from "tests/utils/productUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { addPrefixToProducts } from "../../attach/utils.js";
let userItem = constructArrearProratedItem({
const userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: 50,
includedUsage: 1,
@@ -35,7 +35,7 @@ let userItem = constructArrearProratedItem({
},
});
export let pro = constructProduct({
const pro = constructProduct({
items: [userItem],
type: "pro",
});
@@ -43,8 +43,8 @@ export let pro = constructProduct({
const testCase = "entity2";
describe(`${chalk.yellowBright(`contUse/${testCase}: Testing entities, prorate now`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
@@ -86,7 +86,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing entities, prorate n
});
let usage = 0;
let firstEntities = [
const firstEntities = [
{
id: "1",
name: "test",
@@ -94,7 +94,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing entities, prorate n
},
];
it("should create entity, then attach pro", async function () {
it("should create entity, then attach pro", async () => {
await autumn.entities.create(customerId, firstEntities);
usage += 1;
@@ -128,7 +128,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing entities, prorate n
},
];
it("should create 2 entities and have correct invoice", async function () {
it("should create 2 entities and have correct invoice", async () => {
curUnix = await advanceTestClock({
stripeCli,
testClockId,
@@ -150,6 +150,8 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing entities, prorate n
itemQuantity: usage,
});
await timeout(5000);
await calcProrationAndExpectInvoice({
autumn,
stripeSubs,
@@ -161,7 +163,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing entities, prorate n
});
});
it("should delete 1 entity and have correct invoice amount", async function () {
it("should delete 1 entity and have correct invoice amount", async () => {
curUnix = await advanceTestClock({
stripeCli,
testClockId,

View File

@@ -0,0 +1,410 @@
import { BillingInterval } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { expect } from "chai";
import { describe, it } from "mocha";
import {
addIntervalForProration,
subtractIntervalForProration,
} from "@/internal/products/prices/billingIntervalUtils.js";
describe("Billing Interval Utils - Stripe-like behavior", () => {
describe("addIntervalForProration", () => {
describe("Sep 30 anchor", () => {
const sep30_2024 = new UTCDate("2024-09-30T12:00:00Z").getTime();
it("Sep 30 -> Oct 30", () => {
const result = addIntervalForProration({
unixTimestamp: sep30_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 1 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-10-30",
);
});
it("Sep 30 -> Nov 30", () => {
const result = addIntervalForProration({
unixTimestamp: sep30_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 2 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-11-30",
);
});
it("Sep 30 -> Dec 30", () => {
const result = addIntervalForProration({
unixTimestamp: sep30_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 3 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-12-30",
);
});
it("Sep 30 -> Jan 30 (next year)", () => {
const result = addIntervalForProration({
unixTimestamp: sep30_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 4 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2025-01-30",
);
});
it("Sep 30 -> Feb 28 (next year, non-leap)", () => {
const result = addIntervalForProration({
unixTimestamp: sep30_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 5 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2025-02-28",
);
});
it("Sep 30 -> Feb 29 (leap year)", () => {
const sep30_2023 = new UTCDate("2023-09-30T12:00:00Z").getTime();
const result = addIntervalForProration({
unixTimestamp: sep30_2023,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 5 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-02-29",
);
});
});
describe("Aug 31 anchor", () => {
const aug31_2024 = new UTCDate("2024-08-31T12:00:00Z").getTime();
it("Aug 31 -> Sep 30", () => {
const result = addIntervalForProration({
unixTimestamp: aug31_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 1 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-09-30",
);
});
it("Aug 31 -> Oct 31", () => {
const result = addIntervalForProration({
unixTimestamp: aug31_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 2 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-10-31",
);
});
it("Aug 31 -> Nov 30", () => {
const result = addIntervalForProration({
unixTimestamp: aug31_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 3 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-11-30",
);
});
it("Aug 31 -> Dec 31", () => {
const result = addIntervalForProration({
unixTimestamp: aug31_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 4 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-12-31",
);
});
it("Aug 31 -> Jan 31 (next year)", () => {
const result = addIntervalForProration({
unixTimestamp: aug31_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 5 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2025-01-31",
);
});
it("Aug 31 -> Feb 28 (next year, non-leap)", () => {
const result = addIntervalForProration({
unixTimestamp: aug31_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 6 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2025-02-28",
);
});
});
describe("Time preservation", () => {
it("preserves time components", () => {
const sep30_1430 = new UTCDate("2024-09-30T14:30:45Z").getTime();
const result = addIntervalForProration({
unixTimestamp: sep30_1430,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 1 },
});
const resultDate = new UTCDate(result);
expect(resultDate.toISOString()).to.equal("2024-10-30T14:30:45.000Z");
});
});
});
describe("subtractIntervalForProration", () => {
describe("Oct 30 anchor (going backwards to Sep 30)", () => {
const oct30_2024 = new UTCDate("2024-10-30T12:00:00Z").getTime();
it("Oct 30 -> Sep 30", () => {
const result = subtractIntervalForProration({
unixTimestamp: oct30_2024,
interval: BillingInterval.Month,
intervalCount: 1,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-09-30",
);
});
it("Oct 30 -> Aug 30", () => {
const result = subtractIntervalForProration({
unixTimestamp: oct30_2024,
interval: BillingInterval.Month,
intervalCount: 2,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-08-30",
);
});
});
describe("Feb 28 anchor (going backwards)", () => {
const feb28_2025 = new UTCDate("2025-02-28T12:00:00Z").getTime();
it("Feb 28 -> Jan 28", () => {
const result = subtractIntervalForProration({
unixTimestamp: feb28_2025,
interval: BillingInterval.Month,
intervalCount: 1,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2025-01-28",
);
});
it("Feb 28 -> Dec 28 (previous year)", () => {
const result = subtractIntervalForProration({
unixTimestamp: feb28_2025,
interval: BillingInterval.Month,
intervalCount: 2,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-12-28",
);
});
});
describe("Sep 30 anchor (going backwards from Sep 30)", () => {
const sep30_2024 = new UTCDate("2024-09-30T12:00:00Z").getTime();
it("Sep 30 -> Aug 30", () => {
const result = subtractIntervalForProration({
unixTimestamp: sep30_2024,
interval: BillingInterval.Month,
intervalCount: 1,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-08-30",
);
});
it("Sep 30 -> Jul 30", () => {
const result = subtractIntervalForProration({
unixTimestamp: sep30_2024,
interval: BillingInterval.Month,
intervalCount: 2,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-07-30",
);
});
it("Sep 30 -> Jun 30", () => {
const result = subtractIntervalForProration({
unixTimestamp: sep30_2024,
interval: BillingInterval.Month,
intervalCount: 3,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-06-30",
);
});
it("Sep 30 -> May 30", () => {
const result = subtractIntervalForProration({
unixTimestamp: sep30_2024,
interval: BillingInterval.Month,
intervalCount: 4,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-05-30",
);
});
it("Sep 30 -> Apr 30", () => {
const result = subtractIntervalForProration({
unixTimestamp: sep30_2024,
interval: BillingInterval.Month,
intervalCount: 5,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-04-30",
);
});
it("Sep 30 -> Mar 30", () => {
const result = subtractIntervalForProration({
unixTimestamp: sep30_2024,
interval: BillingInterval.Month,
intervalCount: 6,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-03-30",
);
});
it("Sep 30 -> Feb 29 (leap year)", () => {
const result = subtractIntervalForProration({
unixTimestamp: sep30_2024,
interval: BillingInterval.Month,
intervalCount: 7,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-02-29",
);
});
});
describe("Jan 31 anchor (going backwards from Jan 31)", () => {
const jan31_2025 = new UTCDate("2025-01-31T12:00:00Z").getTime();
it("Jan 31 -> Dec 31 (previous year)", () => {
const result = subtractIntervalForProration({
unixTimestamp: jan31_2025,
interval: BillingInterval.Month,
intervalCount: 1,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-12-31",
);
});
it("Jan 31 -> Nov 30 (previous year)", () => {
const result = subtractIntervalForProration({
unixTimestamp: jan31_2025,
interval: BillingInterval.Month,
intervalCount: 2,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-11-30",
);
});
it("Jan 31 -> Oct 31 (previous year)", () => {
const result = subtractIntervalForProration({
unixTimestamp: jan31_2025,
interval: BillingInterval.Month,
intervalCount: 3,
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-10-31",
);
});
});
describe("Time preservation", () => {
it("preserves time components", () => {
const oct30_1430 = new UTCDate("2024-10-30T14:30:45Z").getTime();
const result = subtractIntervalForProration({
unixTimestamp: oct30_1430,
interval: BillingInterval.Month,
intervalCount: 1,
});
const resultDate = new UTCDate(result);
expect(resultDate.toISOString()).to.equal("2024-09-30T14:30:45.000Z");
});
});
});
describe("Edge cases", () => {
describe("Leap year handling", () => {
it("Feb 29 -> Mar 29 (leap year)", () => {
const feb29_2024 = new UTCDate("2024-02-29T12:00:00Z").getTime();
const result = addIntervalForProration({
unixTimestamp: feb29_2024,
intervalConfig: { interval: BillingInterval.Month, intervalCount: 1 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-03-29",
);
});
it("Feb 29 -> Feb 28 (next year, non-leap)", () => {
const feb29_2024 = new UTCDate("2024-02-29T12:00:00Z").getTime();
const result = addIntervalForProration({
unixTimestamp: feb29_2024,
intervalConfig: {
interval: BillingInterval.Month,
intervalCount: 12,
},
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2025-02-28",
);
});
});
describe("Quarterly intervals", () => {
it("Jan 31 -> Apr 30 (quarterly)", () => {
const jan31_2024 = new UTCDate("2024-01-31T12:00:00Z").getTime();
const result = addIntervalForProration({
unixTimestamp: jan31_2024,
intervalConfig: {
interval: BillingInterval.Quarter,
intervalCount: 1,
},
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2024-04-30",
);
});
it("Oct 31 -> Jan 31 (quarterly)", () => {
const oct31_2024 = new UTCDate("2024-10-31T12:00:00Z").getTime();
const result = addIntervalForProration({
unixTimestamp: oct31_2024,
intervalConfig: {
interval: BillingInterval.Quarter,
intervalCount: 1,
},
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2025-01-31",
);
});
});
describe("Yearly intervals", () => {
it("Feb 29 -> Feb 28 (yearly, non-leap to leap)", () => {
const feb29_2024 = new UTCDate("2024-02-29T12:00:00Z").getTime();
const result = addIntervalForProration({
unixTimestamp: feb29_2024,
intervalConfig: { interval: BillingInterval.Year, intervalCount: 1 },
});
expect(new UTCDate(result).toISOString().slice(0, 10)).to.equal(
"2025-02-28",
);
});
});
});
});