lib/billing-gift.ts
Rama main · réplica de lectura del repositorio original.
/**
* Offer a plan for a given time — the admin override (MIN-72) carries
* now an EXPIRATION.
*
* Nothing comes to take back the gift: the expiry date is stored on the account
* (`billing_accounts.admin_override_expires_at`) and the RESOLUTION of the plan
* ignores it as soon as it passes. The right therefore falls to the nearest second, without
* depending on a cron or a webhook — the time scan
* (`expireAdminOverrides`) only cleans the line after the fact.
*
* Pure module (UTC dates), importable client AND server: the admin chooses a
* DURATION, the server transforms it into a date. The client never sends the deadline
* itself — it is the server's clock that takes precedence.
*/
export const GIFT_DURATIONS = [
"7d",
"1m",
"3m",
"6m",
"1y",
"unlimited",
] as const;
export type GiftDuration = (typeof GIFT_DURATIONS)[number];
/** The duration proposed by default: “offer one month” is the common gesture. */
export const DEFAULT_GIFT_DURATION: GiftDuration = "1m";
/** Duration expressed in calendar months (the rest is counted in days). */
const GIFT_MONTHS: Partial<Record<GiftDuration, number>> = {
"1m": 1,
"3m": 3,
"6m": 6,
"1y": 12,
};
const DAY_MS = 24 * 60 * 60 * 1000;
export function coerceGiftDuration(value: unknown): GiftDuration | null {
return typeof value === "string" &&
(GIFT_DURATIONS as readonly string[]).includes(value)
? (value as GiftDuration)
: null;
}
function daysInUtcMonth(year: number, monthIndex: number): number {
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
}
/**
* `from` + n calendar month, day clamped to the last day of the target month:
* a month offered on January 31 ends on February 28, not March 3. Same rule
* as annual sub-cycles (lib/billing-cycle.ts).
*/
function addUtcMonths(from: Date, months: number): Date {
const year = from.getUTCFullYear();
const monthIndex = from.getUTCMonth() + months;
const target = new Date(Date.UTC(year, monthIndex, 1));
const day = Math.min(
from.getUTCDate(),
daysInUtcMonth(target.getUTCFullYear(), target.getUTCMonth())
);
return new Date(
Date.UTC(
target.getUTCFullYear(),
target.getUTCMonth(),
day,
from.getUTCHours(),
from.getUTCMinutes(),
from.getUTCSeconds(),
from.getUTCMilliseconds()
)
);
}
/** The deadline for a gift placed now (ISO). `null` = unlimited. */
export function giftExpiresAt(
duration: GiftDuration,
from: Date = new Date()
): string | null {
if (duration === "unlimited") return null;
const months = GIFT_MONTHS[duration];
if (months) return addUtcMonths(from, months).toISOString();
return new Date(from.getTime() + 7 * DAY_MS).toISOString();
}
/**
* A gift that has expired no longer gives anything. An unreadable date
* does NOT count as expired: we do not withdraw a right on a doubt of
* parsing (the column is a `timestamptz`, the case should not exist).
*/
export function isGiftExpired(
expiresAt: string | null | undefined,
now: number = Date.now()
): boolean {
if (!expiresAt) return false;
const at = new Date(expiresAt).getTime();
if (!Number.isFinite(at)) return false;
return at <= now;
}