fix: proration calculation

This commit is contained in:
John Yeo
2025-08-30 22:18:13 -07:00
parent 0869d7867f
commit 3adc47d990
6 changed files with 106 additions and 9 deletions

View File

@@ -17,7 +17,11 @@ import { getCheckPreview } from "./getCheckPreview.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
import {
formatUnixToDate,
formatUnixToDateTime,
notNullish,
} from "@/utils/genUtils.js";
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { Decimal } from "decimal.js";
@@ -126,10 +130,8 @@ export const getOptions = ({
now,
});
// if (finalProration) {
// console.log("Start:", formatUnixToDate(finalProration.start));
// console.log("End:", formatUnixToDate(finalProration.end));
// }
if (finalProration) {
}
let priceData = itemToPriceOrTiers({
item: i,

View File

@@ -12,7 +12,11 @@ import {
import { logger } from "better-auth";
import Stripe from "stripe";
import { isTrialing } from "../../cusProducts/cusProductUtils.js";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
import {
formatUnixToDate,
formatUnixToDateTime,
notNullish,
} from "@/utils/genUtils.js";
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
import {
formatPrice,

View File

@@ -45,6 +45,7 @@ import {
getAlignedIntervalUnix,
subtractBillingIntervalUnix,
subtractFromUnixTillAligned,
subtractIntervalForProration,
} from "../../products/prices/billingIntervalUtils.js";
import {
priceToFeature,
@@ -131,7 +132,7 @@ export const getProration = ({
// alwaysReturn: true,
// });
let start = subtractBillingIntervalUnix({
let start = subtractIntervalForProration({
unixTimestamp: end!,
interval,
intervalCount,

View File

@@ -249,3 +249,84 @@ 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)
export const subtractIntervalForProration = ({
unixTimestamp,
interval,
intervalCount = 1,
}: {
unixTimestamp: number;
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 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()
);
preserved = new UTCDate(
setSeconds(preserved, getSeconds(endDate)).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());
};
switch (interval) {
case BillingInterval.Week: {
const sub = new UTCDate(subWeeks(endDate, 1 * intervalCount).getTime());
return getTime(sub);
}
case BillingInterval.Month: {
let sub = new UTCDate(subMonths(endDate, 1 * intervalCount).getTime());
if (isEndOfMonth()) {
sub = setToLastDayOfMonth(sub);
}
sub = preserveTime(sub);
return getTime(sub);
}
case BillingInterval.Quarter: {
let sub = new UTCDate(subMonths(endDate, 3 * intervalCount).getTime());
if (isEndOfMonth()) {
sub = setToLastDayOfMonth(sub);
}
sub = preserveTime(sub);
return getTime(sub);
}
case BillingInterval.SemiAnnual: {
let sub = new UTCDate(subMonths(endDate, 6 * intervalCount).getTime());
if (isEndOfMonth()) {
sub = setToLastDayOfMonth(sub);
}
sub = preserveTime(sub);
return getTime(sub);
}
case BillingInterval.Year: {
let sub = new UTCDate(subYears(endDate, 1 * intervalCount).getTime());
if (isEndOfMonth()) {
sub = setToLastDayOfMonth(sub);
}
sub = preserveTime(sub);
return getTime(sub);
}
default:
throw new Error(`Invalid billing interval: ${interval}`);
}
};

View File

@@ -2,6 +2,7 @@ import { format } from "date-fns";
import KSUID from "ksuid";
import RecaseError from "./errorUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import { UTCDate } from "@date-fns/utc";
export const generateId = (prefix: string) => {
if (!prefix) {
@@ -53,11 +54,17 @@ export const notNullish = (value: any) => {
return !nullish(value);
};
export const formatUnixToDateTime = (unixDate?: number | null) => {
export const formatUnixToDateTime = (
unixDate?: number | null,
withTimezone?: boolean
) => {
if (!unixDate) {
return "undefined unix date";
}
return format(new Date(unixDate), "dd MMM yyyy HH:mm:ss");
return format(
new UTCDate(unixDate),
withTimezone ? "dd MMM yyyy HH:mm:ss z" : "dd MMM yyyy HH:mm:ss"
);
};
export const formatUnixToDate = (unixDate?: number) => {

View File

@@ -117,6 +117,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid singl
waitForSeconds: 20,
});
return;
await attachAndExpectCorrect({
autumn,
customerId,