fix(billing): compute plan expiry with Jalali months

Server/plugin/support charges used Gregorian Date#setMonth, so 3 months from 1405/06/01 became 1405/09/02. Now add N Jalali months minus one day (1405/08/30).
This commit is contained in:
2026-08-24 18:03:54 +03:30
parent 83f5834aff
commit 67281710ac
6 changed files with 103 additions and 36 deletions
+70
View File
@@ -1,5 +1,7 @@
/** Shared Iran timezone helpers for SuperAdmin API. */
import { toGregorian, jalaaliMonthLength } from 'jalaali-js';
export const IRAN_TZ = 'Asia/Tehran';
/** Gregorian YYYY-MM-DD for the calendar day in Asia/Tehran. */
@@ -38,3 +40,71 @@ export function iranDaysUntil(expiresAt: Date, now: Date = new Date()): number {
const end = Date.parse(`${iranYmd(expiresAt)}T12:00:00+03:30`);
return Math.round((end - start) / 86_400_000);
}
export type IranJalaliYmd = { year: number; month: number; day: number };
/** Jalali Y/M/D for a Date in Asia/Tehran. */
export function iranJalaliParts(date: Date): IranJalaliYmd {
const parts = new Intl.DateTimeFormat('en-u-ca-persian', {
timeZone: IRAN_TZ,
year: 'numeric',
month: 'numeric',
day: 'numeric',
}).formatToParts(date);
const num = (type: Intl.DateTimeFormatPartTypes) => {
const raw = parts.find((p) => p.type === type)?.value ?? '';
const n = Number(raw);
if (!Number.isFinite(n)) {
throw new Error(`Cannot read Jalali ${type} from ${date.toISOString()}`);
}
return n;
};
return { year: num('year'), month: num('month'), day: num('day') };
}
/** Noon Iran (+03:30) for a Jalali calendar day. */
export function iranDateFromJalali(year: number, month: number, day: number): Date {
const g = toGregorian(year, month, day);
const iso = `${pad2(g.gy)}-${pad2(g.gm)}-${pad2(g.gd)}T12:00:00+03:30`;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
throw new Error(`Invalid Jalali date ${year}/${month}/${day}`);
}
return d;
}
/** Add calendar days on the Iran civil day (noon Tehran). */
export function addIranDays(from: Date, days: number): Date {
const ymd = iranYmd(from);
const base = Date.parse(`${ymd}T12:00:00+03:30`);
return new Date(base + days * 86_400_000);
}
/**
* Add Jalali months (same day-of-month, clamped to month length).
* Example: 1405/06/01 + 3 → 1405/09/01.
*/
export function addIranMonths(from: Date, months: number): Date {
const { year, month, day } = iranJalaliParts(from);
const total = year * 12 + (month - 1) + months;
const nextYear = Math.floor(total / 12);
const nextMonth = (total % 12) + 1;
const maxDay = jalaaliMonthLength(nextYear, nextMonth);
const nextDay = Math.min(day, maxDay);
return iranDateFromJalali(nextYear, nextMonth, nextDay);
}
/**
* Plan expiry for server / plugin / support charges.
* Start day + N Jalali months, then minus 1 day (last inclusive day of the period).
* Example: 1405/06/01 + 3 months → expires 1405/08/30.
*/
export function addIranPlanExpiry(from: Date, months: number): Date {
return addIranDays(addIranMonths(from, months), -1);
}
function pad2(n: number) {
return String(n).padStart(2, '0');
}