Files
cfw-autumn/shared/utils/usageWindowUtils/getUsageWindowBounds.ts

99 lines
3.0 KiB
TypeScript

import { UTCDate } from "@date-fns/utc";
import {
startOfDay,
startOfHour,
startOfMinute,
startOfMonth,
startOfQuarter,
startOfWeek,
startOfYear,
} from "date-fns";
import { EntInterval } from "../../models/productModels/intervals/entitlementInterval.js";
import { getCycleEnd } from "../billingUtils/cycleUtils/getCycleEnd.js";
import { getCycleStart } from "../billingUtils/cycleUtils/getCycleStart.js";
import { addInterval } from "../billingUtils/intervalUtils/intervalArithmetic.js";
const LIFETIME_WINDOW_END = Number.MAX_SAFE_INTEGER;
// UTC-calendar-aligned start of the interval containing `now`. Used only as the
// fallback when no billing-cycle anchor is available; alignment keeps the window
// (and its key) deterministic from `now` alone so TS and Lua never disagree.
const startOfWindow = ({
interval,
now,
}: {
interval: EntInterval;
now: number;
}): number => {
const from = new UTCDate(now);
switch (interval) {
case EntInterval.Minute:
return startOfMinute(from).getTime();
case EntInterval.Hour:
return startOfHour(from).getTime();
case EntInterval.Day:
return startOfDay(from).getTime();
case EntInterval.Week:
return startOfWeek(from, { weekStartsOn: 1 }).getTime();
case EntInterval.Month:
return startOfMonth(from).getTime();
case EntInterval.Quarter:
return startOfQuarter(from).getTime();
case EntInterval.SemiAnnual: {
const yearStart = startOfYear(from).getTime();
return from.getUTCMonth() < 6
? yearStart
: addInterval({
from: yearStart,
interval: EntInterval.Month,
intervalCount: 6,
});
}
case EntInterval.Year:
return startOfYear(from).getTime();
default:
return now;
}
};
/**
* Current usage-window bounds for an interval. When a billing-cycle `anchor` is
* given, bounds align to the customer's cycle (so a "daily" cap rolls on their
* billing time-of-day, not UTC midnight). Without an anchor, falls back to UTC
* calendar alignment. `lifetime` never resets. Each window spans one interval.
*/
export const getUsageWindowBounds = ({
interval,
now,
anchor,
}: {
interval: EntInterval;
now: number;
anchor?: number | null;
}): { windowStartAt: number; windowEndAt: number } => {
if (interval === EntInterval.Lifetime) {
return { windowStartAt: 0, windowEndAt: LIFETIME_WINDOW_END };
}
if (anchor != null && Number.isFinite(anchor)) {
const cycleStartAt = getCycleStart({ anchor, interval, now });
const cycleEndAt = getCycleEnd({ anchor, interval, now });
// Only trust cycle bounds that are finite and actually bracket `now`; a bad
// billing anchor must fall back to calendar, never poison the deduction.
if (
Number.isFinite(cycleStartAt) &&
Number.isFinite(cycleEndAt) &&
cycleStartAt <= now &&
now < cycleEndAt
) {
return { windowStartAt: cycleStartAt, windowEndAt: cycleEndAt };
}
}
const windowStartAt = startOfWindow({ interval, now });
const windowEndAt = addInterval({ from: windowStartAt, interval });
return { windowStartAt, windowEndAt };
};