diff --git a/package-lock.json b/package-lock.json index 9ba8496..83272fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "bcrypt": "5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "jalaali-js": "^2.0.1", "mysql2": "^3.23.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -35,6 +36,7 @@ "@nestjs/testing": "^11.0.1", "@types/bcrypt": "5.0.2", "@types/express": "^5.0.0", + "@types/jalaali-js": "^1.2.0", "@types/jest": "^30.0.0", "@types/multer": "^2.2.0", "@types/node": "^22.0.0", @@ -3007,6 +3009,13 @@ "@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": { "version": "30.0.0", "resolved": "https://registry.npmmirror.com/@types/jest/-/jest-30.0.0.tgz", @@ -7058,6 +7067,15 @@ "@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": { "version": "30.4.2", "resolved": "https://registry.npmmirror.com/jest/-/jest-30.4.2.tgz", diff --git a/package.json b/package.json index 0f3d70b..f1dd779 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "bcrypt": "5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "jalaali-js": "^2.0.1", "mysql2": "^3.23.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -71,6 +72,7 @@ "@nestjs/testing": "^11.0.1", "@types/bcrypt": "5.0.2", "@types/express": "^5.0.0", + "@types/jalaali-js": "^1.2.0", "@types/jest": "^30.0.0", "@types/multer": "^2.2.0", "@types/node": "^22.0.0", diff --git a/src/common/iran-time.util.ts b/src/common/iran-time.util.ts index ab79019..19eb9d4 100644 --- a/src/common/iran-time.util.ts +++ b/src/common/iran-time.util.ts @@ -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'); +} diff --git a/src/plugins/plugins.service.ts b/src/plugins/plugins.service.ts index 7f3db12..4acb4e7 100644 --- a/src/plugins/plugins.service.ts +++ b/src/plugins/plugins.service.ts @@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AdminNotifyService } from '../notifications/admin-notify.service'; import { + addIranPlanExpiry, formatIranDate, iranDaysUntil, iranYmd, @@ -204,7 +205,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy { if (!PLUGIN_PLAN_MONTHS.includes(planMonths as any)) { throw new BadRequestException('مدت اشتراک نامعتبر است'); } - expiresAt = this.addMonths(chargedAt, planMonths); + expiresAt = addIranPlanExpiry(chargedAt, planMonths); } const plugin = await this.prisma.pluginSubscription.create({ @@ -277,7 +278,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy { chargedAt && planMonths ) { - expiresAt = this.addMonths(chargedAt, planMonths); + expiresAt = addIranPlanExpiry(chargedAt, planMonths); } const plugin = await this.prisma.pluginSubscription.update({ @@ -329,7 +330,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy { if (Number.isNaN(chargedAt.getTime())) { throw new BadRequestException('تاریخ شارژ نامعتبر است'); } - const expiresAt = this.addMonths(chargedAt, dto.planMonths); + const expiresAt = addIranPlanExpiry(chargedAt, dto.planMonths); await this.prisma.$transaction([ 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) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant) throw new NotFoundException('نسخه یافت نشد'); diff --git a/src/servers/servers.service.ts b/src/servers/servers.service.ts index 7cb78c1..f82df2c 100644 --- a/src/servers/servers.service.ts +++ b/src/servers/servers.service.ts @@ -17,6 +17,7 @@ import { UpdateServerAccountDto, } from './dto/server-account.dto'; import { + addIranPlanExpiry, formatIranDate, iranDaysUntil, iranYmd, @@ -210,7 +211,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy { chargedAt: dto.chargedAt ? new Date(dto.chargedAt) : null, expiresAt: dto.isDedicated !== true && dto.chargedAt && dto.planMonths - ? this.addMonths(new Date(dto.chargedAt), dto.planMonths) + ? addIranPlanExpiry(new Date(dto.chargedAt), dto.planMonths) : null, }, }); @@ -256,7 +257,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy { chargedAt && planMonths ) { - expiresAt = this.addMonths(chargedAt, planMonths); + expiresAt = addIranPlanExpiry(chargedAt, planMonths); } const updated = await this.prisma.serverAccount.update({ @@ -298,7 +299,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy { const fixed = await this.prisma.serverAccount.update({ where: { id: updated.id }, data: { - expiresAt: this.addMonths(updated.chargedAt, updated.planMonths), + expiresAt: addIranPlanExpiry(updated.chargedAt, updated.planMonths), }, }); Object.assign(updated, fixed); @@ -384,7 +385,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy { if (Number.isNaN(chargedAt.getTime())) { throw new BadRequestException('تاریخ شارژ نامعتبر است'); } - const expiresAt = this.addMonths(chargedAt, dto.planMonths); + const expiresAt = addIranPlanExpiry(chargedAt, dto.planMonths); await this.prisma.$transaction([ 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) { const account = await this.prisma.serverAccount.findUnique({ where: { id: accountId }, diff --git a/src/support/support.service.ts b/src/support/support.service.ts index 9593270..9e16dff 100644 --- a/src/support/support.service.ts +++ b/src/support/support.service.ts @@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AdminNotifyService } from '../notifications/admin-notify.service'; import { + addIranPlanExpiry, formatIranDate, iranDaysUntil, iranYmd, @@ -207,7 +208,7 @@ export class SupportService implements OnModuleInit, OnModuleDestroy { if (!SUPPORT_PLAN_MONTHS.includes(planMonths as any)) { throw new BadRequestException('مدت اشتراک نامعتبر است'); } - expiresAt = this.addMonths(chargedAt, planMonths); + expiresAt = addIranPlanExpiry(chargedAt, planMonths); } const support = await this.prisma.supportSubscription.create({ @@ -282,7 +283,7 @@ export class SupportService implements OnModuleInit, OnModuleDestroy { chargedAt && planMonths ) { - expiresAt = this.addMonths(chargedAt, planMonths); + expiresAt = addIranPlanExpiry(chargedAt, planMonths); } const support = await this.prisma.supportSubscription.update({ @@ -342,7 +343,7 @@ export class SupportService implements OnModuleInit, OnModuleDestroy { if (Number.isNaN(chargedAt.getTime())) { throw new BadRequestException('تاریخ شارژ نامعتبر است'); } - const expiresAt = this.addMonths(chargedAt, dto.planMonths); + const expiresAt = addIranPlanExpiry(chargedAt, dto.planMonths); const noteBase = dto.note?.trim() || null; const note = @@ -578,14 +579,6 @@ export class SupportService implements OnModuleInit, OnModuleDestroy { 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) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant) throw new NotFoundException('نسخه یافت نشد');