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:
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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('نسخه یافت نشد');
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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('نسخه یافت نشد');
|
||||
|
||||
Reference in New Issue
Block a user