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
+18
View File
@@ -21,6 +21,7 @@
"bcrypt": "5.1.1", "bcrypt": "5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"jalaali-js": "^2.0.1",
"mysql2": "^3.23.1", "mysql2": "^3.23.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -35,6 +36,7 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jalaali-js": "^1.2.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
@@ -3007,6 +3009,13 @@
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"node_modules/@types/jalaali-js": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/@types/jalaali-js/-/jalaali-js-1.2.0.tgz",
"integrity": "sha512-DRQKjbfFe0/v3siCou9eFzYjDxJItkJpxbU+/CSptvoRqq2/VmTA6y8QvqK/VELKW6xX3qb174nLZlrFV9OT3Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jest": { "node_modules/@types/jest": {
"version": "30.0.0", "version": "30.0.0",
"resolved": "https://registry.npmmirror.com/@types/jest/-/jest-30.0.0.tgz", "resolved": "https://registry.npmmirror.com/@types/jest/-/jest-30.0.0.tgz",
@@ -7058,6 +7067,15 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/jalaali-js": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/jalaali-js/-/jalaali-js-2.0.1.tgz",
"integrity": "sha512-qzTmjw6lZ3V4znyNA8ceYoGgFR+CcF+xEfIYZNcK2ZR0mzdMpMZ6iAcdRPvMgxUVdPMh6QLWCANpTcCJow2U+Q==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/jest": { "node_modules/jest": {
"version": "30.4.2", "version": "30.4.2",
"resolved": "https://registry.npmmirror.com/jest/-/jest-30.4.2.tgz", "resolved": "https://registry.npmmirror.com/jest/-/jest-30.4.2.tgz",
+2
View File
@@ -57,6 +57,7 @@
"bcrypt": "5.1.1", "bcrypt": "5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"jalaali-js": "^2.0.1",
"mysql2": "^3.23.1", "mysql2": "^3.23.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -71,6 +72,7 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jalaali-js": "^1.2.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+70
View File
@@ -1,5 +1,7 @@
/** Shared Iran timezone helpers for SuperAdmin API. */ /** Shared Iran timezone helpers for SuperAdmin API. */
import { toGregorian, jalaaliMonthLength } from 'jalaali-js';
export const IRAN_TZ = 'Asia/Tehran'; export const IRAN_TZ = 'Asia/Tehran';
/** Gregorian YYYY-MM-DD for the calendar day in 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`); const end = Date.parse(`${iranYmd(expiresAt)}T12:00:00+03:30`);
return Math.round((end - start) / 86_400_000); 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');
}
+4 -11
View File
@@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AdminNotifyService } from '../notifications/admin-notify.service'; import { AdminNotifyService } from '../notifications/admin-notify.service';
import { import {
addIranPlanExpiry,
formatIranDate, formatIranDate,
iranDaysUntil, iranDaysUntil,
iranYmd, iranYmd,
@@ -204,7 +205,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
if (!PLUGIN_PLAN_MONTHS.includes(planMonths as any)) { if (!PLUGIN_PLAN_MONTHS.includes(planMonths as any)) {
throw new BadRequestException('مدت اشتراک نامعتبر است'); throw new BadRequestException('مدت اشتراک نامعتبر است');
} }
expiresAt = this.addMonths(chargedAt, planMonths); expiresAt = addIranPlanExpiry(chargedAt, planMonths);
} }
const plugin = await this.prisma.pluginSubscription.create({ const plugin = await this.prisma.pluginSubscription.create({
@@ -277,7 +278,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
chargedAt && chargedAt &&
planMonths planMonths
) { ) {
expiresAt = this.addMonths(chargedAt, planMonths); expiresAt = addIranPlanExpiry(chargedAt, planMonths);
} }
const plugin = await this.prisma.pluginSubscription.update({ const plugin = await this.prisma.pluginSubscription.update({
@@ -329,7 +330,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
if (Number.isNaN(chargedAt.getTime())) { if (Number.isNaN(chargedAt.getTime())) {
throw new BadRequestException('تاریخ شارژ نامعتبر است'); throw new BadRequestException('تاریخ شارژ نامعتبر است');
} }
const expiresAt = this.addMonths(chargedAt, dto.planMonths); const expiresAt = addIranPlanExpiry(chargedAt, dto.planMonths);
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.pluginSubscription.update({ this.prisma.pluginSubscription.update({
@@ -541,14 +542,6 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
} }
} }
private addMonths(from: Date, months: number) {
const d = new Date(from.getTime());
const day = d.getDate();
d.setMonth(d.getMonth() + months);
if (d.getDate() < day) d.setDate(0);
return d;
}
private async requireTenant(tenantId: string) { private async requireTenant(tenantId: string) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('نسخه یافت نشد'); if (!tenant) throw new NotFoundException('نسخه یافت نشد');
+5 -14
View File
@@ -17,6 +17,7 @@ import {
UpdateServerAccountDto, UpdateServerAccountDto,
} from './dto/server-account.dto'; } from './dto/server-account.dto';
import { import {
addIranPlanExpiry,
formatIranDate, formatIranDate,
iranDaysUntil, iranDaysUntil,
iranYmd, iranYmd,
@@ -210,7 +211,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
chargedAt: dto.chargedAt ? new Date(dto.chargedAt) : null, chargedAt: dto.chargedAt ? new Date(dto.chargedAt) : null,
expiresAt: expiresAt:
dto.isDedicated !== true && dto.chargedAt && dto.planMonths dto.isDedicated !== true && dto.chargedAt && dto.planMonths
? this.addMonths(new Date(dto.chargedAt), dto.planMonths) ? addIranPlanExpiry(new Date(dto.chargedAt), dto.planMonths)
: null, : null,
}, },
}); });
@@ -256,7 +257,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
chargedAt && chargedAt &&
planMonths planMonths
) { ) {
expiresAt = this.addMonths(chargedAt, planMonths); expiresAt = addIranPlanExpiry(chargedAt, planMonths);
} }
const updated = await this.prisma.serverAccount.update({ const updated = await this.prisma.serverAccount.update({
@@ -298,7 +299,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
const fixed = await this.prisma.serverAccount.update({ const fixed = await this.prisma.serverAccount.update({
where: { id: updated.id }, where: { id: updated.id },
data: { data: {
expiresAt: this.addMonths(updated.chargedAt, updated.planMonths), expiresAt: addIranPlanExpiry(updated.chargedAt, updated.planMonths),
}, },
}); });
Object.assign(updated, fixed); Object.assign(updated, fixed);
@@ -384,7 +385,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
if (Number.isNaN(chargedAt.getTime())) { if (Number.isNaN(chargedAt.getTime())) {
throw new BadRequestException('تاریخ شارژ نامعتبر است'); throw new BadRequestException('تاریخ شارژ نامعتبر است');
} }
const expiresAt = this.addMonths(chargedAt, dto.planMonths); const expiresAt = addIranPlanExpiry(chargedAt, dto.planMonths);
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.serverAccount.update({ this.prisma.serverAccount.update({
@@ -648,16 +649,6 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
} }
} }
private addMonths(from: Date, months: number) {
const d = new Date(from.getTime());
const day = d.getDate();
d.setMonth(d.getMonth() + months);
if (d.getDate() < day) {
d.setDate(0);
}
return d;
}
private async requireAccount(accountId: string) { private async requireAccount(accountId: string) {
const account = await this.prisma.serverAccount.findUnique({ const account = await this.prisma.serverAccount.findUnique({
where: { id: accountId }, where: { id: accountId },
+4 -11
View File
@@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AdminNotifyService } from '../notifications/admin-notify.service'; import { AdminNotifyService } from '../notifications/admin-notify.service';
import { import {
addIranPlanExpiry,
formatIranDate, formatIranDate,
iranDaysUntil, iranDaysUntil,
iranYmd, iranYmd,
@@ -207,7 +208,7 @@ export class SupportService implements OnModuleInit, OnModuleDestroy {
if (!SUPPORT_PLAN_MONTHS.includes(planMonths as any)) { if (!SUPPORT_PLAN_MONTHS.includes(planMonths as any)) {
throw new BadRequestException('مدت اشتراک نامعتبر است'); throw new BadRequestException('مدت اشتراک نامعتبر است');
} }
expiresAt = this.addMonths(chargedAt, planMonths); expiresAt = addIranPlanExpiry(chargedAt, planMonths);
} }
const support = await this.prisma.supportSubscription.create({ const support = await this.prisma.supportSubscription.create({
@@ -282,7 +283,7 @@ export class SupportService implements OnModuleInit, OnModuleDestroy {
chargedAt && chargedAt &&
planMonths planMonths
) { ) {
expiresAt = this.addMonths(chargedAt, planMonths); expiresAt = addIranPlanExpiry(chargedAt, planMonths);
} }
const support = await this.prisma.supportSubscription.update({ const support = await this.prisma.supportSubscription.update({
@@ -342,7 +343,7 @@ export class SupportService implements OnModuleInit, OnModuleDestroy {
if (Number.isNaN(chargedAt.getTime())) { if (Number.isNaN(chargedAt.getTime())) {
throw new BadRequestException('تاریخ شارژ نامعتبر است'); throw new BadRequestException('تاریخ شارژ نامعتبر است');
} }
const expiresAt = this.addMonths(chargedAt, dto.planMonths); const expiresAt = addIranPlanExpiry(chargedAt, dto.planMonths);
const noteBase = dto.note?.trim() || null; const noteBase = dto.note?.trim() || null;
const note = const note =
@@ -578,14 +579,6 @@ export class SupportService implements OnModuleInit, OnModuleDestroy {
return isFree ? `${label} (رایگان)` : label; return isFree ? `${label} (رایگان)` : label;
} }
private addMonths(from: Date, months: number) {
const d = new Date(from.getTime());
const day = d.getDate();
d.setMonth(d.getMonth() + months);
if (d.getDate() < day) d.setDate(0);
return d;
}
private async requireTenant(tenantId: string) { private async requireTenant(tenantId: string) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('نسخه یافت نشد'); if (!tenant) throw new NotFoundException('نسخه یافت نشد');