Initial commit: Hedena Gold monorepo.

First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
s.esmaeili
2026-10-15 17:19:10 +03:30
commit ea86636027
1121 changed files with 401065 additions and 0 deletions
@@ -0,0 +1,107 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { AccountingAdminService } from './accounting-admin.service';
/**
* «اتصال حسابداری» (D27) — every route behind `config.accounting`. Nothing
* here ever returns a token: the configuration comes back with tokens reduced
* to `tokenSet` / `tokenReadable` flags.
*/
@Controller('admin/accounting')
@RequirePermission('config.accounting')
export class AccountingAdminController {
constructor(private readonly admin: AccountingAdminService) {}
@Get()
overview() {
return this.admin.overview();
}
@Put('config')
save(@Body() body: Record<string, unknown>) {
return this.admin.save(body);
}
@Post('test')
@HttpCode(200)
test(@Body() body: { provider?: string; connection?: Record<string, unknown> }) {
return this.admin.test(body?.provider, body?.connection);
}
@Post('certificate')
@HttpCode(200)
certificate(@Body() body: { baseUrl?: string }) {
return this.admin.certificate(body?.baseUrl);
}
@Get('options')
options(@Query('provider') provider?: string) {
return this.admin.options(provider);
}
@Post('run/:job')
@HttpCode(200)
run(@Param('job') job: string) {
return this.admin.run(job);
}
@Get('outbox')
outbox(@Query() q: Record<string, unknown>) {
return this.admin.outbox(q);
}
@Post('outbox/:id/retry')
@HttpCode(200)
retry(@Param('id') id: string) {
return this.admin.retry(id);
}
@Post('outbox/:id/resolve')
@HttpCode(200)
resolve(@Param('id') id: string, @CurrentAdmin() me: AuthenticatedAdmin, @Body() body: { note?: string }) {
return this.admin.resolve(id, me.id, body?.note);
}
@Get('issues')
issues(@Query() q: Record<string, unknown>) {
return this.admin.issues(q);
}
@Post('issues/:id/resolve')
@HttpCode(200)
resolveIssue(@Param('id') id: string) {
return this.admin.resolveIssue(id);
}
@Get('links')
links(@Query() q: Record<string, unknown>) {
return this.admin.links(q);
}
@Post('links')
@HttpCode(200)
link(@Body() body: Record<string, unknown>) {
return this.admin.manualLink(body);
}
@Delete('links/:id')
unlink(@Param('id') id: string) {
return this.admin.unlink(id);
}
@Get('customers/:id')
customer(@Param('id') id: string) {
return this.admin.customer(id);
}
@Get('products/status')
productsStatus(@Query('ids') ids: string) {
return this.admin.productsStatus(ids);
}
@Get('movement')
movement(@Query() q: Record<string, unknown>) {
return this.admin.movement(q);
}
}
@@ -0,0 +1,366 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AccountingEntityType, AccountingOutboxStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { cleanUrl } from './accounting-config';
import { AccountingConfigService, JobName } from './accounting-config.service';
import { fetchServerCertificate } from './accounting-http';
import { AccountingSyncService } from './accounting-sync.service';
import { AccountingWorkerService, PULL_FEATURE } from './accounting-worker.service';
import { parseJalali, toJalali } from './jalali';
import { ACCOUNTING_PROVIDERS, ProviderName, isProviderName } from './providers';
const JOBS: JobName[] = ['customers', 'balances', 'products', 'outbox'];
const STATUSES: AccountingOutboxStatus[] = ['PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED'];
function page(v: unknown) {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function limit(v: unknown, max = 100) {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? Math.min(n, max) : 25;
}
/** Backs the «اتصال حسابداری» admin screen (plan §5). */
@Injectable()
export class AccountingAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly config: AccountingConfigService,
private readonly sync: AccountingSyncService,
private readonly worker: AccountingWorkerService,
) {}
async overview() {
const [config, state, counts, openIssues, raw] = await Promise.all([
this.config.getPublic(),
this.config.getState(),
this.prisma.accountingOutbox.groupBy({ by: ['status'], _count: { _all: true } }),
this.prisma.accountingIssue.count({ where: { resolvedAt: null } }),
this.config.get(),
]);
return {
config,
inactiveReason: this.config.inactiveReason(raw),
state,
busy: this.worker.busy,
realtime: this.worker.realtime,
outbox: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
openIssues,
};
}
async save(body: Record<string, unknown>) {
const { providerChanged } = await this.config.save(body);
return { ...(await this.overview()), providerChanged };
}
private async settingsOf(name: unknown) {
const config = await this.config.get();
const provider = isProviderName(name) ? name : config.provider;
if (provider === 'none') throw new BadRequestException('نرم‌افزار حسابداری انتخاب نشده است');
const settings = this.config.settingsFor(config, provider);
if (!settings) throw new BadRequestException('آدرس یا توکن این نرم‌افزار تنظیم نشده (یا توکن قابل‌خواندن نیست)');
return { provider: ACCOUNTING_PROVIDERS[provider], settings, config };
}
/** For the sync buttons on the lists: connected or not, nothing more. */
async connectionStatus() {
const config = await this.config.get();
const reason = this.config.inactiveReason(config);
return {
connected: !reason,
provider: reason || config.provider === 'none' ? null : ACCOUNTING_PROVIDERS[config.provider].label,
};
}
/** The list-page sync buttons (plan §12): run now, wait, report. */
async syncNow(jobs: ('customers' | 'balances' | 'products')[]) {
const reason = this.config.inactiveReason(await this.config.get());
if (reason) throw new BadRequestException(`سینک با حسابداری انجام نمی‌شود: ${reason}`);
const results = await this.worker.runPullsNow(jobs);
if (!results) throw new BadRequestException('یک همگام‌سازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید');
return { ok: Object.values(results).every((r) => r.ok), results };
}
/** Tests the connection as typed in the form (`connection`), falling back to what is saved. */
async test(name: unknown, connection?: Record<string, unknown>) {
if (!isProviderName(name)) throw new BadRequestException('نرم‌افزار حسابداری نامعتبر است');
const provider = ACCOUNTING_PROVIDERS[name];
const settings = this.config.draftSettings(await this.config.get(), name, connection);
if (!settings) throw new BadRequestException('آدرس و توکن را وارد کنید');
const started = Date.now();
try {
const info = await provider.health(settings);
return { ok: true, provider: provider.label, ms: Date.now() - started, info };
} catch (err) {
return { ok: false, provider: provider.label, ms: Date.now() - started, error: this.sync.describeError(err) };
}
}
/**
* The certificate the server at `baseUrl` presents, for the admin to compare
* and pin (a self-signed Tahesab install cannot be verified otherwise).
*/
async certificate(baseUrl: unknown) {
const url = cleanUrl(baseUrl, 'آدرس');
if (!url) throw new BadRequestException('آدرس را وارد کنید');
try {
return await fetchServerCertificate(url, 10_000);
} catch (err) {
throw new BadRequestException(`گواهی دریافت نشد: ${this.sync.describeError(err)}`);
}
}
/** Dropdown options for the mapping tab; each list fails on its own. */
async options(name: unknown) {
const { provider, settings } = await this.settingsOf(name);
const safe = async <T>(fn: () => Promise<T>) => {
try {
return { ok: true as const, items: await fn() };
} catch (err) {
return { ok: false as const, items: [] as never[], error: this.sync.describeError(err) };
}
};
const [customerGroups, karSakhteGroups, karSakhteTypes] = await Promise.all([
safe(() => provider.listCustomerGroups(settings)),
safe(() => provider.listKarSakhteGroups(settings)),
safe(() => provider.listKarSakhteTypes(settings)),
]);
const categories = await this.prisma.category.findMany({ select: { id: true, title: true }, orderBy: { title: 'asc' } });
return { provider: provider.name, customerGroups, karSakhteGroups, karSakhteTypes, categories };
}
async run(job: string) {
if (!JOBS.includes(job as JobName)) throw new BadRequestException('کار نامعتبر است');
const config = await this.config.get();
const reason = this.config.inactiveReason(config);
if (reason) throw new BadRequestException(`همگام‌سازی انجام نمی‌شود: ${reason}`);
if (job !== 'outbox' && !config.features[PULL_FEATURE[job as Exclude<JobName, 'outbox'>]]) {
throw new BadRequestException('این قابلیت در تب «قابلیت‌ها و زمان‌بندی» خاموش است؛ اول روشن و ذخیره کنید');
}
const started = this.worker.runNow(job as JobName);
return { started, message: started ? 'شروع شد' : 'یک همگام‌سازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید' };
}
// ── outbox log ──────────────────────────────────────────────────────
async outbox(q: Record<string, unknown>) {
const where: Prisma.AccountingOutboxWhereInput = {};
if (typeof q.status === 'string' && STATUSES.includes(q.status as AccountingOutboxStatus)) {
where.status = q.status as AccountingOutboxStatus;
}
if (typeof q.kind === 'string' && q.kind) where.kind = q.kind as Prisma.AccountingOutboxWhereInput['kind'];
if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId;
const p = page(q.page);
const l = limit(q.limit);
const [rows, total] = await Promise.all([
this.prisma.accountingOutbox.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }),
this.prisma.accountingOutbox.count({ where }),
]);
const labels = await this.labelsFor(rows.map((r) => ({ kind: r.kind, entityId: r.entityId })));
return { rows: rows.map((r) => ({ ...r, entityLabel: labels.get(r.entityId) ?? null })), total, page: p, limit: l };
}
private async labelsFor(items: { kind: string; entityId: string }[]) {
const orderIds = items.filter((i) => i.kind.startsWith('ORDER')).map((i) => i.entityId);
const customerIds = items.filter((i) => i.kind === 'CUSTOMER_PUSH').map((i) => i.entityId);
const productIds = items.filter((i) => i.kind === 'PRODUCT_PUSH').map((i) => i.entityId);
const [orders, customers, products] = await Promise.all([
orderIds.length ? this.prisma.order.findMany({ where: { id: { in: orderIds } }, select: { id: true, orderNumber: true, customer: { select: { fullName: true } } } }) : [],
customerIds.length ? this.prisma.customer.findMany({ where: { id: { in: customerIds } }, select: { id: true, fullName: true, mobile: true } }) : [],
productIds.length ? this.prisma.product.findMany({ where: { id: { in: productIds } }, select: { id: true, title: true } }) : [],
]);
const m = new Map<string, string>();
for (const o of orders) m.set(o.id, `سفارش ${o.orderNumber} — ${o.customer.fullName}`);
for (const c of customers) m.set(c.id, `${c.fullName} (${c.mobile})`);
for (const p of products) m.set(p.id, p.title);
return m;
}
async retry(id: string) {
const row = await this.prisma.accountingOutbox.findUnique({ where: { id } });
if (!row) throw new NotFoundException('ردیف پیدا نشد');
if (row.status === 'SENT' || row.status === 'SENDING') throw new BadRequestException('این ردیف قابل تلاش دوباره نیست');
return this.prisma.accountingOutbox.update({
where: { id },
data: { status: 'PENDING', nextAttemptAt: new Date(), attempts: row.status === 'PENDING' ? row.attempts : 0 },
});
}
async resolve(id: string, adminId: string, note?: string) {
const row = await this.prisma.accountingOutbox.findUnique({ where: { id } });
if (!row) throw new NotFoundException('ردیف پیدا نشد');
if (row.status === 'SENDING') throw new BadRequestException('در حال ارسال است');
return this.prisma.accountingOutbox.update({
where: { id },
data: { status: 'RESOLVED', resolvedById: adminId, lastError: note ? `حل‌شده: ${note}` : row.lastError },
});
}
// ── issues ──────────────────────────────────────────────────────────
async issues(q: Record<string, unknown>) {
const where: Prisma.AccountingIssueWhereInput = q.resolved === '1' ? { resolvedAt: { not: null } } : { resolvedAt: null };
const p = page(q.page);
const l = limit(q.limit);
const [rows, total] = await Promise.all([
this.prisma.accountingIssue.findMany({ where, orderBy: { updatedAt: 'desc' }, skip: (p - 1) * l, take: l }),
this.prisma.accountingIssue.count({ where }),
]);
return { rows, total, page: p, limit: l };
}
resolveIssue(id: string) {
return this.prisma.accountingIssue.update({ where: { id }, data: { resolvedAt: new Date() } });
}
// ── links ───────────────────────────────────────────────────────────
async links(q: Record<string, unknown>) {
const config = await this.config.get();
const where: Prisma.AccountingLinkWhereInput = {};
if (q.entityType === 'CUSTOMER' || q.entityType === 'PRODUCT') where.entityType = q.entityType;
where.provider = isProviderName(q.provider) ? q.provider : config.provider === 'none' ? undefined : config.provider;
if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId;
if (typeof q.q === 'string' && q.q.trim()) {
where.OR = [{ externalCode: { contains: q.q.trim() } }, { externalName: { contains: q.q.trim() } }];
}
const p = page(q.page);
const l = limit(q.limit);
const [rows, total] = await Promise.all([
this.prisma.accountingLink.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }),
this.prisma.accountingLink.count({ where }),
]);
return { rows, total, page: p, limit: l };
}
async manualLink(body: Record<string, unknown>) {
const config = await this.config.get();
if (config.provider === 'none') throw new BadRequestException('نرم‌افزار حسابداری انتخاب نشده است');
const provider = config.provider as ProviderName;
const entityType = body.entityType === 'PRODUCT' ? AccountingEntityType.PRODUCT : AccountingEntityType.CUSTOMER;
const entityId = String(body.entityId ?? '').trim();
const externalCode = String(body.externalCode ?? '').trim();
if (!entityId || !externalCode) throw new BadRequestException('شناسه و کد حسابداری الزامی است');
const exists =
entityType === 'CUSTOMER'
? await this.prisma.customer.findUnique({ where: { id: entityId }, select: { id: true } })
: await this.prisma.product.findUnique({ where: { id: entityId }, select: { id: true } });
if (!exists) throw new NotFoundException('ردیف اپ پیدا نشد');
const taken = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_externalCode: { provider, entityType, externalCode } },
});
if (taken && taken.entityId !== entityId) throw new BadRequestException('این کد به ردیف دیگری لینک است');
return this.prisma.accountingLink.upsert({
where: { provider_entityType_entityId: { provider, entityType, entityId } },
update: { externalCode, origin: 'MANUAL', syncedAt: new Date() },
create: { provider, entityType, entityId, externalCode, origin: 'MANUAL', externalName: (body.externalName as string) || null },
});
}
async unlink(id: string) {
await this.prisma.accountingLink.delete({ where: { id } }).catch(() => {
throw new NotFoundException('لینک پیدا نشد');
});
return { success: true };
}
// ── per-row panels ──────────────────────────────────────────────────
async customer(id: string) {
const config = await this.config.get();
const c = await this.prisma.customer.findUnique({
where: { id },
select: {
id: true,
goldDebtGrams: true,
fiatDebtRial: true,
accountingGoldBalance: true,
accountingRialBalance: true,
accountingBalanceAt: true,
},
});
if (!c) throw new NotFoundException('مشتری یافت نشد');
const link =
config.provider === 'none'
? null
: await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: config.provider, entityType: 'CUSTOMER', entityId: id } },
});
const fresh = link ? await this.sync.customerBalance(id) : null;
const jobs = await this.prisma.accountingOutbox.findMany({
where: { entityId: id, kind: 'CUSTOMER_PUSH' },
orderBy: { createdAt: 'desc' },
take: 5,
});
return {
provider: config.provider,
link,
internal: { gold: c.goldDebtGrams, rial: c.fiatDebtRial },
accounting: fresh
? { gold: fresh.gold, rial: fresh.rial, at: fresh.at }
: c.accountingBalanceAt
? { gold: c.accountingGoldBalance, rial: c.accountingRialBalance, at: c.accountingBalanceAt }
: null,
jobs,
};
}
async productsStatus(idsCsv: string) {
const ids = String(idsCsv ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 500);
const config = await this.config.get();
if (ids.length === 0) return {};
const [products, links] = await Promise.all([
this.prisma.product.findMany({
where: { id: { in: ids } },
select: {
id: true,
accountingStockWeight: true,
accountingStockCount: true,
accountingStockAt: true,
_count: { select: { images: true } },
},
}),
config.provider === 'none'
? []
: this.prisma.accountingLink.findMany({ where: { provider: config.provider, entityType: 'PRODUCT', entityId: { in: ids } } }),
]);
const linkBy = new Map(links.map((l) => [l.entityId, l]));
return Object.fromEntries(
products.map((p) => {
const l = linkBy.get(p.id);
return [
p.id,
{
linked: !!l,
locked: !!l && config.features.products,
code: l?.externalCode ?? null,
origin: l?.origin ?? null,
stockWeight: p.accountingStockWeight,
stockCount: p.accountingStockCount,
stockAt: p.accountingStockAt,
hasImage: p._count.images > 0,
},
];
}),
);
}
async movement(q: Record<string, unknown>) {
const a = await this.config.active();
if (!a) throw new BadRequestException('اتصال حسابداری فعال نیست');
const to = parseJalali(q.to as string) ?? toJalali(new Date());
const from = parseJalali(q.from as string) ?? toJalali(new Date(Date.now() - 30 * 24 * 3600 * 1000));
const rows = await a.provider.getKarSakhteMovement(a.settings, from, to);
return { provider: a.provider.label, scope: a.provider.capabilities.movementScope, rows };
}
}
@@ -0,0 +1,304 @@
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import {
ACCOUNTING_CONFIG_KEY,
ACCOUNTING_STATE_KEY,
AccountingConfig,
StoredConnection,
cleanBool,
cleanCa,
cleanGroup,
cleanGroups,
cleanInt,
cleanUrl,
documentStatusRank,
withDefaults,
} from './accounting-config';
import { ACCOUNTING_PROVIDERS, AccountingProvider, ProviderName, ProviderSettings, isProviderName } from './providers';
import { openSecret, sealSecret, secretKeySource } from './secret-box';
import { parsePemCertificates } from './accounting-http';
import { CatalogCacheService } from '../catalog/catalog-cache.service';
/** The first certificate of a pasted bundle, for display; null when none parses. */
function describeCertificate(pem: string | null) {
const [c] = parsePemCertificates(pem);
if (!c) return null;
return {
subject: c.subject,
subjectAltName: c.subjectAltName ?? null,
validTo: c.validTo,
expired: new Date(c.validTo).getTime() < Date.now(),
fingerprint256: c.fingerprint256,
};
}
export interface ActiveProvider {
provider: AccountingProvider;
settings: ProviderSettings;
config: AccountingConfig;
}
export type JobName = 'customers' | 'balances' | 'products' | 'outbox';
export interface JobState {
lastRunAt?: string;
lastOkAt?: string;
lastError?: string | null;
summary?: Record<string, unknown>;
}
export interface AccountingState {
jobs: Partial<Record<JobName, JobState>>;
}
/** How long a read config is reused before going back to the database. */
const CACHE_MS = 5_000;
@Injectable()
export class AccountingConfigService implements OnModuleInit {
private readonly logger = new Logger('Accounting');
private cache: { at: number; config: AccountingConfig } | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly catalogCache: CatalogCacheService,
) {}
onModuleInit() {
if (secretKeySource() !== 'ACCOUNTING_SECRET_KEY') {
this.logger.warn(
'⚠️ ACCOUNTING_SECRET_KEY تنظیم نشده است. توکن نرم‌افزار حسابداری با کلیدی مشتق از JWT_SECRET رمز می‌شود؛ ' +
'با عوض‌شدن JWT_SECRET توکن ذخیره‌شده قابل‌خواندن نمی‌ماند و باید دوباره وارد شود. (D27)',
);
}
}
async get(): Promise<AccountingConfig> {
if (this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.config;
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_CONFIG_KEY } });
const config = withDefaults(row?.value);
this.cache = { at: Date.now(), config };
return config;
}
/** The configuration as the admin screen sees it: tokens reduced to flags. */
async getPublic() {
const c = await this.get();
const publicConn = (conn: StoredConnection) => {
const { tokenSealed, ...rest } = conn;
return {
...rest,
tokenSet: !!tokenSealed,
tokenReadable: !!tokenSealed && openSecret(tokenSealed) !== null,
// What the pinned certificate is, so the screen can show it instead of PEM.
caInfo: describeCertificate(rest.caCert),
};
};
return {
...c,
tahesab: publicConn(c.tahesab),
zarrin: publicConn(c.zarrin),
secretKeySource: secretKeySource(),
providers: Object.values(ACCOUNTING_PROVIDERS).map((p) => ({
name: p.name,
label: p.label,
capabilities: p.capabilities,
})),
};
}
/**
* Applies an admin's edit. Anything not sent is kept. A token is replaced
* only by a non-empty string and removed only by an explicit null — so the
* form can be saved without ever having seen the token.
*/
async save(input: Record<string, any>): Promise<{ providerChanged: boolean }> {
const current = await this.get();
const next: AccountingConfig = JSON.parse(JSON.stringify(current));
if (input.provider !== undefined) {
if (input.provider !== 'none' && !isProviderName(input.provider)) {
throw new BadRequestException('نرم‌افزار حسابداری نامعتبر است');
}
next.provider = input.provider;
}
const conn = (key: ProviderName) => {
const src = input[key];
if (!src || typeof src !== 'object') return;
const label = ACCOUNTING_PROVIDERS[key].label;
const dst: StoredConnection = next[key];
if (src.baseUrl !== undefined) dst.baseUrl = cleanUrl(src.baseUrl, `آدرس ${label}`);
if (src.caCert !== undefined) dst.caCert = cleanCa(src.caCert);
if (src.timeoutMs !== undefined) dst.timeoutMs = cleanInt(src.timeoutMs, 'مهلت پاسخ (میلی‌ثانیه)', 3000, 120000, dst.timeoutMs);
if (src.token === null) dst.tokenSealed = null;
else if (typeof src.token === 'string' && src.token.trim()) {
try {
dst.tokenSealed = sealSecret(src.token.trim());
} catch (err) {
throw new BadRequestException((err as Error).message);
}
}
};
conn('tahesab');
conn('zarrin');
if (input.tahesab?.dbName !== undefined) {
const db = String(input.tahesab.dbName ?? '').trim();
if (!/^[A-Za-z0-9_]{1,64}$/.test(db)) throw new BadRequestException('نام دیتابیس ته‌حساب نامعتبر است');
next.tahesab.dbName = db;
}
if (input.zarrin?.balanceOwesWhen !== undefined) {
if (input.zarrin.balanceOwesWhen !== 'negative' && input.zarrin.balanceOwesWhen !== 'positive') {
throw new BadRequestException('علامت مانده‌ی زرین نامعتبر است');
}
next.zarrin.balanceOwesWhen = input.zarrin.balanceOwesWhen;
}
if (input.features && typeof input.features === 'object') {
for (const k of Object.keys(next.features) as (keyof AccountingConfig['features'])[]) {
next.features[k] = cleanBool(input.features[k], next.features[k]);
}
}
if (input.intervals && typeof input.intervals === 'object') {
const i = input.intervals;
next.intervals.customersMinutes = cleanInt(i.customersMinutes, 'بازه‌ی سینک مشتری', 5, 1440, next.intervals.customersMinutes);
next.intervals.balancesMinutes = cleanInt(i.balancesMinutes, 'بازه‌ی سینک مانده', 1, 1440, next.intervals.balancesMinutes);
next.intervals.productsMinutes = cleanInt(i.productsMinutes, 'بازه‌ی سینک کارساخته', 5, 1440, next.intervals.productsMinutes);
}
if (input.mapping && typeof input.mapping === 'object') {
const m = input.mapping;
if (m.importCustomerGroups !== undefined) next.mapping.importCustomerGroups = cleanGroups(m.importCustomerGroups);
if (m.newCustomerGroup !== undefined) next.mapping.newCustomerGroup = cleanGroup(m.newCustomerGroup);
if (m.newKarSakhteGroup !== undefined) next.mapping.newKarSakhteGroup = cleanGroup(m.newKarSakhteGroup);
if (m.newKarSakhteType !== undefined) next.mapping.newKarSakhteType = cleanGroup(m.newKarSakhteType);
if (m.categoryGroups !== undefined) {
next.mapping.categoryGroups = (Array.isArray(m.categoryGroups) ? m.categoryGroups : [])
.map((x: any) => ({ categoryId: String(x?.categoryId ?? '').trim(), group: cleanGroup(x?.group) }))
.filter((x: any) => x.categoryId && x.group);
}
if (m.karat !== undefined) next.mapping.karat = cleanInt(m.karat, 'عیار پایه', 1, 1000, next.mapping.karat);
}
if (input.documents && typeof input.documents === 'object' && input.documents.triggerStatus !== undefined) {
if (!documentStatusRank(String(input.documents.triggerStatus))) {
throw new BadRequestException('وضعیت ثبت سند سفارش نامعتبر است');
}
next.documents.triggerStatus = input.documents.triggerStatus;
}
if (input.report && typeof input.report === 'object') {
next.report.windowDays = cleanInt(input.report.windowDays, 'بازه‌ی گزارش (روز)', 7, 3650, next.report.windowDays);
}
// Selecting a software that has no address/token would switch the sync
// off without a word (the worker finds nothing usable).
if (next.provider !== 'none' && (!next[next.provider].baseUrl || !next[next.provider].tokenSealed)) {
const label = ACCOUNTING_PROVIDERS[next.provider].label;
throw new BadRequestException(`«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`);
}
await this.prisma.setting.upsert({
where: { key: ACCOUNTING_CONFIG_KEY },
update: { value: next as unknown as Prisma.InputJsonValue },
create: { key: ACCOUNTING_CONFIG_KEY, value: next as unknown as Prisma.InputJsonValue },
});
this.cache = null;
// The provider and product-sync switch decide which products read as out of stock.
this.catalogCache.invalidate();
return { providerChanged: current.provider !== next.provider };
}
/** Decrypted settings for one provider; null when it is not usable. */
settingsFor(config: AccountingConfig, name: ProviderName): ProviderSettings | null {
const conn = config[name];
if (!conn.baseUrl) return null;
const token = openSecret(conn.tokenSealed);
if (!token) return null;
return {
baseUrl: conn.baseUrl,
token,
caCert: conn.caCert,
timeoutMs: conn.timeoutMs,
dbName: name === 'tahesab' ? config.tahesab.dbName : undefined,
balanceOwesWhen: name === 'zarrin' ? config.zarrin.balanceOwesWhen : undefined,
};
}
/**
* Settings as the admin form currently shows them, saved or not, so «تست
* اتصال» checks what is on screen. A blank token means "the saved one".
* Validated like a save; nothing is stored.
*/
draftSettings(config: AccountingConfig, name: ProviderName, input: Record<string, any> | undefined): ProviderSettings | null {
const src = input && typeof input === 'object' ? input : {};
const label = ACCOUNTING_PROVIDERS[name].label;
const saved = config[name];
const baseUrl = src.baseUrl !== undefined ? cleanUrl(src.baseUrl, `آدرس ${label}`) : saved.baseUrl;
const token = typeof src.token === 'string' && src.token.trim() ? src.token.trim() : openSecret(saved.tokenSealed);
if (!baseUrl || !token) return null;
let dbName = config.tahesab.dbName;
if (name === 'tahesab' && src.dbName !== undefined) {
dbName = String(src.dbName ?? '').trim();
if (!/^[A-Za-z0-9_]{1,64}$/.test(dbName)) throw new BadRequestException('نام دیتابیس ته‌حساب نامعتبر است');
}
let balanceOwesWhen = config.zarrin.balanceOwesWhen;
if (name === 'zarrin' && (src.balanceOwesWhen === 'negative' || src.balanceOwesWhen === 'positive')) {
balanceOwesWhen = src.balanceOwesWhen;
}
return {
baseUrl,
token,
caCert: src.caCert !== undefined ? cleanCa(src.caCert) : saved.caCert,
timeoutMs: cleanInt(src.timeoutMs, 'مهلت پاسخ (میلی‌ثانیه)', 3000, 120000, saved.timeoutMs),
dbName: name === 'tahesab' ? dbName : undefined,
balanceOwesWhen: name === 'zarrin' ? balanceOwesWhen : undefined,
};
}
/** Why nothing syncs, in words for the admin screen; null when a provider is usable. */
inactiveReason(config: AccountingConfig): string | null {
if (config.provider === 'none') return 'سوییچ روی «بدون اتصال» است';
const label = ACCOUNTING_PROVIDERS[config.provider].label;
const conn = config[config.provider];
if (!conn.baseUrl || !conn.tokenSealed) return `«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`;
if (!this.settingsFor(config, config.provider)) return `توکن ذخیره‌شده‌ی «${label}» قابل‌خواندن نیست؛ دوباره وارد کنید`;
return null;
}
/** The selected, fully configured provider — or null when the switch is off. */
async active(): Promise<ActiveProvider | null> {
const config = await this.get();
if (config.provider === 'none') return null;
const settings = this.settingsFor(config, config.provider);
if (!settings) return null;
return { provider: ACCOUNTING_PROVIDERS[config.provider], settings, config };
}
/** Active provider with this feature switched on, or null. */
async activeFor(feature: keyof AccountingConfig['features']): Promise<ActiveProvider | null> {
const a = await this.active();
return a && a.config.features[feature] ? a : null;
}
// ── job bookkeeping (shown on the admin screen) ───────────────────────
async getState(): Promise<AccountingState> {
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_STATE_KEY } });
const v = (row?.value ?? {}) as Partial<AccountingState>;
return { jobs: v.jobs ?? {} };
}
async recordJob(job: JobName, patch: JobState) {
const state = await this.getState();
state.jobs[job] = { ...(state.jobs[job] ?? {}), ...patch };
await this.prisma.setting.upsert({
where: { key: ACCOUNTING_STATE_KEY },
update: { value: state as unknown as Prisma.InputJsonValue },
create: { key: ACCOUNTING_STATE_KEY, value: state as unknown as Prisma.InputJsonValue },
});
}
}
@@ -0,0 +1,181 @@
import { BadRequestException } from '@nestjs/common';
import { ExternalGroup, ProviderName } from './providers/accounting-provider';
/**
* The admin-editable configuration of the accounting integration (D27),
* stored as one Setting row. Every `accounting.*` Setting key is refused by
* the generic `const` routes (admin-misc), so the only way in or out is the
* dedicated, `config.accounting`-guarded screen — and even that never returns
* a token.
*/
export const ACCOUNTING_SETTING_PREFIX = 'accounting.';
export const ACCOUNTING_CONFIG_KEY = 'accounting.config';
export const ACCOUNTING_STATE_KEY = 'accounting.state';
/** Keys the generic `const` settings routes must never read or write. */
export function isAccountingSettingKey(key: string) {
return key.trim().toLowerCase().startsWith(ACCOUNTING_SETTING_PREFIX);
}
export type ProviderChoice = 'none' | ProviderName;
export interface StoredConnection {
baseUrl: string;
/** AES-GCM sealed (secret-box.ts). Never leaves the server. */
tokenSealed: string | null;
/** PEM CA of a self-signed installation; verification is never turned off. */
caCert: string | null;
timeoutMs: number;
}
export interface AccountingFeatures {
/** F1 — customer import/link and push. */
customers: boolean;
/** F2 — balance pull; the customer sees the accounting balance. */
balances: boolean;
/** F6 — «گزارش» reads the accounting statement. */
report: boolean;
/** F4 — کارساخته ↔ product sync and the out-of-stock lock. */
products: boolean;
/** F3 — order documents. */
documents: boolean;
}
export interface AccountingConfig {
provider: ProviderChoice;
tahesab: StoredConnection & { dbName: string };
zarrin: StoredConnection & { balanceOwesWhen: 'negative' | 'positive' };
features: AccountingFeatures;
intervals: {
customersMinutes: number;
balancesMinutes: number;
productsMinutes: number;
};
mapping: {
/** Account groups imported into the app; empty = every group. */
importCustomerGroups: ExternalGroup[];
/** Group a customer created from the app is put in. */
newCustomerGroup: ExternalGroup | null;
/** Zarrin: group (گروه ساخته) for a product created in the admin… */
newKarSakhteGroup: ExternalGroup | null;
/** …overridden per app category. */
categoryGroups: { categoryId: string; group: ExternalGroup }[];
/** Zarrin: item type (گرمی/تعدادی…) for a product created in the admin. */
newKarSakhteType: ExternalGroup | null;
/** Purity the app's grams are in. */
karat: number;
};
/** How far back «گزارش» reads the accounting statement. */
report: { windowDays: number };
/** F3 — the order status at (or past) which the temporary document (پیش‌فاکتور) is posted. */
documents: { triggerStatus: DocumentTriggerStatus };
}
/** Order statuses in the order an order passes them; SUSPENDED/CANCELLED never trigger. */
export const DOCUMENT_TRIGGER_STATUSES = [
'PLACED',
'ADMIN_APPROVED',
'SOURCING',
'PREPARING',
'DELIVERED_TO_AGENT',
'DELIVERED_TO_CUSTOMER',
] as const;
export type DocumentTriggerStatus = (typeof DOCUMENT_TRIGGER_STATUSES)[number];
/** 1-based place of a status in DOCUMENT_TRIGGER_STATUSES; 0 = never triggers. */
export function documentStatusRank(status: string): number {
return DOCUMENT_TRIGGER_STATUSES.indexOf(status as DocumentTriggerStatus) + 1;
}
export const DEFAULT_TIMEOUT_MS = 20_000;
export function defaultConfig(): AccountingConfig {
return {
provider: 'none',
tahesab: { baseUrl: '', tokenSealed: null, caCert: null, timeoutMs: DEFAULT_TIMEOUT_MS, dbName: 'TahesabDB' },
zarrin: { baseUrl: '', tokenSealed: null, caCert: null, timeoutMs: DEFAULT_TIMEOUT_MS, balanceOwesWhen: 'negative' },
features: { customers: false, balances: false, report: false, products: false, documents: false },
intervals: { customersMinutes: 60, balancesMinutes: 10, productsMinutes: 30 },
mapping: {
importCustomerGroups: [],
newCustomerGroup: null,
newKarSakhteGroup: null,
categoryGroups: [],
newKarSakhteType: null,
karat: 750,
},
report: { windowDays: 365 },
// The point the debt is recognised — the behaviour before the rule existed.
documents: { triggerStatus: 'DELIVERED_TO_AGENT' },
};
}
/** Stored JSON → a complete config, tolerating rows written by older code. */
export function withDefaults(raw: unknown): AccountingConfig {
const d = defaultConfig();
const r = (raw && typeof raw === 'object' ? raw : {}) as Partial<AccountingConfig>;
return {
provider: r.provider === 'tahesab' || r.provider === 'zarrin' ? r.provider : 'none',
tahesab: { ...d.tahesab, ...(r.tahesab ?? {}) },
zarrin: { ...d.zarrin, ...(r.zarrin ?? {}) },
features: { ...d.features, ...(r.features ?? {}) },
intervals: { ...d.intervals, ...(r.intervals ?? {}) },
mapping: { ...d.mapping, ...(r.mapping ?? {}) },
report: { ...d.report, ...(r.report ?? {}) },
documents: { ...d.documents, ...(r.documents ?? {}) },
};
}
// ── input validation ────────────────────────────────────────────────────
export function cleanUrl(v: unknown, label: string): string {
const s = typeof v === 'string' ? v.trim() : '';
if (!s) return '';
let u: URL;
try {
u = new URL(s);
} catch {
throw new BadRequestException(`${label}: آدرس معتبر نیست`);
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new BadRequestException(`${label}: آدرس باید با http:// یا https:// شروع شود`);
}
if (u.username || u.password) throw new BadRequestException(`${label}: آدرس نباید نام کاربری/رمز داشته باشد`);
return s;
}
export function cleanCa(v: unknown): string | null {
const s = typeof v === 'string' ? v.trim() : '';
if (!s) return null;
if (!s.includes('-----BEGIN CERTIFICATE-----')) {
throw new BadRequestException('گواهی CA باید با قالب PEM (BEGIN CERTIFICATE) باشد');
}
return s;
}
export function cleanInt(v: unknown, label: string, min: number, max: number, fallback: number): number {
if (v === undefined || v === null || v === '') return fallback;
const n = Number(v);
if (!Number.isInteger(n) || n < min || n > max) {
throw new BadRequestException(`${label}: عدد صحیح بین ${min} و ${max}`);
}
return n;
}
export function cleanGroup(v: unknown): ExternalGroup | null {
if (!v || typeof v !== 'object') return null;
const g = v as Record<string, unknown>;
const id = g.id === undefined || g.id === null ? '' : String(g.id).trim();
const name = typeof g.name === 'string' ? g.name.trim() : '';
if (!id && !name) return null;
return { id, name };
}
export function cleanGroups(v: unknown): ExternalGroup[] {
return Array.isArray(v) ? v.map(cleanGroup).filter((g): g is ExternalGroup => !!g) : [];
}
export function cleanBool(v: unknown, fallback: boolean): boolean {
return typeof v === 'boolean' ? v : fallback;
}
@@ -0,0 +1,106 @@
import { AccountingHooksService } from './accounting-hooks.service';
import { AccountingConfig, defaultConfig } from './accounting-config';
type Row = { id: string; provider: string; kind: string; entityId: string; status: string };
/** Just enough of a Prisma transaction client for the outbox rules. */
function fakeTx(rows: Row[]) {
const match = (r: Row, where: any) =>
(!where.provider || r.provider === where.provider) &&
(!where.kind || r.kind === where.kind) &&
(!where.entityId || r.entityId === where.entityId) &&
(!where.status ||
(where.status.in ? where.status.in.includes(r.status) : true) &&
(where.status.notIn ? !where.status.notIn.includes(r.status) : true) &&
(where.status.not ? r.status !== where.status.not : true));
return {
accountingOutbox: {
findFirst: async ({ where }: any) => rows.find((r) => match(r, where)) ?? null,
findMany: async ({ where }: any) => rows.filter((r) => match(r, where)),
create: async ({ data }: any) => {
rows.push({ id: String(rows.length + 1), status: 'PENDING', ...data });
},
update: async ({ where, data }: any) => {
Object.assign(rows.find((r) => r.id === where.id)!, data);
},
},
};
}
function hooks(trigger: AccountingConfig['documents']['triggerStatus'], provider: 'tahesab' | 'zarrin' = 'tahesab') {
const c = defaultConfig();
c.provider = provider;
c.features.documents = true;
c.documents.triggerStatus = trigger;
const config = { get: async () => c } as any;
const worker = { kickSoon: jest.fn() } as any;
return new AccountingHooksService({} as any, config, {} as any, worker);
}
const docs = (rows: Row[], kind = 'ORDER_DOCUMENT') => rows.filter((r) => r.kind === kind);
describe('document rule (پیش‌فاکتور at a chosen status)', () => {
it('rule «ثبت سفارش»: the document is queued as the order is placed', async () => {
const rows: Row[] = [];
await hooks('PLACED').orderStatusReached(fakeTx(rows) as any, 'o1', 'PLACED');
expect(docs(rows)).toHaveLength(1);
});
it('default rule: nothing before «تحویل مسئول فروش», then once', async () => {
const rows: Row[] = [];
const h = hooks('DELIVERED_TO_AGENT');
for (const s of ['PLACED', 'ADMIN_APPROVED', 'PREPARING']) await h.orderStatusReached(fakeTx(rows) as any, 'o1', s);
expect(docs(rows)).toHaveLength(0);
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_AGENT');
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_CUSTOMER');
expect(docs(rows)).toHaveLength(1);
});
it('an order that jumps past the rule status still gets its document', async () => {
const rows: Row[] = [];
await hooks('PREPARING').orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_CUSTOMER');
expect(docs(rows)).toHaveLength(1);
});
it('a suspended order never triggers', async () => {
const rows: Row[] = [];
await hooks('PLACED').orderStatusReached(fakeTx(rows) as any, 'o1', 'SUSPENDED');
expect(rows).toHaveLength(0);
});
it('rolled back below the rule: a waiting document is dropped, a sent one gets a delete job', async () => {
const waiting: Row[] = [{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'PENDING' }];
await hooks('PREPARING').orderStatusReached(fakeTx(waiting) as any, 'o1', 'ADMIN_APPROVED');
expect(waiting[0].status).toBe('RESOLVED');
const sent: Row[] = [{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' }];
await hooks('PREPARING').orderStatusReached(fakeTx(sent) as any, 'o1', 'ADMIN_APPROVED');
expect(docs(sent, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
});
it('Tahesab: reaching the rule again after a withdrawal queues a new document behind the delete', async () => {
const rows: Row[] = [
{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' },
{ id: '2', provider: 'tahesab', kind: 'ORDER_DOCUMENT_DELETE', entityId: 'o1', status: 'PENDING' },
];
await hooks('PREPARING').orderStatusReached(fakeTx(rows) as any, 'o1', 'PREPARING');
expect(docs(rows)).toHaveLength(2);
});
it('Zarrin: never queued twice (no duplicate guard in its API)', async () => {
const rows: Row[] = [
{ id: '1', provider: 'zarrin', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' },
{ id: '2', provider: 'zarrin', kind: 'ORDER_DOCUMENT_DELETE', entityId: 'o1', status: 'PENDING' },
];
await hooks('PREPARING', 'zarrin').orderStatusReached(fakeTx(rows) as any, 'o1', 'PREPARING');
expect(docs(rows)).toHaveLength(1);
});
it('does nothing while the document feature is off', async () => {
const rows: Row[] = [];
const h = hooks('PLACED');
((await (h as any).config.get()) as AccountingConfig).features.documents = false;
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'PLACED');
expect(rows).toHaveLength(0);
});
});
@@ -0,0 +1,176 @@
import { Injectable } from '@nestjs/common';
import { AccountingEntityType, AccountingOutboxKind, AccountingOutboxStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingConfigService } from './accounting-config.service';
import { AccountingFeatures, documentStatusRank } from './accounting-config';
import { AccountingSyncService } from './accounting-sync.service';
import { AccountingWorkerService } from './accounting-worker.service';
import { ACCOUNTING_PROVIDERS } from './providers';
type Tx = Prisma.TransactionClient | PrismaService;
type StockFields = {
accountingStockAt: Date | null;
accountingStockWeight: Prisma.Decimal | null;
accountingStockCount: Prisma.Decimal | null;
};
const OPEN: AccountingOutboxStatus[] = ['PENDING', 'SENDING'];
/**
* The narrow surface the rest of the app calls into (D27): orders, customer
* admin, product admin, catalog. Every write goes through the caller's own
* transaction client, so the outbox row commits or rolls back with the change
* that caused it. Nothing here talks to the accounting software.
*/
@Injectable()
export class AccountingHooksService {
constructor(
private readonly prisma: PrismaService,
private readonly config: AccountingConfigService,
private readonly sync: AccountingSyncService,
private readonly worker: AccountingWorkerService,
) {}
private async enqueue(tx: Tx, feature: keyof AccountingFeatures, kind: AccountingOutboxKind, entityId: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features[feature]) return false;
const open = await tx.accountingOutbox.findFirst({
where: { provider: c.provider, kind, entityId, status: { in: OPEN } },
select: { id: true },
});
if (open) return false;
await tx.accountingOutbox.create({ data: { provider: c.provider, kind, entityId } });
this.worker.kickSoon();
return true;
}
/**
* F3 — the order is now in `status` (created, advanced, or rolled back).
* The owner's rule (`documents.triggerStatus`, «اتصال حسابداری») says from
* which status on the temporary document (پیش‌فاکتور) belongs in the books:
* at or past it the document is queued once; back below it, a document
* already sent is withdrawn like a cancellation.
*/
async orderStatusReached(tx: Tx, orderId: string, status: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.documents) return;
const rank = documentStatusRank(status);
if (!rank) return;
if (rank >= documentStatusRank(c.documents.triggerStatus)) await this.queueOrderDocument(tx, c.provider, orderId);
else await this.orderCancelled(tx, orderId);
}
private async queueOrderDocument(tx: Tx, provider: string, orderId: string) {
const existing = await tx.accountingOutbox.findFirst({
where: { provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { notIn: ['RESOLVED'] } },
select: { id: true },
});
if (existing) {
// Withdrawn and wanted again: Tahesab deletes first (older job), then
// the new one posts — and a duplicate is refused by its Factor_Code key.
// Zarrin has no such key, so its document is never queued twice.
const deleting = await tx.accountingOutbox.findFirst({
where: { provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { in: ['PENDING', 'SENDING'] } },
select: { id: true },
});
if (!(provider === 'tahesab' && deleting)) return;
}
await this.enqueue(tx, 'documents', 'ORDER_DOCUMENT', orderId);
}
/**
* F3 — the order was cancelled. A document still waiting is simply dropped;
* one that may already be in the books gets a delete job, which itself only
* deletes a document that is still temporary (worker).
*/
async orderCancelled(tx: Tx, orderId: string) {
const docs = await tx.accountingOutbox.findMany({
where: { kind: 'ORDER_DOCUMENT', entityId: orderId, status: { not: 'RESOLVED' } },
});
for (const doc of docs) {
if (doc.status === 'PENDING') {
await tx.accountingOutbox.update({
where: { id: doc.id },
data: { status: 'RESOLVED', lastError: 'سفارش پیش از ارسال سند لغو شد' },
});
continue;
}
const open = await tx.accountingOutbox.findFirst({
where: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { not: 'RESOLVED' } },
select: { id: true },
});
if (!open) {
await tx.accountingOutbox.create({
data: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId },
});
}
}
}
/** F1 — an admin marked the customer VERIFIED. */
async customerVerified(tx: Tx, customerId: string) {
await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
}
/** F1 — a linked customer's contact details changed. */
async customerEdited(tx: Tx, customerId: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.customers) return;
const link = await tx.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: c.provider, entityType: 'CUSTOMER', entityId: customerId } },
select: { id: true },
});
if (link) await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
}
/** F4 — a product was created in the admin panel. */
async productCreated(tx: Tx, productId: string) {
const c = await this.config.get();
if (c.provider === 'none' || !ACCOUNTING_PROVIDERS[c.provider].capabilities.createKarSakhte) return;
await this.enqueue(tx, 'products', 'PRODUCT_PUSH', productId);
}
/**
* F4 — title and code of a linked product belong to the accounting software
* (D27); the admin form locks them. Null when the product is not linked.
*/
async productLink(productId: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.products) return null;
return this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: c.provider, entityType: AccountingEntityType.PRODUCT, entityId: productId } },
});
}
/**
* Order time: the product with its stock read live from the accounting
* software, or as last stored when that cannot answer in a moment.
*/
liveStock<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(product: T) {
return this.sync.liveStock(product);
}
/**
* F4 — is this product out of stock in the accounting software? Only
* meaningful while the product sync is on; stock fields of products that are
* not linked under the active provider are cleared by every product pull.
*/
async stockBlocked(product: StockFields) {
return (await this.stockBlocker())(product);
}
/**
* The same rule as stockBlocked, with the configuration read once — for
* checking a whole list without an await per product.
*/
async stockBlocker(): Promise<(product: StockFields) => boolean> {
const c = await this.config.get();
const active = c.provider !== 'none' && c.features.products;
return (product) => {
if (!active || !product.accountingStockAt) return false;
const stock = product.accountingStockWeight ?? product.accountingStockCount;
return stock !== null && new Prisma.Decimal(stock).lte(0);
};
}
}
@@ -0,0 +1,87 @@
import * as https from 'https';
import { AddressInfo } from 'net';
import { AccountingHttpError, fetchServerCertificate, httpRequest, pinnedFingerprints } from './accounting-http';
// Throwaway self-signed test certificates (EC P-256, valid to 2126), issued
// for *.invalid names so connecting to 127.0.0.1 never matches them — the
// same situation as a Tahesab install whose certificate says `localhost`.
const CERT_A = `-----BEGIN CERTIFICATE-----
MIIBxTCCAWugAwIBAgIUNaqVnWRRUyN8hOGPQtARjZymRcUwCgYIKoZIzj0EAwIw
JDEiMCAGA1UEAwwZYWNjb3VudGluZy10ZXN0LWEuaW52YWxpZDAgFw0yNjEwMDUx
MjA3NDJaGA8yMTI2MDkxMTEyMDc0MlowJDEiMCAGA1UEAwwZYWNjb3VudGluZy10
ZXN0LWEuaW52YWxpZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFC+h8KB1h41
56L9+l8nHbMZ5hrrp9nwqmz5RoAqrkVI8Ii5RFYUjssaH2Y0P9+B9L9SvijfB5Xi
Wwk6eVzGKtSjeTB3MB0GA1UdDgQWBBSokIrPzA6ur33vyVeBY+2/NWHx1TAfBgNV
HSMEGDAWgBSokIrPzA6ur33vyVeBY+2/NWHx1TAPBgNVHRMBAf8EBTADAQH/MCQG
A1UdEQQdMBuCGWFjY291bnRpbmctdGVzdC1hLmludmFsaWQwCgYIKoZIzj0EAwID
SAAwRQIgCLZVDlrE2aO8poWAdZMOd+obOeB23TU1Jfh3nnbEDF0CIQCB5+8PNRQe
aWzW6sgCcDWdiga81Nhm7FxYj4AorbPO1w==
-----END CERTIFICATE-----`;
const KEY_A = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgOQ//h9ZRCK1KaiXW
Ij3FN+77RDPYSWgY6DwZqC5x0A6hRANCAARQvofCgdYeNeei/fpfJx2zGeYa66fZ
8Kps+UaAKq5FSPCIuURWFI7LGh9mND/fgfS/Ur4o3weV4lsJOnlcxirU
-----END PRIVATE KEY-----`;
const CERT_B = `-----BEGIN CERTIFICATE-----
MIIBxjCCAWugAwIBAgIUMO+HE/U7Z4EXTnw9ucoKjRz/u7MwCgYIKoZIzj0EAwIw
JDEiMCAGA1UEAwwZYWNjb3VudGluZy10ZXN0LWIuaW52YWxpZDAgFw0yNjEwMDUx
MjA3NDJaGA8yMTI2MDkxMTEyMDc0MlowJDEiMCAGA1UEAwwZYWNjb3VudGluZy10
ZXN0LWIuaW52YWxpZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABI2Naohejia1
fQnFx33/5jasQJudFTmAdPEHxlIyBlUUi2IoaMWbSyMBBfgy+dX6gM7QetK/99ex
rt/d5OQNyvqjeTB3MB0GA1UdDgQWBBSgpkkpJptOajy+1aSgPZ8gre559TAfBgNV
HSMEGDAWgBSgpkkpJptOajy+1aSgPZ8gre559TAPBgNVHRMBAf8EBTADAQH/MCQG
A1UdEQQdMBuCGWFjY291bnRpbmctdGVzdC1iLmludmFsaWQwCgYIKoZIzj0EAwID
SQAwRgIhALJMbkGHT256tdNcVJfnB0eqxivGD7hooljD6epmvTgbAiEA1r/cX59l
0bu56aMEi0TeoQAAQRiEpdBIwe0F/YjWXL4=
-----END CERTIFICATE-----`;
describe('accounting HTTP client — TLS with a pinned certificate', () => {
let server: https.Server;
let url: string;
beforeAll(async () => {
server = https.createServer({ cert: CERT_A, key: KEY_A }, (_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"Status":"OK"}');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
url = `https://127.0.0.1:${(server.address() as AddressInfo).port}/`;
});
afterAll(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
const call = (ca: string | null) => httpRequest({ method: 'POST', url, body: '{}', timeoutMs: 5000, ca });
it('rejects a self-signed server when nothing is pinned, without claiming it may have been received', async () => {
const err = await call(null).catch((e) => e);
expect(err).toBeInstanceOf(AccountingHttpError);
expect(err.ambiguous).toBe(false);
expect(err.message).toContain('بررسی گواهی سرور');
});
it('accepts exactly the pinned certificate even though it names another host', async () => {
await expect(call(CERT_A)).resolves.toEqual({ status: 200, body: '{"Status":"OK"}' });
});
it('still rejects when a different certificate is pinned', async () => {
const err = await call(CERT_B).catch((e) => e);
expect(err).toBeInstanceOf(AccountingHttpError);
expect(err.message).toContain('یکی نیست');
});
it('reads the presented certificate for the admin to compare and pin', async () => {
const c = await fetchServerCertificate(url, 5000);
expect(c.fingerprint256).toBe([...pinnedFingerprints(CERT_A)][0]);
expect(c.selfSigned).toBe(true);
expect(c.trustedBySystem).toBe(false);
expect(c.subjectAltName).toContain('accounting-test-a.invalid');
expect(pinnedFingerprints(c.pem).has(c.fingerprint256)).toBe(true);
});
it('refuses to fetch a certificate from a plain-http address', async () => {
await expect(fetchServerCertificate('http://127.0.0.1:1/', 1000)).rejects.toThrow('https');
});
});
@@ -0,0 +1,296 @@
import { X509Certificate } from 'crypto';
import * as http from 'http';
import * as https from 'https';
import * as net from 'net';
import * as tls from 'tls';
/**
* The one HTTP client both accounting drivers use.
*
* Built on node:http(s) rather than global fetch because a self-hosted
* Tahesab/Zarrin often serves a self-signed certificate, and fetch cannot be
* given a CA without an extra dependency. The reference "solved" that with
* `Http::withoutVerifying()` (SEC-17); here verification is ALWAYS on and the
* admin can paste the installation's certificate instead.
*
* A pasted certificate is trusted two ways: as a CA (the normal chain and
* hostname checks), and as a pin — when the server presents exactly that
* certificate it is accepted whatever name it was issued for. Tahesab's own
* certificate is self-signed for `localhost`, so a remote install reached by
* IP can only be verified by the pin.
*
* Errors say whether the request may have reached the server. That decides
* what the outbox may do next: a refused connection is safe to retry, a
* timeout after the request went out is not (Zarrin has no idempotency key).
*/
export interface HttpRequest {
method: 'GET' | 'POST';
url: string;
headers?: Record<string, string>;
body?: string;
timeoutMs: number;
/** PEM CA certificate(s) to trust in addition to the system store. */
ca?: string | null;
}
export interface HttpResponse {
status: number;
body: string;
}
export class AccountingHttpError extends Error {
constructor(
message: string,
/** True when the request may have been received and acted on. */
readonly ambiguous: boolean,
readonly code?: string,
) {
super(message);
this.name = 'AccountingHttpError';
}
}
const MAX_BODY_BYTES = 20 * 1024 * 1024;
const TLS_CERT_ERRORS = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/;
/** The PEM blocks in a pasted bundle, parsed; unparsable blocks are skipped. */
export function parsePemCertificates(pem: string | null | undefined): X509Certificate[] {
const blocks = (pem ?? '').match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
const out: X509Certificate[] = [];
for (const block of blocks) {
try {
out.push(new X509Certificate(block));
} catch {
// not a certificate — the CA list below will reject it too
}
}
return out;
}
/** SHA-256 fingerprints, as Node prints them (`AB:CD:…`), of the pasted certificates. */
export function pinnedFingerprints(pem: string | null | undefined): Set<string> {
return new Set(parsePemCertificates(pem).map((c) => c.fingerprint256));
}
function tlsErrorMessage(err: NodeJS.ErrnoException, pinned: boolean): string {
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
return pinned
? `گواهی سرور برای این آدرس صادر نشده و با گواهی ثبت‌شده در تنظیمات هم یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
: `گواهی TLS سرور برای این آدرس صادر نشده (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
}
return pinned
? `گواهی فعلی سرور با گواهی ثبت‌شده در تنظیمات یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
: `گواهی TLS سرور قابل‌اعتماد نیست (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
}
export function httpRequest(req: HttpRequest): Promise<HttpResponse> {
return send(req, false);
}
/**
* One attempt. `fresh` = on a new connection: a server that closes idle
* kept-alive connections (Zarrin v228 does) makes the next request on a pooled
* one fail with ECONNRESET / "socket hang up" before it was received. That one
* case is repeated once on a fresh connection; nothing else is ever retried.
*/
function send(req: HttpRequest, fresh: boolean): Promise<HttpResponse> {
let url: URL;
try {
url = new URL(req.url);
} catch {
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
}
const isHttps = url.protocol === 'https:';
if (!isHttps && url.protocol !== 'http:') {
return Promise.reject(new AccountingHttpError('آدرس باید با http یا https شروع شود', false, 'BAD_URL'));
}
const headers: Record<string, string | number> = { ...(req.headers ?? {}) };
if (req.body !== undefined) headers['Content-Length'] = Buffer.byteLength(req.body);
const options: https.RequestOptions = {
method: req.method,
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: `${url.pathname}${url.search}`,
headers,
...(fresh ? { agent: false } : {}),
};
const pins = isHttps ? pinnedFingerprints(req.ca) : new Set<string>();
if (isHttps) {
options.rejectUnauthorized = true;
if (req.ca && req.ca.trim()) {
// Extra CA on top of the system store, never instead of verification.
options.ca = [...tls.rootCertificates, req.ca];
// Runs only after the chain verified. The exact pasted certificate is
// accepted under any name; anything else gets the normal name check.
// (Pooled sockets are keyed by `ca`, so a reused one had the same pins.)
options.checkServerIdentity = (host, cert) =>
pins.has(cert.fingerprint256) ? undefined : tls.checkServerIdentity(host, cert);
}
}
return new Promise<HttpResponse>((resolve, reject) => {
let connected = false;
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
const client = isHttps ? https : http;
const request = client.request(options, (res) => {
const chunks: Buffer[] = [];
let size = 0;
res.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
request.destroy();
finish(() => reject(new AccountingHttpError('پاسخ سرور حسابداری بیش از حد بزرگ است', true, 'TOO_LARGE')));
return;
}
chunks.push(chunk);
});
res.on('end', () =>
finish(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })),
);
res.on('error', (err) => finish(() => reject(new AccountingHttpError(err.message, true, 'RESPONSE'))));
});
request.on('socket', (socket) => {
// A kept-alive socket is already connected; listening on it again would
// pile up a listener per request.
if (!(socket as { connecting?: boolean }).connecting) {
connected = true;
return;
}
socket.once(isHttps ? 'secureConnect' : 'connect', () => {
connected = true;
});
});
const timer = setTimeout(() => {
request.destroy();
finish(() =>
reject(
new AccountingHttpError(
connected ? 'پاسخ سرور حسابداری در زمان مقرر نرسید' : 'اتصال به سرور حسابداری برقرار نشد (timeout)',
connected,
'TIMEOUT',
),
),
);
}, req.timeoutMs);
request.on('error', (err: NodeJS.ErrnoException) => {
if (!fresh && request.reusedSocket && err.code === 'ECONNRESET') {
finish(() => resolve(send(req, true)));
return;
}
// Node's own text for these suggests flags that do not apply here.
const text = isHttps && err.code && TLS_CERT_ERRORS.test(err.code) ? tlsErrorMessage(err, pins.size > 0) : err.message;
finish(() => reject(new AccountingHttpError(text, connected, err.code)));
});
if (req.body !== undefined) request.write(req.body);
request.end();
});
}
export interface ServerCertificate {
pem: string;
subject: string;
issuer: string;
subjectAltName: string | null;
validFrom: string;
validTo: string;
expired: boolean;
fingerprint256: string;
selfSigned: boolean;
/** Valid chain to a system CA and issued for this host: no pin needed. */
trustedBySystem: boolean;
}
/**
* Reads the certificate an https server presents, WITHOUT trusting it, so the
* admin can compare its fingerprint with the one on the accounting server and
* pin it. Nothing but the TLS handshake is sent.
*/
export function fetchServerCertificate(baseUrl: string, timeoutMs: number): Promise<ServerCertificate> {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
}
if (url.protocol !== 'https:') {
return Promise.reject(new AccountingHttpError('این آدرس https نیست؛ گواهی ندارد', false, 'NOT_HTTPS'));
}
const host = url.hostname.replace(/^\[|\]$/g, '');
return new Promise<ServerCertificate>((resolve, reject) => {
const socket = tls.connect({
host,
port: Number(url.port || 443),
servername: net.isIP(host) ? undefined : host,
rejectUnauthorized: false,
});
const timer = setTimeout(() => {
socket.destroy();
reject(new AccountingHttpError('اتصال به سرور حسابداری برقرار نشد (timeout)', false, 'TIMEOUT'));
}, timeoutMs);
socket.once('secureConnect', () => {
clearTimeout(timer);
const peer = socket.getPeerCertificate();
const chainOk = socket.authorized;
socket.end();
if (!peer?.raw) {
reject(new AccountingHttpError('سرور گواهی ارسال نکرد', false, 'NO_CERT'));
return;
}
const cert = new X509Certificate(peer.raw);
const nameOk = net.isIP(host) ? !!cert.checkIP(host) : !!cert.checkHost(host);
const b64 = peer.raw.toString('base64').match(/.{1,64}/g)!.join('\n');
resolve({
pem: `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`,
subject: cert.subject,
issuer: cert.issuer,
subjectAltName: cert.subjectAltName ?? null,
validFrom: cert.validFrom,
validTo: cert.validTo,
expired: new Date(cert.validTo).getTime() < Date.now(),
fingerprint256: cert.fingerprint256,
// checkIssued() also wants keyCertSign, which Tahesab's certificate lacks.
selfSigned: cert.subject === cert.issuer && cert.verify(cert.publicKey),
trustedBySystem: chainOk && nameOk,
});
});
socket.once('error', (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
reject(new AccountingHttpError(err.message, false, err.code));
});
});
}
/**
* Parses a JSON body that may carry junk around it. Tahesab replies have been
* seen with a BOM, raw control characters and unescaped backslashes inside
* strings (henza_server TahesabController::test had to scrub them the same
* way before json_decode would accept the body).
*/
export function parseLooseJson(body: string): unknown {
try {
return JSON.parse(body);
} catch {
// fall through to the scrubbed attempt
}
let raw = body.replace(/^/, '');
raw = raw.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
raw = raw.replace(/\\(?!["\\/bfnrtu])/g, '-');
const start = raw.search(/[{[]/);
if (start > 0) raw = raw.slice(start);
return JSON.parse(raw);
}
@@ -0,0 +1,48 @@
import { Controller, Get, HttpCode, Post } from '@nestjs/common';
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
import { RequirePermission } from '../auth/guards';
import { AccountingAdminService } from './accounting-admin.service';
import { AccountingSyncService } from './accounting-sync.service';
/**
* D27, plan §12 — sync where people work, on top of the «اتصال حسابداری»
* screen: a button on the wholesale-customer list and on the product list,
* and the customer's own balance read live each time the app's profile opens.
* Each is behind the permission of the screen it sits on, and works whenever a
* provider is connected, whatever the feature switches say (owner's ruling).
*/
@Controller()
export class AccountingQuickController {
constructor(
private readonly admin: AccountingAdminService,
private readonly sync: AccountingSyncService,
) {}
/** Whether to show the sync buttons. Any signed-in admin; reveals no settings. */
@Get('admin/accounting-status')
status() {
return this.admin.connectionStatus();
}
@Post('admin/customers-omde/accounting-sync')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
syncCustomers() {
return this.admin.syncNow(['customers', 'balances']);
}
@Post('admin/products/accounting-sync')
@HttpCode(200)
@RequirePermission('product.list')
syncProducts() {
return this.admin.syncNow(['products']);
}
/** The app's profile: this customer's balance, read from the software now. */
@CustomerAuth()
@Get('customer/me/accounting-balance')
myBalance(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.sync.customerBalanceLive(me.id);
}
}
@@ -0,0 +1,27 @@
import { Global, Module } from '@nestjs/common';
import { AccountingAdminController } from './accounting-admin.controller';
import { AccountingAdminService } from './accounting-admin.service';
import { AccountingConfigService } from './accounting-config.service';
import { AccountingHooksService } from './accounting-hooks.service';
import { AccountingQuickController } from './accounting-quick.controller';
import { AccountingSyncService } from './accounting-sync.service';
import { AccountingWorkerService } from './accounting-worker.service';
/**
* Accounting software integration (D27). Global so orders, customer admin,
* product admin, the catalog and the report can call the hooks without each
* re-importing it — the same reason SmsModule is global.
*/
@Global()
@Module({
controllers: [AccountingAdminController, AccountingQuickController],
providers: [
AccountingConfigService,
AccountingHooksService,
AccountingSyncService,
AccountingWorkerService,
AccountingAdminService,
],
exports: [AccountingConfigService, AccountingHooksService, AccountingSyncService],
})
export class AccountingSyncModule {}
@@ -0,0 +1,950 @@
import { Injectable, Logger } from '@nestjs/common';
import { AccountingOutbox, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { ActiveProvider, AccountingConfigService } from './accounting-config.service';
import { AccountingConfig } from './accounting-config';
import { JalaliDate, parseJalali, toJalali } from './jalali';
import {
AccountingProviderError,
CustomerPayload,
DocumentLine,
ExternalCustomer,
ExternalGroup,
ExternalKarSakhte,
ProviderName,
StatementRow,
normalizeMobile,
splitName,
} from './providers';
/** A job that must stop and wait for a human, not be retried. */
export class NeedsReviewError extends Error {
constructor(message: string) {
super(message);
this.name = 'NeedsReviewError';
}
}
const MAX_CUSTOMER_PAGES = 1000;
// Real-time (owner, 2026-10-05): the change poll keeps stored figures a few
// seconds old; these windows only absorb a burst of reads of the same figure.
const STATEMENT_CACHE_MS = 10_000;
const BALANCE_FRESH_MS = 5_000;
const BALANCE_ON_DEMAND_TIMEOUT_MS = 3_000;
const STOCK_ON_DEMAND_TIMEOUT_MS = 2_500;
/** Lowercase alphanumeric, ≥ 20 chars: Tahesab lowercases Factor_Code and dedupes on it. */
export function factorCodeFor(orderId: string) {
return `gbord${orderId}`.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function groupMatches(selected: ExternalGroup[], c: { groupId: string | null; groupName: string | null }) {
if (selected.length === 0) return true;
return selected.some(
(g) => (g.id !== '' && c.groupId !== null && g.id === c.groupId) || (!!g.name && !!c.groupName && g.name === c.groupName),
);
}
/** A real Iranian national code (10 digits, checksum), excluding the all-same placeholders. */
export function validNationalCode(v: string | null): string | null {
if (!v) return null;
const d = v.replace(/\D/g, '');
if (!/^\d{10}$/.test(d) || /^(\d)\1{9}$/.test(d)) return null;
const check = Number(d[9]);
const sum = d
.slice(0, 9)
.split('')
.reduce((s, ch, i) => s + Number(ch) * (10 - i), 0);
const r = sum % 11;
return (r < 2 ? check === r : check === 11 - r) ? d : null;
}
/**
* Everything the accounting integration does (D27, plan
* docs/ACCOUNTING-INTEGRATION-PLAN.md §4): pulls on a schedule, outbox jobs,
* and the reads the customer app makes. Each public pull takes the active
* provider so the worker resolves configuration once per run.
*/
@Injectable()
export class AccountingSyncService {
private readonly logger = new Logger('Accounting');
private readonly statementCache = new Map<string, { at: number; rows: StatementRow[] }>();
private itemCache: { provider: string; at: number; items: ExternalKarSakhte[] } | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly config: AccountingConfigService,
) {}
// ════════════════════════════════════════════════════════════════════
// F1 — customers
// ════════════════════════════════════════════════════════════════════
/**
* Reads every account (in the selected groups) and links or imports it.
* Two passes: all accounts first, so two accounts sharing one mobile are
* caught before either is linked.
*/
async pullCustomers(a: ActiveProvider) {
// No groups must not mean "all": cash boxes, staff and investors are
// accounts too, and would become app customers.
if (a.config.mapping.importCustomerGroups.length === 0) {
throw new Error('هیچ گروه مشتری برای ورود انتخاب نشده است (تب «نگاشت»)');
}
const provider = a.provider.name;
const all: ExternalCustomer[] = [];
let cursor: string | null = null;
for (let page = 0; page < MAX_CUSTOMER_PAGES; page += 1) {
const res = await a.provider.listCustomers(a.settings, cursor);
all.push(...res.items);
if (!res.next) break;
cursor = res.next;
}
const inScope = all.filter((c) => groupMatches(a.config.mapping.importCustomerGroups, c));
const byMobile = new Map<string, ExternalCustomer[]>();
for (const c of inScope) {
if (!c.mobile) continue;
byMobile.set(c.mobile, [...(byMobile.get(c.mobile) ?? []), c]);
}
const existingLinks = await this.prisma.accountingLink.findMany({
where: { provider, entityType: 'CUSTOMER' },
select: { id: true, entityId: true, externalCode: true },
});
const linkByCode = new Map(existingLinks.map((l) => [l.externalCode, l]));
const linkedCustomerIds = new Set(existingLinks.map((l) => l.entityId));
const summary = { scanned: all.length, inScope: inScope.length, refreshed: 0, linked: 0, imported: 0, issues: 0 };
for (const ext of inScope) {
const snapshot = ext as unknown as Prisma.InputJsonValue;
const link = linkByCode.get(ext.code);
if (link) {
await this.prisma.accountingLink.update({
where: { id: link.id },
data: { externalName: ext.name, snapshot, syncedAt: new Date() },
});
summary.refreshed += 1;
continue;
}
if (!ext.mobile) {
await this.issue(provider, 'CUSTOMER_NO_MOBILE', ext.code, `حساب «${ext.name}» (کد ${ext.code}) موبایل معتبر ندارد`, ext);
summary.issues += 1;
continue;
}
const siblings = byMobile.get(ext.mobile) ?? [];
if (siblings.length > 1) {
await this.issue(
provider,
'CUSTOMER_DUPLICATE_MOBILE',
ext.code,
`موبایل ${ext.mobile} در ${siblings.length} حساب حسابداری تکرار شده (کدها: ${siblings.map((s) => s.code).join('، ')})`,
siblings,
);
summary.issues += 1;
continue;
}
const appCustomer = await this.prisma.customer.findUnique({ where: { mobile: ext.mobile }, select: { id: true } });
if (appCustomer) {
if (linkedCustomerIds.has(appCustomer.id)) {
await this.issue(
provider,
'CUSTOMER_LINK_CONFLICT',
ext.code,
`مشتری با موبایل ${ext.mobile} قبلاً به حساب دیگری لینک شده؛ حساب ${ext.code} لینک نشد`,
ext,
appCustomer.id,
);
summary.issues += 1;
continue;
}
await this.prisma.accountingLink.create({
data: { provider, entityType: 'CUSTOMER', entityId: appCustomer.id, externalCode: ext.code, externalName: ext.name, origin: 'LINKED', snapshot },
});
linkedCustomerIds.add(appCustomer.id);
summary.linked += 1;
continue;
}
const created = await this.importCustomer(provider, ext);
linkedCustomerIds.add(created);
summary.imported += 1;
}
return summary;
}
/**
* An account that exists only in the accounting software becomes an app
* customer, keyed by mobile (owner: «اولویت با شماره هست نه کد ملی»). The
* national code is the account's own when it is a real one and still free,
* else none (the column is optional since 2026-10-07 and holds national
* codes only — never the mobile or a made-up key).
* Credit limit 0 and UNVERIFIED: everything beyond identity is the app's.
* OTP-only: an imported account has no app password.
*/
private async importCustomer(provider: ProviderName, ext: ExternalCustomer): Promise<string> {
const mobile = ext.mobile as string;
let nationalCode = validNationalCode(ext.nationalCode);
if (nationalCode) {
const taken = await this.prisma.customer.findUnique({ where: { nationalCode }, select: { id: true } });
if (taken) nationalCode = null;
}
const { firstName, lastName } = splitName(ext.name || mobile);
return this.prisma.$transaction(async (tx) => {
const customer = await tx.customer.create({
data: {
// The DB trigger rebuilds fullName from the parts (schema.prisma).
fullName: [firstName, lastName].filter(Boolean).join(' ') || mobile,
firstName: firstName || null,
lastName: lastName || null,
mobile,
nationalCode,
passwordHash: OTP_ONLY_PASSWORD_HASH,
phone: ext.phone,
address: ext.address,
},
select: { id: true },
});
await tx.accountingLink.create({
data: {
provider,
entityType: 'CUSTOMER',
entityId: customer.id,
externalCode: ext.code,
externalName: ext.name,
origin: 'IMPORTED',
snapshot: ext as unknown as Prisma.InputJsonValue,
},
});
return customer.id;
});
}
private customerPayload(
// nationalCode is nullable since 2026-10-07 (no legacy customer has one —
// docs/DATA-MIGRATION-MAP.md §10.4). validNationalCode() already takes
// `string | null` and maps empty/invalid to null, so the providers below
// keep receiving exactly what they received before.
c: { firstName: string | null; lastName: string | null; fullName: string; mobile: string; phone: string | null; address: string | null; nationalCode: string | null },
group: ExternalGroup | null,
): CustomerPayload {
const parts = c.firstName || c.lastName ? { firstName: c.firstName ?? '', lastName: c.lastName ?? '' } : splitName(c.fullName);
return {
firstName: parts.firstName,
lastName: parts.lastName,
fullName: c.fullName || [parts.firstName, parts.lastName].filter(Boolean).join(' '),
mobile: normalizeMobile(c.mobile) ?? c.mobile,
phone: c.phone,
address: c.address,
nationalCode: validNationalCode(c.nationalCode),
group: { id: group?.id || null, name: group?.name || null },
};
}
/**
* Makes sure the customer has an account in the accounting software and
* returns its code: existing link → update it (when `update`); else search
* by mobile → link; else create → link. Search-before-create is what makes a
* retried push safe on both providers.
*/
async ensureCustomerAccount(a: ActiveProvider, customerId: string, opts: { update: boolean }) {
const provider = a.provider.name;
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NeedsReviewError('مشتری در اپ پیدا نشد');
const link = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider, entityType: 'CUSTOMER', entityId: customerId } },
});
if (link) {
if (opts.update) {
// Keep the account in its current group: Zarrin moves it otherwise.
const snap = (link.snapshot ?? {}) as Partial<ExternalCustomer>;
const group = snap.groupName || snap.groupId ? { id: snap.groupId ?? '', name: snap.groupName ?? '' } : a.config.mapping.newCustomerGroup;
const payload = this.customerPayload(customer, group);
const res = await a.provider.updateCustomer(a.settings, link.externalCode, payload);
await this.prisma.accountingLink.update({ where: { id: link.id }, data: { syncedAt: new Date() } });
return { code: link.externalCode, request: { action: 'update', code: link.externalCode, payload }, response: res.response };
}
return { code: link.externalCode, request: { action: 'none' }, response: null };
}
const mobile = normalizeMobile(customer.mobile);
if (!mobile) throw new NeedsReviewError(`موبایل مشتری (${customer.mobile}) معتبر نیست`);
const matches = await a.provider.findCustomersByMobile(a.settings, mobile);
const free = [];
for (const m of matches) {
const taken = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_externalCode: { provider, entityType: 'CUSTOMER', externalCode: m.code } },
select: { id: true },
});
if (!taken) free.push(m);
}
if (free.length > 1) {
throw new NeedsReviewError(
`موبایل ${mobile} در چند حساب حسابداری هست (کدها: ${free.map((m) => m.code).join('، ')}). از «لینک دستی» یکی را انتخاب کنید.`,
);
}
if (free.length === 1) {
const m = free[0];
await this.prisma.accountingLink.create({
data: { provider, entityType: 'CUSTOMER', entityId: customerId, externalCode: m.code, externalName: m.name, origin: 'LINKED', snapshot: m as unknown as Prisma.InputJsonValue },
});
return { code: m.code, request: { action: 'link', mobile }, response: m };
}
if (!a.config.mapping.newCustomerGroup) {
throw new NeedsReviewError('گروه مشتری جدید در تب «نگاشت» تعیین نشده؛ بعد از تعیین، «تلاش دوباره» را بزنید');
}
const payload = this.customerPayload(customer, a.config.mapping.newCustomerGroup);
const res = await a.provider.createCustomer(a.settings, payload);
await this.prisma.accountingLink.create({
data: {
provider,
entityType: 'CUSTOMER',
entityId: customerId,
externalCode: res.code,
externalName: payload.fullName,
origin: 'PUSHED',
snapshot: { ...payload, groupId: payload.group.id, groupName: payload.group.name } as unknown as Prisma.InputJsonValue,
},
});
return { code: res.code, request: { action: 'create', payload }, response: res.response };
}
// ════════════════════════════════════════════════════════════════════
// F2 — balances
// ════════════════════════════════════════════════════════════════════
async pullBalances(a: ActiveProvider) {
const links = await this.prisma.accountingLink.findMany({
where: { provider: a.provider.name, entityType: 'CUSTOMER' },
select: { entityId: true, externalCode: true },
});
if (links.length === 0) return { linked: 0, updated: 0 };
const balances = await a.provider.getBalances(
a.settings,
links.map((l) => l.externalCode),
);
const byCode = new Map(balances.map((b) => [b.code, b]));
const now = new Date();
let updated = 0;
for (const l of links) {
const b = byCode.get(l.externalCode);
if (!b) continue;
await this.prisma.customer.update({
where: { id: l.entityId },
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: now },
});
updated += 1;
}
return { linked: links.length, updated };
}
/**
* The balance the customer sees (D27): the accounting software's, refreshed
* on demand when older than a minute, with a short timeout so the app never
* waits on a slow installation — on any failure the last stored figure is
* served. Null when the integration does not apply to this customer.
*/
async customerBalance(customerId: string) {
const a = await this.config.activeFor('balances');
if (!a) return null;
const link = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
});
if (!link) return null;
let c = await this.prisma.customer.findUnique({
where: { id: customerId },
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
});
const stale = !c?.accountingBalanceAt || Date.now() - c.accountingBalanceAt.getTime() > BALANCE_FRESH_MS;
if (stale) {
try {
const [b] = await a.provider.getBalances(
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
[link.externalCode],
);
if (b) {
c = await this.prisma.customer.update({
where: { id: customerId },
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: new Date() },
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
});
}
} catch (err) {
this.logger.warn(`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
}
}
if (!c?.accountingBalanceAt) return null;
return {
gold: c.accountingGoldBalance ?? new Prisma.Decimal(0),
rial: c.accountingRialBalance ?? new Prisma.Decimal(0),
at: c.accountingBalanceAt,
provider: a.provider.label,
};
}
// ════════════════════════════════════════════════════════════════════
// Real-time: change poll + live reads
// ════════════════════════════════════════════════════════════════════
/**
* One poll of the software's change feed. Only the accounts and کارساخته
* that changed are re-read, so this is cheap enough to run every few
* seconds. Returns the new cursor and what the slow jobs should still do.
*/
async pollChanges(a: ActiveProvider, cursor: string | null) {
const batch = await a.provider.getChanges(a.settings, cursor);
const out = { cursor: batch.cursor, balances: 0, stocks: 0, fullBalances: false, fullProducts: false, newAccounts: false };
if (cursor === null) return out;
const provider = a.provider.name;
if (a.config.features.balances) {
if (batch.overflow) out.fullBalances = true;
else if (batch.accountCodes.length > 0) {
const links = await this.prisma.accountingLink.findMany({
where: { provider, entityType: 'CUSTOMER', externalCode: { in: batch.accountCodes } },
select: { entityId: true, externalCode: true },
});
out.newAccounts = links.length < batch.accountCodes.length;
if (links.length > 0) out.balances = await this.storeBalances(a, links);
}
}
if (a.config.features.products) {
if (batch.overflow) out.fullProducts = true;
else if (batch.itemNames.length > 0) {
const links = await this.prisma.accountingLink.findMany({
where: { provider, entityType: 'PRODUCT', externalCode: { in: batch.itemNames } },
select: { entityId: true, externalCode: true },
});
// A name with no product yet is a new کارساخته: the product pull imports it.
if (links.length < batch.itemNames.length) out.fullProducts = true;
for (const l of links) {
if (await this.storeStock(a, l.entityId, l.externalCode, l.externalCode)) out.stocks += 1;
}
}
}
return out;
}
/** Reads the balances of these linked customers and stores them. */
private async storeBalances(a: ActiveProvider, links: { entityId: string; externalCode: string }[]) {
const balances = await a.provider.getBalances(a.settings, links.map((l) => l.externalCode));
const byCode = new Map(balances.map((b) => [b.code, b]));
const now = new Date();
let n = 0;
for (const l of links) {
const b = byCode.get(l.externalCode);
if (!b) continue;
await this.prisma.customer.update({
where: { id: l.entityId },
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: now },
});
n += 1;
}
return n;
}
private async storeStock(a: ActiveProvider, productId: string, code: string, name: string, timeoutMs?: number) {
const settings = timeoutMs ? { ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, timeoutMs) } : a.settings;
const stock = await a.provider.getKarSakhteStock(settings, { code, name });
const known = stock.weight !== null || stock.count !== null;
await this.prisma.product.update({
where: { id: productId },
data: {
accountingStockWeight: stock.weight,
accountingStockCount: stock.count,
accountingStockAt: known ? new Date() : null,
},
});
return known;
}
/**
* At order time: the product's stock straight from the accounting software.
* If it cannot answer in time the order goes ahead on the last stored figure
* (owner's ruling) and the next change poll corrects it. Returns the product
* fields stockBlocked() reads, fresh when it could be.
*/
async liveStock<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(
product: T,
): Promise<T> {
const a = await this.config.activeFor('products');
if (!a) return product;
const link = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'PRODUCT', entityId: product.id } },
select: { externalCode: true, externalName: true },
});
if (!link) return product;
try {
await this.storeStock(a, product.id, link.externalCode, link.externalName ?? link.externalCode, STOCK_ON_DEMAND_TIMEOUT_MS);
const fresh = await this.prisma.product.findUnique({
where: { id: product.id },
select: { accountingStockAt: true, accountingStockWeight: true, accountingStockCount: true },
});
return fresh ? { ...product, ...fresh } : product;
} catch (err) {
this.logger.warn(`Live stock of product ${product.id} unavailable, using the stored figure: ${(err as Error).message}`);
return product;
}
}
/**
* The app's profile (plan §12): this one customer's balance, read from the
* accounting software on every call — no freshness window, no feature
* switch, only a connected provider. On no answer the stored figure is
* served and labelled as such.
*/
async customerBalanceLive(customerId: string) {
const none = { source: 'none' as const, gold: null, rial: null, at: null, provider: null };
const a = await this.config.active();
if (!a) return none;
const link = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
select: { externalCode: true },
});
if (!link) return none;
try {
const [b] = await a.provider.getBalances(
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
[link.externalCode],
);
if (b) {
const at = new Date();
await this.prisma.customer.update({
where: { id: customerId },
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: at },
});
return { source: 'accounting' as const, gold: b.goldGrams.toString(), rial: b.rial.toString(), at, provider: a.provider.label };
}
} catch (err) {
this.logger.warn(`Live balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
}
const c = await this.prisma.customer.findUnique({
where: { id: customerId },
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
});
if (!c?.accountingBalanceAt) return none;
return {
source: 'stored' as const,
gold: (c.accountingGoldBalance ?? 0).toString(),
rial: (c.accountingRialBalance ?? 0).toString(),
at: c.accountingBalanceAt,
provider: a.provider.label,
};
}
// ════════════════════════════════════════════════════════════════════
// F6 — statement («گزارش»)
// ════════════════════════════════════════════════════════════════════
/**
* The customer's statement from the accounting software, oldest first, or
* null when the integration does not apply (the report then falls back to
* the internal ledger). Cached for a minute per customer so paging through
* the report costs one call.
*/
async customerStatement(customerId: string, range?: { from?: string; to?: string }) {
const a = await this.config.activeFor('report');
if (!a) return null;
const link = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
});
if (!link) return null;
const to: JalaliDate = parseJalali(range?.to) ?? toJalali(new Date());
const from: JalaliDate =
parseJalali(range?.from) ?? toJalali(new Date(Date.now() - a.config.report.windowDays * 24 * 3600 * 1000));
const key = `${a.provider.name}:${link.externalCode}:${from.jy}-${from.jm}-${from.jd}:${to.jy}-${to.jm}-${to.jd}`;
const hit = this.statementCache.get(key);
if (hit && Date.now() - hit.at < STATEMENT_CACHE_MS) return { rows: hit.rows, code: link.externalCode, provider: a.provider.label };
const rows = await a.provider.getStatement(a.settings, { code: link.externalCode, from, to });
// SEC-16: never hand back a line that belongs to another account, whatever
// the software returned.
const own = rows.filter((r) => !r.accountCode || r.accountCode === link.externalCode);
this.statementCache.set(key, { at: Date.now(), rows: own });
if (this.statementCache.size > 500) this.statementCache.delete(this.statementCache.keys().next().value as string);
return { rows: own, code: link.externalCode, provider: a.provider.label };
}
// ════════════════════════════════════════════════════════════════════
// F4 — کارساخته ↔ products
// ════════════════════════════════════════════════════════════════════
/**
* Accounting items → app products. A new item becomes a product with no
* image and no variant, so it stays out of the app (F5) until an admin
* photographs it and adds its weights. Title, code and stock follow the
* accounting software; everything else is the app's (D27).
*/
async pullProducts(a: ActiveProvider) {
const provider = a.provider.name;
const items = await a.provider.listKarSakhte(a.settings);
const links = await this.prisma.accountingLink.findMany({ where: { provider, entityType: 'PRODUCT' } });
const linkByCode = new Map(links.map((l) => [l.externalCode, l]));
const linkedProductIds = new Set(links.map((l) => l.entityId));
const summary = { items: items.length, updated: 0, linked: 0, imported: 0, stockErrors: 0, issues: 0 };
const now = new Date();
const touched = new Set<string>();
for (const item of items) {
let stock = { weight: item.stockWeight, count: item.stockCount };
if (stock.weight === null && stock.count === null) {
try {
stock = await a.provider.getKarSakhteStock(a.settings, item);
} catch (err) {
summary.stockErrors += 1;
this.logger.warn(`Stock of «${item.name}» unavailable: ${(err as Error).message}`);
}
}
const stockData = {
accountingStockWeight: stock.weight,
accountingStockCount: stock.count,
accountingStockAt: stock.weight !== null || stock.count !== null ? now : null,
};
const snapshot = item as unknown as Prisma.InputJsonValue;
const link = linkByCode.get(item.code);
if (link) {
await this.applyItemToProduct(provider, link.entityId, item, stockData);
await this.prisma.accountingLink.update({
where: { id: link.id },
data: { externalName: item.name, snapshot, syncedAt: now },
});
touched.add(link.entityId);
summary.updated += 1;
continue;
}
// A product an admin created by the same name (in Tahesab an item only
// appears after its first document, which is how it gets linked).
const sameName = await this.prisma.product.findMany({ where: { title: item.name }, select: { id: true } });
const candidates = sameName.filter((p) => !linkedProductIds.has(p.id));
if (candidates.length > 1) {
await this.issue(provider, 'PRODUCT_DUPLICATE_TITLE', item.code, `چند محصول با عنوان «${item.name}» هست؛ کارساخته لینک نشد`, item);
summary.issues += 1;
continue;
}
if (candidates.length === 1) {
const productId = candidates[0].id;
await this.prisma.accountingLink.create({
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: item.code, externalName: item.name, origin: 'LINKED', snapshot },
});
await this.applyItemToProduct(provider, productId, item, stockData);
linkedProductIds.add(productId);
touched.add(productId);
summary.linked += 1;
continue;
}
const productId = await this.prisma.$transaction(async (tx) => {
const product = await tx.product.create({
data: { title: item.name, ...stockData },
select: { id: true },
});
await tx.accountingLink.create({
data: { provider, entityType: 'PRODUCT', entityId: product.id, externalCode: item.code, externalName: item.name, origin: 'IMPORTED', snapshot },
});
return product.id;
});
await this.setProductCode(provider, productId, item);
linkedProductIds.add(productId);
touched.add(productId);
summary.imported += 1;
}
// Stock figures only mean something for products linked under the active
// provider; anything else (unlinked, or from a provider switched away
// from) must not keep blocking orders.
await this.prisma.product.updateMany({
where: { accountingStockAt: { not: null }, id: { notIn: [...touched] } },
data: { accountingStockWeight: null, accountingStockCount: null, accountingStockAt: null },
});
return summary;
}
private async applyItemToProduct(
provider: ProviderName,
productId: string,
item: ExternalKarSakhte,
stockData: Prisma.ProductUpdateInput,
) {
await this.prisma.product.update({ where: { id: productId }, data: { title: item.name, ...stockData } });
await this.setProductCode(provider, productId, item);
}
/**
* Zarrin items have a numeric id, which becomes the product's code. Tahesab
* items have none (the name is the key), so the code stays the app's.
*/
private async setProductCode(provider: ProviderName, productId: string, item: ExternalKarSakhte) {
if (provider !== 'zarrin') return;
const owner = await this.prisma.product.findUnique({ where: { productCode: item.code }, select: { id: true } });
if (owner && owner.id !== productId) {
await this.issue(provider, 'PRODUCT_CODE_TAKEN', item.code, `کد ${item.code} روی محصول دیگری است؛ کد محصول «${item.name}» عوض نشد`, item, productId);
return;
}
if (!owner) await this.prisma.product.update({ where: { id: productId }, data: { productCode: item.code } });
}
/**
* Makes sure a product has its کارساخته: existing link → done; an item by
* the same name → link; else create it (Zarrin). Tahesab cannot create items
* through its API — the item comes into being with the first document under
* the product's title, and the next pull links it by name.
*/
async ensureProductItem(a: ActiveProvider, productId: string): Promise<{ code: string; name: string; created: boolean } | null> {
const provider = a.provider.name;
const link = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider, entityType: 'PRODUCT', entityId: productId } },
});
const product = await this.prisma.product.findUnique({ where: { id: productId }, select: { id: true, title: true, categoryId: true } });
if (!product) throw new NeedsReviewError('محصول در اپ پیدا نشد');
if (link) return { code: link.externalCode, name: link.externalName ?? product.title, created: false };
if (!a.provider.capabilities.createKarSakhte) return null;
const existing = (await this.itemsCached(a)).filter((i) => i.name === product.title);
for (const item of existing) {
const taken = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_externalCode: { provider, entityType: 'PRODUCT', externalCode: item.code } },
select: { id: true },
});
if (!taken) {
await this.prisma.accountingLink.create({
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: item.code, externalName: item.name, origin: 'LINKED', snapshot: item as unknown as Prisma.InputJsonValue },
});
await this.setProductCode(provider, productId, item);
return { code: item.code, name: item.name, created: false };
}
}
const m = a.config.mapping;
const group = (product.categoryId && m.categoryGroups.find((x) => x.categoryId === product.categoryId)?.group) || m.newKarSakhteGroup;
if (!group?.id) throw new NeedsReviewError('گروه ساخته برای این دسته‌بندی/پیش‌فرض در تنظیمات حسابداری تعیین نشده');
if (!m.newKarSakhteType?.id) throw new NeedsReviewError('نوع کارساخته‌ی پیش‌فرض در تنظیمات حسابداری تعیین نشده');
const res = await a.provider.createKarSakhte(a.settings, { name: product.title, groupId: group.id, typeId: m.newKarSakhteType.id });
const item: ExternalKarSakhte = {
code: res.code,
name: product.title,
groupId: group.id,
groupName: group.name,
typeId: m.newKarSakhteType.id,
typeName: m.newKarSakhteType.name,
saleWagePercent: null,
stockWeight: null,
stockCount: null,
};
await this.prisma.accountingLink.create({
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: res.code, externalName: product.title, origin: 'PUSHED', snapshot: item as unknown as Prisma.InputJsonValue },
});
await this.setProductCode(provider, productId, item);
return { code: res.code, name: product.title, created: true };
}
/** Zarrin's listKarSakhte takes seconds; an order with several lines needs it once. */
private async itemsCached(a: ActiveProvider) {
const c = this.itemCache;
if (c && c.provider === a.provider.name && Date.now() - c.at < STATEMENT_CACHE_MS) return c.items;
const items = await a.provider.listKarSakhte(a.settings);
this.itemCache = { provider: a.provider.name, at: Date.now(), items };
return items;
}
// ════════════════════════════════════════════════════════════════════
// F3 — order documents
// ════════════════════════════════════════════════════════════════════
async buildOrderDocument(a: ActiveProvider, orderId: string) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { items: { include: { productVariant: { include: { product: true } } } } },
});
if (!order) throw new NeedsReviewError('سفارش در اپ پیدا نشد');
if (order.items.length === 0) throw new NeedsReviewError('سفارش قلمی ندارد');
const customer = await this.ensureCustomerAccount(a, order.customerId, { update: false });
const lines: DocumentLine[] = [];
let total = new Prisma.Decimal(0);
for (const item of order.items) {
const product = item.productVariant.product;
let ref: { code: string; name: string };
const ensured = await this.ensureProductItem(a, product.id);
if (ensured) ref = ensured;
else {
// Tahesab: the item is identified by name and created by this document.
ref = { code: product.title, name: product.title };
}
const qty = item.quantity;
const wage = new Prisma.Decimal(item.wagePercentSnapshot).sub(item.discountPercentSnapshot);
const weight = new Prisma.Decimal(item.rashWeightGramsSnapshot).mul(qty);
total = total.add(new Prisma.Decimal(item.computedWeightGrams).mul(qty));
lines.push({
itemCode: ref.code,
itemName: ref.name,
weightGrams: weight,
count: qty,
wagePercent: wage,
flatRial: new Prisma.Decimal(item.flatRialCharge).mul(qty),
karat: a.config.mapping.karat,
description: `${product.title} — ${item.productVariant.title} — سفارش ${order.orderNumber} — اجرت ${wage.toString()}٪`,
});
}
// Acceptance rule of F3: the gold the document moves must equal the
// order's own total, or the two books disagree from the first day.
const docGold = lines.reduce(
(s, l) => s.add(l.weightGrams.mul(new Prisma.Decimal(1).add(l.wagePercent.div(100)))),
new Prisma.Decimal(0),
);
const mismatch = docGold.sub(order.totalGoldWeightGrams).abs();
if (mismatch.gt('0.01')) {
throw new NeedsReviewError(
`جمع وزن سند (${docGold.toFixed(3)}) با وزن سفارش (${new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3)}) نمی‌خواند`,
);
}
return {
order,
input: {
factorCode: factorCodeFor(order.id),
customerCode: customer.code,
date: order.deliveredAt ?? new Date(),
description: `سفارش ${order.orderNumber} از اپ`,
temporary: true, // D27: the accountant finalises
lines,
},
check: { orderTotal: new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3), documentGold: docGold.toFixed(3), linesTotal: total.toFixed(3) },
};
}
// ════════════════════════════════════════════════════════════════════
// outbox jobs
// ════════════════════════════════════════════════════════════════════
/** Runs one outbox job. Throws to signal failure; the worker decides retry vs review. */
async runJob(a: ActiveProvider, job: AccountingOutbox): Promise<{ externalRef?: string | null; request?: unknown; response?: unknown }> {
switch (job.kind) {
case 'CUSTOMER_PUSH': {
const res = await this.ensureCustomerAccount(a, job.entityId, { update: true });
return { externalRef: res.code, request: res.request, response: res.response };
}
case 'PRODUCT_PUSH': {
const res = await this.ensureProductItem(a, job.entityId);
return {
externalRef: res?.code ?? null,
request: { productId: job.entityId },
response: res ?? { note: `${a.provider.label} ساخت کارساخته ندارد؛ با اولین سند ساخته می‌شود` },
};
}
case 'ORDER_DOCUMENT': {
const doc = await this.buildOrderDocument(a, job.entityId);
const request = { ...doc.input, lines: doc.input.lines.map((l) => ({ ...l, weightGrams: l.weightGrams.toFixed(3), wagePercent: l.wagePercent.toString(), flatRial: l.flatRial.toFixed(0) })), check: doc.check };
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { lastRequest: request as unknown as Prisma.InputJsonValue } });
const result = await a.provider.postOrderDocument(
a.settings,
doc.input,
(job.progress ?? {}) as Record<string, unknown>,
async (p) => {
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { progress: p as unknown as Prisma.InputJsonValue } });
},
);
return { externalRef: result.ref, request, response: result.response };
}
case 'ORDER_DOCUMENT_DELETE': {
const sent = await this.prisma.accountingOutbox.findFirst({
where: { provider: job.provider, kind: 'ORDER_DOCUMENT', entityId: job.entityId, status: { in: ['SENT', 'SENDING', 'NEEDS_REVIEW', 'FAILED'] } },
orderBy: { createdAt: 'desc' },
});
if (!sent) return { response: { note: 'سندی برای حذف ثبت نشده بود' } };
// The order row may be gone (deleted from the admin panel); the sent
// document's own request says which account it went to.
const order = await this.prisma.order.findUnique({ where: { id: job.entityId }, select: { customerId: true, deliveredAt: true } });
let customerCode = (sent.lastRequest as { customerCode?: string } | null)?.customerCode ?? null;
if (!customerCode && order) {
const link = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: job.provider, entityType: 'CUSTOMER', entityId: order.customerId } },
});
customerCode = link?.externalCode ?? null;
}
if (!customerCode) throw new NeedsReviewError('حساب مشتری در حسابداری معلوم نیست؛ سند را دستی حذف کنید');
const link = { externalCode: customerCode };
const factorCode = factorCodeFor(job.entityId);
// Only a document still temporary may be deleted automatically (plan
// F3). Tahesab reports that per row; Zarrin cannot, so a person checks.
if (job.provider === 'tahesab') {
// The document is dated when it was built (or at delivery, if earlier).
const posted = order?.deliveredAt && order.deliveredAt < sent.createdAt ? order.deliveredAt : sent.createdAt;
const date = toJalali(posted);
const rows = await a.provider.getStatement(a.settings, { code: link.externalCode, from: date, to: toJalali(new Date()) });
const docRows = rows.filter((r) => r.documentRef === factorCode);
if (docRows.some((r) => !r.temporary)) {
throw new NeedsReviewError('سند در حسابداری نهایی شده؛ حذف خودکار انجام نشد. حسابدار باید دستی اصلاح کند.');
}
} else {
throw new NeedsReviewError(
`${a.provider.label} وضعیت موقت/نهایی سند را گزارش نمی‌دهد؛ سند ${sent.externalRef ?? ''} را حسابدار بررسی و حذف کند.`,
);
}
const res = await a.provider.deleteDocument(a.settings, { factorCode, ref: sent.externalRef, customerCode: link.externalCode });
await this.prisma.accountingOutbox.update({
where: { id: sent.id },
data: { status: 'RESOLVED', lastError: 'سند به‌خاطر لغو سفارش حذف شد' },
});
return { externalRef: sent.externalRef, request: { factorCode }, response: res.response };
}
}
}
/**
* Is a job whose call may or may not have landed safe to send again?
* Customer/product pushes search before they create; Tahesab rejects a
* repeated document row; a Zarrin document has no such guard.
*/
isRetrySafe(provider: ProviderName, kind: AccountingOutbox['kind']) {
if (kind === 'ORDER_DOCUMENT') return provider === 'tahesab';
return true;
}
// ════════════════════════════════════════════════════════════════════
async issue(provider: ProviderName, kind: string, externalCode: string, message: string, data?: unknown, entityId?: string) {
const json =
data === undefined || data === null ? Prisma.JsonNull : (JSON.parse(JSON.stringify(data)) as Prisma.InputJsonValue);
await this.prisma.accountingIssue.upsert({
where: { provider_kind_externalCode: { provider, kind, externalCode } },
update: { message, data: json, entityId: entityId ?? null, resolvedAt: null },
create: { provider, kind, externalCode, message, data: json, entityId: entityId ?? null },
});
}
describeError(err: unknown) {
if (err instanceof AccountingProviderError) return err.message;
if (err instanceof NeedsReviewError) return err.message;
return (err as Error)?.message ?? String(err);
}
/** For callers that only need to know the configuration. */
activeConfig(): Promise<AccountingConfig> {
return this.config.get();
}
}
@@ -0,0 +1,128 @@
import { Prisma } from '@prisma/client';
import { formatJalali, jalaliToIsoDate, parseJalali, toIsoDateTehran, toJalali } from './jalali';
import { openSecret, sealSecret } from './secret-box';
import { dec, normalizeMobile, splitName } from './providers/accounting-provider';
import { factorCodeFor, validNationalCode } from './accounting-sync.service';
import { parseLooseJson } from './accounting-http';
import { isAccountingSettingKey, withDefaults } from './accounting-config';
describe('jalali', () => {
it('converts a Tehran calendar day', () => {
// The Tahesab test server reported 1405/07/13 on 2026-10-05.
expect(formatJalali(toJalali(new Date('2026-10-05T08:00:00Z')), '/')).toBe('1405/07/13');
// Nowruz 1404: 2025-03-21 00:30 in Tehran is still 20 March in UTC.
expect(formatJalali(toJalali(new Date('2025-03-20T21:00:00Z')))).toBe('1404-01-01');
});
it('goes back to Gregorian', () => {
expect(jalaliToIsoDate({ jy: 1405, jm: 7, jd: 13 })).toBe('2026-10-05');
expect(jalaliToIsoDate({ jy: 1403, jm: 12, jd: 30 })).toBe('2025-03-20');
});
it('parses the forms the apps and Tahesab use, and rejects impossible days', () => {
expect(parseJalali('1405/7/13')).toEqual({ jy: 1405, jm: 7, jd: 13 });
expect(parseJalali('۱۴۰۵-۰۷-۱۳')).toEqual({ jy: 1405, jm: 7, jd: 13 });
expect(parseJalali('1403/12/30')).not.toBeNull(); // leap year
expect(parseJalali('1404/12/30')).toBeNull(); // common year
expect(parseJalali('1405/07/31')).toBeNull();
expect(parseJalali('2026-10-05x')).toBeNull();
});
it('formats a Tehran ISO day', () => {
expect(toIsoDateTehran(new Date('2026-10-04T21:00:00Z'))).toBe('2026-10-05');
});
});
describe('secret-box', () => {
const saved = { ...process.env };
afterEach(() => {
process.env = { ...saved };
});
it('round-trips and never stores the plain token', () => {
process.env.ACCOUNTING_SECRET_KEY = 'k1';
const sealed = sealSecret('M3Y3D2Y7');
expect(sealed.startsWith('enc:v1:')).toBe(true);
expect(sealed).not.toContain('M3Y3D2Y7');
expect(openSecret(sealed)).toBe('M3Y3D2Y7');
});
it('cannot be opened with another key', () => {
process.env.ACCOUNTING_SECRET_KEY = 'k1';
const sealed = sealSecret('secret');
process.env.ACCOUNTING_SECRET_KEY = 'k2';
expect(openSecret(sealed)).toBeNull();
expect(openSecret('plain-text')).toBeNull();
});
it('falls back to JWT_SECRET when the dedicated key is blank', () => {
process.env.ACCOUNTING_SECRET_KEY = ' ';
process.env.JWT_SECRET = 'jwt';
expect(openSecret(sealSecret('x'))).toBe('x');
});
});
describe('helpers', () => {
it('normalises every mobile form the two programs store', () => {
expect(normalizeMobile('9121234567')).toBe('09121234567');
expect(normalizeMobile('09121234567')).toBe('09121234567');
expect(normalizeMobile('+98 912 123 4567')).toBe('09121234567');
expect(normalizeMobile('00989121234567')).toBe('09121234567');
expect(normalizeMobile('۰۹۱۲۱۲۳۴۵۶۷')).toBe('09121234567');
expect(normalizeMobile('02155662233')).toBeNull();
expect(normalizeMobile('')).toBeNull();
expect(normalizeMobile(null)).toBeNull();
});
it('reads the numbers Tahesab sends as strings, and its literal "null"', () => {
expect(dec('-177.431')?.toString()).toBe('-177.431');
expect(dec('1,234')?.toString()).toBe('1234');
expect(dec('null')).toBeNull();
expect(dec(null)).toBeNull();
expect(dec('abc')).toBeNull();
});
it('accepts only real national codes', () => {
expect(validNationalCode('0499370899')).toBe('0499370899');
expect(validNationalCode('0000000000')).toBeNull(); // seen on the Tahesab test server
expect(validNationalCode('1234567890')).toBeNull();
expect(validNationalCode(null)).toBeNull();
});
it('builds a Tahesab-safe Factor_Code from an order id', () => {
const code = factorCodeFor('cm1abcdefghijklmnopqrstuv');
expect(code).toBe(code.toLowerCase());
expect(code).toMatch(/^[a-z0-9]+$/);
expect(code.length).toBeGreaterThanOrEqual(20);
});
it('splits a name the way the DB trigger does', () => {
expect(splitName('علی رضا محمدی')).toEqual({ firstName: 'علی', lastName: 'رضا محمدی' });
expect(splitName('masoud')).toEqual({ firstName: 'masoud', lastName: '' });
});
it('scrubs the junk Tahesab replies have carried', () => {
expect(parseLooseJson('{"a":"x\\y"}')).toEqual({ a: 'x-y' });
expect(parseLooseJson('garbage [1,2]')).toEqual([1, 2]);
});
it('fences off every accounting Setting key from the const routes', () => {
expect(isAccountingSettingKey('accounting.config')).toBe(true);
expect(isAccountingSettingKey('Accounting.lease')).toBe(true);
expect(isAccountingSettingKey('wallet.config')).toBe(false);
});
it('fills a partial stored config with defaults', () => {
const c = withDefaults({ provider: 'tahesab', features: { balances: true } });
expect(c.provider).toBe('tahesab');
expect(c.features).toEqual({ customers: false, balances: true, report: false, products: false, documents: false });
expect(c.tahesab.dbName).toBe('TahesabDB');
expect(c.mapping.karat).toBe(750);
expect(withDefaults({ provider: 'other' }).provider).toBe('none');
});
it('keeps Decimal maths exact for the wage-as-karat rule', () => {
const w = new Prisma.Decimal(18.5);
expect(new Prisma.Decimal(750).mul(new Prisma.Decimal(1).add(w.div(100))).toString()).toBe('888.75');
});
});
@@ -0,0 +1,375 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { AccountingOutbox, Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { envOr } from '../../common/env';
import { PrismaService } from '../../prisma/prisma.service';
import { ActiveProvider, AccountingConfigService, JobName } from './accounting-config.service';
import { AccountingSyncService, NeedsReviewError } from './accounting-sync.service';
import { AccountingNotSupportedError, AccountingProviderError } from './providers';
/** Scheduled pulls (customers, full balances, full products): the safety net. */
const TICK_MS = 30_000;
/** Outbox and change poll, the real-time path: checked this often. */
const FAST_TICK_MS = 2_000;
/** How often the change feed is polled. */
const CHANGES_POLL_MS = 5_000;
/** Long enough for a full customer import; renewed before every step. */
const LEASE_MS = 10 * 60_000;
const LEASE_KEY = 'accounting.lease';
const OUTBOX_BATCH = 25;
const MAX_ATTEMPTS = 8;
type PullJob = Exclude<JobName, 'outbox'>;
export const PULL_FEATURE: Record<PullJob, 'customers' | 'balances' | 'products'> = {
customers: 'customers',
balances: 'balances',
products: 'products',
};
function toJson(v: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
if (v === undefined || v === null) return Prisma.JsonNull;
return JSON.parse(JSON.stringify(v)) as Prisma.InputJsonValue;
}
/**
* Runs the accounting integration in the background (D27, plan §3.4). Two
* loops: a fast one (every couple of seconds) for the outbox and the change
* feed, the real-time path; and the 30-second one for the scheduled pulls that
* reconcile what the feed cannot see (new customers, edits and deletions,
* overflow).
*
* In-process, no queue dependency. A lease row in the Setting table
* (`accounting.lease`) keeps a second API instance from running the same jobs
* at the same time; the in-memory flag keeps one instance's ticks from
* overlapping. Set ACCOUNTING_WORKER=off to keep an instance out entirely.
*/
@Injectable()
export class AccountingWorkerService implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger('Accounting');
private readonly owner = randomUUID();
private timer: NodeJS.Timeout | null = null;
private running = false;
private fastRunning = false;
private fastTimer: NodeJS.Timeout | null = null;
private kickTimer: NodeJS.Timeout | null = null;
private changes: { provider: string; cursor: string | null } = { provider: '', cursor: null };
private lastChangePollAt = 0;
private lastChangeWarnAt = 0;
private forced = new Set<PullJob>();
private lastForcedCustomersAt = 0;
/** Shown on the admin screen. */
readonly realtime: {
lastPollAt?: string;
lastOkAt?: string;
lastError?: string | null;
lastChangeAt?: string;
lastSummary?: Record<string, unknown>;
} = {};
private recovered = new Set<string>();
constructor(
private readonly prisma: PrismaService,
private readonly config: AccountingConfigService,
private readonly sync: AccountingSyncService,
) {}
onApplicationBootstrap() {
if (envOr('ACCOUNTING_WORKER', 'on') === 'off' || process.env.NODE_ENV === 'test') return;
this.timer = setInterval(() => void this.tick(), TICK_MS);
this.timer.unref?.();
this.fastTimer = setInterval(() => void this.fastTick(), FAST_TICK_MS);
this.fastTimer.unref?.();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
if (this.fastTimer) clearInterval(this.fastTimer);
if (this.kickTimer) clearTimeout(this.kickTimer);
}
/**
* Something was just queued. Hooks run inside the caller's transaction, so
* wake the outbox a moment later, once it has committed, instead of waiting
* for the next tick.
*/
kickSoon() {
if (this.kickTimer || !this.fastTimer) return;
this.kickTimer = setTimeout(() => {
this.kickTimer = null;
void this.fastTick(true);
}, 700);
this.kickTimer.unref?.();
}
/** True when this instance holds (or just took) the lease. */
private async acquireLease(): Promise<boolean> {
const value = JSON.stringify({ owner: this.owner, until: new Date(Date.now() + LEASE_MS).toISOString() });
await this.prisma.$executeRaw`
INSERT INTO "Setting" ("key", "value", "updatedAt") VALUES (${LEASE_KEY}, ${value}::jsonb, now())
ON CONFLICT ("key") DO NOTHING`;
const n = await this.prisma.$executeRaw`
UPDATE "Setting" SET "value" = ${value}::jsonb, "updatedAt" = now()
WHERE "key" = ${LEASE_KEY}
AND ("value"->>'owner' = ${this.owner} OR ("value"->>'until')::timestamptz < now())`;
return n > 0;
}
async tick(only?: JobName): Promise<boolean> {
if (this.running) return false;
this.running = true;
try {
if (!(await this.acquireLease())) return false;
const a = await this.config.active();
if (!a) return false;
if (!this.recovered.has(a.provider.name)) {
await this.recoverInterrupted(a);
this.recovered.add(a.provider.name);
}
if (only === 'outbox') await this.processOutbox(a);
for (const job of ['balances', 'customers', 'products'] as PullJob[]) {
if (only && only !== job) continue;
if (!a.config.features[PULL_FEATURE[job]]) continue;
const wasForced = this.forced.delete(job);
if (!only && !wasForced && !(await this.isDue(job, a))) continue;
await this.acquireLease();
await this.runPull(job, a);
}
return true;
} catch (err) {
this.logger.error(`Accounting tick failed: ${(err as Error).message}`);
return false;
} finally {
this.running = false;
}
}
/**
* The real-time path, every couple of seconds: send queued documents and
* edits at once, and poll the software's change feed so a balance or a stock
* figure changed there is changed here moments later. Never waits behind a
* long scheduled pull (it has its own guard, apart from tick()).
*/
async fastTick(force = false): Promise<void> {
if (this.fastRunning) return;
this.fastRunning = true;
try {
if (!(await this.acquireLease())) return;
const a = await this.config.active();
if (!a) return;
if (!this.recovered.has(a.provider.name)) {
await this.recoverInterrupted(a);
this.recovered.add(a.provider.name);
}
await this.processOutbox(a);
if (!force && Date.now() - this.lastChangePollAt < CHANGES_POLL_MS) return;
if (!a.provider.capabilities.changeFeed || !(a.config.features.balances || a.config.features.products)) return;
this.lastChangePollAt = Date.now();
await this.pollChanges(a);
} catch (err) {
this.logger.error(`Accounting fast tick failed: ${(err as Error).message}`);
} finally {
this.fastRunning = false;
}
}
private async pollChanges(a: ActiveProvider) {
if (this.changes.provider !== a.provider.name) this.changes = { provider: a.provider.name, cursor: null };
this.realtime.lastPollAt = new Date().toISOString();
try {
const baseline = this.changes.cursor === null;
const r = await this.sync.pollChanges(a, this.changes.cursor);
this.changes.cursor = r.cursor;
this.realtime.lastOkAt = new Date().toISOString();
this.realtime.lastError = null;
if (r.balances > 0 || r.stocks > 0) {
this.realtime.lastChangeAt = this.realtime.lastOkAt;
this.realtime.lastSummary = { balances: r.balances, stocks: r.stocks };
}
if (baseline) return;
// What the feed cannot describe goes to the scheduled jobs right away.
if (r.fullBalances) this.forced.add('balances');
if (r.fullProducts) this.forced.add('products');
if (r.newAccounts && a.config.features.customers && Date.now() - this.lastForcedCustomersAt > 60_000) {
this.lastForcedCustomersAt = Date.now();
this.forced.add('customers');
}
if (this.forced.size > 0) void this.tick();
} catch (err) {
const msg = this.sync.describeError(err);
this.realtime.lastError = msg;
if (Date.now() - this.lastChangeWarnAt > 60_000) {
this.lastChangeWarnAt = Date.now();
this.logger.warn(`Accounting change poll failed: ${msg}`);
}
}
}
/**
* A sync button (plan §12): runs these pulls now and waits for them. Needs
* only a connected provider — the feature switches do not apply. Each job
* reports on its own, so one failing does not hide the others.
*/
async runPullsNow(jobs: PullJob[]): Promise<Record<string, { ok: boolean; summary?: unknown; error?: string }> | null> {
if (this.running) return null;
this.running = true;
try {
const a = await this.config.active();
if (!a) return null;
const out: Record<string, { ok: boolean; summary?: unknown; error?: string }> = {};
for (const job of jobs) {
await this.acquireLease();
const startedAt = new Date().toISOString();
await this.config.recordJob(job, { lastRunAt: startedAt });
try {
const summary =
job === 'customers'
? await this.sync.pullCustomers(a)
: job === 'balances'
? await this.sync.pullBalances(a)
: await this.sync.pullProducts(a);
await this.config.recordJob(job, { lastOkAt: new Date().toISOString(), lastError: null, summary });
out[job] = { ok: true, summary };
} catch (err) {
const error = this.sync.describeError(err);
await this.config.recordJob(job, { lastError: error });
out[job] = { ok: false, error };
}
}
return out;
} finally {
this.running = false;
}
}
/** «سینک الان» from the admin screen: runs in the background, returns at once. */
runNow(job: JobName): boolean {
if (job === 'outbox') {
if (this.fastRunning) return false;
void this.fastTick(true);
return true;
}
if (this.running) return false;
void this.tick(job);
return true;
}
get busy() {
return this.running;
}
private async isDue(job: PullJob, a: ActiveProvider) {
const minutes = {
customers: a.config.intervals.customersMinutes,
balances: a.config.intervals.balancesMinutes,
products: a.config.intervals.productsMinutes,
}[job];
const state = await this.config.getState();
const last = state.jobs[job]?.lastRunAt;
return !last || Date.now() - new Date(last).getTime() >= minutes * 60_000;
}
private async runPull(job: PullJob, a: ActiveProvider) {
const startedAt = new Date().toISOString();
await this.config.recordJob(job, { lastRunAt: startedAt });
try {
const summary =
job === 'customers'
? await this.sync.pullCustomers(a)
: job === 'balances'
? await this.sync.pullBalances(a)
: await this.sync.pullProducts(a);
await this.config.recordJob(job, { lastOkAt: new Date().toISOString(), lastError: null, summary });
} catch (err) {
const msg = this.sync.describeError(err);
this.logger.warn(`Accounting ${job} pull failed: ${msg}`);
await this.config.recordJob(job, { lastError: msg });
}
}
/**
* A job left in SENDING means the process stopped mid-call: its outcome is
* unknown. Safe kinds go back to the queue; a Zarrin document goes to a
* person, since sending it again could post it twice.
*/
private async recoverInterrupted(a: ActiveProvider) {
const stuck = await this.prisma.accountingOutbox.findMany({ where: { provider: a.provider.name, status: 'SENDING' } });
for (const job of stuck) {
const safe = this.sync.isRetrySafe(a.provider.name, job.kind);
await this.prisma.accountingOutbox.update({
where: { id: job.id },
data: safe
? { status: 'PENDING', nextAttemptAt: new Date() }
: {
status: 'NEEDS_REVIEW',
lastError: 'سرور هنگام ارسال متوقف شد؛ معلوم نیست سند ثبت شده یا نه. پیش از «تلاش دوباره» در حسابداری بررسی کنید.',
},
});
}
}
private async processOutbox(a: ActiveProvider) {
const jobs = await this.prisma.accountingOutbox.findMany({
where: { provider: a.provider.name, status: 'PENDING', nextAttemptAt: { lte: new Date() } },
orderBy: { createdAt: 'asc' },
take: OUTBOX_BATCH,
});
const startedAt = new Date().toISOString();
let sent = 0;
let failed = 0;
for (const job of jobs) {
await this.acquireLease();
const ok = await this.runOne(a, job);
if (ok) sent += 1;
else failed += 1;
}
if (jobs.length > 0) {
await this.config.recordJob('outbox', { lastRunAt: startedAt, lastOkAt: new Date().toISOString(), summary: { picked: jobs.length, sent, failed } });
}
}
private async runOne(a: ActiveProvider, job: AccountingOutbox): Promise<boolean> {
const attempts = job.attempts + 1;
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { status: 'SENDING', attempts } });
const fresh = { ...job, attempts };
try {
const res = await this.sync.runJob(a, fresh);
await this.prisma.accountingOutbox.update({
where: { id: job.id },
data: {
status: 'SENT',
sentAt: new Date(),
externalRef: res.externalRef ?? null,
lastRequest: toJson(res.request),
lastResponse: toJson(res.response),
lastError: null,
},
});
return true;
} catch (err) {
const msg = this.sync.describeError(err);
const response = err instanceof AccountingProviderError ? err.response : undefined;
let data: Prisma.AccountingOutboxUpdateInput;
if (err instanceof NeedsReviewError || err instanceof AccountingNotSupportedError) {
data = { status: 'NEEDS_REVIEW', lastError: msg };
} else if (err instanceof AccountingProviderError && err.ambiguous && !this.sync.isRetrySafe(a.provider.name, job.kind)) {
data = {
status: 'NEEDS_REVIEW',
lastError: `${msg} — ممکن است سند ثبت شده باشد؛ پیش از «تلاش دوباره» در حسابداری بررسی کنید.`,
};
} else if (attempts >= MAX_ATTEMPTS) {
data = { status: 'FAILED', lastError: msg };
} else {
const delayMin = Math.min(2 ** (attempts - 1), 60);
data = { status: 'PENDING', lastError: msg, nextAttemptAt: new Date(Date.now() + delayMin * 60_000) };
}
if (response !== undefined) data.lastResponse = toJson(response);
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data });
this.logger.warn(`Accounting job ${job.kind} ${job.entityId} → ${String(data.status)}: ${msg}`);
return false;
}
}
}
@@ -0,0 +1,152 @@
/**
* Jalali (Shamsi) ⇄ Gregorian conversion.
*
* Tahesab wants a document date as separate Shamsi year/month/day integers and
* its list filters as `1405-04-24`; the customer app sends report ranges as
* `1405/04/24`; Zarrin wants Gregorian `2024-10-12` on most calls. One small
* converter covers all three without a dependency.
*
* The arithmetic is the well-known jalaali-js algorithm (Borkowski's leap-year
* break table), valid for Jalali years -61 … 3177.
*/
export interface JalaliDate {
jy: number;
jm: number;
jd: number;
}
const BREAKS = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456,
3178,
];
function div(a: number, b: number) {
return ~~(a / b);
}
function mod(a: number, b: number) {
return a - ~~(a / b) * b;
}
function jalCal(jy: number) {
const gy = jy + 621;
let leapJ = -14;
let jp = BREAKS[0];
let jump = 0;
if (jy < jp || jy >= BREAKS[BREAKS.length - 1]) throw new RangeError(`Invalid Jalali year ${jy}`);
for (let i = 1; i < BREAKS.length; i += 1) {
const jm = BREAKS[i];
jump = jm - jp;
if (jy < jm) break;
leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
jp = jm;
}
let n = jy - jp;
leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
const march = 20 + leapJ - leapG;
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
let leap = mod(mod(n + 1, 33) - 1, 4);
if (leap === -1) leap = 4;
return { leap, gy, march };
}
function g2d(gy: number, gm: number, gd: number) {
let d =
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + div(153 * mod(gm + 9, 12) + 2, 5) + gd - 34840408;
d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
return d;
}
function d2g(jdn: number) {
let j = 4 * jdn + 139361631;
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
const i = div(mod(j, 1461), 4) * 5 + 308;
const gd = div(mod(i, 153), 5) + 1;
const gm = mod(div(i, 153), 12) + 1;
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
return { gy, gm, gd };
}
function j2d(jy: number, jm: number, jd: number) {
const r = jalCal(jy);
return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
}
function d2j(jdn: number): JalaliDate {
const gy = d2g(jdn).gy;
let jy = gy - 621;
const r = jalCal(jy);
const jdn1f = g2d(gy, 3, r.march);
let k = jdn - jdn1f;
if (k >= 0) {
if (k <= 185) return { jy, jm: 1 + div(k, 31), jd: mod(k, 31) + 1 };
k -= 186;
} else {
jy -= 1;
k += 179;
if (r.leap === 1) k += 1;
}
return { jy, jm: 7 + div(k, 30), jd: mod(k, 30) + 1 };
}
/** Iran has had no DST since 2022, so a fixed +03:30 gives the local calendar day. */
const TEHRAN_OFFSET_MS = (3 * 60 + 30) * 60 * 1000;
/** The Jalali calendar day of an instant, as seen in Tehran. */
export function toJalali(date: Date): JalaliDate {
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
return d2j(g2d(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate()));
}
/** Gregorian calendar day for a Jalali day: `{ gy, gm, gd }`. */
export function jalaliToGregorian(jy: number, jm: number, jd: number) {
return d2g(j2d(jy, jm, jd));
}
/**
* Parses `1405/04/24`, `1405-4-24` or Persian digits. Returns null for anything
* that is not a real Jalali date.
*/
export function parseJalali(input: string | null | undefined): JalaliDate | null {
if (!input) return null;
const ascii = String(input)
.trim()
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
const m = ascii.match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/);
if (!m) return null;
const jy = Number(m[1]);
const jm = Number(m[2]);
const jd = Number(m[3]);
if (jm < 1 || jm > 12 || jd < 1 || jd > 31 || (jm > 6 && jd > 30)) return null;
try {
// Round-trip rejects 30 Esfand in a common year.
const back = d2j(j2d(jy, jm, jd));
return back.jy === jy && back.jm === jm && back.jd === jd ? { jy, jm, jd } : null;
} catch {
return null;
}
}
const pad = (n: number) => String(n).padStart(2, '0');
/** `1405-04-24` (or with another separator). */
export function formatJalali(d: JalaliDate, sep = '-') {
return `${d.jy}${sep}${pad(d.jm)}${sep}${pad(d.jd)}`;
}
/** `2026-07-15` for a Jalali day. */
export function jalaliToIsoDate(d: JalaliDate) {
const g = jalaliToGregorian(d.jy, d.jm, d.jd);
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}`;
}
/** `2026-07-15` for an instant, Tehran calendar day. */
export function toIsoDateTehran(date: Date) {
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
return `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`;
}
@@ -0,0 +1,310 @@
import { Prisma } from '@prisma/client';
import { JalaliDate } from '../jalali';
/**
* One accounting software (D27). AccountingSyncService picks a driver by the
* admin's switch and owns everything around the call — links, outbox, retries,
* logging — so a driver only translates between the app's shapes and its
* software's API.
*
* Every figure a driver returns is ALREADY in the app's convention:
* * gold in grams of 750, positive balance = the customer owes;
* * rial in rial, positive balance = the customer owes.
* Each software's own sign and unit quirks stay inside its driver.
*/
export type ProviderName = 'tahesab' | 'zarrin';
/** Decrypted connection settings, handed to every call. */
export interface ProviderSettings {
baseUrl: string;
token: string;
caCert: string | null;
timeoutMs: number;
/** Tahesab only: the `DBname` header (`TahesabDB` for SQL Server, `DB` for Access). */
dbName?: string;
/** Zarrin only, until verified on a real installation (plan §9). */
balanceOwesWhen?: 'negative' | 'positive';
}
export interface ExternalGroup {
id: string;
name: string;
}
export interface ExternalCustomer {
code: string;
name: string;
/** Normalised to 09xxxxxxxxx, or null when the account has no usable mobile. */
mobile: string | null;
phone: string | null;
nationalCode: string | null;
address: string | null;
groupId: string | null;
groupName: string | null;
}
export interface CustomerPayload {
firstName: string;
lastName: string;
fullName: string;
mobile: string;
phone: string | null;
address: string | null;
nationalCode: string | null;
group: { id: string | null; name: string | null };
}
export interface ExternalBalance {
code: string;
goldGrams: Prisma.Decimal;
rial: Prisma.Decimal;
}
export interface StatementQuery {
code: string;
from: JalaliDate;
to: JalaliDate;
}
/** One line of a customer's statement, normalised across providers. */
export interface StatementRow {
/** Stable within the provider (Tahesab ID + Radif, Zarrin idRadif). */
key: string;
/** The document the line belongs to — what a detail lookup is keyed on. */
documentRef: string;
/** The account the line belongs to, for the SEC-16 ownership check. */
accountCode: string;
documentNo: string;
/** Jalali `1405/07/12`. */
date: string;
time: string | null;
title: string;
description: string | null;
itemName: string | null;
weight: Prisma.Decimal | null;
karat: number | null;
count: Prisma.Decimal | null;
unitPrice: Prisma.Decimal | null;
wagePercent: Prisma.Decimal | null;
/** 750-equivalent gold received from the customer (reduces what they owe). */
goldIn: Prisma.Decimal;
goldOut: Prisma.Decimal;
rialIn: Prisma.Decimal;
rialOut: Prisma.Decimal;
/** Running balance after this line, when the software reports one. */
goldBalance: Prisma.Decimal | null;
rialBalance: Prisma.Decimal | null;
metal: string | null;
temporary: boolean;
}
export interface ExternalKarSakhte {
/** Zarrin idKar; for Tahesab, which has no item ids, the item name itself. */
code: string;
name: string;
groupId: string | null;
groupName: string | null;
typeId: string | null;
typeName: string | null;
saleWagePercent: Prisma.Decimal | null;
stockWeight: Prisma.Decimal | null;
stockCount: Prisma.Decimal | null;
}
export interface KarSakhteStock {
weight: Prisma.Decimal | null;
count: Prisma.Decimal | null;
}
export interface MovementRow {
itemCode: string | null;
itemName: string;
date: string;
description: string | null;
weight: Prisma.Decimal | null;
amount: Prisma.Decimal | null;
count: Prisma.Decimal | null;
}
export interface DocumentLine {
/** The linked کارساخته: Zarrin idKar / Tahesab Name_Kar. */
itemCode: string;
itemName: string;
/** Physical (raw) weight of the line, all pieces. */
weightGrams: Prisma.Decimal;
count: number;
/** Effective making charge: variant wage minus discount, in percent. */
wagePercent: Prisma.Decimal;
/** Flat rial charge of the line (stones), all pieces. */
flatRial: Prisma.Decimal;
karat: number;
description: string;
}
export interface OrderDocumentInput {
/** Deterministic per order; Tahesab dedupes on it (≥ 20 chars, lowercase). */
factorCode: string;
customerCode: string;
date: Date;
description: string;
temporary: boolean;
lines: DocumentLine[];
}
/** Resumable state of a multi-call document (Zarrin posts one call per line). */
export interface DocumentProgress {
factorId?: string;
doneLines?: number;
}
export interface DocumentPostResult {
ref: string;
progress: DocumentProgress;
response: unknown;
}
/** What changed in the accounting software since the last poll (real-time sync). */
export interface ChangeBatch {
/** Opaque; hand it back on the next poll. */
cursor: string;
/** Accounts that got a new or edited document. */
accountCodes: string[];
/** کارساخته names that got a new or edited document. */
itemNames: string[];
/** More changed than one poll can see: the caller must do a full refresh. */
overflow: boolean;
}
export interface ProviderCapabilities {
/** Can a کارساخته be created through the API? (Zarrin yes, Tahesab no.) */
createKarSakhte: boolean;
/** Can a posted document be looked up by our own key before a retry? */
documentInquiry: boolean;
/** Does the movement report cover every account, or only recent documents? */
movementScope: 'all' | 'recent-documents';
/** Can recent changes be polled cheaply (see getChanges)? */
changeFeed: boolean;
}
export interface AccountingProvider {
readonly name: ProviderName;
readonly label: string;
readonly capabilities: ProviderCapabilities;
health(s: ProviderSettings): Promise<Record<string, unknown>>;
listCustomerGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
/** One page of accounts; `next` is null after the last page. */
listCustomers(s: ProviderSettings, cursor: string | null): Promise<{ items: ExternalCustomer[]; next: string | null }>;
findCustomersByMobile(s: ProviderSettings, mobile: string): Promise<ExternalCustomer[]>;
createCustomer(s: ProviderSettings, p: CustomerPayload): Promise<{ code: string; response: unknown }>;
updateCustomer(s: ProviderSettings, code: string, p: CustomerPayload): Promise<{ response: unknown }>;
getBalances(s: ProviderSettings, codes: string[]): Promise<ExternalBalance[]>;
/**
* Documents added or edited since `cursor` (null = first poll: only returns
* the starting cursor). Cheap enough to call every few seconds.
*/
getChanges(s: ProviderSettings, cursor: string | null): Promise<ChangeBatch>;
getStatement(s: ProviderSettings, q: StatementQuery): Promise<StatementRow[]>;
listKarSakhteGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
listKarSakhteTypes(s: ProviderSettings): Promise<ExternalGroup[]>;
listKarSakhte(s: ProviderSettings): Promise<ExternalKarSakhte[]>;
getKarSakhteStock(s: ProviderSettings, item: { code: string; name: string }): Promise<KarSakhteStock>;
getKarSakhteMovement(s: ProviderSettings, from: JalaliDate, to: JalaliDate): Promise<MovementRow[]>;
createKarSakhte(
s: ProviderSettings,
item: { name: string; groupId: string | null; typeId: string | null },
): Promise<{ code: string; response: unknown }>;
postOrderDocument(
s: ProviderSettings,
doc: OrderDocumentInput,
progress: DocumentProgress,
onProgress: (p: DocumentProgress) => Promise<void>,
): Promise<DocumentPostResult>;
/** True when a document with this key exists. */
documentExists(s: ProviderSettings, doc: { factorCode: string; ref: string | null }): Promise<boolean>;
deleteDocument(
s: ProviderSettings,
doc: { factorCode: string; ref: string | null; customerCode: string },
): Promise<{ response: unknown }>;
}
/**
* A call the software answered with an error, or could not be made.
* `ambiguous` = the software may have acted on it anyway (timeout after send).
*/
export class AccountingProviderError extends Error {
constructor(
message: string,
readonly ambiguous = false,
readonly response?: unknown,
readonly notFound = false,
) {
super(message);
this.name = 'AccountingProviderError';
}
}
/** Not offered by this software's API. */
export class AccountingNotSupportedError extends AccountingProviderError {
constructor(what: string) {
super(`${what} در API این نرم‌افزار وجود ندارد`);
this.name = 'AccountingNotSupportedError';
}
}
// ── shared helpers ─────────────────────────────────────────────────────
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
export function asciiDigits(v: string) {
return v
.replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d)));
}
/**
* `9121234567`, `09121234567`, `+989121234567`, `00989121234567`, Persian
* digits → `09121234567`. Anything that is not an Iranian mobile → null.
*/
export function normalizeMobile(v: unknown): string | null {
if (v === null || v === undefined) return null;
let d = asciiDigits(String(v)).replace(/\D/g, '');
if (d.startsWith('0098')) d = d.slice(4);
else if (d.startsWith('98') && d.length === 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** A Prisma.Decimal from a value the software sent, or null for "null"/blank/NaN. */
export function dec(v: unknown): Prisma.Decimal | null {
if (v === null || v === undefined) return null;
const s = asciiDigits(String(v)).replace(/,/g, '').trim();
if (s === '' || s.toLowerCase() === 'null' || s.toLowerCase() === 'nan') return null;
try {
const d = new Prisma.Decimal(s);
return d.isFinite() ? d : null;
} catch {
return null;
}
}
export const ZERO = new Prisma.Decimal(0);
export function str(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v).trim();
return s === '' || s.toLowerCase() === 'null' ? null : s;
}
/** Split a full name into first/last the way the app's DB trigger does. */
export function splitName(full: string): { firstName: string; lastName: string } {
const parts = full.trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return { firstName: parts[0] ?? '', lastName: '' };
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
}
@@ -0,0 +1,18 @@
import { AccountingProvider, ProviderName } from './accounting-provider';
import { tahesabProvider } from './tahesab.provider';
import { zarrinProvider } from './zarrin.provider';
/**
* Every accounting software, keyed by the admin switch's value. A new one is a
* driver file plus one line here — the same shape as the SMS drivers (D26).
*/
export const ACCOUNTING_PROVIDERS: Readonly<Record<ProviderName, AccountingProvider>> = {
tahesab: tahesabProvider,
zarrin: zarrinProvider,
};
export function isProviderName(v: unknown): v is ProviderName {
return typeof v === 'string' && Object.prototype.hasOwnProperty.call(ACCOUNTING_PROVIDERS, v);
}
export * from './accounting-provider';
@@ -0,0 +1,298 @@
import { Prisma } from '@prisma/client';
import { httpRequest } from '../accounting-http';
import { AccountingProviderError, OrderDocumentInput, ProviderSettings } from './accounting-provider';
import { karatWithWage, tahesabProvider as t } from './tahesab.provider';
jest.mock('../accounting-http', () => ({
...jest.requireActual('../accounting-http'),
httpRequest: jest.fn(),
}));
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
const s: ProviderSettings = { baseUrl: 'https://tahesab.local:8081/', token: 'TOK', caCert: null, timeoutMs: 5000, dbName: 'TahesabDB' };
const reply = (status: number, body: unknown) =>
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
/** The body of the n-th request, parsed. */
const sent = (n = 0) => JSON.parse(http.mock.calls[n][0].body as string);
beforeEach(() => http.mockReset());
describe('wire format', () => {
it('posts {"Method": [args]} to the root with the bearer token and DBname', async () => {
http.mockReturnValueOnce(reply(200, [{ Name: 'بنکدار', GID: 2 }]));
const groups = await t.listCustomerGroups(s);
const req = http.mock.calls[0][0];
expect(req.method).toBe('POST');
expect(req.url).toBe('https://tahesab.local:8081/');
expect(req.headers).toMatchObject({ Authorization: 'Bearer TOK', DBname: 'TahesabDB' });
expect(sent()).toEqual({ DoListGorooh: [] });
// GID 0 (accounts in no group) is offered too.
expect(groups).toEqual([
{ id: '2', name: 'بنکدار' },
{ id: '0', name: 'بدون گروه (GID 0)' },
]);
});
it('turns {"ERROR": …} into a provider error, flagging "not found"', async () => {
http.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.', Command: 'POST', SSL: '1' }));
await expect(t.updateCustomer(s, '5', { firstName: 'a', lastName: 'b', fullName: 'a b', mobile: '09120000000', phone: null, address: null, nationalCode: null, group: { id: null, name: null } })).rejects.toMatchObject({
notFound: true,
response: { ERROR: 'هیچ رکوردی یافت نشد.' },
});
});
it('reports a timeout after the request went out as ambiguous', async () => {
const { AccountingHttpError } = jest.requireActual('../accounting-http');
http.mockRejectedValueOnce(new AccountingHttpError('timeout', true, 'TIMEOUT'));
await expect(t.listCustomerGroups(s)).rejects.toMatchObject({ ambiguous: true });
});
});
describe('customers', () => {
it('pages through code windows and stops after empty ones', async () => {
http.mockReturnValueOnce(
reply(200, {
'1': { Code: 1, Name: 'masoud', GID: '2', GoroupName: 'بنکدار', Mobile: '9121234567', CodeMelli: '', Address: 'آدرس' },
}),
);
const page1 = await t.listCustomers(s, null);
expect(sent()).toEqual({ Dolistmoshtari: [1, 500] });
expect(page1.items[0]).toMatchObject({ code: '1', mobile: '09121234567', groupId: '2', nationalCode: null });
expect(page1.next).toBe('501:0');
http.mockReturnValue(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
let cursor: string | null = page1.next;
let calls = 0;
while (cursor) {
cursor = (await t.listCustomers(s, cursor)).next;
calls += 1;
}
expect(calls).toBe(4);
});
it('looks a mobile up in both stored forms and keeps exact matches only', async () => {
http
.mockReturnValueOnce(reply(200, { '1': { Code: 1, Name: 'a', Mobile: '9121234567' }, '790': { Code: 790, Name: 'b', Mobile: '9121234567' } }))
.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
const found = await t.findCustomersByMobile(s, '09121234567');
expect(sent(0)).toEqual({ DoListMoshtari: [9121234567] });
expect(sent(1)).toEqual({ DoListMoshtari: ['09121234567'] });
expect(found.map((c) => c.code)).toEqual(['1', '790']);
});
it('creates with an auto code and returns it', async () => {
http.mockReturnValueOnce(reply(200, { OK: 35 }));
const res = await t.createCustomer(s, {
firstName: 'علی',
lastName: 'محمدی',
fullName: 'علی محمدی',
mobile: '09121112233',
phone: null,
address: null,
nationalCode: null,
group: { id: '13', name: 'مشتري' },
});
expect(res.code).toBe('35');
const args = sent().DoNewMoshtari;
expect(args[0]).toBe('علی محمدی');
expect(args[1]).toBe('مشتري');
expect(args[2]).toBe('09121112233');
expect(args[8]).toBe(-1);
});
});
describe('balances and statement', () => {
it('flips the sign: in Tahesab negative means the customer owes', async () => {
http.mockReturnValueOnce(
reply(200, { MandeHesab: [{ Code: '1', MandeyeMali: '-238999230', MandeyeVazni: '-177.431' }, { Code: '2', MandeyeMali: '5', MandeyeVazni: '1.5' }] }),
);
const b = await t.getBalances(s, ['1', '2', '999']);
expect(sent()).toEqual({ getmandehesabbycode: ['1', '2', '999'] });
expect(b).toEqual([
{ code: '1', goldGrams: new Prisma.Decimal('177.431'), rial: new Prisma.Decimal('238999230') },
{ code: '2', goldGrams: new Prisma.Decimal('-1.5'), rial: new Prisma.Decimal('-5') },
]);
});
it('maps DoListAsnad rows (real test-server rows)', async () => {
http.mockReturnValueOnce(
reply(200, {
'11558': {
ID: 11558, Radif: 1, Factor_Code: '123456789123421578191111985', MCode: 1, Tarikh: '1405/07/11', Tarikh_Time: '19:47:44',
NO: 'ورود کارساخته', Sh_Factor: 228, Vazn: 1.25, No_Kar: 'testmm', Ayar: 750, Fi: 10000, Mazaneh: 'null', Count: 1,
TabdilVazn: 1.25, Mali: 100000, Sharh1: 'sharhtest', TahesabVazni: 11526.174, TahesabMali: -209358396276,
Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: false, DarsadSood: 5.5,
},
'12261': {
ID: 12261, Radif: 1, Factor_Code: 'GBORDX', MCode: 1, Tarikh: '1405/07/12', NO: 'خروج کارساخته', Vazn: 2,
TabdilVazn: -2, Mali: 'null', TahesabVazni: 0, TahesabMali: 0, Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: true,
},
}),
);
const rows = await t.getStatement(s, { code: '1', from: { jy: 1405, jm: 6, jd: 1 }, to: { jy: 1405, jm: 7, jd: 13 } });
expect(sent()).toEqual({ DoListAsnad: [-1, 1, '1405-06-01', '1405-07-13', '', 0, 'IRR'] });
expect(rows[0]).toMatchObject({
documentRef: '123456789123421578191111985',
accountCode: '1',
title: 'ورود کارساخته',
itemName: 'testmm',
metal: 'طلا',
temporary: false,
});
expect(rows[0].goldIn.toString()).toBe('1.25');
expect(rows[0].goldOut.toString()).toBe('0');
expect(rows[0].rialIn.toString()).toBe('100000');
expect(rows[0].goldBalance?.toString()).toBe('-11526.174');
expect(rows[0].rialBalance?.toString()).toBe('209358396276');
// A temporary row moves nothing yet: no balance of its own.
expect(rows[1]).toMatchObject({ documentRef: 'gbordx', temporary: true, goldBalance: null });
expect(rows[1].goldOut.toString()).toBe('2');
});
});
describe('order documents', () => {
const doc: OrderDocumentInput = {
factorCode: 'gbordcm1abcdefghijklmnopqrstuv',
customerCode: '2',
date: new Date('2026-10-05T08:00:00Z'),
description: 'سفارش 1001 از اپ',
temporary: true,
lines: [
{ itemCode: 'انگشتر', itemName: 'انگشتر', weightGrams: new Prisma.Decimal('4.200'), count: 2, wagePercent: new Prisma.Decimal(10), flatRial: new Prisma.Decimal(0), karat: 750, description: 'line 1' },
{ itemCode: 'النگو', itemName: 'النگو', weightGrams: new Prisma.Decimal('10'), count: 1, wagePercent: new Prisma.Decimal('18.5'), flatRial: new Prisma.Decimal(500000), karat: 750, description: 'line 2' },
],
};
it('raises the karat by the making charge — the only field that moves the gold balance', () => {
expect(karatWithWage(750, new Prisma.Decimal(10)).toString()).toBe('825');
expect(karatWithWage(750, new Prisma.Decimal('18.5')).toString()).toBe('888.75');
});
it('posts one temporary exit row per line under one Factor_Code', async () => {
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
const progress: unknown[] = [];
const res = await t.postOrderDocument(s, doc, {}, async (p) => {
progress.push(p);
});
const r1 = sent(0).DoNewSanadVKHKar;
const r2 = sent(1).DoNewSanadVKHKar;
expect(r1).toHaveLength(27);
expect(r1[0]).toBe(0); // temporary
expect(r1[1]).toBe(2); // Moshtari_Code
expect([r1[3], r2[3]]).toEqual([1, 2]); // Radif_Number
expect([r1[4], r1[5], r1[6]]).toEqual([1405, 7, 13]);
expect(r1[7]).toBe(4.2); // physical weight
expect(r1[8]).toBe(825); // karat with the charge
expect(r1[9]).toBe(2);
expect(r1[10]).toBe('انگشتر');
expect(r1[11]).toBe(1); // exit
expect(r1[16]).toBe(0); // Darsad_Talayee never double-counts
expect(r2[13]).toBe(500000); // stones in MablaghKol
expect(r2[20]).toBe(0); // not PoolSang
expect(r1[22]).toBe(doc.factorCode);
expect(r1[24]).toBe(1); // MultiRadif
expect(progress).toEqual([{ doneLines: 1 }, { doneLines: 2 }]);
expect(res.ref).toBe(doc.factorCode);
});
it('resumes after the last confirmed line', async () => {
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
await t.postOrderDocument(s, doc, { doneLines: 1 }, async () => undefined);
expect(http).toHaveBeenCalledTimes(1);
expect(sent(0).DoNewSanadVKHKar[3]).toBe(2);
});
it('treats a duplicate-row rejection as "already there", which makes a retry safe', async () => {
http
.mockReturnValueOnce(
reply(400, {
ERROR:
"[EUniError] The statement has been terminated.\r\nCannot insert duplicate key row in object 'dbo.hesab_detail' with unique index 'UX_hesab_detail_UniqRowID'.",
}),
)
.mockReturnValueOnce(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
const res = await t.postOrderDocument(s, doc, {}, async () => undefined);
expect(res.progress).toEqual({ doneLines: 2 });
});
it('does not swallow other errors', async () => {
http.mockReturnValueOnce(reply(400, { ERROR: 'کد حساب نامعتبر است' }));
await expect(t.postOrderDocument(s, doc, {}, async () => undefined)).rejects.toBeInstanceOf(AccountingProviderError);
});
it('inquires and deletes by Factor_Code', async () => {
http.mockReturnValueOnce(reply(200, { Found: doc.factorCode })).mockReturnValueOnce(reply(404, { ERROR: 'Not_Found' }));
expect(await t.documentExists(s, { factorCode: doc.factorCode, ref: null })).toBe(true);
expect(await t.documentExists(s, { factorCode: 'x', ref: null })).toBe(false);
http.mockReturnValueOnce(reply(404, { ERROR: 'سند یافت نشد.' }));
expect((await t.deleteDocument(s, { factorCode: 'x', ref: null, customerCode: '2' })).response).toEqual({ alreadyDeleted: true });
});
});
describe('items', () => {
it('skips item names that lost their encoding and reads stock per name', async () => {
http.mockReturnValueOnce(reply(200, [{ Name: 'انگشتر', Ayar: 750 }, { Name: '???????0.400????' }]));
const items = await t.listKarSakhte(s);
expect(items.map((i) => i.name)).toEqual(['انگشتر']);
http.mockReturnValueOnce(reply(200, { Name: 'انگشتر', VaznMojood: 12.5, TedadMojood: 3, MaxAyar: 750 }));
const stock = await t.getKarSakhteStock(s, { code: 'انگشتر', name: 'انگشتر' });
expect(sent(1)).toEqual({ GetMojoodiKarSakhte: ['انگشتر', 0] });
expect(stock.weight?.toString()).toBe('12.5');
expect(stock.count?.toString()).toBe('3');
});
it('cannot create items through the API', async () => {
await expect(t.createKarSakhte(s, { name: 'x', groupId: null, typeId: null })).rejects.toThrow('وجود ندارد');
});
});
describe('change feed (real-time sync)', () => {
const row = (ID: number, MCode: number, extra: Record<string, unknown> = {}) => ({
ID,
MCode,
NO: 'فروش طلا',
No_Kar: 'null',
ZamanEdit: null,
...extra,
});
const feed = (rows: ReturnType<typeof row>[]) => reply(200, Object.fromEntries(rows.map((r) => [String(r.ID), r])));
it('the first poll only returns the starting cursor', async () => {
http.mockReturnValueOnce(feed([row(10, 1), row(11, 2)]));
expect(await t.getChanges(s, null)).toEqual({ cursor: '11|', accountCodes: [], itemNames: [], overflow: false });
expect(sent()).toEqual({ DoListUpdatedAsnad: [200] });
});
it('later polls report the accounts and کارساخته of new documents only', async () => {
http.mockReturnValueOnce(
feed([row(11, 2), row(12, 7), row(13, 7), row(14, 9, { NO: 'خروج کارساخته', No_Kar: 'انگشتر' })]),
);
const r = await t.getChanges(s, '11|');
expect(r.accountCodes.sort()).toEqual(['7', '9']);
expect(r.itemNames).toEqual(['انگشتر']);
expect(r.cursor).toBe('14|');
expect(r.overflow).toBe(false);
});
it('sees an edit of an old document through ZamanEdit', async () => {
http.mockReturnValueOnce(feed([row(11, 2, { ZamanEdit: '1405/07/12 18:00:00' }), row(12, 3)]));
const r = await t.getChanges(s, '12|1405/07/12 17:00:00');
expect(r.accountCodes).toEqual(['2']);
});
it('says overflow when every row of the window is new', async () => {
const rows = Array.from({ length: 200 }, (_, i) => row(100 + i, i));
http.mockReturnValueOnce(feed(rows));
expect((await t.getChanges(s, '50|')).overflow).toBe(true);
});
it('reports nothing when nothing changed', async () => {
http.mockReturnValueOnce(feed([row(11, 2), row(12, 3)]));
expect(await t.getChanges(s, '12|')).toMatchObject({ accountCodes: [], itemNames: [], overflow: false });
});
});
@@ -0,0 +1,511 @@
import { Prisma } from '@prisma/client';
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
import { formatJalali, toJalali } from '../jalali';
import {
AccountingNotSupportedError,
AccountingProvider,
AccountingProviderError,
ExternalCustomer,
ProviderSettings,
StatementRow,
ZERO,
dec,
normalizeMobile,
str,
} from './accounting-provider';
/**
* Tahesab (ته‌حساب) — https://tahesab.ir/APiDoc/ (OpenAPI 3.1, API 10.1405.x).
*
* Wire format, confirmed against the public test server on 2026-10-05:
* POST <base>/ body {"<Method>": [args by position]}
* headers Authorization: Bearer <token>, DBname: TahesabDB (SQL Server) | DB (Access)
* The server dispatches on the body key; the path in the docs is cosmetic.
* Errors come back as {"ERROR": "..."} with HTTP 400/404.
*
* Behaviour measured on the test server (plan §9), which this driver relies on:
* * Balance sign: an exit (خروج) LOWERS MandeyeVazni — negative = the
* customer owes, as henza_server's `* -1` assumed. Flipped here.
* * A temporary (موقت) document does not move the balance until the
* accountant finalises it.
* * The gold a document moves is Vazn × Ayar / 750. Darsad_Talayee and
* Ojrat are stored but do NOT move the balance, so the making charge is sent
* as a raised Ayar (750 at 10% → 825); Vazn stays the physical weight.
* * PoolSang does not move the rial balance; MablaghKol does.
* * Factor_Code is stored lowercased; re-sending a row with the same
* Factor_Code + Radif_Number is rejected as a duplicate key — which is what
* makes a retry safe.
*/
const CUSTOMER_PAGE = 500;
/** Consecutive empty code windows before the account list is considered done. */
const EMPTY_WINDOWS_TO_STOP = 4;
const MAX_CUSTOMER_CODE = 2_000_000;
const BALANCE_CHUNK = 100;
const RECENT_DOCUMENTS = 1000;
const CHANGE_WINDOW = 200;
function baseUrl(s: ProviderSettings) {
return s.baseUrl.replace(/\/+$/, '');
}
function headers(s: ProviderSettings) {
return {
Authorization: `Bearer ${s.token}`,
DBname: s.dbName || 'TahesabDB',
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
function isNotFoundMessage(msg: string) {
return /یافت نشد|Not_Found|not found/i.test(msg);
}
async function call(s: ProviderSettings, method: string, args: unknown[]): Promise<unknown> {
let res;
try {
res = await httpRequest({
method: 'POST',
url: `${baseUrl(s)}/`,
headers: headers(s),
body: JSON.stringify({ [method]: args }),
timeoutMs: s.timeoutMs,
ca: s.caCert,
});
} catch (err) {
if (err instanceof AccountingHttpError) {
throw new AccountingProviderError(`ته‌حساب (${method}): ${err.message}`, err.ambiguous);
}
throw err;
}
let json: unknown;
try {
json = parseLooseJson(res.body);
} catch {
throw new AccountingProviderError(
`ته‌حساب (${method}): پاسخ نامعتبر (HTTP ${res.status})`,
res.status >= 200 && res.status < 300,
res.body.slice(0, 500),
);
}
const error = json && typeof json === 'object' && !Array.isArray(json) ? (json as { ERROR?: unknown }).ERROR : undefined;
if (error !== undefined || res.status < 200 || res.status >= 300) {
const msg = typeof error === 'string' ? error : `HTTP ${res.status}`;
throw new AccountingProviderError(
`ته‌حساب (${method}): ${msg.split('\r\n')[0]}`,
false,
trimDiagnostics(json),
res.status === 404 || isNotFoundMessage(msg),
);
}
return json;
}
/** Tahesab error bodies carry ~30 diagnostic fields; keep only the message. */
function trimDiagnostics(json: unknown) {
if (json && typeof json === 'object' && 'ERROR' in (json as object)) {
return { ERROR: (json as { ERROR: unknown }).ERROR };
}
return json;
}
/** A method that answers "nothing found" with an ERROR, as an empty result. */
async function callList(s: ProviderSettings, method: string, args: unknown[]) {
try {
return await call(s, method, args);
} catch (err) {
if (err instanceof AccountingProviderError && err.notFound) return null;
throw err;
}
}
/** Tahesab keys row objects by id: {"1": {...}, "2": {...}}. */
function rowsOf(json: unknown): Record<string, unknown>[] {
if (!json) return [];
if (Array.isArray(json)) return json as Record<string, unknown>[];
if (typeof json === 'object') {
return Object.values(json as Record<string, unknown>).filter(
(v): v is Record<string, unknown> => !!v && typeof v === 'object',
);
}
return [];
}
function toCustomer(r: Record<string, unknown>): ExternalCustomer {
return {
code: String(r.Code),
name: str(r.Name) ?? '',
mobile: normalizeMobile(r.Mobile),
phone: null,
nationalCode: str(r.CodeMelli),
address: str(r.Address),
groupId: str(r.GID),
groupName: str(r.GoroupName),
};
}
const neg = (d: Prisma.Decimal | null) => (d ? d.negated() : null);
const METALS: Record<string, string> = { '0': 'طلا', '1': 'پلاتین', '2': 'نقره' };
/**
* Rounds the raised karat to 3 decimals, which is what the desktop app keeps.
* 750 × (1 + 10/100) = 825.
*/
export function karatWithWage(karat: number, wagePercent: Prisma.Decimal) {
return new Prisma.Decimal(karat).mul(new Prisma.Decimal(1).add(wagePercent.div(100))).toDecimalPlaces(3);
}
export const tahesabProvider: AccountingProvider = {
name: 'tahesab',
label: 'ته‌حساب',
capabilities: { createKarSakhte: false, documentInquiry: true, movementScope: 'recent-documents', changeFeed: true },
async health(s) {
const path = baseUrl(s).startsWith('https:') ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL';
let info: Record<string, unknown> = {};
try {
const res = await httpRequest({
method: 'GET',
url: `${baseUrl(s)}/${path}`,
headers: headers(s),
timeoutMs: s.timeoutMs,
ca: s.caCert,
});
const json = parseLooseJson(res.body) as Record<string, unknown>;
info = {
status: json.Status,
api: json.Api_Status,
db: json.DB_Status,
version: json.TahesabVersion,
dbType: json.DBType,
dbName: json.DBName,
};
} catch (err) {
if (err instanceof AccountingHttpError) throw new AccountingProviderError(`ته‌حساب: ${err.message}`);
throw new AccountingProviderError('ته‌حساب: پاسخ CheckHealth نامعتبر است');
}
// CheckHealth ignores the token and DBname; a real call proves both.
const groups = rowsOf(await call(s, 'DoListGorooh', []));
return { ...info, customerGroups: groups.length };
},
async listCustomerGroups(s) {
const groups = rowsOf(await call(s, 'DoListGorooh', [])).map((g) => ({
id: String(g.GID),
name: str(g.Name) ?? String(g.GID),
}));
// Accounts outside every defined group carry GID 0 (seen on the test
// server as «مشتري»); offer it so it can be picked for import.
if (!groups.some((g) => g.id === '0')) groups.push({ id: '0', name: 'بدون گروه (GID 0)' });
return groups;
},
async listCustomers(s, cursor) {
let [start, empty] = (cursor ?? '1:0').split(':').map(Number);
if (!Number.isFinite(start) || start < 1) start = 1;
if (!Number.isFinite(empty)) empty = 0;
const json = await callList(s, 'Dolistmoshtari', [start, start + CUSTOMER_PAGE - 1]);
const items = rowsOf(json).map(toCustomer);
empty = items.length === 0 ? empty + 1 : 0;
const nextStart = start + CUSTOMER_PAGE;
const done = empty >= EMPTY_WINDOWS_TO_STOP || nextStart > MAX_CUSTOMER_CODE;
return { items, next: done ? null : `${nextStart}:${empty}` };
},
async findCustomersByMobile(s, mobile) {
const target = normalizeMobile(mobile);
if (!target) return [];
// The account's Mobile is free text: some rows hold 9121234567, others
// 09121234567, and the lookup matches the stored form. Ask both ways.
const found = new Map<string, ExternalCustomer>();
for (const form of [Number(target.slice(1)), target]) {
for (const r of rowsOf(await callList(s, 'DoListMoshtari', [form]))) {
const c = toCustomer(r);
if (c.mobile === target) found.set(c.code, c);
}
}
return [...found.values()];
},
async createCustomer(s, p) {
const json = (await call(s, 'DoNewMoshtari', [
p.fullName,
p.group.name ?? '',
p.mobile,
p.address ?? '',
p.nationalCode ?? '',
'',
'',
'-1',
-1, // Moshtari_Code -1: Tahesab assigns the code
0, // Jens_Felez: gold
])) as { OK?: unknown };
if (json?.OK === undefined || json.OK === null) {
throw new AccountingProviderError('ته‌حساب: کد حساب جدید برنگشت', false, json);
}
return { code: String(json.OK), response: json };
},
async updateCustomer(s, code, p) {
// "-1" leaves a field unchanged (DoEditMoshtari example in the docs).
const json = await call(s, 'DoEditMoshtari', [
p.fullName,
p.group.name ?? '-1',
p.mobile,
p.address ?? '-1',
p.nationalCode ?? '-1',
'-1',
'-1',
'-1',
Number(code),
]);
return { response: json };
},
async getBalances(s, codes) {
const out = [];
for (let i = 0; i < codes.length; i += BALANCE_CHUNK) {
const chunk = codes.slice(i, i + BALANCE_CHUNK);
// Unknown codes are simply left out of MandeHesab.
const json = (await callList(s, 'getmandehesabbycode', chunk)) as { MandeHesab?: unknown } | null;
for (const r of rowsOf(json?.MandeHesab)) {
out.push({
code: String(r.Code),
goldGrams: (dec(r.MandeyeVazni) ?? ZERO).negated(),
rial: (dec(r.MandeyeMali) ?? ZERO).negated(),
});
}
}
return out;
},
async getChanges(s, cursor) {
// DoListUpdatedAsnad lists the newest documents by registration/edit time.
// Cursor "maxId|latestEdit": a row is news when its ID is higher, or it
// carries a later ZamanEdit. Nothing older than the window is visible, so
// a window with no already-seen row means "too many changes: refresh all".
const rows = rowsOf(await callList(s, 'DoListUpdatedAsnad', [CHANGE_WINDOW]));
let maxId = 0;
let latestEdit = '';
for (const r of rows) {
maxId = Math.max(maxId, Number(r.ID) || 0);
const edited = String(r.ZamanEdit ?? '');
if (edited && edited !== 'null' && edited > latestEdit) latestEdit = edited;
}
const next = `${maxId}|${latestEdit}`;
if (cursor === null) return { cursor: next, accountCodes: [], itemNames: [], overflow: false };
const [seenId, seenEdit] = [Number(cursor.split('|')[0]) || 0, cursor.split('|')[1] ?? ''];
const fresh = rows.filter((r) => {
const edited = String(r.ZamanEdit ?? '');
return (Number(r.ID) || 0) > seenId || (!!edited && edited !== 'null' && edited > seenEdit);
});
const accounts = new Set<string>();
const items = new Set<string>();
for (const r of fresh) {
const code = str(r.MCode);
if (code) accounts.add(code);
const kar = str(r.No_Kar);
if (kar && /کار/.test(String(r.NO ?? ''))) items.add(kar);
}
return {
cursor: next,
accountCodes: [...accounts],
itemNames: [...items],
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
};
},
async getStatement(s, q) {
// Count_Last = -1 asks for the running balance on every row.
const json = await callList(s, 'DoListAsnad', [
-1,
Number(q.code),
formatJalali(q.from),
formatJalali(q.to),
'',
0,
'IRR',
]);
return rowsOf(json).map((r) => toStatementRow(r));
},
async listKarSakhteGroups() {
return [];
},
async listKarSakhteTypes() {
return [];
},
async listKarSakhte(s) {
// Only items that already have a document appear here (Tahesab has no
// item master in its API). Names that arrive as "????" lost their
// encoding upstream and cannot be matched back; they are skipped.
return rowsOf(await callList(s, 'DoListNameKarsakhte', []))
.map((r) => str(r.Name))
.filter((name): name is string => !!name && !name.includes('??'))
.map((name) => ({
code: name,
name,
groupId: null,
groupName: null,
typeId: null,
typeName: null,
saleWagePercent: null,
stockWeight: null,
stockCount: null,
}));
},
async getKarSakhteStock(s, item) {
const json = (await callList(s, 'GetMojoodiKarSakhte', [item.name, 0])) as Record<string, unknown> | null;
return { weight: dec(json?.VaznMojood), count: dec(json?.TedadMojood) };
},
async getKarSakhteMovement(s, from, to) {
// Tahesab cannot list documents across all accounts by date; the closest
// is the most recent N documents, filtered here.
const lo = formatJalali(from, '/');
const hi = formatJalali(to, '/');
return rowsOf(await callList(s, 'DoListUpdatedAsnad', [RECENT_DOCUMENTS]))
.filter((r) => /کار/.test(String(r.NO ?? '')))
.filter((r) => {
const d = String(r.Tarikh ?? '');
return d >= lo && d <= hi;
})
.map((r) => ({
itemCode: str(r.No_Kar),
itemName: str(r.No_Kar) ?? '—',
date: String(r.Tarikh ?? ''),
description: [str(r.NO), str(r.Sharh1)].filter(Boolean).join(' — ') || null,
weight: dec(r.Vazn),
amount: dec(r.Mali),
count: dec(r.Count),
}));
},
async createKarSakhte() {
throw new AccountingNotSupportedError('ساخت کارساخته');
},
async postOrderDocument(s, doc, progress, onProgress) {
const d = toJalali(doc.date);
const multi = doc.lines.length > 1 ? 1 : -1;
let done = progress.doneLines ?? 0;
let last: unknown = null;
for (let i = done; i < doc.lines.length; i += 1) {
const line = doc.lines[i];
const args = [
doc.temporary ? 0 : 1, // Sabte_Kol_Or_Movaghat: 0 = temporary
Number(doc.customerCode),
-1, // Factor_Number: assigned by Tahesab
i + 1, // Radif_Number
d.jy,
d.jm,
d.jd,
Number(line.weightGrams.toFixed(3)),
Number(karatWithWage(line.karat, line.wagePercent).toString()),
line.count,
line.itemName,
1, // IsVoroodOrKhorooj: 1 = exit (goods to the customer)
1, // OjratTedadiOrGerami: per gram
Number(line.flatRial.toFixed(0)), // MablaghKol — the only rial field that moves the balance
0,
0,
0, // Darsad_Talayee: the charge is already in Ayar; never count it twice
0,
'',
0,
0, // PoolSang: does not move the balance; the stones are in MablaghKol
line.description,
doc.factorCode,
-1,
multi,
0,
0,
];
try {
last = await call(s, 'DoNewSanadVKHKar', args);
} catch (err) {
// The row is already there (an earlier attempt landed but its reply
// was lost): Factor_Code + Radif_Number is a unique key in Tahesab.
if (!(err instanceof AccountingProviderError && /duplicate key/i.test(err.message + JSON.stringify(err.response ?? '')))) {
throw err;
}
last = { duplicate: true };
}
done = i + 1;
await onProgress({ doneLines: done });
}
const ref = String((last as { Factor_Code?: unknown })?.Factor_Code ?? doc.factorCode).toLowerCase();
return { ref, progress: { doneLines: done }, response: last };
},
async documentExists(s, doc) {
try {
await call(s, 'DoNewSanadInquiry', [doc.factorCode]);
return true;
} catch (err) {
if (err instanceof AccountingProviderError && err.notFound) return false;
// A multi-row document answers {"ERROR":"Duplicate"}: found, more than
// one row under the code (seen on the test server).
if (err instanceof AccountingProviderError && /:\s*Duplicate$/i.test(err.message)) return true;
throw err;
}
},
async deleteDocument(s, doc) {
try {
return { response: await call(s, 'DoDeleteSanad', [doc.factorCode]) };
} catch (err) {
// Already gone — the goal is reached.
if (err instanceof AccountingProviderError && err.notFound) return { response: { alreadyDeleted: true } };
throw err;
}
},
};
function toStatementRow(r: Record<string, unknown>): StatementRow {
const metal = String(r.Jens_Felez ?? '0');
const temporary = r.isSanadMovaghat === true;
const isGold = metal === '0';
const isRial = !r.Arz || String(r.Arz).toUpperCase() === 'IRR';
// Positive TabdilVazn / Mali = in the customer's favour (they gave us gold
// or money); negative = they took goods or money from us.
const tabdil = isGold ? dec(r.TabdilVazn) ?? ZERO : ZERO;
const mali = isRial ? dec(r.Mali) ?? ZERO : ZERO;
return {
key: `${r.ID}-${r.Radif ?? 1}`,
documentRef: String(r.Factor_Code ?? r.ID).toLowerCase(),
accountCode: String(r.MCode ?? ''),
documentNo: String(r.Sh_Factor ?? ''),
date: String(r.Tarikh ?? ''),
time: str(r.Tarikh_Time),
title: str(r.NO) ?? '',
description: [str(r.Sharh1), str(r.Sharh2), str(r.Baabat)].filter(Boolean).join(' — ') || null,
itemName: str(r.No_Kar),
weight: dec(r.Vazn),
karat: dec(r.Ayar)?.toNumber() ?? null,
count: dec(r.Count),
unitPrice: dec(r.Fi) ?? dec(r.Mazaneh),
wagePercent: dec(r.DarsadSood),
goldIn: tabdil.gt(0) ? tabdil : ZERO,
goldOut: tabdil.lt(0) ? tabdil.negated() : ZERO,
rialIn: mali.gt(0) ? mali : ZERO,
rialOut: mali.lt(0) ? mali.negated() : ZERO,
// A temporary row reports TahesabVazni/Mali = 0 because it moves nothing
// yet; null lets the report carry the previous balance forward instead.
goldBalance: temporary ? null : neg(dec(r.TahesabVazni)),
rialBalance: temporary ? null : neg(dec(r.TahesabMali)),
metal: METALS[metal] ?? metal,
temporary,
};
}
@@ -0,0 +1,377 @@
import { Prisma } from '@prisma/client';
import { httpRequest } from '../accounting-http';
import { OrderDocumentInput, ProviderSettings } from './accounting-provider';
import { __zarrinInternals, zarrinProvider as z } from './zarrin.provider';
jest.mock('../accounting-http', () => ({
...jest.requireActual('../accounting-http'),
httpRequest: jest.fn(),
}));
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
const s: ProviderSettings = {
baseUrl: 'http://zarrin.local:8000/ZarrinService/accounting',
token: 'SECRET',
caCert: null,
timeoutMs: 5000,
balanceOwesWhen: 'negative',
};
const reply = (body: unknown, status = 200) =>
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
const url = (n = 0) => new URL(http.mock.calls[n][0].url);
beforeEach(() => http.mockReset());
describe('wire format', () => {
it('sends GET ?kind=…&token=… with the parameters, and nothing blank', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '4', name: 'مشتری (خریدار)' }] }));
const groups = await z.listCustomerGroups(s);
const u = url();
expect(http.mock.calls[0][0].method).toBe('GET');
expect(u.pathname).toBe('/ZarrinService/accounting');
expect(u.searchParams.get('kind')).toBe('listGorooheHesabha');
expect(u.searchParams.get('token')).toBe('SECRET');
expect(groups).toEqual([{ id: '4', name: 'مشتری (خریدار)' }]);
});
it('unwraps the JSON-in-a-string replies the documentation shows', async () => {
const inner = JSON.stringify({ state: true, msg: 'حساب با موفقیت ایجاد شد', id: 132 });
http.mockReturnValueOnce(reply(JSON.stringify(inner)));
const res = await z.createCustomer(s, {
firstName: 'تست',
lastName: 'تست',
fullName: 'تست تست',
mobile: '09121112233',
phone: null,
address: null,
nationalCode: null,
group: { id: '4', name: 'مشتری' },
});
expect(res.code).toBe('132');
expect(url().searchParams.get('fkNoe')).toBe('4');
expect(url().searchParams.has('telephone')).toBe(false);
});
it('turns state:false into an error carrying the message', async () => {
http.mockReturnValueOnce(reply({ state: false, msg: 'دسترسی مجاز نیست' }));
await expect(z.listCustomerGroups(s)).rejects.toThrow('دسترسی مجاز نیست');
});
it('shows the server reason on a non-2xx reply, without the token', async () => {
http.mockReturnValueOnce(reply('<html><body><h1>Server Error</h1><p>kind=listGorooheHesabha&token=SECRET failed</p></body></html>', 500));
const err = await z.listCustomerGroups(s).catch((e) => e);
expect(err.message).toContain('HTTP 500 — Server Error');
expect(err.message).toContain('failed');
expect(err.message).not.toContain('SECRET');
http.mockReturnValueOnce(reply({ state: false, msg: 'شناسه نامعتبر' }, 400));
await expect(z.listCustomerGroups(s)).rejects.toThrow('HTTP 400 — شناسه نامعتبر');
});
it('lists the different envelopes Zarrin uses', () => {
const { listOf, fullJalali } = __zarrinInternals;
expect(listOf({ data: [1] })).toEqual([1]);
expect(listOf({ date: [2] })).toEqual([2]); // Get100Account's typo
expect(listOf([3])).toEqual([3]);
expect(listOf({ id: '84', nam: 'x' })).toEqual([{ id: '84', nam: 'x' }]);
expect(fullJalali('04/05/29')).toBe('1404/05/29');
});
});
describe('customers', () => {
it('lists every account with its group from one listhesabha call', async () => {
http.mockReturnValueOnce(
reply({
data: [
{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' },
{ id: '82', nam: 'آرین یوسفی', gorooh: 'سازنده (تامین کننده)', tell: '', mobile: '', codeMeli: '' },
],
}),
);
const res = await z.listCustomers(s, null);
expect(http).toHaveBeenCalledTimes(1);
expect(url(0).searchParams.get('kind')).toBe('listhesabha');
expect(url(0).searchParams.get('az')).toBe('1');
expect(res.items).toHaveLength(2);
expect(res.items[0]).toMatchObject({ code: '81', name: 'حسین اصغری', mobile: '09121112233', groupName: 'مشتری (خریدار)', phone: '021' });
expect(res.items[1].mobile).toBeNull();
expect(res.next).toBeNull();
});
it('finds by mobile with searchAccount, keeping exact matches only', async () => {
http.mockReturnValueOnce(
reply({
state: true,
msg: '',
date: [
{ code: 84, name: 'سعید', namKhanevadegi: 'رجایی', mobile: '09121995971' },
{ code: 90, name: 'x', namKhanevadegi: 'y', mobile: '09121995972' },
],
}),
);
const found = await z.findCustomersByMobile(s, '09121995971');
expect(url(0).searchParams.get('kind')).toBe('searchAccount');
expect(found.map((c) => c.code)).toEqual(['84']);
expect(found[0].name).toBe('سعید رجایی');
});
it('falls back to listhesabha when searchAccount is missing (bare 400)', async () => {
http
.mockReturnValueOnce(reply('', 400))
.mockReturnValueOnce(reply({ data: [{ id: '84', nam: 'رجایی * سعید', gorooh: 'مشتری (خریدار)', mobile: '09121995971' }, { id: '85', nam: 'z', mobile: '' }] }));
const found = await z.findCustomersByMobile(s, '09121995971');
expect(url(1).searchParams.get('kind')).toBe('listhesabha');
expect(found.map((c) => c.code)).toEqual(['84']);
});
it('does not hide other errors behind the fallback', async () => {
http.mockReturnValueOnce(reply('', 500));
await expect(z.findCustomersByMobile(s, '09121995971')).rejects.toThrow('HTTP 500');
});
it('lists karsakhte groups with goroohSakhte when the installation has it', async () => {
http.mockReturnValueOnce(reply({ state: true, detail: 'OK', data: [{ id: '26', name: 'النگو' }] }));
expect(await z.listKarSakhteGroups!(s)).toEqual([{ id: '26', name: 'النگو' }]);
expect(http).toHaveBeenCalledTimes(1);
});
it('scans karsakhte groups with infogorooh when goroohSakhte is missing, skipping ids that fail', async () => {
http.mockImplementation((req) => {
const u = new URL(req.url);
if (u.searchParams.get('kind') === 'goroohSakhte') return reply('', 400);
const id = u.searchParams.get('id');
if (id === '26') return reply({ data: [{ nam: 'النگو', code: 26 }] });
if (id === '115') return reply({ data: [{ nam: 'سکه', code: 115 }] });
if (id === '3') return reply('', 500);
return reply('{\n "data": \n ]\n}');
});
expect(await z.listKarSakhteGroups!(s)).toEqual([
{ id: '26', name: 'النگو' },
{ id: '115', name: 'سکه' },
]);
});
it('keeps the account in its group on edit', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'ویرایش اطلاعات با موفقیت انجام شد' }));
await z.updateCustomer(s, '101', {
firstName: 'حسین',
lastName: 'اصغری',
fullName: 'حسین اصغری',
mobile: '09121427233',
phone: null,
address: 'خیابان مولوی',
nationalCode: null,
group: { id: null, name: 'واسطه' },
});
expect(url().searchParams.get('nameG')).toBe('واسطه');
expect(url().searchParams.get('id')).toBe('101');
});
});
describe('balances and statement', () => {
it('applies the configured sign', async () => {
http.mockReturnValue(reply({ state: true, msg: 'OK', data: [{ id: '108', name: 'x', geram: '-75.409', pool: '-3.211960271E9' }, { id: '9', geram: '1', pool: '1' }] }));
const neg = await z.getBalances(s, ['108', '5']);
expect(neg).toHaveLength(1);
expect(neg[0].goldGrams.toString()).toBe('75.409');
expect(neg[0].rial.toString()).toBe('3211960271');
const pos = await z.getBalances({ ...s, balanceOwesWhen: 'positive' }, ['108', '9']);
expect(pos[0].goldGrams.toString()).toBe('-75.409');
});
it('anchors the running balance on the real balance; «مانده از قبل» is not a movement', async () => {
// 1) the range, opening row first (seen on v228: its figures are not a balance)
http.mockReturnValueOnce(
reply({
state: true,
detail: 'OK',
data: [
{ idRadif: '0', idFactor: '0', idHesab: '81', sharh: 'مانده از قبل', vaznTarazooPardakht: '10.14', vaznTarazooDaryaft: '10.24', poolPardakht: '5', poolDaryaft: '5' },
{ idRadif: '470', idFactor: '221', idHesab: '81', noeRadif: 'چک', noeFactor: 'سند دریافت', tarikh: '04/05/29', vaznTarazooPardakht: '0.0', vaznTarazooDaryaft: '2.5', poolPardakht: '810000000', poolDaryaft: '0', ayar: '0' },
{ idRadif: '471', idFactor: '222', idHesab: '81', noeRadif: 'کار ساخته', noeFactor: 'فاکتور موقت', tarikh: '04/05/30', vaznTarazooPardakht: '1.0', vaznTarazooDaryaft: '0.0', poolPardakht: '0', poolDaryaft: '0', ayar: '750' },
],
}),
);
// 2) the account's balance now: −9.5 g in Zarrin = owes 9.5
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '81', geram: '-9.5', pool: '-810000000' }] }));
// 3) what happened after the range
http.mockReturnValueOnce(
reply({ state: true, detail: 'OK', data: [{ idRadif: '0', sharh: 'مانده از قبل' }, { idRadif: '500', idHesab: '81', vaznTarazooPardakht: '1.0', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' }] }),
);
const rows = await z.getStatement(s, { code: '81', from: { jy: 1404, jm: 5, jd: 1 }, to: { jy: 1404, jm: 5, jd: 31 } });
expect(url().searchParams.get('dateAz')).toBe('2025-07-23');
expect(url(1).searchParams.get('kind')).toBe('listMandeHesabha');
expect(url(2).searchParams.get('dateAz')).toBe('2025-08-23'); // the day after `to`
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ documentRef: '221', date: '1404/05/29', title: 'چک', temporary: false });
// closing(to) = 9.5 − 1.0 after = 8.5; opening = 8.5 − (−2.5 + 1.0) = 10
expect(rows[0].goldBalance?.toString()).toBe('7.5');
expect(rows[0].rialBalance?.toString()).toBe('810000000');
expect(rows[1]).toMatchObject({ temporary: true });
expect(rows[1].goldBalance?.toString()).toBe('8.5');
});
});
describe('order documents', () => {
const line = (code: string, wage: number) => ({
itemCode: code,
itemName: code,
weightGrams: new Prisma.Decimal('10'),
count: 1,
wagePercent: new Prisma.Decimal(wage),
flatRial: new Prisma.Decimal(0),
karat: 750,
description: 'd',
});
const doc: OrderDocumentInput = {
factorCode: 'gbordx00000000000000000',
customerCode: '81',
date: new Date(),
description: 'سفارش',
temporary: true,
lines: [line('19', 12), line('20', 8)],
};
it('opens the invoice with the account, appends with the invoice id, in bonak mode', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } })).mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } }));
const progress: unknown[] = [];
const res = await z.postOrderDocument(s, doc, {}, async (p) => {
progress.push(p);
});
const a = url(0).searchParams;
const b = url(1).searchParams;
expect(a.get('kind')).toBe('foroushSakhte');
expect(a.get('idHesab')).toBe('81');
expect(a.has('idFactor')).toBe(false);
expect(a.get('idSakhte')).toBe('19');
expect(a.get('darsad')).toBe('12');
expect(a.get('vazn')).toBe('10.000');
expect(a.get('pd')).toBe('-1');
expect(a.get('bonak')).toBe('1');
expect(a.get('movaghat')).toBe('1');
expect(b.get('idFactor')).toBe('117764');
expect(b.has('idHesab')).toBe(false);
expect(b.get('movaghat')).toBe('1'); // temporary, as the owner ruled
expect(progress).toEqual([
{ factorId: '117764', doneLines: 1 },
{ factorId: '117764', doneLines: 2 },
]);
expect(res.ref).toBe('117764');
});
it('resumes into the same invoice', async () => {
http.mockReturnValueOnce(reply({ state: true, data: { idFactor: 5 } }));
await z.postOrderDocument(s, doc, { factorId: '5', doneLines: 1 }, async () => undefined);
expect(http).toHaveBeenCalledTimes(1);
expect(url().searchParams.get('idFactor')).toBe('5');
expect(url().searchParams.get('idSakhte')).toBe('20');
});
it('finds the row to delete in the ledger, since foroushSakhte never returned it', async () => {
http
.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '0', idFactor: '0' }, { idRadif: '731', idFactor: '390' }] }))
.mockReturnValueOnce(reply({ state: true, msg: 'حذف با موفقیت انجام شد :)' }));
await z.deleteDocument(s, { factorCode: 'x', ref: '390', customerCode: '81' });
expect(url(1).searchParams.get('kind')).toBe('hazfRadifSanad');
expect(url(1).searchParams.get('pkRadifSanad')).toBe('731');
expect(url(1).searchParams.get('kol')).toBe('1');
});
});
describe('change feed (real-time sync)', () => {
it('reads the newest journal rows and reports accounts above the cursor', async () => {
http.mockReturnValueOnce(
reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }, { idRadif: '733', idHesab: '40' }, { idRadif: '734', idHesab: '40' }] }),
);
const r = await z.getChanges(s, '731');
expect(url().searchParams.get('kind')).toBe('parLastTenRadif');
expect(url().searchParams.has('idHesab')).toBe(false);
expect(r).toMatchObject({ cursor: '734', accountCodes: ['40'], overflow: false });
});
it('first poll only returns the cursor', async () => {
http.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }] }));
expect(await z.getChanges(s, null)).toEqual({ cursor: '731', accountCodes: [], itemNames: [], overflow: false });
});
});
describe('v228 quirks', () => {
it('reads every page of listKarSakhte (100 per page)', async () => {
const page = (from: number, n: number) => reply({ state: true, msg: 'ok', data: Array.from({ length: n }, (_, i) => ({ id: String(from + i), namFelezsakhthe: `کار ${from + i}`, fkNoe: '16', NoeName: 'گرمی' })) });
http.mockReturnValueOnce(page(1, 100)).mockReturnValueOnce(page(101, 100)).mockReturnValueOnce(page(201, 7));
const items = await z.listKarSakhte(s);
expect(items).toHaveLength(207);
expect(url(1).searchParams.get('pageNum')).toBe('2');
});
it('stops when the server ignores pageNum and repeats page 1', async () => {
const page1 = () => reply({ state: true, msg: 'ok', data: Array.from({ length: 100 }, (_, i) => ({ id: String(i + 1), namFelezsakhthe: 'x' })) });
http.mockReturnValueOnce(page1()).mockReturnValueOnce(page1());
expect(await z.listKarSakhte(s)).toHaveLength(100);
expect(http).toHaveBeenCalledTimes(2);
});
it('accepts an HTTP 400 that carries a good reply (goroohSakhte on v228)', async () => {
http.mockReturnValueOnce(reply({ state: true, detail: 'OK', data: [{ id: '26', name: 'النگو' }] }, 400));
expect(await z.listKarSakhteGroups(s)).toEqual([{ id: '26', name: 'النگو' }]);
});
it('repairs the hand-printed JSON of infogardeshsakhte', async () => {
// Verbatim shape of a v228 reply.
const body = [
'{',
' "data": [',
' {',
' "code_kar": 19,',
' "nam_kar": دستبند آروشا,',
' "tarikh_akharin_gardesh": 05/07/15,',
' "sharh_akharin_gardesh": دستبند آروشا,',
' "vazn_akharin_gardesh": -15.12,',
' "mablagh_akharin_gardesh": 0.0,',
' },',
' ]',
'}',
].join('\n');
http.mockReturnValueOnce(reply(body));
const rows = await z.getKarSakhteMovement(s, { jy: 1405, jm: 7, jd: 1 }, { jy: 1405, jm: 7, jd: 20 });
expect(rows).toEqual([
expect.objectContaining({ itemCode: '19', itemName: 'دستبند آروشا', date: '1405/07/15', weight: new Prisma.Decimal('-15.12') }),
]);
});
it('drops the «*» listhesabha puts between first and last name', async () => {
const { accountName } = __zarrinInternals;
expect(accountName('آرین * یوسفی')).toBe('آرین یوسفی');
expect(accountName(' * هزینه هدایا (پیامک گروهی)')).toBe('هزینه هدایا (پیامک گروهی)');
expect(accountName(' * ')).toBeNull();
http.mockReturnValueOnce(reply({ data: [{ id: '953', nam: 'تست اپ * گلدبانک', gorooh: 'متفرقه', mobile: '09990001111' }] }));
const { items } = await z.listCustomers(s, null);
expect(items[0]).toMatchObject({ code: '953', name: 'تست اپ گلدبانک', groupName: 'متفرقه', mobile: '09990001111' });
});
it('sees a document posted with movaghat=1 as temporary (shomareSanad «موقت»)', async () => {
http.mockReturnValueOnce(
reply({
state: true,
data: [
{ idRadif: '71962', idFactor: '16792', idHesab: '953', noeFactor: 'فاکتور فروش', shomareSanad: 'موقت', noeRadif: 'کار ساخته', vaznTarazooPardakht: '1', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' },
{ idRadif: '71710', idFactor: '16722', idHesab: '953', noeFactor: 'فاکتور موقت', shomareSanad: 'باز', vaznTarazooPardakht: '0.1', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' },
{ idRadif: '52961', idFactor: '12282', idHesab: '953', noeFactor: 'فاکتور فروش', shomareSanad: '12282', vaznTarazooPardakht: '1', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' },
],
}),
);
http.mockReturnValueOnce(reply({ state: true, data: [{ id: '953', geram: '-2.1', pool: '0' }] }));
const today = toJalaliToday();
const rows = await z.getStatement(s, { code: '953', from: today, to: today });
expect(rows.map((r) => r.temporary)).toEqual([true, true, false]);
});
});
function toJalaliToday() {
// Today in Tehran, so the statement needs no "after the range" call.
const { toJalali } = jest.requireActual('../jalali');
return toJalali(new Date());
}
@@ -0,0 +1,614 @@
import { Prisma } from '@prisma/client';
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
import { JalaliDate, jalaliToGregorian, jalaliToIsoDate, toIsoDateTehran, toJalali } from '../jalali';
const jalaliKey = (d: JalaliDate) => d.jy * 10000 + d.jm * 100 + d.jd;
function nextDay(d: JalaliDate): JalaliDate {
const g = jalaliToGregorian(d.jy, d.jm, d.jd);
return toJalali(new Date(Date.UTC(g.gy, g.gm - 1, g.gd + 1, 12)));
}
import {
AccountingProvider,
AccountingProviderError,
ExternalCustomer,
ProviderSettings,
StatementRow,
ZERO,
dec,
normalizeMobile,
splitName,
str,
} from './accounting-provider';
/**
* Zarrin (زرین) — https://zarrin.ir/api-docs/ (the page renders
* /wp-content/themes/Hirad/data/api.json, 82 entries).
*
* Wire format: GET <base>?kind=<api>&token=<token>&<params>, where <base> is
* the installation's `http://host:8000/ZarrinService/accounting`. The token
* travels in the query string, so a URL built here must never be logged.
*
* Reads verified on a real installation (v228, 870 accounts) on 2026-10-12,
* ACC-01: negative balance = owes; a «فاکتور موقت» moves the balance at once
* (unlike Tahesab); listKarSakhte pages by 100 (pageNum); parvande opens with
* a «مانده از قبل» row that is not a movement; goroohSakhte answers HTTP 400
* with a good body; infogardeshsakhte answers malformed JSON. Writes verified
* the same day on test account 953 (addAccount, edithesab, foroushSakhte,
* hazfRadifSanad); only addFelezsakhteModel is untested. Re-test with
* scripts/test-accounting-zarrin.ts; the full list is in plan §13.
*
* Differences from Tahesab that shape the sync:
* * No idempotency key on foroushSakhte. A timed-out document call cannot be
* retried blindly — the worker parks it as NEEDS_REVIEW.
* * foroushSakhte does not return the row id needed to delete a document;
* deleteDocument looks it up from the account's ledger (parvande).
* * کارساخته can be created (addFelezsakhteModel) but never edited.
*/
const STATEMENT_PAGE = 250;
const CHANGE_WINDOW = 50;
const MAX_STATEMENT_PAGES = 20;
/** listKarSakhte returns this many per page (pageNum=1,2,…). */
const ITEM_PAGE = 100;
const MAX_ITEM_PAGES = 200;
/** infogardeshsakhte scans every item server-side; seen at 5–33 s. */
const MOVEMENT_TIMEOUT_MS = 90_000;
const OPENING_ROW = 'مانده از قبل';
const ALL_CODES = { az: 1, ta: 999999 };
const GROUP_SCAN_MAX = 300;
const GROUP_SCAN_CONCURRENCY = 10;
function endpoint(s: ProviderSettings) {
return s.baseUrl.trim();
}
class ZarrinHttpError extends AccountingProviderError {
constructor(
message: string,
body: string,
readonly status: number,
) {
super(message, false, body, status === 404);
}
}
/**
* Installations lack some documented kinds (v228 has no Get100Account,
* goroohSakhte or getinfoByPhone) and answer them with a bare 400.
*/
function isMissingKind(err: unknown) {
return err instanceof ZarrinHttpError && err.status === 400;
}
/**
* What the server said on a non-2xx reply, short enough for the admin screen.
* Error pages may echo the request URL, so the token is scrubbed.
*/
function errorBodyText(body: string, token: string) {
let text = body;
try {
const j = parseLooseJson(body) as any;
const o = typeof j === 'string' && /^\s*[{[]/.test(j) ? parseLooseJson(j) : j;
if (o && typeof o === 'object') text = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? str(o.Message) ?? str(o.title) ?? JSON.stringify(o);
else if (typeof o === 'string') text = o;
} catch {
text = body.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ');
}
if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***');
text = text.replace(/\s+/g, ' ').trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
/**
* infogardeshsakhte prints its JSON by hand: string values unquoted and a
* comma before every closing brace. Its layout is one "key": value per line,
* so each bare value is quoted and the dangling commas dropped.
*/
export function repairZarrinJson(body: string): string {
const lines = body.split(/\r?\n/).map((line) => {
const m = line.match(/^(\s*"[^"]+"\s*:\s*)(.*?)(,?)\s*$/);
if (!m) return line;
const value = m[2].trim();
if (value === '' || /^(-?\d+(\.\d+)?|true|false|null)$/.test(value) || /^["{[]/.test(value)) return line;
return `${m[1]}${JSON.stringify(value)}${m[3]}`;
});
return lines.join('\n').replace(/,(\s*[}\]])/g, '$1');
}
async function call(
s: ProviderSettings,
kind: string,
params: Record<string, string | number | null | undefined> = {},
opts: { timeoutMs?: number; repair?: (body: string) => string } = {},
): Promise<Record<string, unknown> | unknown[]> {
let url: URL;
try {
url = new URL(endpoint(s));
} catch {
throw new AccountingProviderError('زرین: آدرس سرور نامعتبر است');
}
url.searchParams.set('kind', kind);
url.searchParams.set('token', s.token);
for (const [k, v] of Object.entries(params)) {
if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, String(v));
}
let res;
try {
res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: opts.timeoutMs ?? s.timeoutMs, ca: s.caCert });
} catch (err) {
if (err instanceof AccountingHttpError) {
throw new AccountingProviderError(`زرین (${kind}): ${err.message}`, err.ambiguous);
}
throw err;
}
if (res.status < 200 || res.status >= 300) {
// v228 answers goroohSakhte with HTTP 400 and a perfectly good body.
const good = goodReply(res.body);
if (good) return good;
const reason = errorBodyText(res.body, s.token);
throw new ZarrinHttpError(`زرین (${kind}): HTTP ${res.status}${reason ? ` — ${reason}` : ''}`, res.body.slice(0, 500), res.status);
}
let json: unknown;
try {
json = parseLooseJson(opts.repair ? opts.repair(res.body) : res.body);
// The documented outputs are JSON-encoded strings of JSON ("{\"state\":…}").
if (typeof json === 'string' && /^\s*[{[]/.test(json)) json = parseLooseJson(json);
} catch {
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, res.body.slice(0, 500));
}
if (!json || typeof json !== 'object') {
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, json);
}
const o = json as Record<string, unknown>;
if (o.state === false || o.status === false) {
const msg = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? 'خطای نامشخص';
throw new AccountingProviderError(`زرین (${kind}): ${msg}`, false, json, /یافت نشد|موجود نیست/.test(msg));
}
return json as Record<string, unknown> | unknown[];
}
function goodReply(body: string): Record<string, unknown> | null {
try {
let j = parseLooseJson(body) as unknown;
if (typeof j === 'string' && /^\s*[{[]/.test(j)) j = parseLooseJson(j);
const o = j as Record<string, unknown> | null;
return o && typeof o === 'object' && (o.state === true || o.status === true) && Array.isArray(o.data) ? o : null;
} catch {
return null;
}
}
/** Zarrin wraps lists as `data`, sometimes as `date` (searchAccount), or not at all. */
function listOf(json: unknown): Record<string, unknown>[] {
if (Array.isArray(json)) return json as Record<string, unknown>[];
if (json && typeof json === 'object') {
const o = json as Record<string, unknown>;
for (const key of ['data', 'date', 'mande']) {
if (Array.isArray(o[key])) return o[key] as Record<string, unknown>[];
}
if ('id' in o || 'code' in o) return [o];
}
return [];
}
function dataOf(json: unknown): Record<string, unknown> {
const o = (json ?? {}) as Record<string, unknown>;
const d = o.data;
if (d && typeof d === 'object' && !Array.isArray(d)) return d as Record<string, unknown>;
return o;
}
function joinName(first: unknown, last: unknown) {
return [str(first), str(last)].filter(Boolean).join(' ').trim();
}
/** Zarrin prints dates as yy/mm/dd of the 1400s: "04/05/29" → "1404/05/29". */
function fullJalali(v: unknown): string {
const s = str(v) ?? '';
const m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/);
if (!m) return s;
return `14${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`;
}
function accountName(v: unknown): string | null {
const n = (str(v) ?? '').replace(/\s*\*\s*/g, ' ').replace(/\s+/g, ' ').trim();
return n || null;
}
async function allAccounts(s: ProviderSettings): Promise<ExternalCustomer[]> {
return listOf(await call(s, 'listhesabha', ALL_CODES))
.filter((r) => r.id !== undefined)
.map((r) => ({
code: String(r.id),
// listhesabha prints «first * last» (and « * name» without a first name).
name: accountName(r.nam) ?? String(r.id),
mobile: normalizeMobile(r.mobile),
phone: str(r.tell),
nationalCode: str(r.codeMeli),
address: str(r.address),
groupId: null,
groupName: str(r.gorooh),
}));
}
/** Every کارساخته: listKarSakhte pages by 100 (pageNum), undocumented. */
async function allKarSakhte(s: ProviderSettings): Promise<Record<string, unknown>[]> {
const out: Record<string, unknown>[] = [];
const seen = new Set<string>();
for (let page = 1; page <= MAX_ITEM_PAGES; page += 1) {
const batch = listOf(await call(s, 'listKarSakhte', { pageNum: page }));
// An installation that ignores pageNum repeats page 1: stop there.
const fresh = batch.filter((r) => !seen.has(String(r.id)));
for (const r of fresh) seen.add(String(r.id));
out.push(...fresh);
if (batch.length < ITEM_PAGE || fresh.length === 0) break;
}
return out;
}
/** An account's ledger rows (parvande) in a date range, every page, without the opening row. */
async function ledgerRows(s: ProviderSettings, code: string, from: string, to: string): Promise<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = [];
for (let page = 1; page <= MAX_STATEMENT_PAGES; page += 1) {
const batch = listOf(await call(s, 'parvande', { id: code, dateAz: from, dateTa: to, pageNum: page, detailCount: STATEMENT_PAGE }));
rows.push(...batch);
if (batch.length < STATEMENT_PAGE) break;
}
// «مانده از قبل» (idRadif 0) is a summary, not a movement.
return rows.filter((r) => String(r.idRadif ?? '') !== '0' && str(r.sharh) !== OPENING_ROW);
}
/** What a row adds to what the customer owes (app convention: positive = owes). */
function owesDelta(r: Record<string, unknown>) {
return {
gold: (dec(r.vaznTarazooPardakht) ?? ZERO).sub(dec(r.vaznTarazooDaryaft) ?? ZERO),
rial: (dec(r.poolPardakht) ?? ZERO).sub(dec(r.poolDaryaft) ?? ZERO),
};
}
function signed(s: ProviderSettings, v: Prisma.Decimal) {
// v228: negative = owes (the default). Kept a setting in case another install differs.
return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v;
}
export const zarrinProvider: AccountingProvider = {
name: 'zarrin',
label: 'زرین',
capabilities: { createKarSakhte: true, documentInquiry: false, movementScope: 'all', changeFeed: true },
async health(s) {
const v = (await call(s, 'version')) as Record<string, unknown>;
// `version` may not check the token; a group listing does.
const groups = listOf(await call(s, 'listGorooheHesabha'));
return { version: v.version, message: v.message, customerGroups: groups.length };
},
async listCustomerGroups(s) {
return listOf(await call(s, 'listGorooheHesabha')).map((g) => ({
id: String(g.id),
name: str(g.name) ?? String(g.id),
}));
},
async listCustomers(s) {
// listhesabha carries everything (group, mobile, address) and returns the
// whole code span in one reply (~865 accounts in under 2 s).
return { items: await allAccounts(s), next: null };
},
async findCustomersByMobile(s, mobile) {
const target = normalizeMobile(mobile);
if (!target) return [];
let rows: Record<string, unknown>[];
try {
// Matches on name or mobile, possibly partially; filtered exactly below.
rows = listOf(await call(s, 'searchAccount', { search: target }));
} catch (err) {
if (err instanceof AccountingProviderError && err.notFound) return [];
if (!isMissingKind(err)) throw err;
return (await allAccounts(s)).filter((c) => c.mobile === target);
}
return rows
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
.map((r) => ({
code: String(r.code),
name: joinName(r.name, r.namKhanevadegi),
mobile: target,
phone: null,
nationalCode: null,
address: null,
groupId: null,
groupName: null,
}));
},
async createCustomer(s, p) {
const json = (await call(s, 'addAccount', {
firstName: p.firstName || p.fullName,
lastName: p.lastName || '-',
fkNoe: p.group.id,
mobile: p.mobile,
telephone: p.phone,
address: p.address,
codeMelli: p.nationalCode,
})) as Record<string, unknown>;
const id = json.id ?? dataOf(json).id;
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد حساب جدید برنگشت', false, json);
return { code: String(id), response: json };
},
async updateCustomer(s, code, p) {
const json = await call(s, 'edithesab', {
id: code,
name: p.firstName || p.fullName,
family: p.lastName,
mobile: p.mobile,
tel: p.phone,
address: p.address,
codeMeli: p.nationalCode,
// Without nameG Zarrin moves the account into its customers group, so the
// current group is always sent back.
nameG: p.group.name,
});
return { response: json };
},
async getBalances(s, codes) {
if (codes.length === 0) return [];
const wanted = new Set(codes);
const rows =
codes.length === 1
? listOf(await call(s, 'listMandeHesabha', { id: codes[0], isRial: 0 }))
: listOf(await call(s, 'listMandeHesabha', { isRial: 0 }));
return rows
.filter((r) => wanted.has(String(r.id)))
.map((r) => ({
code: String(r.id),
goldGrams: signed(s, dec(r.geram) ?? ZERO),
rial: signed(s, dec(r.pool) ?? ZERO),
}));
},
async getChanges(s, cursor) {
// parLastTenRadif without an account is the newest rows of the whole
// journal; each row names its account (idHesab) and has an ascending idRadif.
// Seen working on v228 (plan §13).
const rows = listOf(await call(s, 'parLastTenRadif', { limit: CHANGE_WINDOW }));
let maxId = 0;
for (const r of rows) maxId = Math.max(maxId, Number(r.idRadif) || 0);
if (cursor === null) return { cursor: String(maxId), accountCodes: [], itemNames: [], overflow: false };
const seen = Number(cursor) || 0;
const fresh = rows.filter((r) => (Number(r.idRadif) || 0) > seen);
const accounts = new Set<string>();
for (const r of fresh) {
const code = str(r.idHesab);
if (code) accounts.add(code);
}
return {
cursor: String(Math.max(maxId, seen)),
accountCodes: [...accounts],
itemNames: [],
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
};
},
async getStatement(s, q) {
const rows = await ledgerRows(s, q.code, jalaliToIsoDate(q.from), jalaliToIsoDate(q.to));
// parvande has no running balance and its «مانده از قبل» row cannot be
// read as one, so the balance is anchored on the account's real balance
// now and walked back: closing(to) = now − movements after `to`.
const [now] = await zarrinProvider.getBalances(s, [q.code]);
let closingGold = now?.goldGrams ?? ZERO;
let closingRial = now?.rial ?? ZERO;
const today = toJalali(new Date());
if (jalaliKey(q.to) < jalaliKey(today)) {
for (const r of await ledgerRows(s, q.code, jalaliToIsoDate(nextDay(q.to)), jalaliToIsoDate(today))) {
const d = owesDelta(r);
closingGold = closingGold.sub(d.gold);
closingRial = closingRial.sub(d.rial);
}
}
let gold = closingGold;
let rial = closingRial;
for (const r of rows) {
const d = owesDelta(r);
gold = gold.sub(d.gold);
rial = rial.sub(d.rial);
}
return rows.map((r): StatementRow => {
const goldIn = dec(r.vaznTarazooDaryaft) ?? ZERO;
const goldOut = dec(r.vaznTarazooPardakht) ?? ZERO;
const rialIn = dec(r.poolDaryaft) ?? ZERO;
const rialOut = dec(r.poolPardakht) ?? ZERO;
gold = gold.add(goldOut).sub(goldIn);
rial = rial.add(rialOut).sub(rialIn);
const factor = str(r.idFactor);
return {
key: String(r.idRadif ?? ''),
documentRef: factor && factor !== '0' ? factor : '',
accountCode: String(r.idHesab ?? q.code),
documentNo: factor && factor !== '0' ? factor : '',
date: fullJalali(r.tarikh),
time: null,
title: str(r.noeRadif) ?? str(r.noeFactor) ?? str(r.sharh) ?? '',
description: str(r.sharh),
itemName: str(r.namFelez),
weight: dec(r.vaznDaryaft)?.gt(0) ? dec(r.vaznDaryaft) : dec(r.vaznPardakht),
karat: dec(r.ayar)?.toNumber() || null,
count: null,
unitPrice: null,
wagePercent: null,
goldIn,
goldOut,
rialIn,
rialOut,
goldBalance: gold,
rialBalance: rial,
metal: null,
// A document posted with movaghat=1 says so in shomareSanad («موقت»);
// one left open in Zarrin's own UI is a «فاکتور موقت».
temporary: /موقت/.test(`${str(r.noeFactor) ?? ''} ${str(r.shomareSanad) ?? ''}`),
};
});
},
async listKarSakhteGroups(s) {
try {
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
} catch (err) {
if (!isMissingKind(err)) throw err;
}
// Without goroohSakhte the only way is one infogorooh per id. Ids are
// sparse and unknown ids fail (some with a 500), so failures are skipped.
const found: { id: string; name: string }[] = [];
let next = 1;
const worker = async () => {
while (next <= GROUP_SCAN_MAX) {
const id = next++;
try {
for (const g of listOf(await call(s, 'infogorooh', { id }))) {
const name = str(g.nam);
if (name) found.push({ id: String(g.code ?? id), name });
}
} catch {
// not a group
}
}
};
await Promise.all(Array.from({ length: GROUP_SCAN_CONCURRENCY }, worker));
return found.sort((a, b) => Number(a.id) - Number(b.id));
},
async listKarSakhteTypes(s) {
const types = new Map<string, string>();
for (const r of await allKarSakhte(s)) {
if (r.fkNoe !== undefined) types.set(String(r.fkNoe), str(r.NoeName) ?? String(r.fkNoe));
}
return [...types].map(([id, name]) => ({ id, name }));
},
async listKarSakhte(s) {
return (await allKarSakhte(s))
.filter((r) => r.id !== undefined && str(r.namFelezsakhthe))
.map((r) => ({
code: String(r.id),
name: str(r.namFelezsakhthe) as string,
// listKarSakhte carries the item's type (گرمی/تعدادی…) but not its
// group (النگو/انگشتر…); category stays the app's call (D27).
groupId: null,
groupName: null,
typeId: str(r.fkNoe),
typeName: str(r.NoeName),
saleWagePercent: dec(r.darsadForoosh),
stockWeight: dec(r.vazn),
stockCount: dec(r.tedad),
}));
},
async getKarSakhteStock(s, item) {
const d = dataOf(await call(s, 'mojoodiKala', { id: item.code, dateTa: toIsoDateTehran(new Date()) }));
const qty = dec(d.mojoodi);
const byWeight = /گرم/.test(String(d.noeName ?? 'گرمی'));
return { weight: byWeight ? qty : null, count: byWeight ? null : qty };
},
async getKarSakhteMovement(s, from, to) {
const json = await call(
s,
'infogardeshsakhte',
{ az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) },
{ timeoutMs: Math.max(s.timeoutMs, MOVEMENT_TIMEOUT_MS), repair: repairZarrinJson },
);
return listOf(json).map(
(r) => ({
itemCode: str(r.code_kar),
itemName: str(r.nam_kar) ?? '—',
// One row per item: its LAST movement, which may predate the range.
date: fullJalali(r.tarikh_akharin_gardesh),
description: str(r.sharh_akharin_gardesh),
weight: dec(r.vazn_akharin_gardesh),
amount: dec(r.mablagh_akharin_gardesh),
count: null,
}),
);
},
async createKarSakhte(s, item) {
const json = (await call(s, 'addFelezsakhteModel', {
nameKar: item.name,
noeKar: item.typeId,
gorooheKar: item.groupId,
})) as Record<string, unknown>;
const id = json.idKar ?? dataOf(json).idKar;
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد کارساخته‌ی جدید برنگشت', false, json);
return { code: String(id), response: json };
},
async postOrderDocument(s, doc, progress, onProgress) {
let factorId = progress.factorId;
let done = progress.doneLines ?? 0;
let last: unknown = null;
for (let i = done; i < doc.lines.length; i += 1) {
const line = doc.lines[i];
const isLast = i === doc.lines.length - 1;
last = await call(s, 'foroushSakhte', {
// The first call opens the invoice for the account; the rest append.
...(factorId ? { idFactor: factorId } : { idHesab: doc.customerCode }),
idSakhte: line.itemCode,
// Bonak mode: mablagh is the rial charge, darsad the per-gram making charge.
mablagh: line.flatRial.toFixed(0),
sharh: line.description,
vazn: line.weightGrams.toFixed(3),
vaznSang: 0,
mablaghSang: 0,
pd: -1, // پرداخت: goods leave to the customer
darsad: line.wagePercent.toString(),
// The invoice has to stay open while lines are still to come; the last
// line closes it unless the document is to stay temporary (D27: it is).
movaghat: doc.temporary || !isLast ? 1 : 0,
bonak: 1,
});
const id = (dataOf(last).idFactor ?? (last as Record<string, unknown>).idFactor) as unknown;
if (!factorId) {
if (id === undefined || id === null) throw new AccountingProviderError('زرین: شماره فاکتور برنگشت', true, last);
factorId = String(id);
}
done = i + 1;
await onProgress({ factorId, doneLines: done });
}
return { ref: String(factorId ?? ''), progress: { factorId, doneLines: done }, response: last };
},
async documentExists() {
// No lookup by our own key exists in Zarrin's API.
return false;
},
async deleteDocument(s, doc) {
if (!doc.ref) throw new AccountingProviderError('زرین: شماره فاکتور سند معلوم نیست');
// hazfRadifSanad needs a row id, which foroushSakhte never returned; find
// one in the account's ledger for the last year.
const today = new Date();
const yearAgo = new Date(today.getTime() - 366 * 24 * 3600 * 1000);
const rows = await ledgerRows(s, doc.customerCode, toIsoDateTehran(yearAgo), toIsoDateTehran(today));
const row = rows.find((r) => String(r.idFactor) === doc.ref && str(r.idRadif) && String(r.idRadif) !== '0');
if (!row) return { response: { alreadyDeleted: true } };
const json = await call(s, 'hazfRadifSanad', {
pkRadifSanad: String(row.idRadif),
kol: 1,
commentUser: 'لغو سفارش در اپ',
});
return { response: json };
},
};
/** Exposed for tests. */
export const __zarrinInternals = { listOf, fullJalali, repairZarrinJson, accountName };
@@ -0,0 +1,56 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
import { envOrNull } from '../../common/env';
/**
* Encrypts the accounting-software token at rest (D27).
*
* The token sits in the Setting table, so a database dump or a backup would
* otherwise hand out a live credential to the shop's books. AES-256-GCM with a
* key derived from `ACCOUNTING_SECRET_KEY`.
*
* When that variable is unset the key falls back to one derived from
* `JWT_SECRET`, with a loud warning at boot — the same "warn, don't refuse"
* trade-off D23 made — so an admin can configure the integration before ops
* add the variable. The cost: rotating JWT_SECRET then makes the stored token
* unreadable and the admin has to enter it again (the screen says so).
*/
const PREFIX = 'enc:v1:';
export function secretKeySource(): 'ACCOUNTING_SECRET_KEY' | 'JWT_SECRET' | null {
if (envOrNull('ACCOUNTING_SECRET_KEY')) return 'ACCOUNTING_SECRET_KEY';
if (envOrNull('JWT_SECRET')) return 'JWT_SECRET';
return null;
}
function key(): Buffer {
const material = envOrNull('ACCOUNTING_SECRET_KEY') ?? envOrNull('JWT_SECRET');
if (!material) {
throw new Error('ACCOUNTING_SECRET_KEY تنظیم نشده است؛ ذخیره‌ی توکن حسابداری ممکن نیست.');
}
return createHash('sha256').update(`hedena-gold-accounting:${material}`).digest();
}
export function sealSecret(plain: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key(), iv);
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return PREFIX + Buffer.concat([iv, tag, body]).toString('base64');
}
/** Null when the value cannot be decrypted (wrong key, tampered, malformed). */
export function openSecret(sealed: string | null | undefined): string | null {
if (!sealed || !sealed.startsWith(PREFIX)) return null;
try {
const raw = Buffer.from(sealed.slice(PREFIX.length), 'base64');
const iv = raw.subarray(0, 12);
const tag = raw.subarray(12, 28);
const body = raw.subarray(28);
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
} catch {
return null;
}
}
@@ -0,0 +1,55 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { LedgerService } from './ledger.service';
import { PostGoldRepaymentDto } from './dto/post-gold-repayment.dto';
import { PostRialRepaymentDto } from './dto/post-rial-repayment.dto';
import { RequirePermission } from '../auth/guards';
/**
* Ledger operations — admin only.
*
* These were `@Public()` until the security hotfix. That was faithful to the
* reference, where the whole admin route file is unauthenticated (the
* `auth:api` group wrapper is commented out at henza_server/routes/admin.php:12
* and :272), but it contradicted decision D1 in docs/parity/DECISIONS.md, which
* confirms the target keeps its stricter auth. Four of these five routes WRITE
* to the ledger, so an anonymous caller could reconcile a balance or post and
* confirm a repayment.
*
* Rids follow the legacy vocabulary: reading a balance is a wallet read,
* repayments are financial documents, and confirming an entry is the existing
* document-confirm permission.
*/
@Controller('accounting')
export class AccountingController {
constructor(private readonly ledger: LedgerService) {}
@Get('customers/:customerId/balance')
@RequirePermission('wallet.list')
getBalance(@Param('customerId') customerId: string) {
return this.ledger.getBalance(customerId);
}
@Post('customers/:customerId/reconcile')
@RequirePermission('wallet.charge')
reconcile(@Param('customerId') customerId: string) {
return this.ledger.reconcileBalance(customerId);
}
@Post('repayments/gold')
@RequirePermission('document.create')
postGoldRepayment(@Body() dto: PostGoldRepaymentDto) {
return this.ledger.postGoldRepayment(dto);
}
@Post('repayments/rial')
@RequirePermission('document.create')
postRialRepayment(@Body() dto: PostRialRepaymentDto) {
return this.ledger.postRialRepayment(dto);
}
@Post('entries/:entryId/confirm')
@RequirePermission('document.list.confirm')
confirmEntry(@Param('entryId') entryId: string) {
return this.ledger.confirmEntry(entryId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LedgerService } from './ledger.service';
import { AccountingController } from './accounting.controller';
@Module({
controllers: [AccountingController],
providers: [LedgerService],
exports: [LedgerService],
})
export class AccountingModule {}
@@ -0,0 +1,26 @@
import { IsIn, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
export class PostGoldRepaymentDto {
@IsString()
customerId!: string;
@IsIn(['MELTED_GOLD', 'TRANSFER', 'CHEQUE', 'CASH'])
instrument!: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
@IsNumber()
@IsPositive()
rawValue!: number;
@IsOptional()
@IsNumber()
karatPerMille?: number;
@IsOptional()
@IsNumber()
@IsPositive()
pricePerGramRial?: number;
@IsOptional()
@IsString()
note?: string;
}
@@ -0,0 +1,14 @@
import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
export class PostRialRepaymentDto {
@IsString()
customerId!: string;
@IsNumber()
@IsPositive()
amount!: number;
@IsOptional()
@IsString()
note?: string;
}
@@ -0,0 +1,94 @@
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
import { LedgerService } from './ledger.service';
/**
* ACC-03 / MON-04 — an order's debt follows its status, exactly as legacy's
* recomputed `Σ orders.input WHERE sts > 4`: owed once while delivered (to the
* expert or the customer), nothing otherwise, never twice.
*/
describe('LedgerService.syncOrderDebt', () => {
type Entry = { orderId: string; currency: LedgerCurrency; direction: LedgerDirection; amount: Prisma.Decimal; confirmed: boolean };
function build(order: { status: OrderStatus; totalGoldWeightGrams: string; flatRialCharge: string }) {
const entries: Entry[] = [];
const balance = { gold: new Prisma.Decimal(0), rial: new Prisma.Decimal(0) };
const row = { id: 'o1', customerId: 'c1', ...order };
const tx = {
order: { findUnique: jest.fn(async () => row) },
ledgerEntry: {
create: jest.fn(async ({ data }: { data: Entry }) => {
entries.push(data);
return data;
}),
groupBy: jest.fn(async ({ where }: { where: { orderId: string; currency: LedgerCurrency } }) => {
const out: { direction: LedgerDirection; _sum: { amount: Prisma.Decimal } }[] = [];
for (const d of [LedgerDirection.DEBIT, LedgerDirection.CREDIT]) {
const hit = entries.filter((e) => e.orderId === where.orderId && e.currency === where.currency && e.direction === d && e.confirmed);
if (hit.length) out.push({ direction: d, _sum: { amount: hit.reduce((s, e) => s.add(e.amount), new Prisma.Decimal(0)) } });
}
return out;
}),
},
customer: {
update: jest.fn(async ({ data }: { data: { goldDebtGrams?: { increment: Prisma.Decimal }; fiatDebtRial?: { increment: Prisma.Decimal } } }) => {
if (data.goldDebtGrams) balance.gold = balance.gold.add(data.goldDebtGrams.increment);
if (data.fiatDebtRial) balance.rial = balance.rial.add(data.fiatDebtRial.increment);
}),
},
};
const service = new LedgerService(tx as never);
const move = async (status: OrderStatus) => {
row.status = status;
await service.syncOrderDebt('o1', tx as never);
};
return { move, entries, balance };
}
it('posts the gold and rial debt once on delivery to the expert', async () => {
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '10.5', flatRialCharge: '2000' });
await t.move(OrderStatus.DELIVERED_TO_AGENT);
expect(t.balance.gold.toString()).toBe('10.5');
expect(t.balance.rial.toString()).toBe('2000');
expect(t.entries).toHaveLength(2);
});
it('never posts twice when the order reaches delivered again (the ACC-03 bug)', async () => {
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '10.5', flatRialCharge: '0' });
await t.move(OrderStatus.DELIVERED_TO_AGENT);
await t.move(OrderStatus.DELIVERED_TO_AGENT);
await t.move(OrderStatus.DELIVERED_TO_CUSTOMER);
expect(t.balance.gold.toString()).toBe('10.5');
expect(t.entries).toHaveLength(1);
});
it('takes the debt off when the order goes back, and puts it back once', async () => {
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '10.5', flatRialCharge: '0' });
await t.move(OrderStatus.DELIVERED_TO_AGENT);
await t.move(OrderStatus.PREPARING);
expect(t.balance.gold.toString()).toBe('0');
expect(t.entries[1].direction).toBe(LedgerDirection.CREDIT);
await t.move(OrderStatus.DELIVERED_TO_AGENT);
expect(t.balance.gold.toString()).toBe('10.5');
});
it('posts the debt when an order goes straight to delivered-to-customer', async () => {
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '3', flatRialCharge: '0' });
await t.move(OrderStatus.DELIVERED_TO_CUSTOMER);
expect(t.balance.gold.toString()).toBe('3');
});
it('a cancelled delivered order owes nothing', async () => {
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '3', flatRialCharge: '500' });
await t.move(OrderStatus.DELIVERED_TO_AGENT);
await t.move(OrderStatus.CANCELLED);
expect(t.balance.gold.toString()).toBe('0');
expect(t.balance.rial.toString()).toBe('0');
});
it('posts nothing for an order that was never delivered', async () => {
const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '3', flatRialCharge: '500' });
await t.move(OrderStatus.ADMIN_APPROVED);
await t.move(OrderStatus.PREPARING);
expect(t.entries).toHaveLength(0);
});
});
@@ -0,0 +1,332 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { LedgerCurrency, LedgerDirection, LedgerSourceType, OrderStatus, Prisma, PrismaClient } from '@prisma/client';
import { GRAMS_PER_MESGHAL, STANDARD_KARAT_PER_MILLE } from '@hedena-gold/shared-types';
import { PrismaService } from '../../prisma/prisma.service';
type Tx = Prisma.TransactionClient | PrismaClient;
const ZERO = new Prisma.Decimal(0);
/** Legacy `sts > 4`: delivered to the expert (5) or to the customer (6). */
const OWING_STATUSES: OrderStatus[] = [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER];
export interface PostEntryInput {
customerId: string;
currency: LedgerCurrency;
direction: LedgerDirection;
sourceType: LedgerSourceType;
amount: Prisma.Decimal.Value;
orderId?: string;
karatPerMille?: number;
pricePerGramRialAtEntry?: Prisma.Decimal.Value;
note?: string;
/** Order-delivery debt entries are self-confirming; repayments start pending. */
confirmed?: boolean;
postedByAdminUserId?: string;
}
/**
* The dual-currency accounting engine. LedgerEntry is append-only and is the
* sole source of truth for customer debt; Customer.goldDebtGrams/fiatDebtRial
* are denormalized caches kept in sync here and self-healed by reconcileBalance.
*/
@Injectable()
export class LedgerService {
constructor(private readonly prisma: PrismaService) {}
/** Posts a ledger entry and, if confirmed, applies it to the cached customer balance. */
async postEntry(input: PostEntryInput, tx: Tx = this.prisma) {
const amount = new Prisma.Decimal(input.amount);
if (amount.lte(0)) {
throw new BadRequestException('Ledger entry amount must be positive');
}
const confirmed = input.confirmed ?? false;
const entry = await tx.ledgerEntry.create({
data: {
customerId: input.customerId,
currency: input.currency,
direction: input.direction,
sourceType: input.sourceType,
amount,
orderId: input.orderId,
karatPerMille: input.karatPerMille,
pricePerGramRialAtEntry:
input.pricePerGramRialAtEntry !== undefined ? new Prisma.Decimal(input.pricePerGramRialAtEntry) : undefined,
note: input.note,
confirmed,
confirmedAt: confirmed ? new Date() : null,
postedByAdminUserId: input.postedByAdminUserId,
},
});
if (confirmed) {
await this.applyToBalance(input.customerId, input.currency, input.direction, amount, tx);
}
return entry;
}
/**
* Marks a pending repayment entry confirmed. Legacy's confirmed toggle
* (Adm\CashController::toggleConfiremCash) is bidirectional and used for
* post-hoc correction/rejection — legacy Cash rows are auto-confirmed
* (confirmed=1) at creation, not created pending. This system intentionally
* requires an explicit confirmation step before a repayment counts (see
* postGoldRepayment for the full rationale) — that is a deliberate design
* change from legacy, not a faithful mirror of it.
*/
async confirmEntry(entryId: string, adminUserId?: string, tx: Tx = this.prisma) {
const entry = await tx.ledgerEntry.findUnique({ where: { id: entryId } });
if (!entry) throw new NotFoundException('Ledger entry not found');
if (entry.confirmed) return entry;
const updated = await tx.ledgerEntry.update({
where: { id: entryId },
data: { confirmed: true, confirmedAt: new Date(), postedByAdminUserId: adminUserId ?? entry.postedByAdminUserId },
});
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
return updated;
}
private async applyToBalance(
customerId: string,
currency: LedgerCurrency,
direction: LedgerDirection,
amount: Prisma.Decimal,
tx: Tx,
) {
const signedDelta = direction === LedgerDirection.DEBIT ? amount : amount.neg();
await tx.customer.update({
where: { id: customerId },
data:
currency === LedgerCurrency.GOLD_GRAM
? { goldDebtGrams: { increment: signedDelta } }
: { fiatDebtRial: { increment: signedDelta } },
});
}
/**
* Brings an order's debt in line with its status — legacy's rule exactly.
*
* Legacy never "posted" order debt; it RECOMPUTED it
* (CustomerController::updateRemain): Σ orders.input WHERE sts > 4, i.e. an
* order owes its full weight (and its rial charge) while it is delivered —
* to the expert (5) or to the customer (6) — and nothing otherwise. So:
* - moving into a delivered status twice never counts twice (ACC-03 /
* MON-04: the old guard on `deliveredAt` let a second DELIVERED_TO_AGENT
* post the debt again);
* - going straight to DELIVERED_TO_CUSTOMER still creates the debt (the
* old trigger fired only on DELIVERED_TO_AGENT, so that order owed nothing);
* - moving back below delivered, or cancelling, takes it off again.
*
* It compares what the order SHOULD owe with the confirmed ORDER_STOCK
* entries already on it and posts only the difference, as a confirmed
* DEBIT or CREDIT — so it is idempotent and nothing is ever edited or
* deleted (the ledger stays append-only). Entries imported from legacy carry
* their orderId, so imported orders reconcile the same way.
*/
async syncOrderDebt(orderId: string, tx: Tx = this.prisma) {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException('Order not found');
const owes = OWING_STATUSES.includes(order.status);
const targets: [LedgerCurrency, Prisma.Decimal, string][] = [
[LedgerCurrency.GOLD_GRAM, owes ? new Prisma.Decimal(order.totalGoldWeightGrams) : ZERO, 'Gold-weight debt'],
[LedgerCurrency.RIAL, owes ? new Prisma.Decimal(order.flatRialCharge) : ZERO, 'Flat Rial charge (e.g. stones)'],
];
for (const [currency, should, label] of targets) {
const posted = await tx.ledgerEntry.groupBy({
by: ['direction'],
where: { orderId, currency, sourceType: LedgerSourceType.ORDER_STOCK, confirmed: true },
_sum: { amount: true },
});
const sum = (d: LedgerDirection) => posted.find((p) => p.direction === d)?._sum.amount ?? ZERO;
const current = new Prisma.Decimal(sum(LedgerDirection.DEBIT)).sub(sum(LedgerDirection.CREDIT));
const diff = should.sub(current);
if (diff.isZero()) continue;
await this.postEntry(
{
customerId: order.customerId,
currency,
direction: diff.gt(0) ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
sourceType: LedgerSourceType.ORDER_STOCK,
amount: diff.abs(),
orderId: order.id,
confirmed: true,
note: owes ? `${label} — order delivered (${order.status})` : `${label} reversed — order moved to ${order.status}`,
},
tx,
);
}
}
/**
* Normalizes a physical repayment instrument into gold grams and posts a
* pending (unconfirmed) CREDIT entry against the customer's gold debt.
* Formulas preserved verbatim from legacy CashController::store. Note:
* legacy auto-confirms (confirmed=1) every Cash row at creation — posting
* this as pending instead is an intentional design change, not a mirror of
* legacy's default.
*/
async postGoldRepayment(
params: {
customerId: string;
instrument: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
rawValue: Prisma.Decimal.Value;
karatPerMille?: number;
pricePerGramRial?: Prisma.Decimal.Value;
note?: string;
postedByAdminUserId?: string;
},
tx: Tx = this.prisma,
) {
const raw = new Prisma.Decimal(params.rawValue);
let goldGramsValue: Prisma.Decimal;
let sourceType: LedgerSourceType;
switch (params.instrument) {
case 'MELTED_GOLD': {
if (!params.karatPerMille) {
throw new BadRequestException('karatPerMille is required for a melted-gold repayment');
}
// legacy: value = original_value * ayar / 750
goldGramsValue = raw.mul(params.karatPerMille).div(STANDARD_KARAT_PER_MILLE);
sourceType = LedgerSourceType.REPAYMENT_MELTED_GOLD;
break;
}
case 'CASH': {
if (!params.pricePerGramRial) {
throw new BadRequestException('pricePerGramRial is required for a cash repayment');
}
// legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim;
// flagged in migration analysis as a formula worth stakeholder review.
const pricePerGram = new Prisma.Decimal(params.pricePerGramRial);
goldGramsValue = raw.div(pricePerGram.div(GRAMS_PER_MESGHAL));
sourceType = LedgerSourceType.REPAYMENT_CASH;
break;
}
case 'TRANSFER':
goldGramsValue = raw;
sourceType = LedgerSourceType.REPAYMENT_TRANSFER;
break;
case 'CHEQUE':
goldGramsValue = raw;
sourceType = LedgerSourceType.REPAYMENT_CHEQUE;
break;
}
return this.postEntry(
{
customerId: params.customerId,
currency: LedgerCurrency.GOLD_GRAM,
direction: LedgerDirection.CREDIT,
sourceType,
amount: goldGramsValue,
karatPerMille: params.karatPerMille,
pricePerGramRialAtEntry: params.pricePerGramRial,
note: params.note,
confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
postedByAdminUserId: params.postedByAdminUserId,
},
tx,
);
}
/**
* Direct Rial-debt repayment. Not present in the legacy system (its
* rial_remain had no repayment offset at all — a gap flagged during
* migration analysis) but supported here since the new ledger is symmetric.
*/
async postRialRepayment(
params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminUserId?: string },
tx: Tx = this.prisma,
) {
return this.postEntry(
{
customerId: params.customerId,
currency: LedgerCurrency.RIAL,
direction: LedgerDirection.CREDIT,
sourceType: LedgerSourceType.REPAYMENT_CASH,
amount: params.amount,
note: params.note,
confirmed: false,
postedByAdminUserId: params.postedByAdminUserId,
},
tx,
);
}
async postAdjustment(
params: {
customerId: string;
currency: LedgerCurrency;
direction: LedgerDirection;
amount: Prisma.Decimal.Value;
note: string;
postedByAdminUserId: string;
},
tx: Tx = this.prisma,
) {
return this.postEntry(
{
customerId: params.customerId,
currency: params.currency,
direction: params.direction,
sourceType: LedgerSourceType.ADJUSTMENT,
amount: params.amount,
note: params.note,
confirmed: true,
postedByAdminUserId: params.postedByAdminUserId,
},
tx,
);
}
async getBalance(customerId: string) {
const customer = await this.prisma.customer.findUnique({
where: { id: customerId },
select: { id: true, goldDebtGrams: true, fiatDebtRial: true, goldCreditLimitGrams: true },
});
if (!customer) throw new NotFoundException('Customer not found');
return {
customerId: customer.id,
goldDebtGrams: customer.goldDebtGrams,
fiatDebtRial: customer.fiatDebtRial,
goldCreditLimitGrams: customer.goldCreditLimitGrams,
};
}
/**
* Recomputes cached balances from confirmed LedgerEntry rows and overwrites
* the cache — a self-healing reconciliation, run periodically or on demand,
* unlike legacy's recompute-on-every-write (Customer::updateRemain).
*/
async reconcileBalance(customerId: string) {
const [goldDebits, goldCredits, rialDebits, rialCredits] = await Promise.all([
this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.DEBIT),
this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.CREDIT),
this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.DEBIT),
this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.CREDIT),
]);
return this.prisma.customer.update({
where: { id: customerId },
data: {
goldDebtGrams: goldDebits.sub(goldCredits),
fiatDebtRial: rialDebits.sub(rialCredits),
},
});
}
private async sumConfirmed(customerId: string, currency: LedgerCurrency, direction: LedgerDirection) {
const result = await this.prisma.ledgerEntry.aggregate({
where: { customerId, currency, direction, confirmed: true },
_sum: { amount: true },
});
return result._sum.amount ?? new Prisma.Decimal(0);
}
}
@@ -0,0 +1,57 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminBranchesService } from './admin-branches.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
/**
* Legacy endpoints (BranchController + WorkshopController):
* POST branch/table
* POST branch, workshop -> create
* GET/PUT branch/{id}, workshop/{id}
* GET branch/active/{id}, workshop/active/{id} -> toggle
* GET user/list -> the branch-manager picker
*/
@Controller('admin/branches')
@SuperAdminOnly()
export class AdminBranchesController {
constructor(private readonly branches: AdminBranchesService) {}
// ── branches ──────────────────────────────────────────────────────
@Post('list')
@HttpCode(200)
@RequirePermission('branch.list')
listBranches(@Body() query: TableQueryDto) {
return this.branches.listBranches(query);
}
@Get('user-list')
@RequirePermission('branch.create')
userList() {
return this.branches.userList();
}
@Post()
@HttpCode(200)
@RequirePermission('branch.create')
createBranch(@Body() data: Record<string, unknown>) {
return this.branches.createBranch(data);
}
@Get('active/:id')
@RequirePermission('branch.list')
toggleBranchActive(@Param('id') id: string) {
return this.branches.toggleBranchActive(id);
}
@Get(':id')
@RequirePermission('branch.list')
findBranch(@Param('id') id: string) {
return this.branches.findBranch(id);
}
@Put(':id')
@RequirePermission('branch.list')
updateBranch(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.branches.updateBranch(id, data);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminBranchesController } from './admin-branches.controller';
import { AdminBranchesService } from './admin-branches.service';
@Module({
imports: [PrismaModule],
controllers: [AdminBranchesController],
providers: [AdminBranchesService],
// The workshops module reuses this service — branches and workshops are
// near-identical screens and share their logic.
exports: [AdminBranchesService],
})
export class AdminBranchesModule {}
@@ -0,0 +1,362 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AdminRole, Prisma } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { NO_PASSWORD_YET_HASH } from '../auth/password-hash';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Branches and workshops — legacy views/branch/* and views/workshop/*, served by
* BranchController and WorkshopController:
* POST branch/table | workshop/table -> the grids
* POST branch | workshop -> create
* GET branch/{id} | workshop/{id} -> one row
* PUT branch/{id} | workshop/{id} -> update
* GET branch/active/{id} | workshop/active/{id} -> toggle active
*
* The two screens differ in how their manager is handled, and that difference is
* preserved: a BRANCH picks an existing admin (`user_id`), while a WORKSHOP
* creates its supervisor inline from three fields (user_fname/lname/phone).
*/
@Injectable()
export class AdminBranchesService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
// ── Branches ───────────────────────────────────────────────────────
/** Legacy `POST branch/table`. */
async listBranches(query: TableQueryDto) {
this.table.assertNotExport(query);
const aliases: Record<string, string> = { 'user.name': 'adminUsers.some.fullName' };
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['name', 'address', 'phone'],
allowed: ['id', 'name', 'address', 'phone', 'isActive', 'cityId', 'city.name'],
defaultOrderBy: { name: 'asc' },
});
void aliases;
const [rows, total] = await Promise.all([
this.prisma.branch.findMany({
where: where as Prisma.BranchWhereInput,
orderBy: orderBy as Prisma.BranchOrderByWithRelationInput,
skip,
take,
include: {
city: true,
adminUsers: { where: { isActive: true }, take: 1 },
_count: { select: { adminUsers: true, orders: true } },
},
}),
this.prisma.branch.count({ where: where as Prisma.BranchWhereInput }),
]);
return this.table.respond(
rows.map((b) => ({
id: b.id,
name: b.name,
address: b.address,
phone: b.phone,
active: b.isActive ? 1 : 0,
is_active: b.isActive,
city_id: b.cityId,
city: b.city ? { id: b.city.id, title: b.city.name } : null,
// legacy column key is `user.name` — the branch manager
user: b.adminUsers[0]
? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName }
: null,
users_count: b._count.adminUsers,
orders_count: b._count.orders,
})),
total,
query,
page,
limit,
);
}
async findBranch(id: string) {
const b = await this.prisma.branch.findUnique({
where: { id },
include: { city: true, adminUsers: { where: { isActive: true }, take: 1 } },
});
if (!b) throw new NotFoundException('شعبه یافت نشد');
return {
id: b.id,
name: b.name,
address: b.address,
phone: b.phone,
is_active: b.isActive,
city_id: b.cityId,
user_id: b.adminUsers[0]?.id ?? null,
user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null,
};
}
/** Legacy `POST branch` — the manager is an EXISTING admin picked by id. */
async createBranch(data: Record<string, unknown>) {
const name = String(data.name ?? '').trim();
if (!name) throw new BadRequestException('نام شعبه الزامی است');
const branch = await this.prisma.branch.create({
data: {
name,
address: (data.address as string) || null,
phone: (data.phone as string) || null,
cityId: (data.city_id as string) || null,
},
});
if (data.user_id) {
await this.assignBranchManager(branch.id, String(data.user_id));
}
return this.findBranch(branch.id);
}
/** Legacy `PUT branch/{id}`. */
async updateBranch(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.branch.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('شعبه یافت نشد');
const patch: Prisma.BranchUpdateInput = {};
if (data.name !== undefined) patch.name = String(data.name);
if (data.address !== undefined) patch.address = (data.address as string) || null;
if (data.phone !== undefined) patch.phone = (data.phone as string) || null;
if (data.city_id !== undefined) {
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
}
await this.prisma.branch.update({ where: { id }, data: patch });
if (data.user_id !== undefined) {
await this.assignBranchManager(id, (data.user_id as string) || null);
}
return this.findBranch(id);
}
/**
* Moves the named admin onto this branch and clears whoever was on it before,
* so a branch keeps exactly one manager. Legacy did the same through its
* `->update(['branch_id' => null])` / `->update(['branch_id' => $id])` pair.
*/
private async assignBranchManager(branchId: string, userId: string | null) {
await this.prisma.$transaction(async (tx) => {
await tx.adminUser.updateMany({ where: { branchId }, data: { branchId: null } });
if (userId) {
const user = await tx.adminUser.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException('کاربر انتخاب‌شده یافت نشد');
await tx.adminUser.update({ where: { id: userId }, data: { branchId } });
}
});
}
/** Legacy `GET branch/active/{id}` — a toggle. */
async toggleBranchActive(id: string) {
const b = await this.prisma.branch.findUnique({ where: { id } });
if (!b) throw new NotFoundException('شعبه یافت نشد');
return this.prisma.branch.update({
where: { id },
data: { isActive: !b.isActive },
});
}
// ── Workshops ──────────────────────────────────────────────────────
/** Legacy `POST workshop/table`. */
async listWorkshops(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['name', 'address', 'phone'],
allowed: ['id', 'name', 'address', 'phone', 'isActive'],
defaultOrderBy: { name: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.workshop.findMany({
where: where as Prisma.WorkshopWhereInput,
orderBy: orderBy as Prisma.WorkshopOrderByWithRelationInput,
skip,
take,
include: { supervisor: true, _count: { select: { preOrders: true } } },
}),
this.prisma.workshop.count({ where: where as Prisma.WorkshopWhereInput }),
]);
return this.table.respond(
rows.map((w) => this.workshopRow(w)),
total,
query,
page,
limit,
);
}
private workshopRow(
w: Prisma.WorkshopGetPayload<{
include: { supervisor: true; _count: { select: { preOrders: true } } };
}>,
) {
return {
id: w.id,
name: w.name,
address: w.address,
phone: w.phone,
active: w.isActive ? 1 : 0,
is_active: w.isActive,
user: w.supervisor ? { id: w.supervisor.id, name: w.supervisor.fullName } : null,
user_id: w.supervisorId,
preorders_count: w._count.preOrders,
};
}
async findWorkshop(id: string) {
const w = await this.prisma.workshop.findUnique({
where: { id },
include: { supervisor: true, _count: { select: { preOrders: true } } },
});
if (!w) throw new NotFoundException('کارگاه یافت نشد');
const row = this.workshopRow(w);
// The edit form splits the supervisor back into its three fields.
const parts = String(w.supervisor?.fullName ?? '').trim().split(' ');
return {
...row,
workshop_name: w.name,
workshop_address: w.address,
workshop_phone: w.phone,
user_fname: parts[0] ?? '',
user_lname: parts.slice(1).join(' '),
user_phone: w.supervisor?.mobile ?? '',
};
}
/**
* Legacy `POST workshop` — creates the workshop AND its supervisor together,
* from user_fname / user_lname / user_phone.
*/
async createWorkshop(data: Record<string, unknown>) {
const name = String(data.workshop_name ?? data.name ?? '').trim();
if (!name) throw new BadRequestException('نام کارگاه الزامی است');
const supervisorId = await this.upsertSupervisor(data, null);
const w = await this.prisma.workshop.create({
data: {
name,
address: (data.workshop_address as string) || (data.address as string) || null,
phone: (data.workshop_phone as string) || (data.phone as string) || null,
supervisorId,
},
});
return this.findWorkshop(w.id);
}
/** Legacy `PUT workshop/{id}`. */
async updateWorkshop(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.workshop.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('کارگاه یافت نشد');
const patch: Prisma.WorkshopUpdateInput = {};
if (data.workshop_name !== undefined || data.name !== undefined) {
patch.name = String(data.workshop_name ?? data.name);
}
if (data.workshop_address !== undefined || data.address !== undefined) {
patch.address = ((data.workshop_address ?? data.address) as string) || null;
}
if (data.workshop_phone !== undefined || data.phone !== undefined) {
patch.phone = ((data.workshop_phone ?? data.phone) as string) || null;
}
const supervisorId = await this.upsertSupervisor(data, existing.supervisorId);
if (supervisorId !== undefined) {
patch.supervisor = supervisorId
? { connect: { id: supervisorId } }
: { disconnect: true };
}
await this.prisma.workshop.update({ where: { id }, data: patch });
return this.findWorkshop(id);
}
/**
* Creates or updates the workshop's supervisor from the inline fields.
* Returns the id to link, or `undefined` when the form sent no supervisor data
* at all (so an update leaves the existing link alone).
*/
private async upsertSupervisor(
data: Record<string, unknown>,
currentId: string | null,
): Promise<string | null | undefined> {
const fname = String(data.user_fname ?? '').trim();
const lname = String(data.user_lname ?? '').trim();
const phone = String(data.user_phone ?? '').trim();
if (!fname && !lname && !phone) return undefined;
if (!phone) throw new BadRequestException('موبایل سرپرست کارگاه الزامی است');
const fullName = `${fname} ${lname}`.trim();
if (currentId) {
const current = await this.prisma.adminUser.findUnique({ where: { id: currentId } });
if (current) {
if (phone !== current.mobile) {
const clash = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
if (clash && clash.id !== currentId) {
throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
}
}
await this.prisma.adminUser.update({
where: { id: currentId },
data: { fullName, mobile: phone },
});
return currentId;
}
}
// Reuse an existing admin with that mobile rather than colliding on the
// unique constraint.
const existing = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
if (existing) return existing.id;
const created = await this.prisma.adminUser.create({
data: {
fullName,
mobile: phone,
// No password is set: the supervisor cannot sign in until one is issued.
passwordHash: NO_PASSWORD_YET_HASH,
role: AdminRole.WORKSHOP_SUPERVISOR,
},
});
return created.id;
}
/** Legacy `GET workshop/active/{id}` — a toggle. */
async toggleWorkshopActive(id: string) {
const w = await this.prisma.workshop.findUnique({ where: { id } });
if (!w) throw new NotFoundException('کارگاه یافت نشد');
return this.prisma.workshop.update({
where: { id },
data: { isActive: !w.isActive },
});
}
/** The admin picker the branch form uses — legacy `GET user/list`. */
async userList() {
const rows = await this.prisma.adminUser.findMany({
where: { isActive: true },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true, mobile: true, branchId: true },
});
return rows.map((u) => ({
id: u.id,
name: u.fullName,
mobile: u.mobile,
branch_id: u.branchId,
}));
}
}
@@ -0,0 +1,96 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
import { AdminCategoriesService } from './admin-categories.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
/**
* Legacy endpoints (CategoryController + the collection screens):
* POST category -> the grid (a read expressed as POST, legacy's shape)
* POST category/ -> create
* PUT category/{id} -> update
* DELETE category/{id} -> delete
* GET ciny -> collections ("سینی")
*/
@Controller('admin/categories')
export class AdminCategoriesController {
constructor(private readonly categories: AdminCategoriesService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('collection.list')
list(@Body() query: TableQueryDto) {
return this.categories.list(query);
}
@Get('tree')
@RequirePermission('collection.list')
tree(@Query('type') type?: string) {
return this.categories.tree(type ? Number(type) : undefined);
}
@Get('type-options')
@RequirePermission('collection.list')
typeOptions() {
return this.categories.typeOptions();
}
// ── collections ("سینی") ─────────────────────────────────────────
@Post('collections/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('collection.list')
listCollections(@Body() query: TableQueryDto) {
return this.categories.listCollections(query);
}
@Post('collections')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('collection.create')
createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) {
return this.categories.createCollection(body);
}
@Put('collections/:id')
@SuperAdminOnly()
@RequirePermission('collection.list.edit')
updateCollection(
@Param('id') id: string,
@Body() body: { title?: string; description?: string; is_active?: boolean },
) {
return this.categories.updateCollection(id, body);
}
@Delete('collections/:id')
@SuperAdminOnly()
@RequirePermission('collection.list.edit')
removeCollection(@Param('id') id: string) {
return this.categories.removeCollection(id);
}
// ── categories ────────────────────────────────────────────────────
@Post()
@HttpCode(200)
@RequirePermission('collection.create')
create(@Body() data: Record<string, unknown>) {
return this.categories.create(data);
}
@Get(':id')
@RequirePermission('collection.list')
findOne(@Param('id') id: string) {
return this.categories.findOne(id);
}
@Put(':id')
@RequirePermission('collection.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.categories.update(id, data);
}
@Delete(':id')
@RequirePermission('collection.list.edit')
remove(@Param('id') id: string) {
return this.categories.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminCategoriesController } from './admin-categories.controller';
import { AdminCategoriesService } from './admin-categories.service';
@Module({
imports: [PrismaModule],
controllers: [AdminCategoriesController],
providers: [AdminCategoriesService],
})
export class AdminCategoriesModule {}
@@ -0,0 +1,287 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Category + Collection administration — legacy views/apps/category/*
* (CategoryController: `POST category`, `POST category/`, `PUT category/{id}`,
* `DELETE category/{id}`) and the "سینی" collection screens.
*
* Legacy Category.type: 1 سایت | 2 اپلیکشن موبایل.
*/
const TYPE_LABELS: Record<number, string> = {
1: 'سایت',
2: 'اپلیکشن موبایل',
};
@Injectable()
export class AdminCategoriesService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'title',
'type',
'parentId',
'parent.title',
// legacy filter keys
'type_str',
];
/** Legacy `POST category` — the category grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) =>
f.field === 'type_str' ? { ...f, field: 'type', value: Number(f.value) } : f,
),
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['title'],
allowed: AdminCategoriesService.ALLOWED,
defaultOrderBy: { title: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.category.findMany({
where: where as Prisma.CategoryWhereInput,
orderBy: orderBy as Prisma.CategoryOrderByWithRelationInput,
skip,
take,
include: { parent: true, _count: { select: { products: true, children: true } } },
}),
this.prisma.category.count({ where: where as Prisma.CategoryWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
c: Prisma.CategoryGetPayload<{
include: { parent: true; _count: { select: { products: true; children: true } } };
}>,
) {
return {
id: c.id,
title: c.title,
type: c.type,
type_str: TYPE_LABELS[c.type] ?? '-',
image: c.imageUrl,
icon: c.iconUrl,
meta: c.metaTag,
body_html: c.description,
parent_id_id: c.parentId,
parent: c.parent ? { id: c.parent.id, title: c.parent.title } : null,
products_count: c._count.products,
children_count: c._count.children,
};
}
async findOne(id: string) {
const c = await this.prisma.category.findUnique({
where: { id },
include: { parent: true, _count: { select: { products: true, children: true } } },
});
if (!c) throw new NotFoundException('دسته بندی یافت نشد');
return this.toRow(c);
}
/** The parent picker — legacy loaded the full list and built a tree client-side. */
async tree(type?: number) {
const rows = await this.prisma.category.findMany({
where: type ? { type } : {},
orderBy: { title: 'asc' },
include: { parent: true },
});
return rows.map((c) => ({
id: c.id,
title: c.title,
type: c.type,
parent_id_id: c.parentId,
}));
}
/** Legacy `POST category/`. */
async create(data: Record<string, unknown>) {
const title = String(data.title ?? '').trim();
if (!title) throw new BadRequestException('عنوان دسته الزامی است');
return this.prisma.category.create({
data: {
title,
type: data.type !== undefined ? Number(data.type) : 1,
imageUrl: (data.image as string) || null,
iconUrl: (data.icon as string) || null,
metaTag: (data.meta as string) || null,
description: (data.body_html as string) || null,
parentId: (data.parent_id_id as string) || null,
},
});
}
/** Legacy `PUT category/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.category.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('دسته بندی یافت نشد');
// A category cannot be its own parent, nor a descendant's child — that would
// create a cycle in the tree and hang any recursive render. Legacy had no
// such guard.
const newParent = data.parent_id_id as string | undefined;
if (newParent) {
if (newParent === id) {
throw new BadRequestException('یک دسته نمی‌تواند پدر خودش باشد');
}
if (await this.isDescendant(newParent, id)) {
throw new BadRequestException('دسته‌ی انتخاب‌شده زیرمجموعه‌ی همین دسته است');
}
}
const patch: Prisma.CategoryUpdateInput = {};
if (data.title !== undefined) patch.title = String(data.title);
if (data.type !== undefined) patch.type = Number(data.type);
if (data.image !== undefined) patch.imageUrl = (data.image as string) || null;
if (data.icon !== undefined) patch.iconUrl = (data.icon as string) || null;
if (data.meta !== undefined) patch.metaTag = (data.meta as string) || null;
if (data.body_html !== undefined) patch.description = (data.body_html as string) || null;
if (data.parent_id_id !== undefined) {
patch.parent = newParent ? { connect: { id: newParent } } : { disconnect: true };
}
return this.prisma.category.update({ where: { id }, data: patch });
}
/** Walks up from `candidate` to see whether `ancestorId` is above it. */
private async isDescendant(candidate: string, ancestorId: string): Promise<boolean> {
let cursor: string | null = candidate;
const seen = new Set<string>();
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
const row: { parentId: string | null } | null = await this.prisma.category.findUnique({
where: { id: cursor },
select: { parentId: true },
});
if (!row?.parentId) return false;
if (row.parentId === ancestorId) return true;
cursor = row.parentId;
}
return false;
}
/** Legacy `DELETE category/{id}`. */
async remove(id: string) {
const c = await this.prisma.category.findUnique({
where: { id },
include: { _count: { select: { products: true, children: true } } },
});
if (!c) throw new NotFoundException('دسته بندی یافت نشد');
// Refuses while anything still hangs off it. Legacy deleted regardless,
// which orphaned products and left child categories pointing at a missing
// parent. Recorded in docs/ADMIN-PORT-PLAN.md.
if (c._count.products > 0) {
throw new BadRequestException(
`حذف ممکن نیست: ${c._count.products} محصول در این دسته قرار دارد.`,
);
}
if (c._count.children > 0) {
throw new BadRequestException(
`حذف ممکن نیست: این دسته ${c._count.children} زیردسته دارد.`,
);
}
await this.prisma.category.delete({ where: { id } });
return { success: true };
}
typeOptions() {
return Object.entries(TYPE_LABELS).map(([value, title]) => ({ value: Number(value), title }));
}
// ── Collections ("سینی") ───────────────────────────────────────────
/** Legacy `GET ciny` / the collection grid. */
async listCollections(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title'],
allowed: ['id', 'title', 'isActive'],
defaultOrderBy: { title: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.collection.findMany({
where: where as Prisma.CollectionWhereInput,
orderBy: orderBy as Prisma.CollectionOrderByWithRelationInput,
skip,
take,
include: { _count: { select: { products: true } } },
}),
this.prisma.collection.count({ where: where as Prisma.CollectionWhereInput }),
]);
return this.table.respond(
rows.map((c) => ({
id: c.id,
title: c.title,
description: c.description,
is_active: c.isActive,
products_count: c._count.products,
})),
total,
query,
page,
limit,
);
}
async createCollection(data: { title: string; description?: string; is_active?: boolean }) {
if (!data.title?.trim()) throw new BadRequestException('عنوان سینی الزامی است');
return this.prisma.collection.create({
data: {
title: data.title.trim(),
description: data.description ?? null,
isActive: data.is_active ?? true,
},
});
}
async updateCollection(
id: string,
data: { title?: string; description?: string; is_active?: boolean },
) {
const existing = await this.prisma.collection.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('سینی یافت نشد');
return this.prisma.collection.update({
where: { id },
data: {
...(data.title !== undefined ? { title: data.title } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.is_active !== undefined ? { isActive: data.is_active } : {}),
},
});
}
async removeCollection(id: string) {
const c = await this.prisma.collection.findUnique({ where: { id } });
if (!c) throw new NotFoundException('سینی یافت نشد');
// The join rows are this collection's own membership list, so clearing them
// removes no product — unlike the category case above.
await this.prisma.$transaction([
this.prisma.collectionProduct.deleteMany({ where: { collectionId: id } }),
this.prisma.collection.delete({ where: { id } }),
]);
return { success: true };
}
}
@@ -0,0 +1,31 @@
import { Controller, HttpCode, Post } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
/**
* Legacy `POST cities/list` — the city lookup the table filters use.
* Returns `{ rows }`, matching what the users store's setCities() reads.
*
* Deliberately NOT permission-gated: legacy called this from table models at
* module load, before any rid check, and it exposes nothing sensitive.
*/
@Controller('admin/cities')
export class AdminCitiesController {
constructor(private readonly prisma: PrismaService) {}
@Post('list')
@HttpCode(200)
async list() {
const cities = await this.prisma.city.findMany({ orderBy: { name: 'asc' } });
// legacy shape: the cities row — { id, title, parent, percent, percent_aux }.
// `parent` lets the admin pickers draw legacy's tree (PERS-02).
return {
rows: cities.map((c) => ({
id: c.id,
title: c.name,
parent: c.parentId,
percent: c.percent,
percent_aux: c.percentAux,
})),
};
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminCitiesController } from './admin-cities.controller';
@Module({
imports: [PrismaModule],
controllers: [AdminCitiesController],
})
export class AdminCitiesModule {}
@@ -0,0 +1,59 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post } from '@nestjs/common';
import { AdminCommentsService } from './admin-comments.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints (CommentController, routes/admin.php:149-152):
* POST comment/table -> the moderation grid
* POST comment/sts -> approve / reject
* POST comment -> staff reply
* DELETE comment/{id}
*
* All gated on the legacy rid `comment`.
*/
@Controller('admin/comments')
export class AdminCommentsController {
constructor(private readonly comments: AdminCommentsService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('comment')
list(@Body() query: TableQueryDto) {
return this.comments.list(query);
}
@Get('status-options')
@RequirePermission('comment')
statusOptions() {
return this.comments.statusOptions();
}
@Post('sts')
@HttpCode(200)
@RequirePermission('comment')
changeStatus(@Body() body: { id: string; sts: number }) {
return this.comments.changeStatus(body.id, body.sts);
}
@Post()
@HttpCode(200)
@RequirePermission('comment')
create(
@Body() body: { product_id: string; customer_id: string; comment: string; reply_to?: string },
) {
return this.comments.create(body);
}
@Get(':id')
@RequirePermission('comment')
findOne(@Param('id') id: string) {
return this.comments.findOne(id);
}
@Delete(':id')
@RequirePermission('comment')
remove(@Param('id') id: string) {
return this.comments.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminCommentsController } from './admin-comments.controller';
import { AdminCommentsService } from './admin-comments.service';
@Module({
imports: [PrismaModule],
controllers: [AdminCommentsController],
providers: [AdminCommentsService],
})
export class AdminCommentsModule {}
@@ -0,0 +1,204 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CommentStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Product comment moderation — legacy views/customer/Comments/Comments.vue,
* served by CommentController (`POST comment/table`, `POST comment/sts`,
* `POST comment`, `DELETE comment/{id}`).
*
* Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده'].
* The numeric codes are preserved on the wire (`sts`) so the ported view's
* status dropdown posts unchanged, while the DB stores an enum.
*/
const STATUS_BY_CODE: Record<number, CommentStatus> = {
1: CommentStatus.PENDING,
2: CommentStatus.APPROVED,
3: CommentStatus.REJECTED,
};
const CODE_BY_STATUS: Record<CommentStatus, number> = {
PENDING: 1,
APPROVED: 2,
REJECTED: 3,
};
const STATUS_LABELS: Record<CommentStatus, string> = {
PENDING: 'در انتظار تایید',
APPROVED: 'تایید شده',
REJECTED: 'رد شده',
};
@Injectable()
export class AdminCommentsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'status',
'createdAt',
'updatedAt',
'body',
'customer.fullName',
'customer.mobile',
'product.title',
// legacy filter keys
'sts',
'comment',
'caj',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
sts: 'status',
comment: 'body',
caj: 'createdAt',
uaj: 'updatedAt',
'customer.name': 'customer.fullName',
'product.name': 'product.title',
};
/** Legacy `POST comment/table`. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) =>
f ? (AdminCommentsService.FIELD_ALIASES[f] ?? f) : f;
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
// legacy sent the numeric status code
if (mapped.field === 'status' && mapped.value !== undefined) {
mapped.value = STATUS_BY_CODE[Number(mapped.value)] ?? mapped.value;
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['body', 'customer.fullName', 'product.title'],
allowed: AdminCommentsService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.comment.findMany({
where: where as Prisma.CommentWhereInput,
orderBy: orderBy as Prisma.CommentOrderByWithRelationInput,
skip,
take,
include: { customer: true, product: true, replyTo: true, replies: true },
}),
this.prisma.comment.count({ where: where as Prisma.CommentWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
c: Prisma.CommentGetPayload<{
include: { customer: true; product: true; replyTo: true; replies?: true };
}> & { replies?: { id: string; body: string }[] },
) {
return {
id: c.id,
comment: c.body,
body: c.body,
sts: CODE_BY_STATUS[c.status],
sts_str: STATUS_LABELS[c.status],
status: c.status,
reply_to: c.replyToId,
// legacy view reads: text.length > 0 && text[0].comment !== null
reply: (c.replies ?? []).map((r) => ({ id: r.id, comment: r.body })),
product_id: c.productId,
customer_id: c.customerId,
product: { id: c.product.id, title: c.product.title, name: c.product.title },
customer: {
id: c.customer.id,
name: c.customer.fullName,
// D0 — real name parts
fname: c.customer.firstName ?? c.customer.fullName,
lname: c.customer.lastName ?? '',
mobile: c.customer.mobile,
},
caj: c.createdAt,
uaj: c.updatedAt,
};
}
async findOne(id: string) {
const c = await this.prisma.comment.findUnique({
where: { id },
include: { customer: true, product: true, replyTo: true, replies: true },
});
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
return this.toRow(c);
}
/**
* Legacy `POST comment/sts` — moderate a comment.
* Payload is `{ id, sts }` where sts is 1/2/3.
*/
async changeStatus(id: string, sts: number) {
const c = await this.prisma.comment.findUnique({ where: { id } });
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
const status = STATUS_BY_CODE[Number(sts)];
if (!status) {
throw new NotFoundException('وضعیت نامعتبر است');
}
return this.prisma.comment.update({ where: { id }, data: { status } });
}
/** Legacy `POST comment` — post a staff reply to a comment. */
async create(data: {
product_id: string;
customer_id: string;
comment: string;
reply_to?: string;
}) {
return this.prisma.comment.create({
data: {
productId: data.product_id,
customerId: data.customer_id,
body: data.comment,
replyToId: data.reply_to ?? null,
// A staff reply is visible immediately; legacy did the same.
status: CommentStatus.APPROVED,
},
});
}
/** Legacy `DELETE comment/{id}`. */
async remove(id: string) {
const c = await this.prisma.comment.findUnique({
where: { id },
include: { replies: true },
});
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
// Remove replies first so the self-relation does not block the delete.
await this.prisma.$transaction([
this.prisma.comment.deleteMany({ where: { replyToId: id } }),
this.prisma.comment.delete({ where: { id } }),
]);
return { success: true };
}
/** Farsi labels for the status dropdown, keyed by legacy's numeric codes. */
statusOptions() {
return Object.entries(STATUS_LABELS).map(([status, title]) => ({
value: CODE_BY_STATUS[status as CommentStatus],
title,
}));
}
}
@@ -0,0 +1,201 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminCustomersService, type OmdeScope } from './admin-customers.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Wholesale customer lists. Legacy endpoints, one per near-identical view:
* POST customer -> OmdeList
* POST banker -> BankerList
* POST shopkeeper -> ShopkeeperList
* POST major/verify/table -> OmdeNonVerifiedList
* POST major/archive/table -> OmdeArchivedList
* POST customer/toggle-active, customer/toggle-sts, major/verify
* GET major/archive/{id}, major/dashboard/{id}
*/
@Controller('admin/customers-omde')
export class AdminCustomersController {
constructor(private readonly customers: AdminCustomersService) {}
private list(query: TableQueryDto, scope: OmdeScope) {
return this.customers.list(query, scope);
}
@Post('list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listAll(@Body() query: TableQueryDto) {
return this.list(query, 'all');
}
@Post('banker/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('persons.list.client.omde')
listBanker(@Body() query: TableQueryDto) {
return this.list(query, 'banker');
}
@Post('shopkeeper/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('persons.list.client.omde')
listShopkeeper(@Body() query: TableQueryDto) {
return this.list(query, 'shopkeeper');
}
@Post('non-verified/list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listNonVerified(@Body() query: TableQueryDto) {
return this.list(query, 'non_verified');
}
@Post('archived/list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listArchived(@Body() query: TableQueryDto) {
return this.list(query, 'archived');
}
/** Legacy POST single-customer — the retail customer list. */
@Post('single/list')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('persons.list.client.single')
listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.customers.listSingle(query, admin.branchId ?? undefined);
}
/** Legacy `POST single` — singleCustomer.vue, the retail customer form. */
@Post('single')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('persons.create')
createSingle(@Body() data: Record<string, unknown>) {
return this.customers.createSingle(data);
}
/**
* Legacy `GET query/reagent` — the sales-expert picker used by the customer
* form. Returns a plain array of {id, name}, as legacy did.
*/
@Get('query/reagent')
@RequirePermission('persons.create')
queryReagents() {
return this.customers.queryReagents();
}
/** Legacy POST wallet/charge. */
@Post('wallet/charge')
@HttpCode(200)
@RequirePermission('wallet')
chargeWallet(@Body() body: { customer_id: string; value: string | number }) {
return this.customers.chargeWallet(body);
}
@Post('create')
@HttpCode(200)
@RequirePermission('persons.create')
create(@Body() data: Record<string, unknown>) {
return this.customers.create(data);
}
@Put(':id')
@RequirePermission('persons.list.client.omde.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.customers.update(id, data);
}
/** Legacy POST reagent — the sales-expert list. */
@Post('reagent/list')
@HttpCode(200)
@RequirePermission('persons.list.sellers')
listReagents(@Body() query: TableQueryDto) {
return this.customers.listReagents(query);
}
/** A sales agent's own detail screen — see reagentDetail()'s header comment. */
@Get('reagent/:id')
@RequirePermission('persons.list.sellers.view')
reagentDetail(@Param('id') id: string) {
return this.customers.reagentDetail(id);
}
/** Legacy `GET single/order/{id}` — that customer's orders. */
@Get(':id/orders')
@RequirePermission('persons.list.client.omde.view.orders')
customerOrders(@Param('id') id: string) {
return this.customers.customerOrders(id);
}
/** Legacy `GET single/report/{id}` — that customer's ledger documents. */
@Get(':id/documents')
@RequirePermission('persons.list.client.omde.view.documents')
customerDocuments(@Param('id') id: string) {
return this.customers.customerDocuments(id);
}
/** Legacy `GET single/wallet/{id}` — that customer's wallet bonuses. */
@Get(':id/wallet')
@RequirePermission('wallet')
customerWallet(@Param('id') id: string) {
return this.customers.customerWallet(id);
}
@Get('dashboard/:id')
@RequirePermission('persons.list.client.omde.view')
dashboard(@Param('id') id: string) {
return this.customers.dashboard(id);
}
@Get('archive/:id')
@RequirePermission('persons.list.client.omde.edit')
toggleArchive(@Param('id') id: string) {
return this.customers.toggleArchive(id);
}
@Post('toggle-active')
@HttpCode(200)
@RequirePermission('persons.list.client.omde.edit')
toggleActive(@Body() body: { id: string }) {
return this.customers.toggleActive(body.id);
}
@Post('toggle-sts')
@HttpCode(200)
@RequirePermission('persons.list.client.omde.edit')
toggleSts(@Body() body: { id: string }) {
return this.customers.toggleSts(body.id);
}
@Post('verify')
@HttpCode(200)
@RequirePermission('persons.list.client.omde.edit')
verify(
@Body()
body: {
customer_id: string;
sts_verify_step_one?: number;
sts_verify_step_two?: number;
/** Optional, only read on a reject. */
reason?: string | null;
},
) {
return this.customers.verify(body);
}
@Post('credit')
@HttpCode(200)
@RequirePermission('persons.list.client.omde.edit')
updateCredit(@Body() body: { id: string; gram_level?: string; percent?: string }) {
return this.customers.updateCredit(body.id, body);
}
@Get(':id')
@RequirePermission('persons.list.client.omde.view')
findOne(@Param('id') id: string) {
return this.customers.findOne(id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminCustomersController } from './admin-customers.controller';
import { AdminCustomersService } from './admin-customers.service';
@Module({
imports: [PrismaModule],
controllers: [AdminCustomersController],
providers: [AdminCustomersService],
exports: [AdminCustomersService],
})
export class AdminCustomersModule {}
@@ -0,0 +1,102 @@
import { VerificationStep } from '@prisma/client';
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { AdminCustomersService } from './admin-customers.service';
/**
* Owner rulings 2026-10-07:
* - a reject stores REJECTED with an optional reason (it used to fall back to
* UNVERIFIED / STEP_ONE_SUBMITTED and leave no trace);
* - every value sits in its own column: no mobile in nationalCode, no ''
* password — an absent national code is NULL, an absent password is the
* explicit OTP-only marker.
*/
describe('AdminCustomersService', () => {
function build(current: Record<string, unknown> = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED }) {
const writes: Record<string, unknown>[] = [];
const tx = {
customer: {
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
writes.push(data);
return { ...current, ...data };
}),
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
writes.push(data);
return { id: 'new', ...data };
}),
findUnique: jest.fn(async () => current),
},
};
const prisma = { ...tx, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) };
const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn() };
const sms = { send: jest.fn(async () => undefined) };
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never, {} as never);
return { service, writes, accounting };
}
describe('verify()', () => {
it('a step-one reject stores REJECTED with the reason and time', async () => {
const { service, writes } = build();
await service.verify({ customer_id: 'c1', sts_verify_step_one: 3, reason: ' کارت ملی خوانا نیست ' });
expect(writes[0]).toMatchObject({
verificationStep: VerificationStep.REJECTED,
verificationRejectionReason: 'کارت ملی خوانا نیست',
});
expect(writes[0].verificationRejectedAt).toBeInstanceOf(Date);
});
it('a step-two reject is REJECTED too, and the reason is optional', async () => {
const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 });
expect(writes[0]).toMatchObject({ verificationStep: VerificationStep.REJECTED, verificationRejectionReason: null });
});
it('accepting a rejected customer clears the reason', async () => {
const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.REJECTED });
await service.verify({ customer_id: 'c1', sts_verify_step_one: 2 });
expect(writes[0]).toEqual({
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
verificationRejectionReason: null,
verificationRejectedAt: null,
// Accepting step one activates the account (an app self-registration
// is created inactive).
isActive: true,
});
});
it('step-two accept verifies and tells accounting once', async () => {
const { service, writes, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
await service.verify({ customer_id: 'c1', sts_verify_step_two: 2 });
expect(writes[0].verificationStep).toBe(VerificationStep.VERIFIED);
expect(accounting.customerVerified).toHaveBeenCalledTimes(1);
});
it('a reject never tells accounting', async () => {
const { service, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 });
expect(accounting.customerVerified).not.toHaveBeenCalled();
});
});
describe('national code and password', () => {
it('create() without a national code or password stores NULL and the OTP-only marker', async () => {
const { service, writes } = build();
await service.create({ fname: 'علی', lname: 'رضایی', mobile: '09120000000', percent: '3' });
expect(writes[0].nationalCode).toBeNull();
expect(writes[0].mobile).toBe('09120000000');
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
});
it('create() keeps a typed national code, digits normalised', async () => {
const { service, writes } = build();
await service.create({ fname: 'علی', mobile: '09120000000', percent: '3', national_code: ' ۰۰۱۲۳۴۵۶۷۸ ' });
expect(writes[0].nationalCode).toBe('0012345678');
});
it('createSingle() never copies the mobile into nationalCode', async () => {
const { service, writes } = build();
await service.createSingle({ fname: 'علی', mobile: '09120000000' });
expect(writes[0].nationalCode).toBeNull();
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import { Controller, Get, Query } from '@nestjs/common';
import { AdminDashboardService } from './admin-dashboard.service';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { isSuperAdmin } from '../auth/super-admin';
/**
* Legacy shipped two dashboards behind two rids:
* GET get-analytics -> views/DashboardECommerce.vue (wholesale), dashboard.omde
* GET get-analytics-tak -> views/DashboardAnalytics.vue (retail), dashboard.tak
*
* They are now one screen ("پیشخوان") behind one rid (dashboard.view), so this
* is one endpoint returning the union of both plus the new chart aggregates.
* `omde()`/`tak()` stay as thin aliases to the old paths so nothing that still
* calls get-analytics / get-analytics-tak (e.g. a cached branch build) 404s.
*/
@Controller('admin/dashboard')
export class AdminDashboardController {
constructor(private readonly dashboard: AdminDashboardService) {}
@Get('overview')
@RequirePermission('dashboard.view')
overview(@CurrentAdmin() admin: AuthenticatedAdmin) {
return this.dashboard.overview(isSuperAdmin(admin.mobile));
}
/** @deprecated superseded by GET overview; kept as an alias. */
@Get('omde')
@RequirePermission('dashboard.view')
omde() {
return this.dashboard.omdeAnalytics();
}
/** @deprecated superseded by GET overview; kept as an alias. */
@Get('tak')
@RequirePermission('dashboard.view')
tak() {
return this.dashboard.takAnalytics();
}
/** Legacy views/report/order_item.vue. */
@Get('report/order-item')
@RequirePermission('report.order.item')
orderItemReport(@Query('start_date') from?: string, @Query('end_date') to?: string) {
return this.dashboard.orderItemReport(from, to);
}
/** Legacy views/report/product.vue. */
@Get('report/product')
@RequirePermission('report.product')
productReport(@Query('start_date') from?: string, @Query('end_date') to?: string) {
return this.dashboard.productReport(from, to);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminDashboardController } from './admin-dashboard.controller';
import { AdminDashboardService } from './admin-dashboard.service';
@Module({
imports: [PrismaModule],
controllers: [AdminDashboardController],
providers: [AdminDashboardService],
})
export class AdminDashboardModule {}
@@ -0,0 +1,417 @@
import { Injectable } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { agentSalesWhere } from '../sales-agent/agent-orders';
/**
* Dashboards — legacy views/DashboardECommerce.vue (`GET get-analytics`) and
* views/DashboardAnalytics.vue (`GET get-analytics-tak`).
*
* Legacy's ECommerce page is a Vuexy template with most of its English demo
* cards COMMENTED OUT (Congratulations, Support Tracker, Product Orders, Sales
* Stats, Activity Timeline — verified line by line). What is actually live:
* - three 30-day stat cards: new customers, gold sold, order count
* - "عملکرد کارشناسان فروش": agents ranked by grams sold
* - "میزان فروش": sales volume
* - "سفارشات هفته جاری": this week's orders
*
* So this returns exactly those figures rather than the template's demo series.
*
* The two dashboards (عمده/تک) were merged into one screen behind one rid
* (dashboard.view) — see `overview()`, which returns the union of both legacy
* payloads (nothing dropped) plus new chart aggregates built from data that
* already exists (order channel, status, GoldPriceQuote, customer debt).
*/
@Injectable()
export class AdminDashboardService {
constructor(private readonly prisma: PrismaService) {}
private daysAgo(n: number) {
const d = new Date();
d.setDate(d.getDate() - n);
return d;
}
/** Orders that have reached the debt-recognition point count as "sold". */
private static readonly SOLD: Prisma.OrderWhereInput = {
status: { in: [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER] },
};
/**
* The single merged dashboard endpoint (`GET admin/dashboard/overview`).
* Retail («تک فروشی») figures are only built for the super admin; for anyone
* else they never leave the server.
*/
async overview(viewerIsSuperAdmin = false) {
const since30 = this.daysAgo(30);
const since7 = this.daysAgo(7);
const [
omde,
tak,
ordersLast30,
statusGroups,
channelGroups,
topProductRows,
latestGoldQuote,
debtAgg,
] = await Promise.all([
this.omdeAnalytics(),
this.takAnalytics(),
// روند سفارش‌ها در ۳۰ روز گذشته — one row per order, bucketed by day below.
this.prisma.order.findMany({
where: { createdAt: { gte: since30 } },
select: { createdAt: true, channel: true },
}),
// دونات وضعیت سفارشات (۳۰ روز گذشته)
this.prisma.order.groupBy({
by: ['status'],
where: { createdAt: { gte: since30 } },
_count: true,
}),
// تفکیک عمده/تک (۳۰ روز گذشته)
this.prisma.order.groupBy({
by: ['channel'],
where: { createdAt: { gte: since30 } },
_count: true,
_sum: { totalGoldWeightGrams: true },
}),
// محصولات پرفروش (۳۰ روز گذشته) — by shipped weight, top 6.
this.prisma.orderItem.findMany({
where: { order: { createdAt: { gte: since30 } } },
select: {
quantity: true,
computedWeightGrams: true,
productVariant: { select: { product: { select: { id: true, title: true } } } },
},
}),
// نرخ فعلی طلا — legacy's goldPrice widget (18 عیار / 750‰).
this.prisma.goldPriceQuote.findFirst({
where: { karatPerMille: 750 },
orderBy: { quotedAt: 'desc' },
}),
// مجموع بدهی مشتریان (گرم طلا) — cached balance, see Customer.goldDebtGrams.
this.prisma.customer.aggregate({ _sum: { goldDebtGrams: true } }),
]);
// Bucket the 30-day order list into day-strings client-side: Prisma has no
// portable "group by day" across the sqlite/postgres split this project
// supports, and 30 days of order rows is tiny.
const trendByDay = new Map<string, { total: number; retail: number; wholesale: number }>();
for (let i = 29; i >= 0; i--) {
trendByDay.set(this.dayKey(this.daysAgo(i)), { total: 0, retail: 0, wholesale: 0 });
}
for (const o of ordersLast30) {
const key = this.dayKey(o.createdAt);
const bucket = trendByDay.get(key);
if (!bucket) continue; // safety net; every key in range was pre-seeded above
bucket.total += 1;
if (o.channel === 'WHOLESALE') bucket.wholesale += 1;
else bucket.retail += 1;
}
// Non-super admins get wholesale only: their `total` is the wholesale count
// and there is no `retail` field, so the retail number cannot be derived.
const ordersTrend = [...trendByDay.entries()].map(([date, v]) =>
viewerIsSuperAdmin ? { date, ...v } : { date, total: v.wholesale, wholesale: v.wholesale },
);
const statusLabels: Record<OrderStatus, string> = {
SUSPENDED: 'معلق (سقف اعتبار)',
PLACED: 'ثبت شده',
ADMIN_APPROVED: 'تایید مدیر',
SOURCING: 'در حال تامین',
PREPARING: 'در حال آماده‌سازی',
DELIVERED_TO_AGENT: 'تحویل به نماینده',
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
CANCELLED: 'لغو شده',
};
const statusBreakdown = statusGroups.map((g) => ({
status: g.status,
label: statusLabels[g.status],
count: g._count,
}));
const channelLabels: Record<string, string> = { RETAIL: 'تک فروشی', WHOLESALE: 'عمده فروشی' };
const channelBreakdown = viewerIsSuperAdmin
? channelGroups.map((g) => ({
channel: g.channel,
label: channelLabels[g.channel] ?? g.channel,
count: g._count,
grams: g._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0),
}))
: [];
const productTotals = new Map<string, { title: string; weight: Prisma.Decimal; qty: number }>();
for (const it of topProductRows) {
const p = it.productVariant.product;
const row = productTotals.get(p.id) ?? { title: p.title, weight: new Prisma.Decimal(0), qty: 0 };
row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity));
row.qty += it.quantity;
productTotals.set(p.id, row);
}
const topProducts = [...productTotals.values()]
.sort((a, b) => Number(b.weight) - Number(a.weight))
.slice(0, 6);
return {
// ── everything the two legacy dashboards returned, unchanged ──────
...omde,
takLastOrder: tak.lastOrder,
// ── new chart data ─────────────────────────────────────────────
ordersTrend,
statusBreakdown,
channelBreakdown,
topProducts,
goldPricePerGram: latestGoldQuote?.pricePerGramRial ?? null,
goldPriceQuotedAt: latestGoldQuote?.quotedAt ?? null,
totalCustomerDebtGrams: debtAgg._sum.goldDebtGrams ?? new Prisma.Decimal(0),
};
}
private dayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Legacy `GET get-analytics` — the wholesale dashboard. */
async omdeAnalytics() {
const since30 = this.daysAgo(30);
const since7 = this.daysAgo(7);
const [customerCount, gramsAgg, orderCount, lastOrderRows, agents, monthAgg] =
await Promise.all([
// تعداد مشتریان ثبت نامی در 30 روز گذشته
this.prisma.customer.count({ where: { createdAt: { gte: since30 } } }),
// طلای فروش رفته در 30 روز گذشته
this.prisma.order.aggregate({
where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] },
_sum: { totalGoldWeightGrams: true },
}),
// تعداد سفارشات 30 روز گذشته
this.prisma.order.count({ where: { createdAt: { gte: since30 } } }),
// سفارشات هفته جاری
this.prisma.order.findMany({
where: { createdAt: { gte: since7 } },
orderBy: { createdAt: 'desc' },
take: 50,
include: { customer: true, branch: true },
}),
// عملکرد کارشناسان فروش
this.prisma.adminUser.findMany({
where: { role: 'SALES_EXPERT', isActive: true },
select: { id: true, fullName: true },
}),
// میزان فروش (same 30-day window, kept separate for clarity)
this.prisma.order.aggregate({
where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] },
_sum: { totalGoldWeightGrams: true },
_count: true,
}),
]);
// Grams sold per agent, through the customers they referred. The view sorts
// this itself by grams_sell, so the shape matters more than the order.
const reagent = await Promise.all(
agents.map(async (a) => {
const agg = await this.prisma.order.aggregate({
where: {
AND: [
AdminDashboardService.SOLD,
agentSalesWhere(a.id),
{ createdAt: { gte: since30 } },
],
},
_sum: { totalGoldWeightGrams: true },
_count: true,
});
return {
id: a.id,
name: a.fullName,
grams_sell: agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0),
order_count: agg._count,
};
}),
);
return {
customerCount,
allgram: gramsAgg._sum.totalGoldWeightGrams ?? 0,
ordercount: orderCount,
reagent,
lastOrder: lastOrderRows.map((o) => this.orderRow(o)),
sumLastOrder: monthAgg._count,
sumLastOrderMonth: monthAgg._sum.totalGoldWeightGrams ?? 0,
};
}
/** Legacy `GET get-analytics-tak` — the retail dashboard. */
async takAnalytics() {
const since7 = this.daysAgo(7);
const rows = await this.prisma.order.findMany({
where: { createdAt: { gte: since7 } },
orderBy: { createdAt: 'desc' },
take: 50,
include: { customer: true, branch: true },
});
// The retail view reads only `model.lastOrder`; the rest of its cards are
// the Vuexy demo series and are not rendered from server data.
return { lastOrder: rows.map((o) => this.orderRow(o)) };
}
/**
* Legacy `report/order_item` — "گزارش تجمیعی سفارشات و اسناد مالی": order
* lines aggregated over a date range.
*/
async orderItemReport(from?: string, to?: string) {
const where = this.dateWhere(from, to);
const orders = await this.prisma.order.findMany({
where,
include: {
customer: true,
items: { include: { productVariant: { include: { product: true } } } },
},
orderBy: { createdAt: 'desc' },
});
const rows = orders.flatMap((o) =>
o.items.map((it) => ({
order_id: o.id,
order_number: o.orderNumber,
customer: o.customer.fullName,
product: it.productVariant.product.title,
variant: it.productVariant.title,
qty: it.quantity,
w: it.computedWeightGrams,
total_w: it.computedWeightGrams.mul(it.quantity),
caj: o.createdAt,
})),
);
const totalWeight = rows.reduce(
(s, r) => s.add(r.total_w),
new Prisma.Decimal(0),
);
return {
rows,
summary: {
order_count: orders.length,
item_count: rows.length,
total_weight: totalWeight,
},
};
}
/** Legacy `report/product` — per-product sales over a date range. */
async productReport(from?: string, to?: string) {
const where = this.dateWhere(from, to);
const orders = await this.prisma.order.findMany({
where,
include: { items: { include: { productVariant: { include: { product: true } } } } },
});
const byProduct = new Map<
string,
{ id: string; title: string; qty: number; weight: Prisma.Decimal; orders: Set<string> }
>();
for (const o of orders) {
for (const it of o.items) {
const p = it.productVariant.product;
const row = byProduct.get(p.id) ?? {
id: p.id,
title: p.title,
qty: 0,
weight: new Prisma.Decimal(0),
orders: new Set<string>(),
};
row.qty += it.quantity;
row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity));
row.orders.add(o.id);
byProduct.set(p.id, row);
}
}
const rows = [...byProduct.values()]
.map((r) => ({
id: r.id,
title: r.title,
total: r.qty,
w: r.weight,
order_count: r.orders.size,
}))
.sort((a, b) => Number(b.w) - Number(a.w));
return { rows, summary: { product_count: rows.length } };
}
/** Shared date filter for both reports; an open end stays open. */
private dateWhere(from?: string, to?: string): Prisma.OrderWhereInput {
if (!from && !to) return {};
const createdAt: Prisma.DateTimeFilter = {};
if (from) createdAt.gte = new Date(from);
if (to) {
// Include the whole end day, not just its midnight.
const end = new Date(to);
end.setHours(23, 59, 59, 999);
createdAt.lte = end;
}
return { createdAt };
}
/** The row shape both dashboards' tables read. */
private orderRow(
o: Prisma.OrderGetPayload<{ include: { customer: true; branch: true } }>,
) {
const labels: Record<OrderStatus, string> = {
SUSPENDED: 'معلق (سقف اعتبار)',
PLACED: 'ثبت شده',
ADMIN_APPROVED: 'تایید مدیر',
SOURCING: 'در حال تامین',
PREPARING: 'در حال آماده‌سازی',
DELIVERED_TO_AGENT: 'تحویل به نماینده',
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
CANCELLED: 'لغو شده',
};
// legacy reads `statusColor` straight off the row
const colors: Record<OrderStatus, string> = {
SUSPENDED: 'warning',
PLACED: 'primary',
ADMIN_APPROVED: 'primary',
SOURCING: 'warning',
PREPARING: 'warning',
DELIVERED_TO_AGENT: 'success',
DELIVERED_TO_CUSTOMER: 'success',
CANCELLED: 'danger',
};
return {
id: o.id,
name: o.orderNumber,
customer: o.customer.fullName,
w: o.totalGoldWeightGrams,
sts_str: labels[o.status],
statusColor: colors[o.status],
caj: o.createdAt,
// legacy rendered a parent/children tree for split orders; the redesigned
// schema has no such relation, so both are empty rather than faked.
parent: null,
children: [],
};
}
}
@@ -0,0 +1,110 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminDocumentsService } from './admin-documents.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Legacy endpoints (CashController + the wallet screens):
* POST cash/table -> the documents grid
* POST cash -> file a repayment document
* GET cash/{id} -> one document
* PUT cash/{id} -> edit
* DELETE cash/{id} -> delete
* GET query/customer -> the customer picker
* POST wallet -> granted wallet bonuses
*/
@Controller('admin/documents')
export class AdminDocumentsController {
constructor(private readonly documents: AdminDocumentsService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('document.list')
list(@Body() query: TableQueryDto) {
return this.documents.list(query);
}
@Get('type-options')
@RequirePermission('document.list')
typeOptions() {
return this.documents.typeOptions();
}
@Get('query/customer')
@RequirePermission('document.create')
queryCustomers() {
return this.documents.queryCustomers();
}
@Post('wallet/list')
@HttpCode(200)
@RequirePermission('wallet')
listWallet(@Body() query: TableQueryDto) {
return this.documents.listWallet(query);
}
// ── wallet settings (views/manage/wallet.vue) ────────────────────
@Get('wallet/config')
@SuperAdminOnly()
@RequirePermission('config.wallet')
getWalletConfig() {
return this.documents.getSetting('wallet.config');
}
@Post('wallet/config')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('config.wallet')
setWalletConfig(@Body() body: Record<string, unknown>) {
return this.documents.setSetting('wallet.config', body);
}
@Get('wallet/birthday/config')
@SuperAdminOnly()
@RequirePermission('config.wallet')
getBirthdayConfig() {
return this.documents.getSetting('wallet.birthday');
}
@Post('wallet/birthday/config')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('config.wallet')
setBirthdayConfig(@Body() body: Record<string, unknown>) {
return this.documents.setSetting('wallet.birthday', body);
}
@Post()
@HttpCode(200)
@RequirePermission('document.create')
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.documents.create(data, admin.id);
}
/** Legacy POST cash/confirem/{id} — a toggle, not a one-way confirm. */
@Post(':id/confirm')
@HttpCode(200)
@RequirePermission('document.list.edit')
toggleConfirm(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.documents.toggleConfirm(id, admin.id);
}
@Get(':id')
@RequirePermission('document.list')
findOne(@Param('id') id: string) {
return this.documents.findOne(id);
}
@Put(':id')
@RequirePermission('document.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.documents.update(id, data);
}
@Delete(':id')
@RequirePermission('document.list.edit')
remove(@Param('id') id: string) {
return this.documents.remove(id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AccountingModule } from '../accounting/accounting.module';
import { AdminDocumentsController } from './admin-documents.controller';
import { AdminDocumentsService } from './admin-documents.service';
@Module({
imports: [PrismaModule, AccountingModule],
controllers: [AdminDocumentsController],
providers: [AdminDocumentsService],
})
export class AdminDocumentsModule {}
@@ -0,0 +1,466 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { LedgerService } from '../accounting/ledger.service';
import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { brandName } from '../../common/brand';
/**
* Financial documents ("اسناد مالی") — legacy views/finical/*, served by
* CashController (`POST cash/table`, `POST cash`, `PUT cash/{id}`,
* `GET cash/{id}`, `DELETE cash/{id}`).
*
* A legacy "cash" row IS a ledger entry: a customer repayment recorded as melted
* gold, a transfer, a cheque or cash. The four legacy `type` codes map onto the
* existing LedgerSourceType enum:
*
* 1 آب شده -> REPAYMENT_MELTED_GOLD (karat-normalized: value * ayar / 750)
* 2 حواله -> REPAYMENT_TRANSFER
* 3 چک -> REPAYMENT_CHEQUE
* 4 پولی -> REPAYMENT_CASH (normalized via the gold price per gram)
*
* Posting delegates to LedgerService, which owns the karat and price-per-gram
* conversions and the confirm/balance rules. Re-implementing them here would let
* the admin path drift from the customer-facing one.
*/
const TYPE_BY_CODE: Record<number, LedgerSourceType> = {
1: LedgerSourceType.REPAYMENT_MELTED_GOLD,
2: LedgerSourceType.REPAYMENT_TRANSFER,
3: LedgerSourceType.REPAYMENT_CHEQUE,
4: LedgerSourceType.REPAYMENT_CASH,
};
/** The instrument name LedgerService.postGoldRepayment expects. */
const INSTRUMENT_BY_TYPE: Record<string, 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH'> = {
REPAYMENT_MELTED_GOLD: 'MELTED_GOLD',
REPAYMENT_TRANSFER: 'TRANSFER',
REPAYMENT_CHEQUE: 'CHEQUE',
REPAYMENT_CASH: 'CASH',
};
const CODE_BY_TYPE: Partial<Record<LedgerSourceType, number>> = {
REPAYMENT_MELTED_GOLD: 1,
REPAYMENT_TRANSFER: 2,
REPAYMENT_CHEQUE: 3,
REPAYMENT_CASH: 4,
};
const TYPE_LABELS: Record<LedgerSourceType, string> = {
ORDER_STOCK: 'سفارش',
ORDER_PRE_ORDER: 'سفارش ساختنی',
REPAYMENT_MELTED_GOLD: 'آب شده',
REPAYMENT_TRANSFER: 'حواله',
REPAYMENT_CHEQUE: 'چک',
REPAYMENT_CASH: 'پولی',
WALLET_BONUS: 'هدیه کیف پول',
ADJUSTMENT: 'اصلاح',
};
@Injectable()
export class AdminDocumentsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly ledger: LedgerService,
private readonly sms: SmsService,
) {}
private static readonly ALLOWED = [
'id',
'sourceType',
'currency',
'direction',
'amount',
'confirmed',
'createdAt',
'karatPerMille',
'labName',
'customer.fullName',
// legacy filter keys
'type_str',
'ayar',
'lab',
'caj',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
type_str: 'sourceType',
ayar: 'karatPerMille',
lab: 'labName',
caj: 'createdAt',
uaj: 'createdAt',
'customer.name': 'customer.fullName',
value: 'amount',
price: 'priceRial',
original_value: 'originalValue',
account_name: 'accountName',
bank_name: 'bankName',
to: 'transferTo',
recive_date: 'receivedAt',
price_per_gram: 'pricePerGramRialAtEntry',
ang: 'hallmark',
};
/** Legacy `POST cash/table` — the documents grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminDocumentsService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'sourceType' && mapped.value !== undefined) {
mapped.value = TYPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
}
if (mapped.field === 'confirmed' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['note', 'customer.fullName', 'labName'],
allowed: AdminDocumentsService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
// The grid is the REPAYMENT document list; order-generated debt entries are
// not documents an operator files, so they are excluded as legacy's separate
// `cash` table naturally did.
const scoped: Prisma.LedgerEntryWhereInput = {
AND: [
where as Prisma.LedgerEntryWhereInput,
{ sourceType: { in: Object.values(TYPE_BY_CODE) } },
],
};
const [rows, total] = await Promise.all([
this.prisma.ledgerEntry.findMany({
where: scoped,
orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput,
skip,
take,
include: { customer: true, postedByAdminUser: true },
}),
this.prisma.ledgerEntry.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names preserved so the ported table model binds unchanged. */
private toRow(
e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>,
) {
return {
id: e.id,
type: CODE_BY_TYPE[e.sourceType] ?? null,
type_str: TYPE_LABELS[e.sourceType],
sourceType: e.sourceType,
currency: e.currency,
direction: e.direction,
// legacy `value` is the normalized final weight, `original_value` the raw one
value: e.amount,
original_value: e.originalValue,
price: e.priceRial,
price_per_gram: e.pricePerGramRialAtEntry,
ayar: e.karatPerMille,
lab: e.labName,
ang: e.hallmark,
account_name: e.accountName,
bank_name: e.bankName,
to: e.transferTo,
recive_date: e.receivedAt,
note: e.note,
// legacy grid renders `confirmed` through a slot using 0/1
confirmed: e.confirmed ? 1 : 0,
confirmedAt: e.confirmedAt,
customer: {
id: e.customer.id,
name: e.customer.fullName,
// D0 — real name parts
fname: e.customer.firstName ?? e.customer.fullName,
lname: e.customer.lastName ?? '',
mobile: e.customer.mobile,
},
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
caj: e.createdAt,
uaj: e.createdAt,
};
}
/** Legacy `GET cash/{id}`. */
async findOne(id: string) {
const e = await this.prisma.ledgerEntry.findUnique({
where: { id },
include: { customer: true, postedByAdminUser: true },
});
if (!e) throw new NotFoundException('سند یافت نشد');
return this.toRow(e);
}
/**
* Legacy `POST cash` — file a repayment document.
*
* The gram/karat/price conversions are LedgerService's job; this method only
* translates the legacy form shape and attaches the instrument metadata.
*/
async create(data: Record<string, unknown>, actorAdminUserId: string) {
const typeCode = Number(data.type ?? 2);
const sourceType = TYPE_BY_CODE[typeCode];
if (!sourceType) throw new BadRequestException('نوع سند نامعتبر است');
const customerId = String(data.customer_id ?? '');
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
// LedgerService takes the legacy instrument name and the RAW figure, then
// applies the karat / price-per-gram conversion itself.
const instrument = INSTRUMENT_BY_TYPE[sourceType];
// Which field carries the raw figure depends on the instrument, exactly as
// the legacy form did: cash posts a Rial price, melted gold posts the
// pre-normalization weight, transfer/cheque post grams directly.
const rawValue =
instrument === 'CASH'
? String(data.price ?? 0)
: instrument === 'MELTED_GOLD'
? String(data.original_value ?? data.value ?? 0)
: String(data.value ?? 0);
const entry = await this.ledger.postGoldRepayment({
customerId,
instrument,
rawValue,
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
note: (data.note as string) ?? undefined,
postedByAdminUserId: actorAdminUserId,
});
// Attach the instrument metadata the ledger service does not model.
await this.prisma.ledgerEntry.update({
where: { id: entry.id },
data: this.metadataPatch(data),
});
return this.findOne(entry.id);
}
private metadataPatch(data: Record<string, unknown>): Prisma.LedgerEntryUpdateInput {
const patch: Prisma.LedgerEntryUpdateInput = {};
if (data.lab !== undefined) patch.labName = (data.lab as string) || null;
if (data.ang !== undefined) patch.hallmark = (data.ang as string) || null;
if (data.account_name !== undefined) patch.accountName = (data.account_name as string) || null;
if (data.bank_name !== undefined) patch.bankName = (data.bank_name as string) || null;
if (data.to !== undefined) patch.transferTo = (data.to as string) || null;
if (data.original_value !== undefined && data.original_value !== null) {
patch.originalValue = new Prisma.Decimal(String(data.original_value));
}
if (data.price !== undefined && data.price !== null) {
patch.priceRial = new Prisma.Decimal(String(data.price));
}
if (data.recive_date) patch.receivedAt = new Date(String(data.recive_date));
return patch;
}
/**
* Legacy `PUT cash/{id}` — edit a document.
*
* Only metadata and the note are editable. Changing the AMOUNT of a confirmed
* entry would silently rewrite a customer's debt, so that is refused: the
* correct accounting action is a reversing adjustment, not an edit. Legacy
* allowed it. Recorded in docs/ADMIN-PORT-PLAN.md.
*/
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.ledgerEntry.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('سند یافت نشد');
const amountChanged =
data.value !== undefined && !new Prisma.Decimal(String(data.value)).equals(existing.amount);
if (existing.confirmed && amountChanged) {
throw new BadRequestException(
'مبلغ سند تاییدشده قابل ویرایش نیست، چون بدهی مشتری بر اساس آن محاسبه شده است. برای اصلاح، سند جدید ثبت کنید.',
);
}
const patch = this.metadataPatch(data);
if (data.note !== undefined) patch.note = (data.note as string) || null;
if (!existing.confirmed && amountChanged) {
patch.amount = new Prisma.Decimal(String(data.value));
}
await this.prisma.ledgerEntry.update({ where: { id }, data: patch });
return this.findOne(id);
}
/**
* Legacy `POST cash/confirem/{id}` (legacy's own spelling) — a TOGGLE: the grid
* shows a tick on an unconfirmed row and a cross on a confirmed one, both
* calling this same endpoint.
*
* Confirming delegates to LedgerService so the customer balance moves through
* the one code path that owns it. Un-confirming reverses that same amount
* rather than editing the balance directly, so the two stay consistent.
*/
async toggleConfirm(id: string, adminUserId: string) {
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
if (!e) throw new NotFoundException('سند یافت نشد');
if (!e.confirmed) {
await this.ledger.confirmEntry(id, adminUserId);
this.notifyDocumentConfirm(e.customerId, true);
return this.findOne(id);
}
// Un-confirm: undo this entry's effect on the cached balance, then clear the
// flag. A repayment is a CREDIT against gold debt, so reversing it adds the
// amount back.
await this.prisma.$transaction(async (tx) => {
const delta =
e.direction === LedgerDirection.CREDIT ? e.amount : e.amount.negated();
const field =
e.currency === LedgerCurrency.GOLD_GRAM ? 'goldDebtGrams' : 'fiatDebtRial';
await tx.customer.update({
where: { id: e.customerId },
data: { [field]: { increment: delta } },
});
await tx.ledgerEntry.update({
where: { id },
data: { confirmed: false, confirmedAt: null },
});
});
this.notifyDocumentConfirm(e.customerId, false);
return this.findOne(id);
}
// Legacy CashController::toggleConfiremCash sent an SMS on both outcomes.
private notifyDocumentConfirm(customerId: string, confirmed: boolean) {
const text = confirmed
? `${brandName()}\nسند مالی شما توسط مدیر سیستم تایید شد.`
: `${brandName()}\nسند مالی شما توسط مدیر سیستم رد شد.`;
this.prisma.customer
.findUnique({ where: { id: customerId }, select: { mobile: true } })
.then((c) => {
if (c) this.sms.send(c.mobile, text);
})
.catch(() => {});
}
/**
* Legacy `DELETE cash/{id}`.
*
* A confirmed entry has already moved the customer's balance; deleting it
* would leave that balance overstated with no trace. Refused — legacy allowed
* it. Unconfirmed entries never touched a balance, so they delete cleanly.
*/
async remove(id: string) {
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
if (!e) throw new NotFoundException('سند یافت نشد');
if (e.confirmed) {
throw new BadRequestException(
'حذف سند تاییدشده ممکن نیست، چون در تراز مشتری اعمال شده است.',
);
}
await this.prisma.ledgerEntry.delete({ where: { id } });
return { success: true };
}
typeOptions() {
return Object.entries(TYPE_BY_CODE).map(([code, sourceType]) => ({
value: Number(code),
title: TYPE_LABELS[sourceType],
}));
}
// ── Wallet ─────────────────────────────────────────────────────────
/** Legacy `POST wallet` grid — the list of granted wallet bonuses. */
async listWallet(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['customer.fullName'],
allowed: ['id', 'goldGrams', 'grantedAt', 'expiresAt', 'isActive', 'customer.fullName'],
defaultOrderBy: { grantedAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.walletBonus.findMany({
where: where as Prisma.WalletBonusWhereInput,
orderBy: orderBy as Prisma.WalletBonusOrderByWithRelationInput,
skip,
take,
include: { customer: true },
}),
this.prisma.walletBonus.count({ where: where as Prisma.WalletBonusWhereInput }),
]);
return this.table.respond(
rows.map((w) => ({
id: w.id,
value: w.goldGrams,
grams: w.goldGrams,
is_active: w.isActive,
consumed: Boolean(w.consumedAt),
caj: w.grantedAt,
expires_at: w.expiresAt,
customer: {
id: w.customer.id,
name: w.customer.fullName,
mobile: w.customer.mobile,
},
})),
total,
query,
page,
limit,
);
}
/**
* Legacy `GET /wallet/config` and `/wallet/birthday/config`, plus their POST
* counterparts — the wallet settings screen (views/manage/wallet.vue).
* Stored in the Setting key/value table that replaces legacy `config_static`.
*/
async getSetting(key: string) {
const row = await this.prisma.setting.findUnique({ where: { key } });
return row?.value ?? {};
}
async setSetting(key: string, value: Record<string, unknown>) {
const row = await this.prisma.setting.upsert({
where: { key },
update: { value: value as Prisma.InputJsonValue },
create: { key, value: value as Prisma.InputJsonValue },
});
return row.value;
}
/** The customer picker used by the document form — legacy `GET query/customer`. */
async queryCustomers() {
const rows = await this.prisma.customer.findMany({
where: { isActive: true },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true, firstName: true, lastName: true, mobile: true },
});
// D0 — real name parts.
return rows.map((c) => ({
id: c.id,
name: c.fullName,
fname: c.firstName ?? c.fullName,
lname: c.lastName ?? '',
mobile: c.mobile,
}));
}
}
@@ -0,0 +1,62 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminInvoicesService } from './admin-invoices.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Legacy endpoints (BranchController's `branch/product` family):
* POST branch/product/table
* POST branch/product
* GET branch/product/{id}
* DELETE branch/product/{id}
* GET branch/list -> the branch picker
*/
@Controller('admin/invoices')
export class AdminInvoicesController {
constructor(private readonly invoices: AdminInvoicesService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('invoice.list')
list(@Body() query: TableQueryDto) {
return this.invoices.list(query);
}
@Get('branch-list')
@RequirePermission('invoice.create')
branchList() {
return this.invoices.branchList();
}
@Post()
@HttpCode(200)
@RequirePermission('invoice.create')
create(
@Body() data: { branch_id?: string; products?: string[]; note?: string },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.invoices.create(data, admin.id);
}
@Get(':id')
@RequirePermission('invoice.list')
findOne(@Param('id') id: string) {
return this.invoices.findOne(id);
}
@Put(':id')
@RequirePermission('invoice.list')
update(
@Param('id') id: string,
@Body() data: { branch_id?: string; products?: string[]; note?: string },
) {
return this.invoices.update(id, data);
}
@Delete(':id')
@RequirePermission('invoice.list')
remove(@Param('id') id: string) {
return this.invoices.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminInvoicesController } from './admin-invoices.controller';
import { AdminInvoicesService } from './admin-invoices.service';
@Module({
imports: [PrismaModule],
controllers: [AdminInvoicesController],
providers: [AdminInvoicesService],
})
export class AdminInvoicesModule {}
@@ -0,0 +1,245 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Branch stock allocations — legacy views/invoice/* ("فاکتور انتقال"), served by
* BranchController as `branch/product`:
* POST branch/product/table -> the grid
* POST branch/product -> create { branch_id, products: [id...] }
* GET branch/product/{id} -> one allocation
* DELETE branch/product/{id} -> delete
*
* Despite the screen being labelled an invoice, it is not a sales document: it
* records which products head office allocated to a branch, and who did it.
*/
@Injectable()
export class AdminInvoicesService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'note',
'createdAt',
'branchId',
'branch.name',
'allocatorAdminUser.fullName',
// legacy filter keys
'name',
'caj',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'branch.name',
caj: 'createdAt',
'allocator_user.name': 'allocatorAdminUser.fullName',
};
/** Legacy `POST branch/product/table`. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminInvoicesService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => ({ ...f, field: mapField(f.field) })),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['note', 'branch.name'],
allowed: AdminInvoicesService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.branchAllocation.findMany({
where: where as Prisma.BranchAllocationWhereInput,
orderBy: orderBy as Prisma.BranchAllocationOrderByWithRelationInput,
skip,
take,
include: {
branch: true,
allocatorAdminUser: true,
items: { include: { product: true } },
},
}),
this.prisma.branchAllocation.count({ where: where as Prisma.BranchAllocationWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
a: Prisma.BranchAllocationGetPayload<{
include: {
branch: true;
allocatorAdminUser: true;
items: { include: { product: true } };
};
}>,
) {
const sumWeight = a.items.reduce(
(sum, it) => sum.add(it.weightGrams ?? new Prisma.Decimal(0)),
new Prisma.Decimal(0),
);
return {
id: a.id,
// legacy column key is `name` — the receiving branch
name: a.branch.name,
branch_id: a.branchId,
branch: { id: a.branch.id, name: a.branch.name },
sum_weight: sumWeight,
count_item: a.items.length,
note: a.note,
allocator_user: a.allocatorAdminUser
? { id: a.allocatorAdminUser.id, name: a.allocatorAdminUser.fullName }
: null,
caj: a.createdAt,
products: a.items.map((it) => ({
id: it.productId,
pid: it.productId,
title: it.product.title,
product_code: it.product.productCode,
grams: it.weightGrams,
})),
};
}
/** Legacy `GET branch/product/{id}`. */
async findOne(id: string) {
const a = await this.prisma.branchAllocation.findUnique({
where: { id },
include: {
branch: true,
allocatorAdminUser: true,
items: { include: { product: true } },
},
});
if (!a) throw new NotFoundException('فاکتور یافت نشد');
return this.toRow(a);
}
/**
* Legacy `POST branch/product` with `{ branch_id, products: [id...] }`.
*
* Each product's current lightest variant weight is snapshotted, so a later
* catalog edit does not silently rewrite what this document says was moved.
*/
async create(data: { branch_id?: string; products?: string[]; note?: string }, actorId: string) {
const branchId = String(data.branch_id ?? '');
if (!branchId) throw new BadRequestException('شعبه انتخاب نشده است');
const branch = await this.prisma.branch.findUnique({ where: { id: branchId } });
if (!branch) throw new NotFoundException('شعبه یافت نشد');
const productIds = [...new Set((data.products ?? []).map((p) => String(p)).filter(Boolean))];
if (productIds.length === 0) throw new BadRequestException('هیچ محصولی انتخاب نشده است');
const products = await this.prisma.product.findMany({
where: { id: { in: productIds } },
include: { variants: { where: { isActive: true } } },
});
if (products.length !== productIds.length) {
throw new BadRequestException('برخی از محصولات انتخاب‌شده یافت نشدند');
}
const allocation = await this.prisma.branchAllocation.create({
data: {
branchId,
allocatorAdminUserId: actorId,
note: data.note ?? null,
items: {
create: products.map((p) => ({
productId: p.id,
weightGrams: p.variants.length
? p.variants.reduce(
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
p.variants[0].rashWeightGrams,
)
: null,
})),
},
},
});
return this.findOne(allocation.id);
}
/** Legacy `PUT branch/product/{id}` — the edit screen replaces the product set. */
async update(id: string, data: { branch_id?: string; products?: string[]; note?: string }) {
const existing = await this.prisma.branchAllocation.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('فاکتور یافت نشد');
if (data.branch_id) {
const branch = await this.prisma.branch.findUnique({
where: { id: String(data.branch_id) },
});
if (!branch) throw new NotFoundException('شعبه یافت نشد');
}
if (Array.isArray(data.products)) {
const productIds = [...new Set(data.products.map((p) => String(p)).filter(Boolean))];
if (productIds.length === 0) {
throw new BadRequestException('هیچ محصولی انتخاب نشده است');
}
const products = await this.prisma.product.findMany({
where: { id: { in: productIds } },
include: { variants: { where: { isActive: true } } },
});
await this.prisma.$transaction([
this.prisma.branchAllocationItem.deleteMany({ where: { allocationId: id } }),
this.prisma.branchAllocationItem.createMany({
data: products.map((p) => ({
allocationId: id,
productId: p.id,
weightGrams: p.variants.length
? p.variants.reduce(
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
p.variants[0].rashWeightGrams,
)
: null,
})),
skipDuplicates: true,
}),
]);
}
await this.prisma.branchAllocation.update({
where: { id },
data: {
...(data.branch_id ? { branchId: String(data.branch_id) } : {}),
...(data.note !== undefined ? { note: data.note } : {}),
},
});
return this.findOne(id);
}
/** Legacy `DELETE branch/product/{id}`. Items cascade. */
async remove(id: string) {
const a = await this.prisma.branchAllocation.findUnique({ where: { id } });
if (!a) throw new NotFoundException('فاکتور یافت نشد');
await this.prisma.branchAllocation.delete({ where: { id } });
return { success: true };
}
/** Legacy `GET branch/list` — the branch picker for the invoice form. */
async branchList() {
const rows = await this.prisma.branch.findMany({
where: { isActive: true },
orderBy: { name: 'asc' },
select: { id: true, name: true },
});
return rows;
}
}
@@ -0,0 +1,59 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AdminMiscService } from './admin-misc.service';
/** Cities as a tree (PERS-02): legacy's percent inheritance and loop guard. */
describe('AdminMiscService — city tree', () => {
type Row = { id: string; parentId: string | null; percent: Prisma.Decimal | null; percentAux: Prisma.Decimal | null };
const D = (v: number) => new Prisma.Decimal(v);
let rows: Map<string, Row>;
let service: AdminMiscService;
let update: jest.Mock;
beforeEach(() => {
// ایران > استان تهران > تهران > تهران شمال
rows = new Map(
[
{ id: 'iran', parentId: null, percent: null, percentAux: null },
{ id: 'prov', parentId: 'iran', percent: D(10.12), percentAux: D(10.12) },
{ id: 'teh', parentId: 'prov', percent: D(0), percentAux: D(8) },
{ id: 'north', parentId: 'teh', percent: null, percentAux: null },
].map((r) => [r.id, r]),
);
update = jest.fn(async ({ where, data }) => ({ ...rows.get(where.id), ...data }));
const prisma = {
city: {
findUnique: jest.fn(async ({ where }) => rows.get(where.id) ?? null),
update,
},
};
service = new AdminMiscService(prisma as never, {} as never, {} as never);
});
it('a city without its own percent takes the nearest ancestor’s (legacy _getP)', async () => {
expect(String(await service.getCityPercent('north', true))).toBe('8');
// 0 counts as "none", as legacy's `> 0`: تهران has 0, so استان تهران's.
expect(String(await service.getCityPercent('north', false))).toBe('10.12');
});
it('0 when no ancestor has one', async () => {
expect(await service.getCityPercent('iran', false)).toBe(0);
});
it('refuses to put a city under its own descendant', async () => {
await expect(service.updateCity('teh', { parent: 'north' })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.updateCity('teh', { parent: 'teh' })).rejects.toBeInstanceOf(BadRequestException);
expect(update).not.toHaveBeenCalled();
});
it('moves a city and clears a parent', async () => {
await service.updateCity('north', { parent: 'prov' });
expect(update).toHaveBeenLastCalledWith({ where: { id: 'north' }, data: { parentId: 'prov' } });
await service.updateCity('north', { parent: '' });
expect(update).toHaveBeenLastCalledWith({ where: { id: 'north' }, data: { parentId: null } });
});
it('refuses an unknown parent', async () => {
await expect(service.updateCity('north', { parent: 'nope' })).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,236 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Post,
Put,
} from '@nestjs/common';
import { AdminMiscService } from './admin-misc.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { isAccountingSettingKey } from '../accounting-sync/accounting-config';
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
/**
* Cities, SMS, notifications, newsletter, activity log and settings — the
* remaining admin screens. See AdminMiscService for the legacy endpoint map.
*/
@Controller('admin/misc')
export class AdminMiscController {
constructor(private readonly misc: AdminMiscService) {}
// ── cities ────────────────────────────────────────────────────────
@Post('cities/list')
@HttpCode(200)
@RequirePermission('cities.list')
listCities(@Body() query: TableQueryDto) {
return this.misc.listCities(query);
}
@Post('cities')
@HttpCode(200)
@RequirePermission('cities.create')
createCity(@Body() data: Record<string, never>) {
return this.misc.createCity(data);
}
@Get('cities/percent/:id')
@RequirePermission('cities.list')
getCityPercent(@Param('id') id: string) {
return this.misc.getCityPercent(id, false);
}
@Get('cities/percent-aux/:id')
@RequirePermission('cities.list')
getCityPercentAux(@Param('id') id: string) {
return this.misc.getCityPercent(id, true);
}
@Put('cities/:id')
@RequirePermission('cities.list.view.edit')
updateCity(@Param('id') id: string, @Body() data: Record<string, never>) {
return this.misc.updateCity(id, data);
}
@Delete('cities/:id')
@RequirePermission('cities.list.remove')
removeCity(@Param('id') id: string) {
return this.misc.removeCity(id);
}
// ── sms ───────────────────────────────────────────────────────────
@Post('sms/list')
@HttpCode(200)
@RequirePermission('sms.list')
listSms(@Body() query: TableQueryDto) {
return this.misc.listSms(query);
}
@Post('sms')
@HttpCode(200)
@RequirePermission('sms.create')
sendSms(
@Body() data: { type?: string | number; mobile?: string; text?: string },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.misc.sendSms(data, admin.id);
}
// ── notifications ─────────────────────────────────────────────────
@Post('notify/list')
@HttpCode(200)
@RequirePermission('notify.me')
listNotifications(@Body() query: TableQueryDto) {
return this.misc.listNotifications(query);
}
@Post('notify')
@HttpCode(200)
@RequirePermission('notify.create')
createNotification(
@Body() data: { title?: string; text?: string; body?: string; type?: string | number },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.misc.createNotification(data, admin.id);
}
@Post('notify/customer')
@HttpCode(200)
@RequirePermission('notify.create')
notifyCustomer(
@Body() data: { customer_id?: string; title?: string; text?: string; body?: string },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.misc.notifyCustomer(data, admin.id);
}
@Get('notify/read/:id')
@RequirePermission('notify.me')
markRead(@Param('id') id: string) {
return this.misc.markNotificationRead(id);
}
@Get('notify/:id')
@RequirePermission('notify.me')
findNotification(@Param('id') id: string) {
return this.misc.findNotification(id);
}
// ── newsletter ────────────────────────────────────────────────────
@Post('newsletter/list')
@HttpCode(200)
@RequirePermission('newsletters')
listNewsletter(@Body() query: TableQueryDto) {
return this.misc.listNewsletter(query);
}
// ── activity log ──────────────────────────────────────────────────
@Get('log/customers')
@RequirePermission('statics')
logCustomers() {
return this.misc.logCustomers();
}
@Get('log/customer/:id')
@RequirePermission('statics')
customerLog(@Param('id') id: string) {
return this.misc.customerLog(id);
}
// ── settings + brands ("مدیریت ثابت ها") ─────────────────────────
@Get('dashboard-links')
@RequirePermission('config.report')
dashboardLinks() {
return this.misc.dashboardLinks();
}
@Get('const')
@RequirePermission('config.statics')
listConsts() {
return this.misc.listConsts();
}
@Post('brands')
@HttpCode(200)
@RequirePermission('config.statics.brand.create')
createBrand(@Body() body: { title: string }) {
return this.misc.createBrand(body.title);
}
@Put('brands/:id')
@RequirePermission('config.statics.brand.edit')
updateBrand(@Param('id') id: string, @Body() body: { title: string }) {
return this.misc.updateBrand(id, body.title);
}
@Delete('brands/:id')
@RequirePermission('config.statics.brand.remove')
removeBrand(@Param('id') id: string) {
return this.misc.removeBrand(id);
}
// The mobile home slider ("اسلایدر موبایل") is stored under the same
// `Setting` table as every other `const/:key` entry (key: mobile_main_slider),
// but it gets its own permission (`custom.config.statics.slider`, per
// acl.constants.ts / permissionConst.ts) rather than the broad `config.statics`
// that guards the rest of the "مدیریت ثابت ها" screen — uploading/replacing the
// public site banner is more sensitive than editing brand/tag lists, so it
// should not be reachable by everyone who can merely open that screen.
// Routes declared ahead of `const/:key` so Nest doesn't match `const/slider`
// (the legacy REST shape) to the generic key handler instead.
@Get('const/slider')
@RequirePermission('custom.config.statics.slider')
getSlider() {
return this.misc.getConst('mobile_main_slider');
}
@Put('const/slider')
@RequirePermission('custom.config.statics.slider')
setSlider(@Body() body: Record<string, unknown>) {
return this.misc.setConst('mobile_main_slider', body.val !== undefined ? body.val : body);
}
@Delete('const/slider')
@RequirePermission('custom.config.statics.slider')
removeSlider() {
return this.misc.removeConst('mobile_main_slider');
}
@Get('const/:key')
@RequirePermission('config.statics')
getConst(@Param('key') key: string) {
this.assertNotSliderKey(key);
return this.misc.getConst(key);
}
@Put('const/:key')
@RequirePermission('config.statics')
setConst(@Param('key') key: string, @Body() body: Record<string, unknown>) {
this.assertNotSliderKey(key);
// Legacy posts either a bare value under `val` or a whole object.
return this.misc.setConst(key, body.val !== undefined ? body.val : body);
}
@Delete('const/:key')
@RequirePermission('config.statics')
removeConst(@Param('key') key: string) {
this.assertNotSliderKey(key);
return this.misc.removeConst(key);
}
// Blocks the generic `config.statics`-gated routes from reaching the slider
// setting by its raw key — otherwise anyone with `config.statics` could
// bypass the dedicated `const/slider` routes (guarded by the stricter
// `custom.config.statics.slider`) simply by calling `const/mobile_main_slider`
// directly, since the underlying Setting row is keyed the same either way.
private assertNotSliderKey(key: string) {
if (key === 'mobile_main_slider' || isAccountingSettingKey(key) || key.trim().toLowerCase().startsWith(PRICING_SETTING_PREFIX)) {
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
}
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminMiscController } from './admin-misc.controller';
import { PublicSliderController } from './public-slider.controller';
import { AdminMiscService } from './admin-misc.service';
@Module({
imports: [PrismaModule],
controllers: [AdminMiscController, PublicSliderController],
providers: [AdminMiscService],
})
export class AdminMiscModule {}
@@ -0,0 +1,632 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { MessageAudience, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
/**
* The remaining admin screens — legacy views/city, views/sms, views/notify,
* views/newsletters, views/statics and views/manage/*:
*
* cities, cities/{id}, cities/get-percent/{id}, cities/get-percent-aux/{id}
* sms/table, sms
* notify, notify/customer, notify/{id}, notify/read/{id}
* ddf -> the newsletter list (legacy's own opaque name)
* log/list/customer, log/ago/{id}
* const, const/{key}, const/by-id/{id}
* dashboard/link
*/
@Injectable()
export class AdminMiscService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly sms: SmsService,
) {}
// ── Cities ─────────────────────────────────────────────────────────
//
// Legacy cities are a tree (country > province > city > district) and so are
// the target's again (PERS-02): each city may hang under a `parent`.
/** Legacy `POST cities/list` grid. */
async listCities(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['name'],
allowed: ['id', 'name', 'parentId', 'percent', 'percentAux'],
defaultOrderBy: { name: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.city.findMany({
where: where as Prisma.CityWhereInput,
orderBy: orderBy as Prisma.CityOrderByWithRelationInput,
skip,
take,
include: {
parent: { select: { id: true, name: true } },
_count: { select: { customers: true, branches: true, children: true } },
},
}),
this.prisma.city.count({ where: where as Prisma.CityWhereInput }),
]);
return this.table.respond(
rows.map((c) => ({
id: c.id,
title: c.name,
name: c.name,
parent: c.parentId,
parent_title: c.parent?.name ?? null,
percent: c.percent,
percent_aux: c.percentAux,
customers_count: c._count.customers,
branches_count: c._count.branches,
children_count: c._count.children,
})),
total,
query,
page,
limit,
);
}
/**
* The parent a create/update asks for: `parent` (legacy's field name) or
* `parent_id`; empty means top level. Refuses an unknown city, the city
* itself, or one of its own descendants (that would make a loop).
*/
private async resolveCityParent(
data: { parent?: unknown; parent_id?: unknown },
selfId?: string,
): Promise<string | null | undefined> {
const raw = data.parent !== undefined ? data.parent : data.parent_id;
if (raw === undefined) return undefined;
if (raw === null || String(raw).trim() === '') return null;
const parentId = String(raw).trim();
// Walk up from the requested parent; meeting selfId means a loop.
let cur: string | null = parentId;
for (let guard = 0; cur && guard < 50; guard++) {
if (cur === selfId) throw new BadRequestException('شهر نمی‌تواند زیرمجموعه‌ی خودش باشد.');
const row: { parentId: string | null } | null = await this.prisma.city.findUnique({
where: { id: cur },
select: { parentId: true },
});
if (!row) {
if (cur === parentId) throw new BadRequestException('والد انتخاب‌شده پیدا نشد.');
break;
}
cur = row.parentId;
}
return parentId;
}
private static cityPercent(v: unknown): Prisma.Decimal | null | undefined {
if (v === undefined) return undefined;
if (v === null || String(v).trim() === '') return null;
try {
return new Prisma.Decimal(String(v));
} catch {
throw new BadRequestException('درصد باید عدد باشد.');
}
}
/** Legacy `POST cities` — name, parent and the two percents. */
async createCity(data: { title?: string; name?: string; parent?: unknown; parent_id?: unknown; percent?: unknown; percent_aux?: unknown }) {
const name = String(data.title ?? data.name ?? '').trim();
if (!name) throw new BadRequestException('نام شهر الزامی است');
const parentId = await this.resolveCityParent(data);
return this.prisma.city.create({
data: {
name,
parentId: parentId ?? null,
percent: AdminMiscService.cityPercent(data.percent) ?? null,
percentAux: AdminMiscService.cityPercent(data.percent_aux) ?? null,
},
});
}
/** Legacy `PUT cities/{id}` — name, parent and the two percents. */
async updateCity(
id: string,
data: { title?: string; name?: string; parent?: unknown; parent_id?: unknown; percent?: unknown; percent_aux?: unknown },
) {
const existing = await this.prisma.city.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('شهر یافت نشد');
const patch: Prisma.CityUncheckedUpdateInput = {};
if (data.title !== undefined || data.name !== undefined) {
const name = String(data.title ?? data.name ?? '').trim();
if (!name) throw new BadRequestException('نام شهر الزامی است');
patch.name = name;
}
const parentId = await this.resolveCityParent(data, id);
if (parentId !== undefined) patch.parentId = parentId;
const percent = AdminMiscService.cityPercent(data.percent);
if (percent !== undefined) patch.percent = percent;
const percentAux = AdminMiscService.cityPercent(data.percent_aux);
if (percentAux !== undefined) patch.percentAux = percentAux;
return this.prisma.city.update({ where: { id }, data: patch });
}
/**
* Legacy `DELETE cities/{id}`.
*
* Refuses while customers, branches or child cities still point at the city —
* legacy deleted regardless, orphaning those rows.
*/
async removeCity(id: string) {
const c = await this.prisma.city.findUnique({
where: { id },
include: { _count: { select: { customers: true, branches: true, addresses: true, children: true } } },
});
if (!c) throw new NotFoundException('شهر یافت نشد');
if (c._count.children > 0) {
throw new BadRequestException(`حذف این شهر ممکن نیست، چون ${c._count.children} شهر زیرمجموعه‌ی آن است.`);
}
const refs = c._count.customers + c._count.branches + c._count.addresses;
if (refs > 0) {
throw new BadRequestException(
`حذف این شهر ممکن نیست، چون ${refs} مشتری، شعبه یا آدرس به آن ارجاع دارد.`,
);
}
await this.prisma.city.delete({ where: { id } });
return { success: true };
}
/**
* Legacy `GET cities/get-percent/{id}` — returns the bare number. As legacy's
* CitiesController::_getP: a city without its own percent (null or 0) takes
* its parent's, and so on up; 0 when no ancestor has one.
*/
async getCityPercent(id: string, aux: boolean) {
let cur: string | null = id;
for (let guard = 0; cur && guard < 50; guard++) {
const c: { parentId: string | null; percent: Prisma.Decimal | null; percentAux: Prisma.Decimal | null } | null =
await this.prisma.city.findUnique({
where: { id: cur },
select: { parentId: true, percent: true, percentAux: true },
});
if (!c) {
if (cur === id) throw new NotFoundException('شهر یافت نشد');
break;
}
const v = aux ? c.percentAux : c.percent;
if (v !== null && v.gt(0)) return v;
cur = c.parentId;
}
return 0;
}
// ── SMS ────────────────────────────────────────────────────────────
/** Legacy `POST sms/table` — the sent-message log. */
async listSms(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['body', 'recipients'],
allowed: ['id', 'audience', 'body', 'createdAt', 'sentCount'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.smsMessage.findMany({
where: where as Prisma.SmsMessageWhereInput,
orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput,
skip,
take,
include: { sentByAdminUser: true },
}),
this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }),
]);
return this.table.respond(
rows.map((s) => ({
id: s.id,
type: s.audience,
text: s.body,
mobile: s.recipients,
sent_count: s.sentCount,
sender: s.sentByAdminUser?.fullName ?? null,
caj: s.createdAt,
})),
total,
query,
page,
limit,
);
}
/**
* Legacy `POST sms` — resolves the audience, substitutes `%name` per
* recipient (legacy SmsController::batch + replaceName), delivers via the
* configured gateway (D26 relay), and records the send.
*/
async sendSms(
data: { type?: string | number; mobile?: string; text?: string },
actorId: string,
) {
const body = String(data.text ?? '').trim();
if (!body) throw new BadRequestException('متن پیامک الزامی است');
const audience = this.resolveAudience(data.type);
const recipients = await this.resolveRecipients(audience, data.mobile);
const needsNameSub = body.includes('%name');
const customersByMobile = needsNameSub
? await this.customerNamesByMobile(recipients)
: new Map<string, string>();
for (const mobile of recipients) {
const text = needsNameSub
? body.replace(/%name/g, customersByMobile.get(mobile) ?? 'مشتری عزیز')
: body;
this.sms.send(mobile, text, { audience });
}
return this.prisma.smsMessage.create({
data: {
audience,
recipients: recipients.join(','),
body,
sentCount: recipients.length,
sentByAdminUserId: actorId,
},
});
}
private async customerNamesByMobile(mobiles: string[]): Promise<Map<string, string>> {
const rows = await this.prisma.customer.findMany({
where: { mobile: { in: mobiles } },
select: { mobile: true, fullName: true },
});
const map = new Map<string, string>();
for (const r of rows) {
if (r.fullName) map.set(r.mobile, r.fullName + ' عزیز');
}
return map;
}
/**
* Legacy's numeric audience codes, read off views/sms/create.vue where each
* option is gated by its own rid:
* -1 sms.create.single | 0 sms.create.all | 1 sms.create.reagent
* 2 sms.create.omde | 9 sms.create.multiple
* (there is no option for sms.create.single-customer "tak" in the legacy
* markup even though the rid exists — preserved as unreachable.)
*/
private resolveAudience(type?: string | number): MessageAudience {
const byCode: Record<string, MessageAudience> = {
'-1': MessageAudience.SINGLE_NUMBER,
'0': MessageAudience.ALL_CUSTOMERS,
'1': MessageAudience.SALES_AGENTS,
'2': MessageAudience.WHOLESALE_CUSTOMERS,
'9': MessageAudience.MULTIPLE_NUMBERS,
};
const byName: Record<string, MessageAudience> = {
single: MessageAudience.SINGLE_NUMBER,
multiple: MessageAudience.MULTIPLE_NUMBERS,
all: MessageAudience.ALL_CUSTOMERS,
omde: MessageAudience.WHOLESALE_CUSTOMERS,
tak: MessageAudience.RETAIL_CUSTOMERS,
reagent: MessageAudience.SALES_AGENTS,
};
const key = String(type ?? '');
return byCode[key] ?? byName[key] ?? MessageAudience.SINGLE_NUMBER;
}
/** Expands an audience into the actual phone numbers it covers. */
private async resolveRecipients(
audience: MessageAudience,
explicit?: string,
): Promise<string[]> {
if (
audience === MessageAudience.SINGLE_NUMBER ||
audience === MessageAudience.MULTIPLE_NUMBERS
) {
const list = String(explicit ?? '')
.split(/[,\s\n]+/)
.map((x) => x.trim())
.filter(Boolean);
if (list.length === 0) throw new BadRequestException('شماره موبایل وارد نشده است');
return list;
}
if (audience === MessageAudience.SALES_AGENTS) {
const rows = await this.prisma.adminUser.findMany({
where: { role: 'SALES_EXPERT', isActive: true },
select: { mobile: true },
});
return rows.map((r) => r.mobile);
}
// The customer audiences. WHOLESALE/RETAIL had a Customer.type column in
// legacy that the schema redesign removed, so both currently resolve to all
// active customers — the same gap recorded for the omde/tak lists.
const rows = await this.prisma.customer.findMany({
where: { isActive: true },
select: { mobile: true },
});
return rows.map((r) => r.mobile);
}
// ── Notifications ──────────────────────────────────────────────────
/** Legacy `POST notify` — broadcast to an audience. */
async createNotification(
data: { title?: string; text?: string; body?: string; type?: string | number },
actorId: string,
) {
const title = String(data.title ?? '').trim();
const body = String(data.body ?? data.text ?? '').trim();
if (!title) throw new BadRequestException('عنوان نوتیفیکیشن الزامی است');
if (!body) throw new BadRequestException('متن نوتیفیکیشن الزامی است');
const audience = this.resolveAudience(data.type);
const customers = await this.prisma.customer.findMany({
where: { isActive: true },
select: { id: true },
});
return this.prisma.notification.create({
data: {
title,
body,
audience,
sentByAdminUserId: actorId,
recipients: { create: customers.map((c) => ({ customerId: c.id })) },
},
include: { _count: { select: { recipients: true } } },
});
}
/** Legacy `POST notify/customer` — send to one named customer. */
async notifyCustomer(
data: { customer_id?: string; title?: string; text?: string; body?: string },
actorId: string,
) {
const customerId = String(data.customer_id ?? '');
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const title = String(data.title ?? '').trim();
const body = String(data.body ?? data.text ?? '').trim();
if (!title || !body) throw new BadRequestException('عنوان و متن الزامی است');
return this.prisma.notification.create({
data: {
title,
body,
audience: MessageAudience.SINGLE_NUMBER,
sentByAdminUserId: actorId,
recipients: { create: [{ customerId }] },
},
});
}
/** Legacy `GET notify/{id}` — one notification with its delivery state. */
async findNotification(id: string) {
const n = await this.prisma.notification.findUnique({
where: { id },
include: {
sentByAdminUser: true,
recipients: { include: { customer: true } },
},
});
if (!n) throw new NotFoundException('نوتیفیکیشن یافت نشد');
return {
id: n.id,
title: n.title,
body: n.body,
text: n.body,
audience: n.audience,
sender: n.sentByAdminUser?.fullName ?? null,
caj: n.createdAt,
recipients: n.recipients.map((r) => ({
id: r.id,
customer: { id: r.customer.id, name: r.customer.fullName, mobile: r.customer.mobile },
read_at: r.readAt,
is_read: Boolean(r.readAt),
})),
read_count: n.recipients.filter((r) => r.readAt).length,
total_count: n.recipients.length,
};
}
/** Legacy `POST notify/list` — the notification log. */
async listNotifications(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title', 'body'],
allowed: ['id', 'title', 'body', 'audience', 'createdAt'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.notification.findMany({
where: where as Prisma.NotificationWhereInput,
orderBy: orderBy as Prisma.NotificationOrderByWithRelationInput,
skip,
take,
include: {
sentByAdminUser: true,
_count: { select: { recipients: true } },
},
}),
this.prisma.notification.count({ where: where as Prisma.NotificationWhereInput }),
]);
return this.table.respond(
rows.map((n) => ({
id: n.id,
title: n.title,
text: n.body,
audience: n.audience,
sender: n.sentByAdminUser?.fullName ?? null,
total_count: n._count.recipients,
caj: n.createdAt,
})),
total,
query,
page,
limit,
);
}
/** Legacy `GET notify/read/{id}` — mark one recipient row read. */
async markNotificationRead(recipientId: string) {
const r = await this.prisma.notificationRecipient.findUnique({ where: { id: recipientId } });
if (!r) throw new NotFoundException('یافت نشد');
if (r.readAt) return r;
return this.prisma.notificationRecipient.update({
where: { id: recipientId },
data: { readAt: new Date() },
});
}
// ── Newsletter ─────────────────────────────────────────────────────
/** Legacy `POST ddf` — the newsletter signup list (legacy's own endpoint name). */
async listNewsletter(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['email', 'mobile'],
allowed: ['id', 'email', 'mobile', 'createdAt'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.newsletterSubscriber.findMany({
where: where as Prisma.NewsletterSubscriberWhereInput,
orderBy: orderBy as Prisma.NewsletterSubscriberOrderByWithRelationInput,
skip,
take,
}),
this.prisma.newsletterSubscriber.count({
where: where as Prisma.NewsletterSubscriberWhereInput,
}),
]);
return this.table.respond(
rows.map((n) => ({ id: n.id, email: n.email, mobile: n.mobile, caj: n.createdAt })),
total,
query,
page,
limit,
);
}
// ── Activity log ("آمار") ─────────────────────────────────────────
/** Legacy `GET log/list/customer` — customers that have any activity. */
async logCustomers() {
const rows = await this.prisma.customer.findMany({
where: { activityLogs: { some: {} } },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true, mobile: true },
});
return rows.map((c) => ({ id: c.id, name: c.fullName, mobile: c.mobile }));
}
/** Legacy `GET log/ago/{customerId}` — that customer's recent activity. */
async customerLog(customerId: string) {
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const rows = await this.prisma.customerActivityLog.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
take: 200,
});
return rows.map((l) => ({
id: l.id,
action: l.action,
detail: l.detail,
caj: l.createdAt,
}));
}
// ── Settings ("مدیریت ثابت ها") ───────────────────────────────────
/** Legacy `GET const/{key}`. */
async getConst(key: string) {
const row = await this.prisma.setting.findUnique({ where: { key } });
return row?.value ?? null;
}
/** Legacy `POST const` / `PUT const/{key}`. */
async setConst(key: string, value: unknown) {
const row = await this.prisma.setting.upsert({
where: { key },
update: { value: value as Prisma.InputJsonValue },
create: { key, value: value as Prisma.InputJsonValue },
});
return row.value;
}
async listConsts() {
// The accounting integration's rows (connection, encrypted token, worker
// lease) have their own `config.accounting` screen and never appear here
// (D27); the single-key routes refuse them in the controller.
const rows = await this.prisma.setting.findMany({
where: { AND: [{ NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } }, { NOT: { key: { startsWith: PRICING_SETTING_PREFIX } } }] },
orderBy: { key: 'asc' },
});
return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt }));
}
async removeConst(key: string) {
const row = await this.prisma.setting.findUnique({ where: { key } });
if (!row) throw new NotFoundException('تنظیم یافت نشد');
await this.prisma.setting.delete({ where: { key } });
return { success: true };
}
// ── Brands (managed from the same "ثابت ها" screen) ───────────────
async createBrand(title: string) {
if (!title?.trim()) throw new BadRequestException('عنوان برند الزامی است');
return this.prisma.brand.create({ data: { title: title.trim() } });
}
async updateBrand(id: string, title: string) {
const b = await this.prisma.brand.findUnique({ where: { id } });
if (!b) throw new NotFoundException('برند یافت نشد');
return this.prisma.brand.update({ where: { id }, data: { title } });
}
async removeBrand(id: string) {
const b = await this.prisma.brand.findUnique({
where: { id },
include: { _count: { select: { products: true } } },
});
if (!b) throw new NotFoundException('برند یافت نشد');
if (b._count.products > 0) {
throw new BadRequestException(
`حذف این برند ممکن نیست، چون ${b._count.products} محصول به آن ارجاع دارد.`,
);
}
await this.prisma.brand.delete({ where: { id } });
return { success: true };
}
/** Legacy `GET dashboard/link` — the report shortcuts on views/manage/report. */
async dashboardLinks() {
const row = await this.prisma.setting.findUnique({ where: { key: 'dashboard.links' } });
return row?.value ?? [];
}
}
@@ -0,0 +1,26 @@
import { Controller, Get } from '@nestjs/common';
import { AdminMiscService } from './admin-misc.service';
import { Public } from '../auth/guards';
/**
* Public read of the admin-managed home slider ("اسلایدر موبایل" in
* manage/Const.vue), served at GET /api/home/slider.
*
* The admin write path (const/slider, guarded by the dedicated
* `custom.config.statics.slider` permission — see admin-misc.controller.ts)
* stores it under the same `Setting` row (key: mobile_main_slider) that this
* reads. Split into its own controller/prefix, rather than exposing it under
* `admin/misc`, so the shopper-facing site does not call an `/admin/...` path
* unauthenticated.
*/
@Public()
@Controller('home')
export class PublicSliderController {
constructor(private readonly misc: AdminMiscService) {}
@Get('slider')
async getSlider() {
const value = await this.misc.getConst('mobile_main_slider');
return Array.isArray(value) ? value : [];
}
}
@@ -0,0 +1,115 @@
import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
import { AdminOrdersService } from './admin-orders.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { UpdateOrderStatusDto } from './update-status.dto';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Legacy endpoints (henza_server routes/admin.php):
* POST single-order -> the single-sale order grid
* GET order/{id} -> order detail + status timeline
*
* Each route is gated by the same rid the legacy router used in `meta.role`,
* so the API enforces what the menu already hides.
*/
@Controller('admin/orders')
export class AdminOrdersController {
constructor(private readonly orders: AdminOrdersService) {}
/** Legacy POST single-order. POST, not GET, because XTable posts its filters. */
@Post('list')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('order.list')
list(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
/**
* Branch scoping, ported from legacy AdminController::getSingleOrders:
*
* if (!is_null(auth()->user()->branch_id)) {
* $records->where('branch_id', auth()->user()->branch_id);
* }
*
* i.e. ANY user carrying a branch_id sees only that branch. Faithful to legacy.
*
* Consequence worth knowing: the seed gives head-office admins a home branch,
* so under this rule they see only that branch's orders. That is legacy's real
* behaviour, not a defect introduced here — head office was expected to have a
* null branch_id. Recorded in docs/ADMIN-PORT-PLAN.md.
*/
return this.orders.list(query, admin.branchId ?? undefined);
}
/** Legacy POST major/order/table — the wholesale grid. */
@Post('wholesale/list')
@HttpCode(200)
@RequirePermission('order.omde')
listWholesale(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.listWholesale(query, admin.branchId ?? undefined);
}
/** Legacy POST order-tamin — orders that reached the sourcing stage. */
@Post('tamin/list')
@HttpCode(200)
@RequirePermission('order.tamin')
listTamin(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.listTamin(query, admin.branchId ?? undefined);
}
/** Legacy POST delete-order. */
@Post('delete')
@HttpCode(200)
@RequirePermission('order.omde')
remove(@Body() body: { id: string }) {
return this.orders.remove(body.id);
}
/** Legacy POST /order from the admin order builder (views/order/create.vue). */
@Post('create')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('order.create')
createFromAdmin(
@Body()
body: {
customer_id: string;
order_items: { vid: string; qty: number; percent?: number; discount?: number }[];
sts?: number | string;
note?: string;
},
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.orders.createFromAdmin(body, admin.id);
}
/** Legacy GET order/type. */
@Get('type-options')
@SuperAdminOnly()
@RequirePermission('order.create')
typeOptions() {
return this.orders.typeOptions();
}
// The three routes below also serve the wholesale and sourcing lists, whose
// staff hold order.omde / order.tamin rather than the super-admin-only
// order.list.
@Get('status-options')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
statusOptions() {
return this.orders.statusOptions();
}
/** Legacy POST single/order-sts/edit. */
@Post('status')
@HttpCode(200)
@RequirePermission('order.list', 'order.omde', 'order.tamin')
setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.setStatus(dto, admin.id);
}
@Get(':id')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
findOne(@Param('id') id: string) {
return this.orders.findOne(id);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { OrdersModule } from '../orders/orders.module';
import { AccountingModule } from '../accounting/accounting.module';
import { AdminOrdersController } from './admin-orders.controller';
import { AdminOrdersService } from './admin-orders.service';
@Module({
imports: [PrismaModule, OrdersModule, AccountingModule],
controllers: [AdminOrdersController],
providers: [AdminOrdersService],
})
export class AdminOrdersModule {}
@@ -0,0 +1,421 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { OrdersService } from '../orders/orders.service';
import { LedgerService } from '../accounting/ledger.service';
import type { UpdateOrderStatusDto } from './update-status.dto';
/**
* Farsi status labels, matching legacy's `sts_str` column.
*
* Legacy stored status as an integer `sts` and the server derived the string via
* Order::$SELECT_STS_STR. The new schema uses an enum, so the mapping is explicit.
* Debt is recognised at sts>4 (DELIVERED_TO_AGENT) — see the schema notes.
*/
const STATUS_LABELS: Record<OrderStatus, string> = {
SUSPENDED: 'معلق (سقف اعتبار)',
PLACED: 'ثبت شده',
ADMIN_APPROVED: 'تایید مدیر',
SOURCING: 'در حال تامین',
PREPARING: 'در حال آماده‌سازی',
DELIVERED_TO_AGENT: 'تحویل به نماینده',
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
CANCELLED: 'لغو شده',
};
/** Order of the status timeline shown in the legacy "وضعیت سفارش" modal. */
const STATUS_FLOW: OrderStatus[] = [
OrderStatus.PLACED,
OrderStatus.ADMIN_APPROVED,
OrderStatus.SOURCING,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_AGENT,
OrderStatus.DELIVERED_TO_CUSTOMER,
];
@Injectable()
export class AdminOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly orders: OrdersService,
private readonly accounting: AccountingHooksService,
private readonly ledger: LedgerService,
) {}
/** Fields the XTable filter bar and sorter may reference. */
private static readonly ALLOWED = [
'id',
'orderNumber',
'status',
'createdAt',
'updatedAt',
'totalGoldWeightGrams',
'flatRialCharge',
'isSuspendedOverCreditLimit',
'customer.fullName',
'customer.mobile',
'branch.name',
];
/**
* Legacy `POST single-order` — the order list grid.
*
* `branchId` narrows the result to one branch, which is how the branch-mode
* build of the panel scopes its list.
*/
async list(query: TableQueryDto, branchId?: string, channel: OrderChannel = OrderChannel.RETAIL) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
allowed: AdminOrdersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.OrderWhereInput = {
AND: [
where as Prisma.OrderWhereInput,
{ channel },
...(branchId ? [{ branchId }] : []),
],
};
const [rows, total] = await Promise.all([
this.prisma.order.findMany({
where: scoped,
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
skip,
take,
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
},
}),
this.prisma.order.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/**
* Shapes a row the way the ported list view reads it.
*
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`)
* so the ported template bindings keep working unchanged.
*/
private toRow(o: Prisma.OrderGetPayload<{
include: {
customer: true;
branch: true;
items: { include: { productVariant: { include: { product: true } } } };
};
}>) {
return {
id: o.id,
orderNumber: o.orderNumber,
status: o.status,
sts_str: STATUS_LABELS[o.status],
caj: o.createdAt,
uaj: o.updatedAt,
total_price: o.flatRialCharge,
totalGoldWeightGrams: o.totalGoldWeightGrams,
wageWeightGrams: o.wageWeightGrams,
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
deliveredAt: o.deliveredAt,
branch_id: o.branchId,
branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null,
customer: {
id: o.customer.id,
// Legacy split names into fname/lname; the new schema has one fullName.
// Both are exposed so ported templates binding either keep working.
fname: o.customer.firstName ?? o.customer.fullName,
lname: o.customer.lastName ?? '',
fullName: o.customer.fullName,
phone: o.customer.mobile,
},
items: o.items.map((it) => ({
id: it.id,
title: it.productVariant.product.title,
variant_title: it.productVariant.title,
qty: it.quantity,
grams: it.computedWeightGrams,
rash: it.rashWeightGramsSnapshot,
})),
};
}
/**
* Legacy `POST major/order/table` — the wholesale (عمده) order grid.
*
* Legacy kept the two sales channels in SEPARATE tables: `orders_orders`
* (SingleCustomerOrders, served by AdminController::getSingleOrders) and
* `orders` (Order, served by MajorOrderController::index). Neither controller
* applied a type filter because the table itself was the discriminator, so the
* two lists never overlapped.
*
* The redesigned schema has one Order model, so `Order.channel` carries that
* distinction instead and each list filters on it.
*/
async listWholesale(query: TableQueryDto, branchId?: string) {
return this.list(query, branchId, OrderChannel.WHOLESALE);
}
/**
* Legacy `POST order-tamin` — MajorOrderController::getTaminOrders.
*
* Legacy narrowed to orders whose activity contained Order::$STS[3], i.e. those
* that have reached the sourcing stage. The equivalent here is a status event
* recording a transition into SOURCING.
*/
async listTamin(query: TableQueryDto, branchId?: string) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
allowed: AdminOrdersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const conditions: Prisma.OrderWhereInput[] = [
where as Prisma.OrderWhereInput,
{ statusEvents: { some: { toStatus: OrderStatus.SOURCING } } },
];
if (branchId) conditions.push({ branchId });
const scoped: Prisma.OrderWhereInput = { AND: conditions };
const [rows, total] = await Promise.all([
this.prisma.order.findMany({
where: scoped,
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
skip,
take,
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
},
}),
this.prisma.order.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy `POST delete-order` — remove a wholesale order. */
async remove(id: string) {
const order = await this.prisma.order.findUnique({
where: { id },
include: { ledgerEntries: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
// Refuses once debt has been posted: deleting the order would orphan the
// ledger entries and leave the customer permanently in debt. Legacy had no
// such guard. Recorded in docs/ADMIN-PORT-PLAN.md.
if (order.ledgerEntries.length > 0) {
throw new BadRequestException(
'حذف این سفارش ممکن نیست، چون برای آن سند حسابداری ثبت شده است.',
);
}
await this.prisma.$transaction(async (tx) => {
// D27 — a temporary document may already be in the books (the rule can
// post it at placement); withdraw it like a cancellation.
await this.accounting.orderCancelled(tx, id);
await tx.orderStatusEvent.deleteMany({ where: { orderId: id } });
await tx.orderItem.deleteMany({ where: { orderId: id } });
await tx.order.delete({ where: { id } });
});
return { success: true };
}
/** Legacy `GET order/:id` detail, including the status timeline. */
async findOne(id: string) {
const order = await this.prisma.order.findUnique({
where: { id },
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
},
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
const reached = new Set(order.statusEvents.map((e) => e.toStatus));
return {
...this.toRow(order),
// legacy `activity`: one entry per step, `proceed` marking those reached
activity: STATUS_FLOW.map((s) => {
const event = order.statusEvents.find((e) => e.toStatus === s);
return {
status: s,
title: STATUS_LABELS[s],
proceed: reached.has(s),
uaj: event?.createdAt ?? null,
note: event?.note ?? '',
};
}),
};
}
/** Farsi labels for the status dropdowns. */
statusOptions() {
return Object.entries(STATUS_LABELS).map(([value, title]) => ({ value, title }));
}
/**
* Legacy `GET order/type` — the "نوع فروش" dropdown.
* Legacy Order::$TYPES = [1 => 'ثبت سفارش توسط مسئول فروش', 2 => 'ثبت مشتری'].
*/
typeOptions() {
return { 1: 'ثبت سفارش توسط مسئول فروش', 2: 'ثبت مشتری' };
}
/**
* Legacy `POST /order` from views/order/create.vue — the admin order builder.
*
* Delegates to OrdersService.createOrder, which owns the weight formula
* w = rash * (1 + (wagePercent - discountPercent)/100)
* and the credit-limit gate that suspends rather than rejects an over-limit
* order. Reimplementing either here would let the two paths drift.
*
* Legacy also posted total_price / total_weight / gold_price_original computed
* in the BROWSER. Those are ignored: the server recomputes them from the
* variant snapshots, so a tampered or stale client total cannot alter a debt.
*/
async createFromAdmin(
payload: {
customer_id: string;
order_items: {
vid: string;
qty: number;
percent?: number;
discount?: number;
rial_input?: number;
}[];
sts?: number | string;
note?: string;
},
actorAdminUserId: string,
) {
if (!payload.customer_id) throw new BadRequestException('مشتری انتخاب نشده است');
if (!Array.isArray(payload.order_items) || payload.order_items.length === 0) {
throw new BadRequestException('سبد سفارش خالی است');
}
const items = await Promise.all(
payload.order_items.map(async (it) => {
const variant = await this.prisma.productVariant.findUnique({ where: { id: it.vid } });
if (!variant) throw new NotFoundException(`تنوع محصول یافت نشد: ${it.vid}`);
return {
productVariantId: it.vid,
quantity: Number(it.qty) || 1,
// Fall back to the variant's own default wage when the builder did not
// negotiate a different one.
wagePercent: Number(it.percent ?? variant.defaultWagePercent),
discountPercent: Number(it.discount ?? 0),
flatRialCharge: Number(it.rial_input ?? 0),
};
}),
);
// The customer id is now a separate argument rather than a body field, so
// the customer-facing route can take it from the caller's token (SEC-02).
// This path is different: an operator legitimately places an order FOR a
// customer, so it passes the one the builder selected. The route itself is
// admin-guarded, which is what makes that safe.
const order = await this.orders.createOrder(
{
items,
// The admin builder serves the omde (major/*) flow, so orders it creates
// belong to the wholesale channel — legacy wrote them to the `orders`
// table, which MajorOrderController::index listed.
channel: OrderChannel.WHOLESALE,
},
payload.customer_id,
);
// legacy `sts`: advance the new order to the chosen delivery status.
const target = this.statusFromLegacyCode(payload.sts);
if (target && target !== OrderStatus.PLACED) {
await this.orders.updateStatus(order.id, target, actorAdminUserId, payload.note);
}
return this.findOne(order.id);
}
/**
* Legacy delivery-status codes from create.vue's `sts` map:
* 1 ثبت سفارش | 2 تایید مدیر سیستم | 3 در حال تامین
* 4 در حال آماده سازی | 5 ارسال | 6 تحویل مشتری
*/
private statusFromLegacyCode(code?: number | string): OrderStatus | undefined {
const map: Record<number, OrderStatus> = {
1: OrderStatus.PLACED,
2: OrderStatus.ADMIN_APPROVED,
3: OrderStatus.SOURCING,
4: OrderStatus.PREPARING,
5: OrderStatus.DELIVERED_TO_AGENT,
6: OrderStatus.DELIVERED_TO_CUSTOMER,
};
return code === undefined ? undefined : map[Number(code)];
}
/**
* Legacy `POST single/order-sts/edit` — advance or remove a status.
*
* Setting a status delegates to OrdersService.updateStatus rather than writing
* the row here, because that method also keeps the order's debt in line with its status
* (syncOrderDebt). Duplicating the update would silently skip that.
*/
async setStatus(dto: UpdateOrderStatusDto, actorAdminUserId: string) {
if (dto.remove) {
return this.removeStatusEvent(dto.orderId, dto.status);
}
return this.orders.updateStatus(dto.orderId, dto.status, actorAdminUserId, dto.note);
}
/**
* Legacy's `remove: true` branch: drop a recorded status event.
*
* Any status may be removed, as in legacy (MajorOrderController.php:180-203),
* which stepped `sts` back and then RECOMPUTED the debt (updateRemain). The
* rollback below does the same through syncOrderDebt, so taking an order
* back below «تحویل به نماینده» takes its debt off, and nothing is left
* behind. (This used to be refused, on the belief that legacy left the debt
* in place; legacy recomputed it.)
*/
private async removeStatusEvent(orderId: string, status: OrderStatus) {
const event = await this.prisma.orderStatusEvent.findFirst({
where: { orderId, toStatus: status },
orderBy: { createdAt: 'desc' },
});
if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد');
// One transaction: the event, the status and the debt move together or not at all.
return this.prisma.$transaction(async (tx) => {
await tx.orderStatusEvent.delete({ where: { id: event.id } });
// Roll the order back to whatever status is now the latest recorded one.
const latest = await tx.orderStatusEvent.findFirst({
where: { orderId },
orderBy: { createdAt: 'desc' },
});
const rolledBackTo = latest?.toStatus ?? OrderStatus.PLACED;
const updated = await tx.order.update({ where: { id: orderId }, data: { status: rolledBackTo } });
// Legacy updateRemain after the step back: the debt follows the status.
await this.ledger.syncOrderDebt(orderId, tx);
// D27 — rolled back below the document rule: the پیش‌فاکتور is withdrawn.
await this.accounting.orderStatusReached(tx, orderId, rolledBackTo);
return updated;
});
}
}
@@ -0,0 +1,26 @@
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { OrderStatus } from '@prisma/client';
/**
* Body of legacy `POST single/order-sts/edit`.
*
* Legacy posted the whole timeline row back, so it carried `status`, an optional
* `note`, and `remove: true` when the operator was deleting a status instead of
* setting one. The order id travelled on the row as `order_id`/`orderId`.
*/
export class UpdateOrderStatusDto {
@IsString()
orderId!: string;
@IsEnum(OrderStatus)
status!: OrderStatus;
@IsOptional()
@IsString()
note?: string;
/** legacy: row.remove = true -> delete this status event rather than add one */
@IsOptional()
@IsBoolean()
remove?: boolean;
}
@@ -0,0 +1,47 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminPreOrdersService } from './admin-preorders.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints (CustomOrderController):
* POST get-custom-orders -> the "سفارش های ساختنی" grid
* GET get-custom-order/{id}
* PUT custom-order/{id}
* DELETE custom-order/{id}
*/
@Controller('admin/pre-orders')
export class AdminPreOrdersController {
constructor(private readonly preOrders: AdminPreOrdersService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('order.makeOrder')
list(@Body() query: TableQueryDto) {
return this.preOrders.list(query);
}
@Get('status-options')
@RequirePermission('order.makeOrder')
statusOptions() {
return this.preOrders.statusOptions();
}
@Get(':id')
@RequirePermission('order.makeOrder')
findOne(@Param('id') id: string) {
return this.preOrders.findOne(id);
}
@Put(':id')
@RequirePermission('order.makeOrder')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.preOrders.update(id, data);
}
@Delete(':id')
@RequirePermission('order.makeOrder')
remove(@Param('id') id: string) {
return this.preOrders.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminPreOrdersController } from './admin-preorders.controller';
import { AdminPreOrdersService } from './admin-preorders.service';
@Module({
imports: [PrismaModule],
controllers: [AdminPreOrdersController],
providers: [AdminPreOrdersService],
})
export class AdminPreOrdersModule {}
@@ -0,0 +1,159 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PreOrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Custom / made-to-order requests — legacy "سفارش های ساختنی".
*
* Legacy served these from CustomOrderController (`POST get-custom-orders`,
* `PUT custom-order/{id}`, `DELETE custom-order/{id}`). The redesigned schema
* models them as PreOrder: a design request that an admin prices and later
* converts into a real, debt-bearing Order.
*/
const PREORDER_STATUS_LABELS: Record<PreOrderStatus, string> = {
PLACED: 'ثبت شده',
ADMIN_APPROVED: 'تایید مدیر',
WORKSHOP_APPROVED: 'تایید کارگاه',
READY_TO_SHIP: 'آماده ارسال',
DELIVERED: 'تحویل شده',
CANCELLED: 'لغو شده',
};
@Injectable()
export class AdminPreOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'status',
'createdAt',
'updatedAt',
'colorName',
'sizeSpec',
'stoneColor',
'minaColor',
'estimatedWeightGrams',
'estimatedPriceRial',
'customer.fullName',
'customer.mobile',
];
/** Legacy `POST get-custom-orders`. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['customer.fullName', 'customer.mobile'],
allowed: AdminPreOrdersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.preOrder.findMany({
where: where as Prisma.PreOrderWhereInput,
orderBy: orderBy as Prisma.PreOrderOrderByWithRelationInput,
skip,
take,
include: { customer: true, city: true, workshop: true },
}),
this.prisma.preOrder.count({ where: where as Prisma.PreOrderWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names (color, size, color_mina, color_stone, ...) preserved. */
private toRow(
p: Prisma.PreOrderGetPayload<{ include: { customer: true; city: true; workshop: true } }>,
) {
return {
id: p.id,
status: p.status,
sts_str_fa: PREORDER_STATUS_LABELS[p.status],
color: p.colorName,
size: p.sizeSpec,
color_mina: p.minaColor,
color_stone: p.stoneColor,
// legacy 'sum' (quantity); a PreOrder models a single design request
sum: 1,
description: p.description,
designImageUrl: p.designImageUrl,
estimatedWeightGrams: p.estimatedWeightGrams,
estimatedPriceRial: p.estimatedPriceRial,
time_creat_fa: p.createdAt,
time_update_fa: p.updatedAt,
convertedOrderId: p.convertedOrderId,
city: p.city ? { id: p.city.id, title: p.city.name } : null,
workshop: p.workshop ? { id: p.workshop.id, name: p.workshop.name } : null,
customer: {
id: p.customer.id,
// D0 — real name parts
fname: p.customer.firstName ?? p.customer.fullName,
lname: p.customer.lastName ?? '',
name: p.customer.fullName,
fullName: p.customer.fullName,
mobile: p.customer.mobile,
},
};
}
async findOne(id: string) {
const p = await this.prisma.preOrder.findUnique({
where: { id },
include: { customer: true, city: true, workshop: true },
});
if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد');
return this.toRow(p);
}
/** Legacy `PUT custom-order/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.preOrder.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('سفارش ساختنی یافت نشد');
const patch: Prisma.PreOrderUpdateInput = {};
if (data.status !== undefined) patch.status = data.status as PreOrderStatus;
if (data.color !== undefined) patch.colorName = data.color as string;
if (data.size !== undefined) patch.sizeSpec = data.size as string;
if (data.color_mina !== undefined) patch.minaColor = data.color_mina as string;
if (data.color_stone !== undefined) patch.stoneColor = data.color_stone as string;
if (data.description !== undefined) patch.description = data.description as string;
if (data.estimatedWeightGrams !== undefined) {
patch.estimatedWeightGrams = new Prisma.Decimal(data.estimatedWeightGrams as string);
}
if (data.estimatedPriceRial !== undefined) {
patch.estimatedPriceRial = new Prisma.Decimal(data.estimatedPriceRial as string);
}
return this.prisma.preOrder.update({ where: { id }, data: patch });
}
/** Legacy `DELETE custom-order/{id}`. */
async remove(id: string) {
const p = await this.prisma.preOrder.findUnique({ where: { id } });
if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد');
// Once converted into a real Order the design request is part of an
// accounting trail; deleting it would orphan that link. Legacy had no guard.
if (p.convertedOrderId) {
throw new BadRequestException(
'حذف ممکن نیست: این سفارش ساختنی قبلاً به سفارش قطعی تبدیل شده است.',
);
}
await this.prisma.$transaction([
this.prisma.preOrderStatusEvent.deleteMany({ where: { preOrderId: id } }),
this.prisma.preOrder.delete({ where: { id } }),
]);
return { success: true };
}
statusOptions() {
return Object.entries(PREORDER_STATUS_LABELS).map(([value, title]) => ({ value, title }));
}
}
@@ -0,0 +1,137 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminProductsService } from './admin-products.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { isSuperAdmin } from '../auth/super-admin';
/**
* Legacy endpoints (ProductController):
* POST products -> the product grid
* GET product/show/{id}
* POST product/create
* PUT product/update/{id}
* POST delete/product/{id} (a delete expressed as POST — legacy's shape)
* POST get/variants (a read expressed as POST — legacy's shape)
* GET product/{id}/variant
* POST change/percent/count, POST change/all
* GET brand, POST category
*/
@Controller('admin/products')
export class AdminProductsController {
constructor(private readonly products: AdminProductsService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('product.list')
list(@Body() query: TableQueryDto) {
return this.products.list(query);
}
@Post('variants/list')
@HttpCode(200)
@RequirePermission('product.list')
listVariants(@Body() query: TableQueryDto & { product_id?: string }) {
return this.products.listVariants(query);
}
/**
* Legacy `POST product/variant` — the cart's rehydrate call. Given a list of
* product ids it returns each product with `variants_all`, so the basket can
* refresh prices/stock and drop anything that went out of stock.
*/
@Post('by-ids')
@HttpCode(200)
@RequirePermission('product.list')
byIds(@Body() body: { products_id: string[] }) {
return this.products.byIds(body.products_id ?? []);
}
/**
* Legacy `POST add/variant` — creates a bespoke weight variant from the order
* builder's inline weight editor.
*/
@Post('variants/create')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('order.create')
createVariant(@Body() body: { product_id: string; title: string; grams: number }) {
return this.products.createVariant(body);
}
@Get('show-type-options')
@RequirePermission('product.list')
showTypeOptions() {
return this.products.showTypeOptions();
}
@Get('brands')
@RequirePermission('product.list')
brands() {
return this.products.brands();
}
/** Brands as an XTable grid — the exclusion picker needs a selectable table. */
@Post('brands-table')
@HttpCode(200)
@RequirePermission('product.list')
brandsTable(@Body() query: TableQueryDto) {
return this.products.brandsTable(query);
}
@Get('collections')
@SuperAdminOnly()
@RequirePermission('product.list')
collections() {
return this.products.collections();
}
@Post('change-percent/count')
@HttpCode(200)
@RequirePermission('product.percent')
changePercentCount(@Body() body: { brand_id?: string; category_id?: string }) {
return this.products.changePercentCount(body);
}
@Post('change-percent/apply')
@HttpCode(200)
@RequirePermission('product.percent')
changePercent(@Body() body: { percent: number; brand_id?: string; category_id?: string }) {
return this.products.changePercent(body);
}
@Post('create')
@HttpCode(200)
@RequirePermission('product.create')
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
// Trays are super-admin-only; anyone else's product is created without them.
if (!isSuperAdmin(admin.mobile)) delete data.ciny_ids;
return this.products.create(data);
}
@Post('delete/:id')
@HttpCode(200)
@RequirePermission('product.list.edit')
remove(@Param('id') id: string) {
return this.products.remove(id);
}
@Get(':id/variants')
@RequirePermission('product.list')
variantsOf(@Param('id') id: string) {
return this.products.variantsOf(id);
}
@Get(':id')
@RequirePermission('product.list')
findOne(@Param('id') id: string) {
return this.products.findOne(id);
}
@Put(':id')
@RequirePermission('product.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.products.update(id, data);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminProductsController } from './admin-products.controller';
import { AdminProductsService } from './admin-products.service';
@Module({
imports: [PrismaModule],
controllers: [AdminProductsController],
providers: [AdminProductsService],
})
export class AdminProductsModule {}
@@ -0,0 +1,733 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
/**
* Product administration — legacy views/apps/product/* served by
* ProductController (`POST products`, `POST product/create`,
* `PUT product/update/{id}`, `GET product/show/{id}`,
* `POST delete/product/{id}`, `POST get/variants`, `POST change/percent/count`).
*
* Legacy `show_type` values, preserved as integers:
* 1 سایت | 2 گوشی | 3 سایت و گوشی
*/
const SHOW_TYPE_LABELS: Record<number, string> = {
1: 'سایت',
2: 'گوشی',
3: 'سایت و گوشی',
};
@Injectable()
export class AdminProductsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly accounting: AccountingHooksService,
) {}
private static readonly ALLOWED = [
'id',
'title',
'productCode',
'showType',
'isActive',
'createdAt',
'brand.title',
'category.title',
// legacy filter keys
'product_code',
'show_type',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
product_code: 'productCode',
show_type: 'showType',
'category.collection.title': 'category.title',
caj: 'createdAt',
};
private applyAliases(query: TableQueryDto): TableQueryDto {
const mapField = (f?: string) => (f ? (AdminProductsService.FIELD_ALIASES[f] ?? f) : f);
return {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'showType' && mapped.value !== undefined) {
mapped.value = Number(mapped.value);
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
}
/** Legacy `POST products` — the product grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const aliased = this.applyAliases(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['title', 'productCode'],
allowed: AdminProductsService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.product.findMany({
where: where as Prisma.ProductWhereInput,
orderBy: orderBy as Prisma.ProductOrderByWithRelationInput,
skip,
take,
include: {
brand: true,
category: true,
images: { orderBy: { sortOrder: 'asc' } },
variants: true,
},
}),
this.prisma.product.count({ where: where as Prisma.ProductWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names preserved so ported table models bind unchanged. */
private toRow(
p: Prisma.ProductGetPayload<{
include: { brand: true; category: true; images: true; variants: true };
}>,
) {
return {
id: p.id,
title: p.title,
product_code: p.productCode,
show_type: p.showType,
show_type_str: SHOW_TYPE_LABELS[p.showType] ?? '-',
is_active: p.isActive,
description: p.description,
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
// Legacy nested this as category.collection.title in the grid model.
category: p.category
? { id: p.category.id, title: p.category.title, collection: { title: p.category.title } }
: null,
// legacy grid slot reads `ProductImage`
ProductImage: p.images.map((i) => ({ id: i.id, image: i.url, full: i.url, url: i.url })),
// D27 — stock as last read from the accounting software (null = not
// linked / never synced).
accounting_stock: p.accountingStockAt
? { weight: p.accountingStockWeight, count: p.accountingStockCount, at: p.accountingStockAt }
: null,
images: p.images.map((i) => ({ id: i.id, url: i.url, sortOrder: i.sortOrder, role: i.role })),
// The order builder's grid reads `thumb.full` and `variants_all` directly
// off each row, so both travel with the list as they did in legacy. Legacy's
// grid thumbnail was always the featured image (تصویر شاخص) when one was
// set — fall back to the first gallery image only if no featured image
// was uploaded, rather than picking whichever image happens to sort first.
thumb: (() => {
const featured = p.images.find((i) => i.role === 'FEATURED') ?? p.images[0];
return featured ? { full: featured.url } : null;
})(),
variants_all: p.variants.map((v) => this.variantRow(v)),
variants_count: p.variants.length,
// legacy min_gram / max_gram: the product's lightest and heaviest variant,
// shown by the storefront and by the branch-allocation picker.
min_gram: p.variants.length
? p.variants.reduce(
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
p.variants[0].rashWeightGrams,
)
: null,
max_gram: p.variants.length
? p.variants.reduce(
(max, v) => (v.rashWeightGrams.gt(max) ? v.rashWeightGrams : max),
p.variants[0].rashWeightGrams,
)
: null,
caj: p.createdAt,
};
}
/** Legacy `GET product/show/{id}`. */
async findOne(id: string) {
const p = await this.prisma.product.findUnique({
where: { id },
include: {
brand: true,
category: true,
images: { orderBy: { sortOrder: 'asc' } },
variants: { include: { image: true } },
collections: { include: { collection: true } },
},
});
if (!p) throw new NotFoundException('محصول یافت نشد');
// D27 — the form locks title/code of a linked product, and offers the
// accounting software's sale making charge as the default for new variants.
const link = await this.accounting.productLink(p.id);
const snapshot = (link?.snapshot ?? {}) as { saleWagePercent?: string | null };
return {
...this.toRow(p),
accounting: link
? { locked: true, code: link.externalCode, origin: link.origin, sale_wage_percent: snapshot.saleWagePercent ?? null }
: null,
body_html: p.description,
brand_id: p.brandId,
cats: p.categoryId ? [p.categoryId] : [],
ciny_ids: p.collections.map((c) => c.collectionId),
variants_all: p.variants.map((v) => this.variantRow(v)),
// The 4 image slots the product form edits, split back out by role —
// mirrors the shape AddProduct.vue posts (indicator_id / mobile_pic /
// images / images_mobile).
indicator_id: p.images.find((i) => i.role === 'FEATURED')?.url ?? null,
mobile_pic: p.images.find((i) => i.role === 'FEATURED_MOBILE')?.url ?? null,
images_gallery: p.images.filter((i) => i.role === 'GALLERY').map((i) => i.url),
images_mobile: p.images.filter((i) => i.role === 'GALLERY_MOBILE').map((i) => i.url),
// The same images again, but carrying their ids — the variant image
// picker needs something to select. The four URL-only fields above stay
// exactly as they were so the form's existing bindings keep working.
gallery: p.images.map((i) => ({
id: i.id,
url: i.url,
role: i.role,
sortOrder: i.sortOrder,
})),
};
}
private variantRow(v: Prisma.ProductVariantGetPayload<object> & { image?: { id: string; url: string } | null }) {
return {
id: v.id,
product_id_id: v.productId,
title: v.title,
sku: v.sku,
grams: v.rashWeightGrams,
percentage_pay: v.defaultWagePercent,
qty: v.qty,
is_pre_order: v.isPreOrder,
is_active: v.isActive,
// The gallery image picked for this variant. `image_id` is what the form
// posts back; `image` is denormalised so the table can render a thumbnail
// without a second lookup. Only populated where the query included it.
image_id: v.imageId,
image: v.image ? { id: v.image.id, url: v.image.url } : null,
// Legacy had a separate rial making-charge column; the schema models wage
// as a percentage only. Returned as null rather than faked.
rial_pay: null,
color_str: null,
};
}
/** Legacy `POST get/variants` — a POST that reads, per legacy's own routing. */
async listVariants(query: TableQueryDto & { product_id?: string }) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title', 'sku'],
allowed: ['id', 'title', 'sku', 'qty', 'rashWeightGrams', 'isActive', 'product.title'],
defaultOrderBy: { title: 'asc' },
});
const scoped: Prisma.ProductVariantWhereInput = query.product_id
? { AND: [where as Prisma.ProductVariantWhereInput, { productId: query.product_id }] }
: (where as Prisma.ProductVariantWhereInput);
const [rows, total] = await Promise.all([
this.prisma.productVariant.findMany({
where: scoped,
orderBy: orderBy as Prisma.ProductVariantOrderByWithRelationInput,
skip,
take,
include: { product: true, image: true },
}),
this.prisma.productVariant.count({ where: scoped }),
]);
return this.table.respond(
rows.map((v) => ({ ...this.variantRow(v), product: { id: v.product.id, title: v.product.title } })),
total,
query,
page,
limit,
);
}
/**
* Legacy `POST product/variant` — used by the cart to refresh the products it
* holds. Returns each product with `variants_all` attached, the shape
* stores/cart.ts reads.
*/
async byIds(ids: string[]) {
if (!ids.length) return [];
const rows = await this.prisma.product.findMany({
where: { id: { in: ids } },
include: {
brand: true,
category: true,
images: { orderBy: { sortOrder: 'asc' } },
variants: { include: { image: true } },
},
});
return rows.map((p) => ({
...this.toRow(p),
variants_all: p.variants.map((v) => this.variantRow(v)),
// The cart reads `promotion.id`; there is no Promotion model yet (Batch 9).
promotion: null,
thumb: p.images[0] ? { full: p.images[0].url } : null,
}));
}
/**
* Legacy `POST add/variant` — the order builder's inline weight editor creates
* a bespoke variant rather than mutating the shared catalog one, so other
* orders keep the weight they were placed with.
*/
async createVariant(body: { product_id: string; title: string; grams: number }) {
if (!body.product_id) throw new BadRequestException('محصول مشخص نشده است');
const product = await this.prisma.product.findUnique({
where: { id: body.product_id },
include: { variants: { take: 1 } },
});
if (!product) throw new NotFoundException('محصول یافت نشد');
return this.prisma.productVariant.create({
data: {
productId: body.product_id,
title: body.title,
rashWeightGrams: new Prisma.Decimal(String(body.grams ?? 0)),
// Inherit the product's existing wage rate so the bespoke weight prices
// the same way as its siblings.
defaultWagePercent:
product.variants[0]?.defaultWagePercent ?? new Prisma.Decimal(0),
qty: 1,
},
});
}
/** Legacy `GET product/{id}/variant`. */
async variantsOf(productId: string) {
const rows = await this.prisma.productVariant.findMany({
where: { productId },
include: { image: true },
});
return rows.map((v) => this.variantRow(v));
}
/**
* Shape of one inline variant row posted by the product form.
*
* `image_id` is resolved against `allowedImageIds` — the ids of the images
* belonging to *this* product — so a payload can never point a variant at
* another product's image. An absent/empty value clears the picture.
*/
private variantCreateData(v: Record<string, unknown>, allowedImageIds: Set<string>) {
const rawImageId = v.image_id ?? v.imageId;
const imageId = rawImageId ? String(rawImageId) : null;
if (imageId && !allowedImageIds.has(imageId)) {
throw new BadRequestException('تصویر انتخاب‌شده متعلق به این محصول نیست');
}
return {
title: String(v.title ?? ''),
rashWeightGrams: new Prisma.Decimal(String(v.grams ?? 0)),
defaultWagePercent: new Prisma.Decimal(String(v.percentage_pay ?? 0)),
qty: Number(v.qty ?? 0),
isPreOrder: Boolean(v.is_pre_order),
isActive: v.is_active !== undefined ? Boolean(v.is_active) : true,
imageId,
};
}
/**
* Builds a commercial SKU for a newly created variant.
*
* Derived from the product's own code (falling back to a slice of its id when
* no code was entered) plus a running number, e.g. `RING-001-01`. Only ever
* called for variants that have no SKU yet — an existing one is never
* rewritten, because it may already be in use in stock/sales systems.
*
* `taken` carries the SKUs handed out earlier in the same request so two new
* variants in one submit cannot collide before either is written.
*/
private async generateSku(
productCode: string | null,
productId: string,
taken: Set<string>,
): Promise<string> {
const base = (productCode?.trim() || productId.slice(-6)).toUpperCase().replace(/\s+/g, '-');
// Start past the highest suffix already stored for this prefix, so deleting
// and re-adding variants does not reuse a retired code.
const existing = await this.prisma.productVariant.findMany({
where: { sku: { startsWith: `${base}-` } },
select: { sku: true },
});
for (const row of existing) if (row.sku) taken.add(row.sku);
for (let n = 1; n <= 9999; n++) {
const candidate = `${base}-${String(n).padStart(2, '0')}`;
if (!taken.has(candidate)) {
taken.add(candidate);
return candidate;
}
}
// Practically unreachable; keeps the return type honest.
return `${base}-${Date.now()}`;
}
/**
* Legacy had 4 distinct upload slots (تصویر شاخص / تصویر شاخص موبایل /
* گالری تصاویر سایت / گالری تصاویر موبایل — AddProduct.vue's `indicator_id`,
* `mobile_pic`, `images`, and a mobile gallery). The form posts one URL each
* for the two featured slots and one URL array each for the two galleries;
* this flattens all four into the role-tagged rows ProductImage.create wants.
*/
private imageCreateData(data: Record<string, unknown>): { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] {
const rows: { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] = [];
const single = (v: unknown, role: Prisma.ProductImageCreateInput['role']) => {
const url = typeof v === 'string' ? v : String((v as Record<string, unknown>)?.url ?? '');
if (url) rows.push({ url, sortOrder: 0, role });
};
const gallery = (v: unknown, role: Prisma.ProductImageCreateInput['role']) => {
if (!Array.isArray(v)) return;
(v as unknown[]).forEach((item, i) => {
const url = typeof item === 'string' ? item : String((item as Record<string, unknown>)?.url ?? '');
if (url) rows.push({ url, sortOrder: i, role });
});
};
single(data.indicator_id, 'FEATURED');
single(data.mobile_pic, 'FEATURED_MOBILE');
gallery(data.images, 'GALLERY');
gallery(data.images_mobile, 'GALLERY_MOBILE');
return rows;
}
/** Legacy `POST product/create`. */
async create(data: Record<string, unknown>) {
const title = String(data.title ?? '').trim();
if (!title) throw new BadRequestException('عنوان محصول الزامی است');
const cats = Array.isArray(data.cats) ? (data.cats as string[]) : [];
const cinyIds = Array.isArray(data.ciny_ids) ? (data.ciny_ids as string[]) : [];
const variants = Array.isArray(data.variants)
? (data.variants as Record<string, unknown>[])
: [];
const images = this.imageCreateData(data);
const productCode = (data.product_code as string) || null;
// Images are created with the product so their ids exist before variants
// are attached — a variant's `image_id` has to reference a real row.
const created = await this.prisma.product.create({
data: {
title,
description: (data.body_html as string) ?? (data.description as string) ?? null,
productCode,
showType: data.show_type !== undefined ? Number(data.show_type) : 3,
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
brandId: (data.brand_id as string) || null,
categoryId: cats[0] ?? null,
collections: cinyIds.length
? { create: cinyIds.map((collectionId) => ({ collectionId })) }
: undefined,
images: images.length ? { create: images } : undefined,
},
include: { images: true },
});
// On create the form works from freshly uploaded URLs and has no image ids
// to post yet, so in practice `image_id` is absent here — but a client that
// does send one is still validated against this product's own images.
if (variants.length) {
const allowed = new Set(created.images.map((i) => i.id));
const takenSkus = new Set<string>();
for (const v of variants) {
const payload = this.variantCreateData(v, allowed);
await this.prisma.productVariant.create({
data: {
...payload,
productId: created.id,
sku: await this.generateSku(productCode, created.id, takenSkus),
},
});
}
}
// D27 — create the matching کارساخته in the accounting software (queued;
// only where the software's API can create items).
await this.accounting.productCreated(this.prisma, created.id);
return this.findOne(created.id);
}
/** Legacy `PUT product/update/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.product.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('محصول یافت نشد');
// D27 — title and code of a product linked to the accounting software are
// that software's; the form shows them locked. Re-posting the same values
// is fine, changing them is not.
const link = await this.accounting.productLink(id);
if (link) {
const titleChanged = data.title !== undefined && String(data.title).trim() !== existing.title;
const codeChanged =
data.product_code !== undefined && ((data.product_code as string) || null) !== existing.productCode;
if (titleChanged || codeChanged) {
throw new BadRequestException(
'عنوان و کد این محصول از نرم‌افزار حسابداری می‌آید؛ برای تغییر، آن را در حسابداری ویرایش کنید.',
);
}
}
const patch: Prisma.ProductUpdateInput = {};
if (data.title !== undefined) patch.title = String(data.title);
if (data.body_html !== undefined) patch.description = String(data.body_html);
if (data.product_code !== undefined) patch.productCode = (data.product_code as string) || null;
if (data.show_type !== undefined) patch.showType = Number(data.show_type);
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
if (data.brand_id !== undefined) {
patch.brand = data.brand_id ? { connect: { id: String(data.brand_id) } } : { disconnect: true };
}
if (Array.isArray(data.cats)) {
const catId = (data.cats as string[])[0];
patch.category = catId ? { connect: { id: catId } } : { disconnect: true };
}
await this.prisma.product.update({ where: { id }, data: patch });
// Images are reconciled BEFORE variants, because a variant's `image_id`
// must resolve against the image rows this submit leaves behind.
await this.syncImages(id, data);
// Variants are edited inline in the product form, so the posted array is the
// desired end state: update the ones that still carry an id, create the new
// ones, and deactivate (never delete) the ones that disappeared — deleting
// would break the OrderItem snapshots that reference them.
if (Array.isArray(data.variants)) {
const posted = data.variants as Record<string, unknown>[];
// Collect ids as we go: a NEWLY created variant must count as kept, or the
// sweep below would deactivate the row we just added.
const keptIds: string[] = [];
const currentImages = await this.prisma.productImage.findMany({
where: { productId: id },
select: { id: true },
});
const allowed = new Set(currentImages.map((i) => i.id));
const takenSkus = new Set<string>();
const productCode =
data.product_code !== undefined
? (data.product_code as string) || null
: existing.productCode;
for (const v of posted) {
const payload = this.variantCreateData(v, allowed);
if (v.id) {
await this.prisma.productVariant.update({
where: { id: String(v.id) },
data: payload,
});
keptIds.push(String(v.id));
} else {
const created = await this.prisma.productVariant.create({
data: {
...payload,
productId: id,
sku: await this.generateSku(productCode, id, takenSkus),
},
});
keptIds.push(created.id);
}
}
// Anything no longer posted is deactivated, never deleted — OrderItem
// snapshots reference these rows.
await this.prisma.productVariant.updateMany({
where: { productId: id, id: { notIn: keptIds.length ? keptIds : ['__none__'] } },
data: { isActive: false },
});
}
return this.findOne(id);
}
/**
* Applies the 4 posted image slots to a product WITHOUT churning row ids.
*
* This used to be a delete-everything-and-recreate, which was fine while
* nothing referenced an image. ProductVariant.imageId now does, so a rewrite
* would silently blank every variant's picture on each save (the FK is
* ON DELETE SET NULL). Instead each posted (url, role) pair is matched
* against the rows already stored: survivors keep their id and only have
* their sortOrder refreshed, genuinely new URLs are inserted, and rows the
* form dropped are deleted.
*
* The form always posts all 4 image fields together (even as empty/absent),
* so any one of them being present means this is a real submit rather than a
* partial PATCH that happens to omit the others.
*/
private async syncImages(productId: string, data: Record<string, unknown>) {
if (!['indicator_id', 'mobile_pic', 'images', 'images_mobile'].some((k) => k in data)) return;
const desired = this.imageCreateData(data);
const current = await this.prisma.productImage.findMany({ where: { productId } });
// (url, role) identifies an image slot. Duplicates of the same pair are
// consumed one at a time so a gallery listing the same URL twice keeps two
// distinct rows instead of collapsing them.
const pools = new Map<string, typeof current>();
for (const row of current) {
const key = `${row.role}\u0000${row.url}`;
const pool = pools.get(key);
if (pool) pool.push(row);
else pools.set(key, [row]);
}
const survivingIds = new Set<string>();
for (const img of desired) {
const key = `${img.role}\u0000${img.url}`;
const match = pools.get(key)?.shift();
if (match) {
survivingIds.add(match.id);
if (match.sortOrder !== img.sortOrder) {
await this.prisma.productImage.update({
where: { id: match.id },
data: { sortOrder: img.sortOrder },
});
}
} else {
await this.prisma.productImage.create({ data: { ...img, productId } });
}
}
const removedIds = current.filter((row) => !survivingIds.has(row.id)).map((row) => row.id);
if (removedIds.length) {
// Any variant pointing at a removed image is cleared first. The FK would
// do this anyway, but doing it explicitly keeps the behaviour identical
// on databases where the constraint is not enforced the same way.
await this.prisma.productVariant.updateMany({
where: { imageId: { in: removedIds } },
data: { imageId: null },
});
await this.prisma.productImage.deleteMany({ where: { id: { in: removedIds } } });
}
}
/**
* Legacy `POST delete/product/{id}` — a DELETE expressed as POST.
*
* Refuses when the product has ever been ordered: its variants are referenced
* by OrderItem snapshots, and removing them would break order history.
* Legacy had no such guard. Deactivation is offered instead.
*/
async remove(id: string) {
const product = await this.prisma.product.findUnique({
where: { id },
include: { variants: { include: { orderItems: { take: 1 } } } },
});
if (!product) throw new NotFoundException('محصول یافت نشد');
const hasOrders = product.variants.some((v) => v.orderItems.length > 0);
if (hasOrders) {
throw new BadRequestException(
'حذف این محصول ممکن نیست، چون در سفارش‌های ثبت‌شده استفاده شده است. می‌توانید آن را غیرفعال کنید.',
);
}
await this.prisma.$transaction([
this.prisma.collectionProduct.deleteMany({ where: { productId: id } }),
this.prisma.comment.deleteMany({ where: { productId: id } }),
// Variants reference images (ProductVariant.imageId), so they go first —
// the FK is ON DELETE SET NULL, but deleting in dependency order keeps
// this correct regardless of how the constraint is enforced.
this.prisma.productVariant.deleteMany({ where: { productId: id } }),
this.prisma.productImage.deleteMany({ where: { productId: id } }),
this.prisma.product.delete({ where: { id } }),
]);
return { success: true };
}
/**
* Legacy `POST change/percent/count` — how many variants a bulk wage-percent
* change would affect, used by ChangePercent.vue before applying.
*/
async changePercentCount(filter: { brand_id?: string; category_id?: string }) {
const where: Prisma.ProductVariantWhereInput = {
product: {
...(filter.brand_id ? { brandId: filter.brand_id } : {}),
...(filter.category_id ? { categoryId: filter.category_id } : {}),
},
};
return { count: await this.prisma.productVariant.count({ where }) };
}
/** Legacy `POST change/all` — apply a bulk wage-percent change. */
async changePercent(data: { percent: number; brand_id?: string; category_id?: string }) {
if (data.percent === undefined || Number.isNaN(Number(data.percent))) {
throw new BadRequestException('درصد نامعتبر است');
}
const where: Prisma.ProductVariantWhereInput = {
product: {
...(data.brand_id ? { brandId: data.brand_id } : {}),
...(data.category_id ? { categoryId: data.category_id } : {}),
},
};
const result = await this.prisma.productVariant.updateMany({
where,
data: { defaultWagePercent: new Prisma.Decimal(String(data.percent)) },
});
return { updated: result.count };
}
/** Legacy `GET brand` and `POST category` — pickers for the product form. */
async brands() {
return this.prisma.brand.findMany({ orderBy: { title: 'asc' } });
}
/** Brands in the XTable envelope, for the promotion-exclusion picker. */
async brandsTable(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title'],
allowed: ['id', 'title'],
defaultOrderBy: { title: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.brand.findMany({
where: where as Prisma.BrandWhereInput,
orderBy: orderBy as Prisma.BrandOrderByWithRelationInput,
skip,
take,
}),
this.prisma.brand.count({ where: where as Prisma.BrandWhereInput }),
]);
return this.table.respond(rows, total, query, page, limit);
}
async collections() {
return this.prisma.collection.findMany({
where: { isActive: true },
orderBy: { title: 'asc' },
});
}
showTypeOptions() {
return Object.entries(SHOW_TYPE_LABELS).map(([value, title]) => ({
value: Number(value),
title,
}));
}
}
@@ -0,0 +1,93 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminPromotionsService } from './admin-promotions.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints:
* GET/POST/PUT/DELETE product/variant/promotion -> PromotionController
* GET /autopromotion -> the auto-discount screen
* GET/POST/DELETE /ignore -> views/offersIgnore
*/
@Controller('admin/promotions')
export class AdminPromotionsController {
constructor(private readonly promotions: AdminPromotionsService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('offers.list')
list(@Body() query: TableQueryDto) {
return this.promotions.list(query);
}
@Get('scope-options')
@RequirePermission('offers.list')
scopeOptions() {
return this.promotions.scopeOptions();
}
@Get('usage-options')
@RequirePermission('offers.list')
usageOptions() {
return this.promotions.usageOptions();
}
@Get('auto')
@RequirePermission('offers.autopromotion')
getAutoPromotion() {
return this.promotions.getAutoPromotion();
}
@Post('auto')
@HttpCode(200)
@RequirePermission('offers.autopromotion')
setAutoPromotion(@Body() body: { state?: boolean; discount_percent?: number | string }) {
return this.promotions.setAutoPromotion(body);
}
// ── exclusions ────────────────────────────────────────────────────
@Get('exclusions')
@RequirePermission('offersIgnore')
listExclusions() {
return this.promotions.listExclusions();
}
@Post('exclusions')
@HttpCode(200)
@RequirePermission('offersIgnore')
createExclusion(@Body() body: { type: number; item_id?: string[] }) {
return this.promotions.createExclusion(body);
}
@Delete('exclusions/:id')
@RequirePermission('offersIgnore')
removeExclusion(@Param('id') id: string) {
return this.promotions.removeExclusion(id);
}
// ── promotions ────────────────────────────────────────────────────
@Post()
@HttpCode(200)
@RequirePermission('offers.create')
create(@Body() data: Record<string, unknown>) {
return this.promotions.create(data);
}
@Get(':id')
@RequirePermission('offers.list')
findOne(@Param('id') id: string) {
return this.promotions.findOne(id);
}
@Put(':id')
@RequirePermission('offers.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.promotions.update(id, data);
}
@Delete(':id')
@RequirePermission('offers.list.edit')
remove(@Param('id') id: string) {
return this.promotions.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminPromotionsController } from './admin-promotions.controller';
import { AdminPromotionsService } from './admin-promotions.service';
@Module({
imports: [PrismaModule],
controllers: [AdminPromotionsController],
providers: [AdminPromotionsService],
})
export class AdminPromotionsModule {}
@@ -0,0 +1,334 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, PromotionScope, PromotionUsage } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Promotions — legacy views/promotion/*, served by PromotionController
* (`GET/POST/PUT/DELETE product/variant/promotion`) plus the exclusion list
* (`/ignore`) behind views/offersIgnore.
*
* Legacy `type` (scope): 1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند
* Legacy `use_only` (usage): 1 فقط یکبار | 2 تا تاریخ پایان
*/
const SCOPE_BY_CODE: Record<number, PromotionScope> = {
1: PromotionScope.PRODUCT,
2: PromotionScope.CATEGORY,
3: PromotionScope.CUSTOMER,
4: PromotionScope.BRAND,
};
const CODE_BY_SCOPE: Record<PromotionScope, number> = {
PRODUCT: 1,
CATEGORY: 2,
CUSTOMER: 3,
BRAND: 4,
};
const SCOPE_LABELS: Record<PromotionScope, string> = {
PRODUCT: 'محصولات',
CATEGORY: 'دسته بندی',
CUSTOMER: 'مشتری',
BRAND: 'برند',
};
const USAGE_BY_CODE: Record<number, PromotionUsage> = {
1: PromotionUsage.ONCE,
2: PromotionUsage.UNTIL_END_DATE,
};
const CODE_BY_USAGE: Record<PromotionUsage, number> = {
ONCE: 1,
UNTIL_END_DATE: 2,
};
@Injectable()
export class AdminPromotionsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'title',
'scope',
'usage',
'discountPercent',
'isActive',
'platform',
'startsAt',
'endsAt',
'createdAt',
// legacy filter keys
'promotion_type_str',
'status',
'start',
'end',
'discount_percent',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
promotion_type_str: 'scope',
type: 'scope',
use_only: 'usage',
status: 'isActive',
start: 'startsAt',
end: 'endsAt',
discount_percent: 'discountPercent',
caj: 'createdAt',
};
/** Legacy `GET product/variant/promotion` — the promotions grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminPromotionsService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'scope' && mapped.value !== undefined) {
mapped.value = SCOPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
}
if (mapped.field === 'isActive' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['title', 'description'],
allowed: AdminPromotionsService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.promotion.findMany({
where: where as Prisma.PromotionWhereInput,
orderBy: orderBy as Prisma.PromotionOrderByWithRelationInput,
skip,
take,
include: { product: true, category: true, customer: true, brand: true },
}),
this.prisma.promotion.count({ where: where as Prisma.PromotionWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
p: Prisma.PromotionGetPayload<{
include: { product: true; category: true; customer: true; brand: true };
}>,
) {
return {
id: p.id,
title: p.title,
description: p.description,
type: CODE_BY_SCOPE[p.scope],
promotion_type_str: SCOPE_LABELS[p.scope],
use_only: CODE_BY_USAGE[p.usage],
discount_percent: p.discountPercent,
// legacy grid renders `status` through a slot
status: p.isActive ? 1 : 0,
is_active: p.isActive,
/**
* Legacy `type_customer`: 1 = تک (retail), 2 = عمده (wholesale). The grid
* uses it BOTH to pick which permission gates the row's edit/delete icons
* AND to choose which edit route opens. A customer-scoped promotion is the
* retail ("tak") kind; everything else is the wholesale kind.
*/
type_customer: p.scope === PromotionScope.CUSTOMER ? 1 : 2,
platform: p.platform,
start: p.startsAt,
end: p.endsAt,
product_id: p.productId,
category_id: p.categoryId,
customer_id: p.customerId,
brand_id: p.brandId,
product: p.product ? { id: p.product.id, title: p.product.title } : null,
category: p.category ? { id: p.category.id, title: p.category.title } : null,
customer: p.customer ? { id: p.customer.id, name: p.customer.fullName } : null,
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
caj: p.createdAt,
};
}
async findOne(id: string) {
const p = await this.prisma.promotion.findUnique({
where: { id },
include: { product: true, category: true, customer: true, brand: true },
});
if (!p) throw new NotFoundException('تخفیف یافت نشد');
return this.toRow(p);
}
/**
* Maps the legacy form payload onto the model, attaching the ONE target that
* matches the chosen scope and clearing the others — so a promotion can never
* carry, say, both a product and a customer.
*/
private buildData(data: Record<string, unknown>) {
const scope = SCOPE_BY_CODE[Number(data.type ?? 1)];
if (!scope) throw new BadRequestException('نوع تخفیف نامعتبر است');
const usage = USAGE_BY_CODE[Number(data.use_only ?? 1)] ?? PromotionUsage.ONCE;
const percent = new Prisma.Decimal(String(data.discount_percent ?? 0));
if (percent.lt(0) || percent.gt(100)) {
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
}
const startsAt = data.start ? new Date(String(data.start)) : null;
const endsAt = data.end ? new Date(String(data.end)) : null;
if (startsAt && endsAt && endsAt < startsAt) {
throw new BadRequestException('تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد');
}
return {
title: String(data.title ?? '').trim(),
description: (data.description as string) || null,
scope,
usage,
discountPercent: percent,
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
platform: data.platform !== undefined ? Number(data.platform) : 3,
startsAt,
endsAt,
productId: scope === PromotionScope.PRODUCT ? ((data.product_id as string) ?? null) : null,
categoryId: scope === PromotionScope.CATEGORY ? ((data.category_id as string) ?? null) : null,
customerId: scope === PromotionScope.CUSTOMER ? ((data.customer_id as string) ?? null) : null,
brandId: scope === PromotionScope.BRAND ? ((data.brand_id as string) ?? null) : null,
};
}
/** Legacy `POST product/variant/promotion`. */
async create(data: Record<string, unknown>) {
const payload = this.buildData(data);
if (!payload.title) throw new BadRequestException('عنوان تخفیف الزامی است');
return this.prisma.promotion.create({ data: payload });
}
/** Legacy `PUT product/variant/promotion/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.promotion.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('تخفیف یافت نشد');
// legacy disabled the type selector on edit (`:disabled="this.id"`), so the
// scope is not re-read here either.
const payload = this.buildData({ ...data, type: CODE_BY_SCOPE[existing.scope] });
return this.prisma.promotion.update({ where: { id }, data: payload });
}
/** Legacy `DELETE product/variant/promotion/{id}`. */
async remove(id: string) {
const p = await this.prisma.promotion.findUnique({ where: { id } });
if (!p) throw new NotFoundException('تخفیف یافت نشد');
await this.prisma.promotion.delete({ where: { id } });
return { success: true };
}
scopeOptions() {
return Object.entries(SCOPE_BY_CODE).map(([code, scope]) => ({
value: Number(code),
title: SCOPE_LABELS[scope],
}));
}
usageOptions() {
return [
{ value: 1, title: ' فقط یکبار' },
{ value: 2, title: 'تا تاریخ پایان' },
];
}
/**
* Legacy `GET /autopromotion` and its POST counterpart — the settings screen
* for "تخفیف اتوماتیک یکبار مصرف ویژه مشتریان جدید": a one-shot welcome
* discount for new customers, configured as an on/off state plus a percentage.
*
* Stored in the Setting key/value table, like the other config screens.
*/
async getAutoPromotion() {
const row = await this.prisma.setting.findUnique({ where: { key: 'promotion.auto' } });
return row?.value ?? { state: false, discount_percent: null };
}
async setAutoPromotion(data: { state?: boolean; discount_percent?: number | string }) {
const percent = Number(data.discount_percent ?? 0);
if (Number.isNaN(percent) || percent < 0 || percent > 100) {
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
}
const value = { state: Boolean(data.state), discount_percent: percent };
const row = await this.prisma.setting.upsert({
where: { key: 'promotion.auto' },
update: { value },
create: { key: 'promotion.auto', value },
});
return row.value;
}
// ── Exclusions (views/offersIgnore) ────────────────────────────────
/**
* Legacy `GET /ignore` — the full exclusion list.
*
* The view reads it as a flat array and filters client-side:
* items_id(type) { return this.ignoreList.filter(i => i.type == type).map(x => x.item_id) }
* so the flat shape with `item_id` is preserved.
*/
async listExclusions() {
const rows = await this.prisma.promotionExclusion.findMany({
orderBy: [{ type: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((e) => ({ id: e.id, type: e.type, item_id: e.itemId }));
}
/**
* Legacy `POST /ignore` with `{ type, item_id: [...] }`.
*
* The posted array is the COMPLETE selection for that type — the view binds it
* to a checkbox table — so this replaces that type's rows rather than adding
* to them. Anything unchecked is therefore removed.
*/
async createExclusion(data: { type: number; item_id?: string[] }) {
const type = Number(data.type);
if (!type) throw new BadRequestException('نوع استثناء الزامی است');
const itemIds = Array.isArray(data.item_id)
? data.item_id.map((x) => String(x)).filter(Boolean)
: [];
await this.prisma.$transaction([
this.prisma.promotionExclusion.deleteMany({ where: { type } }),
this.prisma.promotionExclusion.createMany({
data: itemIds.map((itemId) => ({ type, itemId })),
skipDuplicates: true,
}),
]);
return this.listExclusions();
}
/**
* Legacy `DELETE /ignore/{id}` — the view's "حذف نوع استثناء" card removes a
* whole TYPE, so a numeric id is treated as a type and a cuid as one row.
*/
async removeExclusion(id: string) {
if (/^\d+$/.test(id)) {
const type = Number(id);
const { count } = await this.prisma.promotionExclusion.deleteMany({ where: { type } });
if (count === 0) throw new NotFoundException('استثنایی برای این نوع یافت نشد');
return { success: true, removed: count };
}
const e = await this.prisma.promotionExclusion.findUnique({ where: { id } });
if (!e) throw new NotFoundException('استثناء یافت نشد');
await this.prisma.promotionExclusion.delete({ where: { id } });
return { success: true, removed: 1 };
}
}
@@ -0,0 +1,21 @@
import { Controller, Get, Query } from '@nestjs/common';
import { AdminReportService } from './admin-report.service';
import { RequirePermission } from '../auth/guards';
/**
* Legacy `GET report/get-order-item-with-details` — a combined order-item +
* financial-document Excel export with 32 Persian-header columns, date-filtered.
*/
@Controller('admin/report')
export class AdminReportController {
constructor(private readonly report: AdminReportService) {}
@Get('order-items')
@RequirePermission('report.order.item')
getOrderItemReport(
@Query('start_date') startDate?: string,
@Query('end_date') endDate?: string,
) {
return this.report.getOrderItemReport(startDate, endDate);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminReportController } from './admin-report.controller';
import { AdminReportService } from './admin-report.service';
@Module({
imports: [PrismaModule],
controllers: [AdminReportController],
providers: [AdminReportService],
})
export class AdminReportModule {}
@@ -0,0 +1,221 @@
import { BadRequestException, Injectable, StreamableFile } from '@nestjs/common';
import { LedgerSourceType, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { toJalali, formatJalali } from '../accounting-sync/jalali';
import * as ExcelJS from 'exceljs';
const TYPE_LABELS: Partial<Record<LedgerSourceType, string>> = {
REPAYMENT_MELTED_GOLD: 'آب شده',
REPAYMENT_TRANSFER: 'حواله',
REPAYMENT_CHEQUE: 'چک',
REPAYMENT_CASH: 'پولی',
};
const REPAYMENT_TYPES: LedgerSourceType[] = [
LedgerSourceType.REPAYMENT_MELTED_GOLD,
LedgerSourceType.REPAYMENT_TRANSFER,
LedgerSourceType.REPAYMENT_CHEQUE,
LedgerSourceType.REPAYMENT_CASH,
];
const HEADERS = [
'کد آیتم',
'نام محصول',
'کد محصول',
'عنوان تنوع',
'وزن خام تحویل مشتری',
'وزن نهایی با درصد',
'تعداد',
'درصد',
'اجرت ریالی',
'توضیحات',
'شماره سفارش',
'تاریخ ثبت',
'تاریخ',
'سال',
'ماه',
'روز',
'ساعت',
'شماره سند',
'نوع سند',
'میزان پول',
'میزان گرم',
'وضعیت سند',
'نام مشتری',
'نام خانوادگی مشتری',
'نام و نام خانوادگی مشتری',
'کارشناس فروش',
'شماره موبایل مشتری',
'آدرس',
'تلفن مغازه',
'بدهکار',
'بستانکار',
'بدهی ریالی',
];
@Injectable()
export class AdminReportService {
constructor(private readonly prisma: PrismaService) {}
async getOrderItemReport(startDate?: string, endDate?: string): Promise<StreamableFile> {
const dateFilter: Prisma.DateTimeFilter = {};
if (startDate) dateFilter.gte = parseDate(startDate, 'از تاریخ');
if (endDate) {
// The whole end day, as the on-screen report (admin-dashboard dateWhere).
const end = parseDate(endDate, 'تا تاریخ');
end.setHours(23, 59, 59, 999);
dateFilter.lte = end;
}
const hasDateFilter = startDate || endDate;
const orderItems = await this.prisma.orderItem.findMany({
where: hasDateFilter ? { order: { createdAt: dateFilter } } : undefined,
orderBy: { id: 'desc' },
include: {
order: {
include: {
customer: true,
salesAgent: true,
},
},
productVariant: { include: { product: true } },
},
});
const ledgerEntries = await this.prisma.ledgerEntry.findMany({
where: {
sourceType: { in: REPAYMENT_TYPES },
...(hasDateFilter ? { createdAt: dateFilter } : {}),
},
include: { customer: true },
});
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('گزارش', {
views: [{ rightToLeft: true }],
});
ws.columns = HEADERS.map((h) => ({ header: h, width: 18 }));
for (const item of orderItems) {
const order = item.order;
const customer = order.customer;
const jalali = toJalali(order.createdAt);
const jalaliStr = formatJalali(jalali, '/');
const hour = new Date(order.createdAt.getTime() + 3.5 * 60 * 60 * 1000)
.toISOString()
.slice(11, 16);
// computedWeightGrams is ONE unit's weight (OrdersService.createOrder
// multiplies it by quantity for the order total), as legacy's order_items.w
// is. Legacy's export divided it by qty again, so every multi-unit line
// under-reported its weight; not repeated here.
const perUnitWeight = Number(item.computedWeightGrams);
// Legacy's «درصد» is the final percent: percent_pay − discount.
const finalPercent = Number(item.wagePercentSnapshot.sub(item.discountPercentSnapshot));
for (let i = 0; i < item.quantity; i++) {
ws.addRow([
item.id,
item.productVariant?.product?.title ?? '',
item.productVariant?.product?.productCode ?? '',
item.productVariant?.title ?? '',
Number(item.rashWeightGramsSnapshot),
round4(perUnitWeight),
1,
finalPercent,
Number(item.flatRialCharge),
'',
order.orderNumber,
jalaliStr,
jalaliStr,
jalali.jy,
jalali.jm,
jalali.jd,
hour,
null,
null,
null,
null,
null,
customer.firstName ?? '',
customer.lastName ?? '',
customer.fullName,
order.salesAgent?.fullName ?? '',
customer.mobile,
customer.address ?? '',
customer.phone ?? '',
round4(perUnitWeight),
0,
Number(item.flatRialCharge),
]);
}
}
for (const entry of ledgerEntries) {
const customer = entry.customer;
const jalali = toJalali(entry.createdAt);
const jalaliStr = formatJalali(jalali, '/');
const hour = new Date(entry.createdAt.getTime() + 3.5 * 60 * 60 * 1000)
.toISOString()
.slice(11, 16);
const isCashOrCheque =
entry.sourceType === LedgerSourceType.REPAYMENT_CASH ||
entry.sourceType === LedgerSourceType.REPAYMENT_CHEQUE;
const rialAmount = isCashOrCheque ? Number(entry.priceRial ?? 0) : 0;
const gramAmount = !isCashOrCheque ? Number(entry.amount) : 0;
ws.addRow([
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
entry.orderId ?? '',
jalaliStr,
jalaliStr,
jalali.jy,
jalali.jm,
jalali.jd,
hour,
entry.id,
TYPE_LABELS[entry.sourceType] ?? '',
rialAmount,
gramAmount,
entry.confirmed ? 'تایید شده' : 'تایید نشده',
customer.firstName ?? '',
customer.lastName ?? '',
customer.fullName,
null,
customer.mobile,
customer.address ?? '',
customer.phone ?? '',
0,
gramAmount,
0,
]);
}
const buffer = await wb.xlsx.writeBuffer();
return new StreamableFile(Buffer.from(buffer), {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename="report.xlsx"',
});
}
}
function parseDate(v: string, field: string): Date {
const d = new Date(v);
if (Number.isNaN(d.getTime())) throw new BadRequestException(`${field}: تاریخ معتبر نیست`);
return d;
}
function round4(n: number): number {
return Math.round(n * 10000) / 10000;
}
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { TableService } from './table.service';
/**
* Global so every admin grid module can inject TableService without re-importing.
* It is stateless — pure request-shape translation.
*/
@Global()
@Module({
providers: [TableService],
exports: [TableService],
})
export class AdminTableModule {}
@@ -0,0 +1,104 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
/**
* One row of the XTable filter bar.
*
* Mirrors the object legacy's XFilterRow pushed into `query.filters`:
* `{ id, field, op, value, value2? }`. `id` is a client-side react key
* (a random float) and is accepted but ignored.
*/
export class TableFilterDto {
@IsOptional()
id?: number | string;
@IsOptional()
@IsString()
field?: string;
/**
* Legacy operators, kept verbatim. `between` uses value + value2 and is what
* the date-range filter emits.
*/
@IsOptional()
@IsIn(['=', '!=', '>', '>=', '<', '<=', 'like', 'in', 'between', 'null', 'notnull'])
op?: string;
@IsOptional()
value?: unknown;
@IsOptional()
value2?: unknown;
}
/**
* The request body XTable POSTs, ported from its requestData():
*
* { ...query, select, titles, report_title, op? }
*
* where query = { search, filters, total, limit, page }.
*/
export class TableQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TableFilterDto)
filters?: TableFilterDto[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
/** Sort spec: '+field' ascending, '-field' descending (legacy `order`). */
@IsOptional()
@IsString()
order?: string;
/** Columns currently visible; drives the export payload. */
@IsOptional()
@IsArray()
select?: string[];
/** key -> Farsi column heading, used for export headers. */
@IsOptional()
titles?: Record<string, string>;
@IsOptional()
@IsString()
report_title?: string;
/**
* Export mode. Legacy expected `{ url }` back and did `window.open(data.url)`.
* Exports are not implemented yet; the controller rejects these explicitly
* rather than returning a broken url.
*/
@IsOptional()
@IsIn(['excel', 'json', 'print'])
op?: 'excel' | 'json' | 'print';
/** Echoed back inside `query`; the client owns this value. */
@IsOptional()
total?: number;
}
@@ -0,0 +1,158 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { TableFilterDto, TableQueryDto } from './table-query.dto';
/**
* Translates the legacy XTable request contract into Prisma query arguments.
*
* The response shape is dictated by legacy's getDataUsage() (x-table mixin):
*
* laravel mode -> { rows, query } <- what the admin panel used
* other mode -> { rows: [[...]], count, page }
*
* The panel's XTbl models all ran in 'laravel' mode, so that is the shape
* produced here: `{ rows, query }` with query carrying total/page/limit.
*/
export interface TableResult<T> {
rows: T[];
query: {
search?: string;
filters: TableFilterDto[];
total: number;
limit: number;
page: number;
};
}
export interface BuildOptions {
/** Columns the free-text `search` box scans (legacy initSearch([...])). */
searchable?: string[];
/** Whitelist of filterable/sortable fields; anything else is rejected. */
allowed?: string[];
/** Sort applied when the request specifies none. */
defaultOrderBy?: Record<string, 'asc' | 'desc'>;
}
@Injectable()
export class TableService {
/**
* Turns a dotted path into a nested Prisma object:
* 'customer.fullName', 'x' -> { customer: { fullName: 'x' } }
*/
private nest(path: string, leaf: unknown): Record<string, unknown> {
const parts = path.split('.');
return parts.reduceRight<unknown>((acc, key, i) => {
return i === parts.length - 1 ? { [key]: leaf } : { [key]: acc };
}, leaf) as Record<string, unknown>;
}
private assertAllowed(field: string, allowed?: string[]) {
if (allowed && !allowed.includes(field)) {
throw new BadRequestException(`Field not filterable: ${field}`);
}
}
/** Maps one legacy filter row to a Prisma condition. */
private toCondition(f: TableFilterDto, allowed?: string[]): Record<string, unknown> | null {
if (!f.field || !f.op) return null;
this.assertAllowed(f.field, allowed);
const v = f.value;
switch (f.op) {
case '=':
return this.nest(f.field, { equals: v });
case '!=':
return this.nest(f.field, { not: v });
case '>':
return this.nest(f.field, { gt: v });
case '>=':
return this.nest(f.field, { gte: v });
case '<':
return this.nest(f.field, { lt: v });
case '<=':
return this.nest(f.field, { lte: v });
case 'like':
// Legacy sent '%value%' to SQL LIKE; `contains` is the Prisma equivalent.
return this.nest(f.field, {
contains: String(v ?? '').replace(/^%|%$/g, ''),
mode: 'insensitive',
});
case 'in':
return this.nest(f.field, { in: Array.isArray(v) ? v : [v] });
case 'between':
return this.nest(f.field, { gte: v, lte: f.value2 });
case 'null':
return this.nest(f.field, { equals: null });
case 'notnull':
return this.nest(f.field, { not: null });
default:
return null;
}
}
/** Builds `where`, `orderBy`, `skip` and `take` from an XTable request. */
build(query: TableQueryDto, opts: BuildOptions = {}) {
const page = query.page && query.page > 0 ? query.page : 1;
const limit = query.limit && query.limit > 0 ? query.limit : 10;
const and: Record<string, unknown>[] = [];
for (const f of query.filters ?? []) {
const cond = this.toCondition(f, opts.allowed);
if (cond) and.push(cond);
}
// Free-text search across the declared columns (legacy: OR over initSearch).
const search = query.search?.trim();
if (search && opts.searchable?.length) {
and.push({
OR: opts.searchable.map((field) =>
this.nest(field, { contains: search, mode: 'insensitive' }),
),
});
}
// legacy `order`: '+field' asc, '-field' desc
let orderBy = opts.defaultOrderBy ?? { createdAt: 'desc' as const };
if (query.order) {
const dir = query.order.startsWith('-') ? 'desc' : 'asc';
const field = query.order.replace(/^[+-]/, '');
if (field) {
this.assertAllowed(field, opts.allowed);
orderBy = this.nest(field, dir) as Record<string, 'asc' | 'desc'>;
}
}
return {
where: and.length ? { AND: and } : {},
orderBy,
skip: (page - 1) * limit,
take: limit,
page,
limit,
};
}
/** Wraps rows + count into the exact envelope XTable expects. */
respond<T>(rows: T[], total: number, query: TableQueryDto, page: number, limit: number): TableResult<T> {
return {
rows,
query: {
search: query.search,
filters: query.filters ?? [],
total,
limit,
page,
},
};
}
/** Exports are not implemented; fail loudly instead of returning a dead url. */
assertNotExport(query: TableQueryDto) {
if (query.op) {
throw new BadRequestException(
`Export ('${query.op}') is not implemented yet in the new backend.`,
);
}
}
}
@@ -0,0 +1,64 @@
import {
Controller,
Get,
HttpCode,
Param,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AdminUploadsService } from './admin-uploads.service';
import { CurrentAdmin } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { Body } from '@nestjs/common';
/**
* The subset of multer's file object this controller uses. Declared locally
* rather than pulling in @types/multer for one interface — multer itself ships
* with @nestjs/platform-express, but its types do not.
*/
interface UploadedMulterFile {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
/**
* Legacy endpoints:
* POST file/create -> storeWithoutData
* PUT file/update/{id} -> updateBody (metadata only; not ported — nothing
* in henza_admin calls it)
*
* The multipart field name `file` matches what every legacy uploader posts
* (MyUpload.vue, SingleUploadInput.vue, UploadInput.vue, PodcastUploadInput.vue).
*
* PERMISSIONS. Legacy gated this on nothing at all: `file/create` sat outside
* any middleware, and henza_server's `auth:api` group is itself commented out
* (routes/admin.php:12), so the endpoint accepted anonymous uploads. That is a
* security bug, not a feature, so it is NOT reproduced — these routes require a
* signed-in admin. No specific rid is demanded, since legacy's permission
* vocabulary has no media/file entry to port and inventing one would let it
* drift from the 144 real rids.
*/
@Controller('admin/files')
export class AdminUploadsController {
constructor(private readonly uploads: AdminUploadsService) {}
@Post('create')
@HttpCode(200)
@UseInterceptors(FileInterceptor('file'))
create(
@UploadedFile() file: UploadedMulterFile | undefined,
@Body('purpose') purpose: string | undefined,
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.uploads.store(file, purpose, admin.id ?? null);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.uploads.findOne(id);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminUploadsController } from './admin-uploads.controller';
import { PublicUploadsController } from './public-uploads.controller';
import { AdminUploadsService } from './admin-uploads.service';
@Module({
imports: [PrismaModule],
controllers: [AdminUploadsController, PublicUploadsController],
providers: [AdminUploadsService],
exports: [AdminUploadsService],
})
export class AdminUploadsModule {}
@@ -0,0 +1,81 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { PrismaService } from '../../prisma/prisma.service';
import { getUploadDir } from './upload-path';
/**
* File uploads — ports legacy `POST file/create`
* (henza_server Adm/FileController::storeWithoutData).
*
* Legacy behaviour, reproduced here:
* - the multipart field is named `file`
* - the stored name is md5(time + original name) + the original extension
* - files land in one flat public directory (legacy: public/storage)
* - the JSON response carries an `id`, which the ant-design Upload wrappers
* (SingleUploadInput.vue:87, MyUpload.vue) require before emitting `ok`
*
* Deliberately NOT ported: legacy's commented-out webp re-encoding, watermarking
* and thumbnailing. That code is dead in legacy too — every branch of it is
* inside a `/* ... *​/` block, leaving only the plain `$file->move(...)` live.
*/
@Injectable()
export class AdminUploadsService {
/** Matches legacy's flat public/storage directory. */
private readonly dir = getUploadDir();
/** Mirrors legacy's `type` field: 1 = image, 2 = any other document. */
private static readonly IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/;
private static readonly ALLOWED = /^(image\/(jpeg|png|gif|webp|svg\+xml)|application\/pdf)$/;
/** 8 MB. Legacy relied on PHP's upload_max_filesize; this makes it explicit. */
private static readonly MAX_BYTES = 8 * 1024 * 1024;
constructor(private readonly prisma: PrismaService) {}
async store(
file: { originalname: string; mimetype: string; size: number; buffer: Buffer } | undefined,
purpose: string | undefined,
actorAdminUserId: string | null,
) {
if (!file) throw new BadRequestException('فایلی ارسال نشده است');
if (file.size > AdminUploadsService.MAX_BYTES) {
throw new BadRequestException('حجم فایل بیش از حد مجاز است (۸ مگابایت)');
}
if (!AdminUploadsService.ALLOWED.test(file.mimetype)) {
throw new BadRequestException('نوع فایل مجاز نیست');
}
// legacy: md5(time() . original_name) . '.' . ext
const ext = extname(file.originalname).toLowerCase();
const stored =
createHash('md5').update(`${Date.now()}${file.originalname}${randomUUID()}`).digest('hex') +
ext;
await mkdir(this.dir, { recursive: true });
await writeFile(join(this.dir, stored), file.buffer);
return this.prisma.uploadedFile.create({
data: {
url: `/uploads/${stored}`,
originalName: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
purpose: purpose ?? null,
uploadedByAdminUserId: actorAdminUserId,
},
});
}
async findOne(id: string) {
const f = await this.prisma.uploadedFile.findUnique({ where: { id } });
if (!f) throw new NotFoundException('فایل یافت نشد');
return f;
}
/** True when the stored file is an image, for callers that care. */
static isImage(mime: string) {
return AdminUploadsService.IMAGE_MIME.test(mime);
}
}
@@ -0,0 +1,28 @@
import { Controller, Get, Header, NotFoundException, Param, StreamableFile } from '@nestjs/common';
import { createReadStream } from 'node:fs';
import { Public } from '../auth/guards';
import { mimeForUpload, resolveStoredUpload } from './upload-path';
/**
* Public file serving at GET /api/uploads/:filename.
*
* ProductImage.url is stored as `/uploads/<file>`, which nginx proxies. Shoppers
* and operators also hit `/api/uploads/<file>` (user-app axios base is `/api`,
* and the API Dokploy domain sits behind the same prefix). useStaticAssets only
* mounts `/uploads/`, so those requests 404'd with Nest's "Cannot GET /api/uploads/...".
*/
@Public()
@Controller('uploads')
export class PublicUploadsController {
@Get(':filename')
@Header('Cache-Control', 'public, max-age=86400')
stream(@Param('filename') filename: string): StreamableFile {
const file = resolveStoredUpload(filename);
if (!file) throw new NotFoundException('فایل یافت نشد');
return new StreamableFile(createReadStream(file), {
type: mimeForUpload(file) ?? 'application/octet-stream',
disposition: 'inline',
});
}
}
@@ -0,0 +1,49 @@
import { existsSync, statSync } from 'node:fs';
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
import { envOr } from '../../common/env';
/**
* Directory files are written to and served from.
*
* Default matches docker-compose's `api_uploads:/app/storage/uploads` volume.
* A nested leftover (`storage/uploads/uploads/`) is also checked when serving,
* because copying a folder named `uploads` into the volume creates that extra
* directory and the previous static root then 404'd every file.
*/
export function getUploadDir(): string {
return resolve(envOr('UPLOAD_DIR', join(process.cwd(), 'storage', 'uploads')));
}
const UPLOAD_MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
};
export function mimeForUpload(filePath: string): string | undefined {
const dot = filePath.lastIndexOf('.');
const ext = dot >= 0 ? filePath.slice(dot).toLowerCase() : '';
return UPLOAD_MIME[ext];
}
/** Resolve a public filename to an on-disk file, or null if it is missing/unsafe. */
export function resolveStoredUpload(rawName: string): string | null {
const safe = basename(rawName);
if (!safe || safe === '.' || safe === '..') return null;
const dir = getUploadDir();
const candidates = [join(dir, safe), join(dir, 'uploads', safe)];
for (const candidate of candidates) {
const resolved = resolve(candidate);
const rel = relative(dir, resolved);
if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
if (existsSync(resolved) && statSync(resolved).isFile()) return resolved;
}
return null;
}
@@ -0,0 +1,100 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminUsersService } from './admin-users.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Legacy endpoints (UserController + RoleController):
* GET/POST/PUT/DELETE users
* GET const/__list__ut -> access-level picker
* POST const -> create an access level
* GET roles/{ut} -> rids of one level
* POST roles/create -> set rids
* DELETE roles/{ut}/{rid} -> revoke one rid
*/
@Controller('admin/users')
export class AdminUsersController {
constructor(private readonly users: AdminUsersService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('user.list')
list(@Body() query: TableQueryDto) {
return this.users.list(query);
}
@Get('role-options')
@RequirePermission('user.list')
roleOptions() {
return this.users.roleOptions();
}
/** Legacy GET const/__list__ut. */
@Get('types')
@RequirePermission('user.list')
listUserTypes() {
return this.users.listUserTypes();
}
/** Legacy POST const {key:"__list__ut", val:"<title>"}. */
@Post('types')
@HttpCode(200)
@RequirePermission('user.permission.create')
createUserType(@Body() body: { val?: string; title?: string }) {
return this.users.createUserType(body.val ?? body.title ?? '');
}
/** Legacy GET roles/{ut}. */
@Get('types/:ut/roles')
@RequirePermission('user.permission')
getRoles(@Param('ut') ut: string) {
return this.users.getRoles(Number(ut));
}
/** Legacy POST roles/create {ut, roles}. */
@Post('roles')
@HttpCode(200)
@RequirePermission('user.permission.edit')
setRoles(@Body() body: { ut: number | string; roles: string[] }) {
return this.users.setRoles(Number(body.ut), body.roles ?? []);
}
/** Legacy DELETE roles/{ut}/{rid}. */
@Delete('types/:ut/roles/:rid')
@RequirePermission('user.permission.edit')
revokeRole(@Param('ut') ut: string, @Param('rid') rid: string) {
return this.users.revokeRole(Number(ut), rid);
}
/** The "اطلاعات من" screen. */
@Get('me')
myData(@CurrentAdmin() admin: AuthenticatedAdmin) {
return this.users.myData(admin.id);
}
@Post()
@HttpCode(200)
@RequirePermission('user.create')
create(@Body() data: Record<string, unknown>) {
return this.users.create(data);
}
@Get(':id')
@RequirePermission('user.list')
findOne(@Param('id') id: string) {
return this.users.findOne(id);
}
@Put(':id')
@RequirePermission('user.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.users.update(id, data);
}
@Delete(':id')
@RequirePermission('user.list.remove')
remove(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.users.remove(id, admin.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
@Module({
imports: [PrismaModule],
controllers: [AdminUsersController],
providers: [AdminUsersService],
})
export class AdminUsersModule {}
@@ -0,0 +1,31 @@
import { AdminUsersService } from './admin-users.service';
/** SEC-07: a role's saved permissions are limited to rids the server knows. */
describe('AdminUsersService.setRoles', () => {
let createMany: jest.Mock;
let service: AdminUsersService;
beforeEach(() => {
createMany = jest.fn();
const prisma = {
userType: { findUnique: jest.fn(async () => ({ id: 90, permissions: [] })) },
permission: { deleteMany: jest.fn(), createMany },
$transaction: jest.fn(async () => []),
};
service = new AdminUsersService(prisma as never, {} as never, {} as never);
});
it('keeps catalogue rids and their tree parents, drops anything else', async () => {
await service.setRoles(90, [
'order.list',
'order', // tree parent of order.list
'persons', // tree parent of persons.*
'notify.create.single.customer', // legacy tree leaf no route checks
'made.up.permission',
'order.list', // duplicate
'',
]);
const rows = createMany.mock.calls[0][0].data.map((d: { rid: string }) => d.rid);
expect(rows.sort()).toEqual(['order', 'order.list', 'persons']);
});
});
@@ -0,0 +1,521 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AdminRole, Prisma } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { AdminSessionService } from '../sessions/admin-session.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { faToEn } from '../customer-auth/digits';
import { ALL_RIDS } from '../auth/acl.constants';
import { SUPER_ADMIN_MOBILE, isSuperAdmin } from '../auth/super-admin';
/**
* SEC-07: the rids a role may hold — every rid in the catalogue and each of
* their tree parents ("persons", "order.omde", …), which the permission tree
* posts when a whole branch is ticked.
*/
const STORABLE_RIDS: ReadonlySet<string> = new Set(
(ALL_RIDS as readonly string[]).flatMap((rid) =>
rid.split('.').map((_, i, parts) => parts.slice(0, i + 1).join('.')),
),
);
/**
* Admin users and the permission matrix — legacy views/user/*, served by
* UserController and RoleController:
* POST/GET/PUT/DELETE users -> the admin user list and form
* GET const/__list__ut -> the user-type ("سطح دسترسی") picker
* POST const -> create a new user type
* GET roles/{ut} -> the rids granted to a user type
* POST roles/create -> grant rids
* DELETE roles/{ut}/{rid} -> revoke one rid
*
* The permission matrix writes the `permission(rid, userTypeId)` rows that
* Phase 1 modelled, so this screen is what actually administers the ACL.
*/
@Injectable()
export class AdminUsersService {
private readonly logger = new Logger(AdminUsersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly sessions: AdminSessionService,
) {}
private static readonly ALLOWED = [
'id',
'fullName',
'mobile',
'role',
'isActive',
'createdAt',
'userTypeId',
'userType.title',
'branch.name',
// legacy filter keys
'name',
'ut',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'fullName',
ut: 'userTypeId',
caj: 'createdAt',
active: 'isActive',
};
/** Legacy `GET users` — the admin user grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminUsersService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'isActive' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
if (mapped.field === 'userTypeId' && mapped.value !== undefined) {
mapped.value = Number(mapped.value);
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile'],
allowed: AdminUsersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
// The support super admin is not listed: to everyone else it does not exist.
const visible: Prisma.AdminUserWhereInput = {
AND: [where as Prisma.AdminUserWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }],
};
const [rows, total] = await Promise.all([
this.prisma.adminUser.findMany({
where: visible,
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
skip,
take,
include: { userType: true, branch: true, avatarFile: true },
}),
this.prisma.adminUser.count({ where: visible }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names preserved; the password hash is never exposed. */
private toRow(
u: Prisma.AdminUserGetPayload<{
include: { userType: true; branch: true; avatarFile: true };
}> & {
// D17 — present on the detail read; absent from the list query, hence optional.
salesExpertProfile?: {
cityId: string | null;
gramLevel: Prisma.Decimal;
phone: string | null;
nationalCode: string | null;
city?: { id: string; name: string } | null;
} | null;
},
) {
return {
id: u.id,
name: u.fullName,
// D0 requirement 7 — the real parts. The fallback only matters for a row
// written before the 20260922180000 migration backfilled them.
fname: u.firstName ?? u.fullName,
lname: u.lastName ?? '',
mobile: u.mobile,
role: u.role,
ut: u.userTypeId,
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
branch_id: u.branchId,
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
percent: u.wagePercent,
active: u.isActive ? 1 : -1,
is_active: u.isActive,
caj: u.createdAt,
// Sales-agent fields (ReagentCustomer.vue) — set only for role=SALES_EXPERT,
// null for every other admin. Same shapes admin-customers.service.ts used
// when this data still lived on Customer, so the form binds unchanged.
address: u.address,
store_name: u.storeName,
pasazh_name: u.pasazhName,
mcode: u.memberCode,
image_id: u.avatarFileId,
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
// D17 — from SalesExpertProfile. Null for a non-expert, and null-safe for
// an expert whose profile row is somehow missing.
city_id: u.salesExpertProfile?.cityId ?? null,
city: u.salesExpertProfile?.city
? { id: u.salesExpertProfile.city.id, title: u.salesExpertProfile.city.name }
: null,
gram_level: u.salesExpertProfile?.gramLevel ?? null,
phone: u.salesExpertProfile?.phone ?? null,
ncode: u.salesExpertProfile?.nationalCode ?? null,
};
}
async findOne(id: string) {
const u = await this.prisma.adminUser.findUnique({
where: { id },
// D17 — the edit form needs the sales-expert fields too.
include: {
userType: true,
branch: true,
avatarFile: true,
salesExpertProfile: { include: { city: true } },
},
});
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
return this.toRow(u);
}
/**
* Legacy `POST users`.
*
* `role` is REQUIRED — it used to default to SALES_EXPERT whenever the
* caller omitted it, which silently turned every admin created without an
* explicit role into a sales agent regardless of the access level ("نوع
* کاربری") actually chosen. UserCreate.vue now always sends one.
*/
async create(data: Record<string, unknown>) {
// D0 requirement 7 — store the real name parts, as Customer does. `name`
// is still accepted for callers that only send a whole name.
const firstName = String(data.fname ?? data.name ?? '').trim();
const lastName = String(data.lname ?? '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
// D19 gap 2 — normalise Persian digits, as the reference does with
// Utility::faTOen (AdminController.php:333,364). Done BEFORE the
// uniqueness lookup below: otherwise ۰۹۱۲… and 0912… are different strings
// and the same number could be registered twice.
const mobile = faToEn(String(data.mobile ?? '').trim());
const password = String(data.password ?? '');
const role = data.role as AdminRole | undefined;
if (!fullName) throw new BadRequestException('نام الزامی است');
if (!mobile) throw new BadRequestException('موبایل الزامی است');
if (!password) throw new BadRequestException('رمز عبور الزامی است');
if (!role) throw new BadRequestException('نقش کاربر الزامی است');
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
return this.prisma.adminUser.create({
data: {
fullName,
firstName: firstName || null,
lastName: lastName || null,
mobile,
passwordHash: await bcrypt.hash(password, 10),
role,
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
branchId: (data.branch_id as string) || null,
wagePercent:
data.percent !== undefined && data.percent !== null
? new Prisma.Decimal(String(data.percent))
: null,
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
// Sales-agent-only fields (ReagentCustomer.vue); undefined for every
// other role, which Prisma leaves at column default (null).
address: (data.address as string) || null,
storeName: (data.store_name as string) || null,
pasazhName: (data.pasazh_name as string) || null,
memberCode: (data.mcode as string) || null,
avatarFileId: (data.image_id as string) || null,
// D17 — a sales expert always gets its profile row, so the grid's city
// and credit-limit columns have somewhere to read from immediately.
...(role === 'SALES_EXPERT'
? { salesExpertProfile: { create: this.salesExpertProfileData(data) } }
: {}),
},
include: {
userType: true,
branch: true,
avatarFile: true,
salesExpertProfile: { include: { city: true } },
},
});
}
/**
* The SalesExpertProfile columns as posted by ReagentCustomer.vue (D17).
*
* `phone` and `nationalCode` are stored exactly as the reference does:
* optional, not unique, no format rule — the reference validates only
* `mobile` and `percent` on this form (AdminController.php:354).
*/
private salesExpertProfileData(data: Record<string, unknown>) {
return {
cityId: (data.city_id as string) || null,
gramLevel:
data.gram_level !== undefined && data.gram_level !== null && data.gram_level !== ''
? new Prisma.Decimal(String(data.gram_level))
: new Prisma.Decimal(0),
phone: (data.phone as string) || null,
nationalCode: (data.ncode as string) || null,
};
}
/** Legacy `PUT users/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
if (!existing || isSuperAdmin(existing.mobile)) throw new NotFoundException('کاربر یافت نشد');
const patch: Prisma.AdminUserUpdateInput = {};
// D0 requirement 7 — write the parts and let the
// admin_user_sync_full_name trigger rebuild `fullName`. When a caller sends
// both, the parts win (see the migration header).
if (data.fname !== undefined || data.lname !== undefined) {
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
const lastName = String(data.lname ?? existing.lastName ?? '').trim();
patch.firstName = firstName || null;
patch.lastName = lastName || null;
} else if (data.name !== undefined) {
// Whole-name-only callers: the trigger derives the parts.
patch.fullName = String(data.name);
}
if (data.mobile !== undefined) {
// Normalised before the clash check, for the same reason as create().
const mobile = faToEn(String(data.mobile));
if (mobile !== existing.mobile) {
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
}
patch.mobile = mobile;
}
// A blank password field means "leave it unchanged" — legacy did the same.
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
if (data.role !== undefined) patch.role = data.role as AdminRole;
if (data.ut !== undefined) {
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
}
if (data.branch_id !== undefined) {
patch.branch = data.branch_id
? { connect: { id: String(data.branch_id) } }
: { disconnect: true };
}
if (data.percent !== undefined) {
patch.wagePercent =
data.percent === null ? null : new Prisma.Decimal(String(data.percent));
}
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
if (data.address !== undefined) patch.address = (data.address as string) || null;
if (data.store_name !== undefined) patch.storeName = (data.store_name as string) || null;
if (data.pasazh_name !== undefined) patch.pasazhName = (data.pasazh_name as string) || null;
if (data.mcode !== undefined) patch.memberCode = (data.mcode as string) || null;
if (data.image_id !== undefined) {
// legacy sent '' to mean "avatar removed"
patch.avatarFile = data.image_id
? { connect: { id: String(data.image_id) } }
: { disconnect: true };
}
// D17 — the profile follows the role.
//
// `upsert` covers all three cases in one statement: a user becoming a sales
// expert gets a profile; an existing expert's profile is updated; and an
// expert whose profile went missing gets one back rather than erroring.
// A profile is never deleted when the role changes away — that would
// silently discard the operator's city and credit limit on a mistaken role
// switch, and it costs nothing to keep.
const effectiveRole = (patch.role as AdminRole | undefined) ?? existing.role;
const touchesProfile =
data.city_id !== undefined ||
data.gram_level !== undefined ||
data.phone !== undefined ||
data.ncode !== undefined;
if (effectiveRole === 'SALES_EXPERT' && (touchesProfile || patch.role !== undefined)) {
const profile = this.salesExpertProfileData(data);
patch.salesExpertProfile = {
upsert: {
create: profile,
// Only the keys the caller actually sent, so an untouched field is
// not overwritten with a default on a partial (diff-send) save.
update: {
...(data.city_id !== undefined ? { cityId: profile.cityId } : {}),
...(data.gram_level !== undefined ? { gramLevel: profile.gramLevel } : {}),
...(data.phone !== undefined ? { phone: profile.phone } : {}),
...(data.ncode !== undefined ? { nationalCode: profile.nationalCode } : {}),
},
},
};
}
const updated = await this.prisma.adminUser.update({
where: { id },
data: patch,
include: {
userType: true,
branch: true,
avatarFile: true,
salesExpertProfile: { include: { city: true } },
},
});
// Disabling an account or changing its password used to leave every already
// issued token working for up to 12h. With session tracking on, those tokens
// end here; with it off, nothing happens and the behaviour is as before.
if (patch.isActive === false || patch.passwordHash !== undefined) {
await this.sessions.revokeAllForAdmin(
id,
patch.isActive === false ? 'account_disabled' : 'password_changed',
);
}
return updated;
}
/**
* Legacy `DELETE users/{id}`.
*
* Refuses to delete an admin who has posted ledger entries or changed order
* statuses — those rows reference them, and losing the actor would erase the
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
*/
async remove(id: string, actorId: string) {
const u = await this.prisma.adminUser.findUnique({
where: { id },
include: {
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
},
});
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
if (id === actorId) {
throw new BadRequestException('نمی‌توانید حساب کاربری خودتان را حذف کنید');
}
const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents;
if (refs > 0) {
throw new BadRequestException(
`حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. می‌توانید غیرفعالش کنید.`,
);
}
await this.prisma.adminUser.delete({ where: { id } });
return { success: true };
}
// ── User types + the permission matrix ─────────────────────────────
/** Legacy `GET const/__list__ut` — the access-level picker. */
async listUserTypes() {
const rows = await this.prisma.userType.findMany({
orderBy: { id: 'asc' },
include: { _count: { select: { adminUsers: true, permissions: true } } },
});
// legacy returned {id, title} pairs; the counts are additive, not breaking.
return rows.map((t) => ({
id: t.id,
title: t.title,
users_count: t._count.adminUsers,
permissions_count: t._count.permissions,
}));
}
/**
* Legacy `POST const` with `{key: "__list__ut", val: "<title>"}` — creates a
* new access level. Legacy's `config_static` used an auto-increment id; here
* the next free integer is taken explicitly since UserType.id is not generated.
*/
async createUserType(title: string) {
if (!title?.trim()) throw new BadRequestException('نام سطح دسترسی الزامی است');
const last = await this.prisma.userType.findFirst({ orderBy: { id: 'desc' } });
const nextId = (last?.id ?? 84) + 1; // 85 is legacy's first, per Permission.vue
return this.prisma.userType.create({
data: { id: nextId, title: title.trim() },
});
}
/** Legacy `GET roles/{ut}` — the rids granted to one access level. */
async getRoles(userTypeId: number) {
const type = await this.prisma.userType.findUnique({
where: { id: userTypeId },
include: { permissions: true },
});
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
// legacy's tree binds to a flat array of rid strings
return type.permissions.map((p) => p.rid);
}
/**
* Legacy `POST roles/create` with `{ut, roles: [...]}`.
*
* The posted array is the complete tick-state of the permission tree, so it
* REPLACES that type's rows — which is what legacy's live code does too: it
* posts the whole `this.rid` array and nothing else.
*
* (Legacy also carries a COMMENTED-OUT block that diffed the old and new sets
* and fired one DELETE per removed rid on a 500ms setTimeout. That path is
* dead in legacy and is not revived here; the transaction below achieves the
* same end atomically.)
*/
async setRoles(userTypeId: number, rids: string[]) {
const type = await this.prisma.userType.findUnique({ where: { id: userTypeId } });
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
const posted = [...new Set((rids ?? []).map((r) => String(r).trim()).filter(Boolean))];
// SEC-07: only rids the server knows are stored. The rest grant nothing —
// most are legacy tree leaves no route checks (e.g. notify.create.single.*)
// — so they are dropped rather than failing the whole save.
const clean = posted.filter((r) => STORABLE_RIDS.has(r));
const dropped = posted.filter((r) => !STORABLE_RIDS.has(r));
if (dropped.length) {
this.logger.warn(`setRoles(${userTypeId}): ignored ${dropped.length} unknown rid(s): ${dropped.slice(0, 20).join(', ')}`);
}
await this.prisma.$transaction([
this.prisma.permission.deleteMany({ where: { userTypeId } }),
this.prisma.permission.createMany({
data: clean.map((rid) => ({ rid, userTypeId })),
skipDuplicates: true,
}),
]);
return this.getRoles(userTypeId);
}
/** Legacy `DELETE roles/{ut}/{rid}` — revoke a single rid. */
async revokeRole(userTypeId: number, rid: string) {
const existing = await this.prisma.permission.findUnique({
where: { rid_userTypeId: { rid, userTypeId } },
});
if (!existing) throw new NotFoundException('این دسترسی وجود ندارد');
await this.prisma.permission.delete({
where: { rid_userTypeId: { rid, userTypeId } },
});
return { success: true };
}
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
async myData(adminId: string) {
return this.findOne(adminId);
}
/** The AdminRole picker for the user form. */
roleOptions() {
return [
{ value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' },
{ value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' },
{ value: AdminRole.SALES_EXPERT, title: 'کارشناس فروش' },
{ value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' },
{ value: AdminRole.ACCOUNTANT, title: 'حسابدار' },
];
}
}
@@ -0,0 +1,59 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminBranchesService } from '../admin-branches/admin-branches.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
/**
* Workshops — legacy views/workshop/*, served by WorkshopController:
* POST workshop/table -> the grid (also the picker the PreOrder detail uses)
* POST workshop -> create (workshop + its supervisor, together)
* GET/PUT workshop/{id}
* GET workshop/active/{id} -> toggle
*
* The list route predates the rest (Batch 3 added it for the made-to-order
* screen's workshop picker); the CRUD arrived with Batch 11. Both live here so
* there is one owner of /admin/workshops, and the logic sits in
* AdminBranchesService alongside the branch code it mirrors.
*
* Managing workshops is super-admin-only; the list stays open because the
* made-to-order screen picks a workshop from it.
*/
@Controller('admin/workshops')
export class AdminWorkshopsController {
constructor(private readonly branches: AdminBranchesService) {}
@Post('list')
@HttpCode(200)
list(@Body() query: TableQueryDto) {
return this.branches.listWorkshops(query);
}
@Post()
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('workshop.create')
create(@Body() data: Record<string, unknown>) {
return this.branches.createWorkshop(data);
}
@Get('active/:id')
@SuperAdminOnly()
@RequirePermission('workshop.list')
toggleActive(@Param('id') id: string) {
return this.branches.toggleWorkshopActive(id);
}
@Get(':id')
@SuperAdminOnly()
@RequirePermission('workshop.list')
findOne(@Param('id') id: string) {
return this.branches.findWorkshop(id);
}
@Put(':id')
@SuperAdminOnly()
@RequirePermission('workshop.list')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.branches.updateWorkshop(id, data);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminBranchesModule } from '../admin-branches/admin-branches.module';
import { AdminWorkshopsController } from './admin-workshops.controller';
@Module({
imports: [PrismaModule, AdminBranchesModule],
controllers: [AdminWorkshopsController],
})
export class AdminWorkshopsModule {}
+206
View File
@@ -0,0 +1,206 @@
/**
* Every permission id (`rid`) the admin panel actually enforces.
*
* Regenerated mechanically from legacy (see scratchpad/convert_tree.js) as the
* union of two enforcement points:
* - 58 gated by a route's `meta.role` (router/admin.js, router/branch.js)
* - 100 gated inside views via `$hasPermission('...')`
* = 144 distinct rids (the two sets overlap)
*
* An earlier revision of this file listed only the 58 route-gated ones, which
* left the seeded super-admin unable to use the in-view actions. Corrected.
*
* These are LEAVES as written. Ancestors ("order" from "order.list", "persons"
* from "persons.list.client.omde", ...) are NOT stored — they are derived at
* runtime by expandWithParents(), exactly as legacy's checkParent() did.
*/
export const ALL_RIDS = [
'branch.create',
'branch.list',
'buy.order.list',
'cities.create',
'cities.list',
'cities.list.remove',
'cities.list.view',
'cities.list.view.edit',
'collection.create',
'collection.list',
'collection.list.edit',
'collection.list.remove',
'comment',
'comment.remove',
'comment.state',
'comment.view',
'comment.view.edit',
// D27 — «اتصال حسابداری» (Tahesab / Zarrin). Not a legacy rid.
'config.accounting',
'config.wage',
'config.report',
'config.statics',
'config.statics.brand',
'config.statics.brand.create',
'config.statics.brand.edit',
'config.statics.brand.remove',
'config.statics.tags',
'config.statics.tags.create',
'config.statics.tags.edit',
'config.statics.tags.remove',
'config.wallet',
'custom.config.statics.slider',
'custom.config.statics.tags.banner.ids',
'dashboard.view',
'document.create',
'document.list',
'document.list.confirm',
'document.list.deny',
'document.list.edit',
'document.list.get',
'document.list.remove',
'invoice.create',
'invoice.list',
'newsletters',
'notify.create',
'notify.me',
'offers.autopromotion',
'offers.create',
'offers.create.tak',
'offers.list',
'offers.list.edit',
'offers.list.remove',
'offers.list.tak.edit',
'offers.list.tak.remove',
'offersIgnore',
'order.create',
'order.list',
'order.list.pdf',
'order.list.sts',
'order.list.sts.remove',
'order.list.sts.submit',
'order.list.view',
'order.list.view.edit',
'order.makeOrder',
'order.makeOrder.get',
'order.makeOrder.remove',
'order.makeOrder.state',
'order.makeOrder.state.remove',
'order.makeOrder.state.submit',
'order.makeOrder.view',
'order.makeOrder.view.edit',
'order.omde',
'order.omde.get',
'order.omde.remove',
'order.omde.state',
'order.omde.state.remove',
'order.omde.state.submit',
'order.omde.view',
'order.omde.view.edit',
'order.omde.view.remove',
'order.tamin',
'order.tamin.state',
'order.tamin.state.remove',
'order.tamin.state.submit',
'order.tamin.view',
'order.tamin.view.edit',
'persons.create',
'persons.create.client',
'persons.create.customer.tak',
'persons.create.seller',
'persons.list.client.omde',
'persons.list.client.omde.able',
'persons.list.client.omde.able.order',
'persons.list.client.omde.edit',
'persons.list.client.omde.unable',
'persons.list.client.omde.unable.order',
'persons.list.client.omde.view',
'persons.list.client.omde.view.documents',
'persons.list.client.omde.view.orders',
'persons.list.client.single',
'persons.list.client.single.edit',
'persons.list.client.single.view',
'persons.list.sellers',
'persons.list.sellers.able',
'persons.list.sellers.able.order',
'persons.list.sellers.edit',
'persons.list.sellers.unable',
'persons.list.sellers.unable.order',
'persons.list.sellers.view',
'persons.list.sellers.view.documents',
'persons.list.sellers.view.orders',
'product.create',
'product.list',
'product.list.edit',
'product.list.group.edit',
'product.list.remove',
'product.list.variants',
'product.list.variants.add',
'product.list.variants.edit',
'product.list.variants.remove',
'product.list.view',
'product.percent',
'report.order.item',
'report.product',
'sms.create',
'sms.create.all',
'sms.create.multiple',
'sms.create.omde',
'sms.create.reagent',
'sms.create.single',
'sms.list',
'statics',
'user.create',
'user.list',
'user.list.create',
'user.list.edit',
'user.list.remove',
'user.permission',
'user.permission.create',
'user.permission.edit',
'wallet',
'wallet.charge',
'wallet.list',
'wallet.percent',
'workshop.create',
'workshop.list',
] as const;
export type Rid = (typeof ALL_RIDS)[number];
/**
* Port of henza_admin/src/store/users/index.js getParents() — returns every
* ancestor of a dotted rid, longest first.
*
* getParents(['a','b','c']) -> ['a.b', 'a']
*/
function getParents(keys: string[]): string[] {
const work = [...keys];
const parents: string[] = [];
while (work.length > 1) {
work.pop();
parents.push(work.join('.'));
}
return parents;
}
/**
* Port of henza_admin/src/store/users/index.js checkParent().
*
* Granting "persons.list.client.omde" implicitly grants "persons.list.client",
* "persons.list" and "persons", so parent menu nodes stay visible. The legacy
* version mutated a module-scoped `rid` variable; this one is pure, which changes
* nothing observable but makes it safe to call concurrently on the server.
*
* Note this only walks UP. A rid never implies its children — which is why the
* legacy nav item guarded by "wallet.list" stays hidden for a user granted only
* "wallet". That mismatch is a legacy bug, recorded in docs/ADMIN-PORT-PLAN.md,
* and is preserved here rather than silently repaired.
*/
export function expandWithParents(values: string[]): string[] {
let rid: string[] = [...values];
for (const x of values) {
const keys = x.split('.');
if (keys.length > 1) {
rid = [...new Set([...rid, ...getParents(keys)])];
}
}
return rid;
}
@@ -0,0 +1,79 @@
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { CurrentAdmin, Public } from './guards';
import type { AuthenticatedAdmin } from './jwt.strategy';
/**
* Legacy endpoints (henza_server routes/admin.php):
* POST admin-login -> AdminController::login
* GET users/show -> UserController::showData
*
* Exposed here under the clean /api/admin/auth prefix; the admin panel's mapping
* layer translates the legacy paths onto these.
*/
@Controller('admin/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@HttpCode(200)
login(@Body() dto: LoginDto, @Ip() ip: string, @Headers('user-agent') userAgent?: string) {
// The IP is forwarded to the identity service (when delegation is on) so its
// per-IP rate limit applies to the real admin, not to this API server; both
// are also recorded on the session row when session tracking is on.
return this.authService.login(dto, ip, userAgent);
}
@Public()
@Get('sso/start')
startSso(@Query('redirect_uri') redirectUri: string | undefined, @Res({ passthrough: true }) res: Response) {
const started = this.authService.beginBrowserSso(redirectUri);
if (!started) return { enabled: false, authorize_url: null };
res.setHeader('Set-Cookie', started.cookie);
return { enabled: true, authorize_url: started.authorizeUrl };
}
@Public()
@Post('sso/callback')
@HttpCode(200)
ssoCallback(
@Body() body: { code?: string; state?: string },
@Headers('cookie') cookieHeader: string | undefined,
@Ip() ip: string,
@Headers('user-agent') userAgent: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
res.setHeader('Set-Cookie', 'gb_admin_iam_sso=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
return this.authService.completeBrowserSso(
readCookie(cookieHeader, 'gb_admin_iam_sso'),
body.code ?? '',
body.state ?? '',
ip,
userAgent,
);
}
@Public()
@Get('sso/logout-url')
ssoLogoutUrl(@Query('return_url') returnUrl?: string) {
return { logout_url: this.authService.centralLogoutUrl(returnUrl) };
}
/** Legacy `users/show` — the router guard's permission refresh. */
@Get('me')
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
return this.authService.showData(admin.id);
}
}
function readCookie(header: string | undefined, name: string): string | null {
if (!header) return null;
for (const part of header.split(';')) {
const [key, ...rest] = part.trim().split('=');
if (key === name) return decodeURIComponent(rest.join('='));
}
return null;
}
+43
View File
@@ -0,0 +1,43 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../../prisma/prisma.module';
import { envOr } from '../../common/env';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard, PermissionsGuard } from './guards';
@Module({
imports: [
PrismaModule,
PassportModule,
// registerAsync: read when the app is created, not when this file is
// imported, so a JWT_SECRET filled in at boot (boot-secrets.ts) is used.
JwtModule.registerAsync({
useFactory: () => ({
// envOr, not `??`: docker-compose passes every optional variable as
// `${X:-}`, so an unconfigured one arrives as "" rather than undefined.
// An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s
// admin login in Docker only. See DEPLOY-02.
secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
// jsonwebtoken types expiresIn as a template-literal union, which a plain
// `string` from process.env does not satisfy; the cast is the documented escape.
signOptions: {
expiresIn: envOr('JWT_EXPIRES_IN', '12h') as `${number}h`,
},
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
// Global: authenticate first, then check the rid. Routes opt out with @Public().
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
exports: [AuthService],
})
export class AuthModule {}
@@ -0,0 +1,334 @@
import { UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
/**
* `@nestjs/jwt` v12 ships as ESM and this repo's jest does not transform
* node_modules, so importing AuthService would fail to load. The real class is
* never exercised here (the test injects its own stub), so replacing the module
* keeps the suite runnable without touching the shared jest config.
*/
jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} }));
/**
* The real bcryptjs, with `compare` wrapped so a test can assert it was never
* reached. It calls straight through, so every other test still runs against
* the real implementation. (A namespace import's properties cannot be
* re-defined, so jest.spyOn is not an option here.)
*/
jest.mock('bcryptjs', () => {
const actual = jest.requireActual('bcryptjs');
return { ...actual, compare: jest.fn(actual.compare) };
});
import { AuthService, compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './auth.service';
import type { AdminSessionService } from '../sessions/admin-session.service';
import {
IamInvalidCredentialsError,
IamMfaRequiredError,
IamService,
IamUnavailableError,
} from '../iam/iam.service';
/**
* Admin login with the identity service in the loop.
*
* The contract being locked down: with the feature off, login behaves exactly
* as before (one bcrypt compare, no network). With it on, an IAM problem must
* never block an admin who still has a valid local password.
*/
describe('AuthService login', () => {
const PASSWORD = 'Secret123!';
let passwordHash: string;
const adminRow = () => ({
id: 'admin-1',
mobile: '09120000000',
fullName: 'مدیر سامانه',
role: 'admin',
userTypeId: 'ut-1',
branchId: 'br-1',
isActive: true,
passwordHash,
userType: { permissions: [{ rid: 10 }, { rid: 20 }] },
});
/** Session tracking off — the shape of a deployment that does not use it. */
const untrackedSessions = (): Partial<AdminSessionService> => ({
newTokenId: jest.fn().mockReturnValue(null),
record: jest.fn(),
});
const build = (
iam: Partial<IamService>,
user: unknown = adminRow(),
sessions: Partial<AdminSessionService> = untrackedSessions(),
) => {
const prisma = {
adminUser: { findUnique: jest.fn().mockResolvedValue(user) },
};
const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') };
return {
service: new AuthService(
prisma as never,
jwt as never,
{ browserBootstrapUrl: () => null, ...iam } as IamService,
sessions as AdminSessionService,
),
prisma,
jwt,
sessions,
};
};
const disabledIam = (): Partial<IamService> => ({
enabled: false,
allowsLocalFallback: true,
verifyCredentials: jest.fn(),
});
beforeAll(async () => {
passwordHash = await bcrypt.hash(PASSWORD, 8);
});
/* ─────────────── feature off: unchanged behaviour ─────────────── */
it('logs in with the local password and never calls the identity service', async () => {
const iam = disabledIam();
const { service, jwt } = build(iam);
const result = await service.login({ phone: '09120000000', password: PASSWORD });
expect(result.api_token).toBe('local.jwt.token');
expect(result.roles).toEqual([{ rid: 10 }, { rid: 20 }]);
expect(jwt.signAsync).toHaveBeenCalledTimes(1);
expect(iam.verifyCredentials).not.toHaveBeenCalled();
});
it('still rejects a wrong local password', async () => {
const { service } = build(disabledIam());
await expect(
service.login({ phone: '09120000000', password: 'wrong' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('still rejects an inactive admin', async () => {
const { service } = build(disabledIam(), { ...adminRow(), isActive: false });
await expect(
service.login({ phone: '09120000000', password: PASSWORD }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('still rejects an unknown phone without leaking that it is unknown', async () => {
const { service } = build(disabledIam(), null);
await expect(
service.login({ phone: '09129999999', password: PASSWORD }),
).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است');
});
/* ─────────────── feature on ─────────────── */
it('accepts a password the identity service approves', async () => {
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
// Deliberately not the local password: the IAM is the authority here
const result = await service.login({ phone: '09120000000', password: 'iam-side-password' });
expect(result.api_token).toBe('local.jwt.token');
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'iam-side-password', undefined);
});
it('forwards the end-user IP to the identity service', async () => {
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5');
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'x', '203.0.113.5');
});
/* ─────────────── the safety net ─────────────── */
it('falls back to the local password when the account is unknown there', async () => {
const verifyCredentials = jest
.fn()
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
const result = await service.login({ phone: '09120000000', password: PASSWORD });
expect(result.api_token).toBe('local.jwt.token');
});
it('falls back to the local password when the identity service is down', async () => {
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
// The single most important guarantee: an IAM outage cannot lock admins out
const result = await service.login({ phone: '09120000000', password: PASSWORD });
expect(result.api_token).toBe('local.jwt.token');
});
it('rejects a wrong password even when falling back', async () => {
const verifyCredentials = jest
.fn()
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
await expect(
service.login({ phone: '09120000000', password: 'wrong' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
/* ─────────────── exclusive mode ─────────────── */
it('refuses the local password in exclusive mode', async () => {
const verifyCredentials = jest
.fn()
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
await expect(
service.login({ phone: '09120000000', password: PASSWORD }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('fails loudly in exclusive mode when the identity service is down', async () => {
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
await expect(
service.login({ phone: '09120000000', password: PASSWORD }),
).rejects.toThrow('سرویس هویت در دسترس نیست');
});
/* ─────────────── revocable sessions ─────────────── */
it('signs the same token as before while session tracking is off', async () => {
const { service, jwt, sessions } = build(disabledIam());
await service.login({ phone: '09120000000', password: PASSWORD });
// No jti, no row: the previous behaviour, byte for byte
expect(jwt.signAsync).toHaveBeenCalledWith({ sub: 'admin-1', mobile: '09120000000' });
expect(sessions.record).not.toHaveBeenCalled();
});
it('records the issued token with the IAM session id when tracking is on', async () => {
const tracked: Partial<AdminSessionService> = {
newTokenId: jest.fn().mockReturnValue('jti-1'),
record: jest.fn(),
};
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' });
const { service, jwt } = build(
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
adminRow(),
tracked,
);
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5', 'Firefox');
expect(jwt.signAsync).toHaveBeenCalledWith({
sub: 'admin-1',
mobile: '09120000000',
jti: 'jti-1',
});
// Without the IAM session id, a back-channel logout could not find this session
expect(tracked.record).toHaveBeenCalledWith({
jti: 'jti-1',
adminUserId: 'admin-1',
iamSessionId: 'sid-1',
iamSub: 'sub-1',
ipAddress: '203.0.113.5',
userAgent: 'Firefox',
});
});
it('still records a session when the login fell back to the local password', async () => {
const tracked: Partial<AdminSessionService> = {
newTokenId: jest.fn().mockReturnValue('jti-2'),
record: jest.fn(),
};
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
const { service } = build(
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
adminRow(),
tracked,
);
await service.login({ phone: '09120000000', password: PASSWORD });
// No IAM identity to attach, but the session must still be revocable locally
expect(tracked.record).toHaveBeenCalledWith(
expect.objectContaining({ jti: 'jti-2', iamSessionId: null, iamSub: null }),
);
});
/* ─────────────── accounts with no usable password ─────────────── */
describe('accounts with no usable password', () => {
const noPassword = ['', OTP_ONLY_PASSWORD_HASH];
it.each(noPassword)('refuses login for hash %j, whatever is typed', async (hash) => {
const { service } = build(disabledIam(), { ...adminRow(), passwordHash: hash });
// Including the empty string and the sentinel itself: neither may open it
for (const typed of ['', 'x', OTP_ONLY_PASSWORD_HASH]) {
await expect(
service.login({ phone: '09120000000', password: typed }),
).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است');
}
});
it.each(noPassword)('refuses hash %j on the IAM fallback path too', async (hash) => {
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
const { service } = build(
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
{ ...adminRow(), passwordHash: hash },
);
await expect(
service.login({ phone: '09120000000', password: '' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('never hands a malformed hash to bcrypt', async () => {
const compare = bcrypt.compare as unknown as jest.Mock;
compare.mockClear();
expect(await compareLocalHash('x', OTP_ONLY_PASSWORD_HASH)).toBe(false);
expect(await compareLocalHash('x', '')).toBe(false);
expect(await compareLocalHash('x', null)).toBe(false);
expect(compare).not.toHaveBeenCalled();
// …while a real hash still goes through it
await compareLocalHash(PASSWORD, passwordHash);
expect(compare).toHaveBeenCalledTimes(1);
});
it("still accepts the legacy server's PHP $2y$ hashes", async () => {
// PHP's $2y$ is the same algorithm as $2b$; the legacy passwords
// (customers and the sales experts moving to AdminUser) use it.
const legacy = '$2y$' + (await bcrypt.hash(PASSWORD, 4)).slice(4);
expect(await compareLocalHash(PASSWORD, legacy)).toBe(true);
expect(await compareLocalHash('wrong', legacy)).toBe(false);
});
});
/* ─────────────── two-factor ─────────────── */
it('explains that a two-factor account cannot log in through this path', async () => {
const verifyCredentials = jest.fn().mockRejectedValue(new IamMfaRequiredError('mfa'));
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
await expect(
service.login({ phone: '09120000000', password: PASSWORD }),
).rejects.toThrow('دو مرحله‌ای');
});
});
+367
View File
@@ -0,0 +1,367 @@
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AdminRole } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import {
IamInvalidCredentialsError,
IamMfaRequiredError,
IamService,
type IamIdentity,
} from '../iam/iam.service';
import { AdminSessionService } from '../sessions/admin-session.service';
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
import type { LoginDto } from './dto/login.dto';
import { compareLocalHash } from './password-hash';
import { SUPER_ADMIN_MOBILE, isSuperAdmin, withSuperAdminRids } from './super-admin';
export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash';
// TEMPORARY hardcoded super-admin login (username = password). Remove before going live.
const STATIC_ADMIN_MOBILE = SUPER_ADMIN_MOBILE;
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly iam: IamService,
private readonly sessions: AdminSessionService,
) {}
/**
* Port of legacy AdminController::login (henza_server).
*
* Legacy issued a random 60-char string as `api_token` and persisted it on the
* user row; this issues a signed JWT instead. The response keeps the legacy
* shape the admin panel's auth store reads:
* - `api_token` -> stored in localStorage under token/branch_token
* - `roles` -> [{ rid }], mapped to rids by the frontend then expanded
* through checkParent()
*
* Deliberate deviation from legacy: a bad phone and a bad password both return
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
* a wrong password, which let anyone enumerate valid admin phone numbers.
*/
async login(dto: LoginDto, clientIp?: string, userAgent?: string) {
const isStaticAdmin =
dto.phone?.trim() === STATIC_ADMIN_MOBILE && dto.password === STATIC_ADMIN_MOBILE;
if (isStaticAdmin) {
await this.ensureStaticAdmin();
}
const user = await this.prisma.adminUser.findUnique({
where: { mobile: isStaticAdmin ? STATIC_ADMIN_MOBILE : dto.phone },
include: { userType: { include: { permissions: true } } },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
const verified = isStaticAdmin
? { ok: true as const, identity: undefined }
: await this.verifyPassword(dto, user.passwordHash, clientIp);
if (!verified.ok) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
// While session tracking is off this is null and the token is signed exactly
// as before — same payload, same claims.
const jti = this.sessions.newTokenId();
const apiToken = await this.jwt.signAsync({
sub: user.id,
mobile: user.mobile,
...(jti ? { jti } : {}),
});
if (jti) {
// Recorded so a central sign-out (or an admin revoking the session) can end
// this token before it expires — see AdminSessionService.
await this.sessions.record({
jti,
adminUserId: user.id,
iamSessionId: verified.identity?.sessionId ?? null,
iamSub: verified.identity?.sub ?? null,
ipAddress: clientIp ?? null,
userAgent: userAgent ?? null,
});
}
const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket);
return {
id: user.id,
name: user.fullName,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
is_super_admin: isSuperAdmin(user.mobile),
api_token: apiToken,
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
(rid) => ({ rid }),
),
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
};
}
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
if (!this.iam.enabled) return null;
const redirectUri = pickExactUri(requestedRedirect, [
envOrNull('IAM_ADMIN_REDIRECT_URI'),
...envList('IAM_ADMIN_REDIRECT_URIS'),
]);
const clientId = envOrNull('IAM_CLIENT_ID');
if (!redirectUri || !clientId) return null;
const state = randomBytes(24).toString('base64url');
const nonce = randomBytes(24).toString('base64url');
const verifier = randomBytes(48).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
const secure = redirectUri.startsWith('https:');
const cookie =
`gb_admin_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
(secure ? '; Secure' : '');
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('prompt', 'none');
return { authorizeUrl: url.toString(), cookie };
}
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip?: string, userAgent?: string) {
const pending = this.readSsoCookie(cookieValue);
if (!pending || pending.state !== state) {
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
}
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
if (!claims.phoneNumber) {
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
}
const user = await this.prisma.adminUser.findFirst({
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
include: { userType: { include: { permissions: true } } },
});
if (!user) {
throw new UnauthorizedException('حساب مدیری با این شماره در این سامانه نیست');
}
const identity: IamIdentity = {
sub: claims.sub,
email: null,
phoneNumber: claims.phoneNumber,
firstName: null,
lastName: null,
sessionId: claims.sessionId,
browserTicket: null,
};
return this.issueAdminToken(user, identity, ip, userAgent);
}
centralLogoutUrl(requestedReturn?: string): string | null {
if (!this.iam.enabled) return null;
const redirect = pickExactUri(requestedReturn, [
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI'),
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL'),
...envList('IAM_ADMIN_ALLOWED_RETURN_URLS'),
]);
const clientId = envOrNull('IAM_CLIENT_ID');
if (!redirect || !clientId) return null;
const url = new URL(`${this.iam.issuer}/oauth/logout`);
url.searchParams.set('client_id', clientId);
url.searchParams.set('post_logout_redirect_uri', redirect);
return url.toString();
}
private async issueAdminToken(
user: {
id: string;
fullName: string;
mobile: string;
role: string;
userTypeId: number | null;
branchId: string | null;
userType: { permissions: { rid: string }[] } | null;
},
identity: IamIdentity | undefined,
ip?: string,
userAgent?: string,
) {
const jti = this.sessions.newTokenId();
const apiToken = await this.jwt.signAsync({
sub: user.id,
mobile: user.mobile,
...(jti ? { jti } : {}),
});
if (jti) {
await this.sessions.record({
jti,
adminUserId: user.id,
iamSessionId: identity?.sessionId ?? null,
iamSub: identity?.sub ?? null,
ipAddress: ip ?? null,
userAgent: userAgent ?? null,
});
}
return {
id: user.id,
name: user.fullName,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
is_super_admin: isSuperAdmin(user.mobile),
api_token: apiToken,
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
(rid) => ({ rid }),
),
};
}
private async ensureStaticAdmin() {
const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } });
const userTypeId = fullAccess ? 85 : null;
const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10);
await this.prisma.adminUser.upsert({
where: { mobile: STATIC_ADMIN_MOBILE },
update: { role: AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true },
create: {
mobile: STATIC_ADMIN_MOBILE,
fullName: 'مدیر موقت',
passwordHash,
role: AdminRole.SUPER_ADMIN,
userTypeId,
branchId: null,
isActive: true,
},
});
}
private ssoSecret(): string {
return envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
}
private readSsoCookie(value: string | null): { state: string; verifier: string; redirectUri: string } | null {
if (!value || !value.includes('.')) return null;
const split = value.lastIndexOf('.');
const payload = value.slice(0, split);
const sig = value.slice(split + 1);
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
if (expected.length !== sig.length) return null;
let same = 0;
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
if (same !== 0) return null;
try {
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return null;
}
}
/**
* Password check, optionally delegated to the central identity service.
*
* With `IAM_ENABLED=false` (the default) this is exactly the previous
* behaviour: a single bcrypt compare. With it on, the identity service is
* asked first and the local hash is only consulted when that check could not
* give a definite answer — so an IAM outage never blocks admin login.
*/
private async verifyPassword(
dto: LoginDto,
passwordHash: string,
clientIp?: string,
): Promise<{ ok: boolean; identity?: IamIdentity }> {
if (!this.iam.enabled) {
return { ok: await compareLocalHash(dto.password, passwordHash) };
}
try {
const identity = await this.iam.verifyCredentials(dto.phone, dto.password, clientIp);
// The IAM session id travels with the recorded session so a back-channel
// logout can find and end exactly this session.
return { ok: true, identity };
} catch (error) {
if (error instanceof IamMfaRequiredError) {
// Nowhere to collect the code on this UI-less path; say so plainly
// instead of silently falling back to a password the IAM may have retired.
throw new UnauthorizedException(
'برای این حساب احراز هویت دو مرحله‌ای فعال است و از این مسیر قابل تکمیل نیست',
);
}
if (error instanceof IamInvalidCredentialsError && !this.iam.allowsLocalFallback) {
return { ok: false };
}
if (!(error instanceof IamInvalidCredentialsError)) {
this.logger.warn(
`falling back to the local password check: ${(error as Error).message}`,
);
if (!this.iam.allowsLocalFallback) {
throw new UnauthorizedException('ورود در حال حاضر ممکن نیست؛ سرویس هویت در دسترس نیست');
}
}
// fallback mode: the account may not have been migrated to the IAM yet
return { ok: await compareLocalHash(dto.password, passwordHash) };
}
}
/**
* Port of legacy UserController::showData, which the router guard calls on every
* cold load to refresh the permission list:
*
* $user['permission'] = Permission::where('ut',$user->ut)->select('rid')->get()->pluck('rid');
*
* Returns raw (unexpanded) rids — the frontend runs checkParent() on them, as in
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
*/
async showData(adminId: string) {
const user = await this.prisma.adminUser.findUnique({
where: { id: adminId },
include: { userType: { include: { permissions: true } } },
});
if (!user) {
throw new UnauthorizedException();
}
return {
id: user.id,
name: user.fullName,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
is_super_admin: isSuperAdmin(user.mobile),
permission: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)),
};
}
}
function mobileVariants(raw: string): string[] {
const digits = raw.replace(/\D/g, '');
const variants = new Set<string>([raw.trim(), digits]);
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
return [...variants].filter((value) => value !== '');
}
@@ -0,0 +1,18 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* Mirrors the legacy contract in AdminController::login:
* $this->validate($request, ['phone' => 'required', 'password' => 'required']);
*
* The field is named `phone` (not `mobile`) even though it is matched against
* `users.mobile` — kept as-is so the ported login form posts an unchanged body.
*/
export class LoginDto {
@IsString()
@IsNotEmpty()
phone!: string;
@IsString()
@IsNotEmpty()
password!: string;
}
+85
View File
@@ -0,0 +1,85 @@
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard, RequirePermission, SuperAdminOnly } from './guards';
import type { AuthenticatedAdmin } from './jwt.strategy';
import { SUPER_ADMIN_MOBILE, withSuperAdminRids } from './super-admin';
class Routes {
@SuperAdminOnly()
@RequirePermission('config.wallet')
superOnly() {}
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('order.list')
superOrBranch() {}
@RequirePermission('order.list', 'order.omde', 'order.tamin')
anyOf() {}
open() {}
}
function admin(over: Partial<AuthenticatedAdmin> = {}): AuthenticatedAdmin {
return {
id: 'a1',
fullName: 'x',
mobile: '09120000000',
role: 'SUPER_ADMIN',
userTypeId: 85,
branchId: null,
permissions: ['config.wallet', 'order.list', 'order.omde'],
...over,
};
}
function run(handler: keyof Routes, user: AuthenticatedAdmin) {
const ctx = {
getHandler: () => Routes.prototype[handler],
getClass: () => Routes,
switchToHttp: () => ({ getRequest: () => ({ user }) }),
} as unknown as ExecutionContext;
return new PermissionsGuard(new Reflector()).canActivate(ctx);
}
describe('PermissionsGuard — super-admin-only routes', () => {
it('admits the super admin', () => {
expect(run('superOnly', admin({ mobile: SUPER_ADMIN_MOBILE }))).toBe(true);
});
it('refuses any other admin, even one holding the rid and the SUPER_ADMIN role', () => {
expect(() => run('superOnly', admin())).toThrow(ForbiddenException);
});
it('refuses a branch admin unless the route allows branches', () => {
expect(() => run('superOnly', admin({ branchId: 'b1' }))).toThrow(ForbiddenException);
expect(run('superOrBranch', admin({ branchId: 'b1' }))).toBe(true);
expect(() => run('superOrBranch', admin())).toThrow(ForbiddenException);
});
it('still requires the rid from a branch admin', () => {
expect(() => run('superOrBranch', admin({ branchId: 'b1', permissions: [] }))).toThrow(
ForbiddenException,
);
});
it('leaves routes without the decorator alone', () => {
expect(run('open', admin({ permissions: [] }))).toBe(true);
});
});
describe('PermissionsGuard — several rids', () => {
it('admits a holder of any one of them', () => {
expect(run('anyOf', admin({ permissions: ['order.tamin'] }))).toBe(true);
});
it('refuses a holder of none', () => {
expect(() => run('anyOf', admin({ permissions: ['product.list'] }))).toThrow(ForbiddenException);
});
});
describe('withSuperAdminRids', () => {
it('adds the super-admin rids for the super admin only', () => {
expect(withSuperAdminRids(SUPER_ADMIN_MOBILE, [])).toContain('config.wallet');
expect(withSuperAdminRids('09120000000', ['product.list'])).toEqual(['product.list']);
});
});
+97
View File
@@ -0,0 +1,97 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
createParamDecorator,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedAdmin } from './jwt.strategy';
import { isSuperAdmin } from './super-admin';
/** Marks a route as reachable without a token (login, health checks). */
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
/**
* Requires the caller to hold this rid (ancestors count — see expandWithParents).
* With several rids, holding any one of them is enough.
*/
export const REQUIRED_RID_KEY = 'requiredRid';
export const RequirePermission = (...rids: [string, ...string[]]) =>
SetMetadata(REQUIRED_RID_KEY, rids);
/**
* Restricts a route (or a whole controller) to the super admin — see
* super-admin.ts. `allowBranch` also admits an admin bound to a branch, because
* the branch panel serves its staff from the same retail endpoints.
*/
export const SUPER_ADMIN_ONLY_KEY = 'superAdminOnly';
export interface SuperAdminOnlyOptions {
allowBranch?: boolean;
}
export const SuperAdminOnly = (options: SuperAdminOnlyOptions = {}) =>
SetMetadata(SUPER_ADMIN_ONLY_KEY, options);
/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */
export const CurrentAdmin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthenticatedAdmin =>
ctx.switchToHttp().getRequest().user,
);
/**
* JWT guard that honours @Public().
*
* Registered globally in AuthModule, so every route is authenticated by default
* and opting out is explicit. The legacy app had the inverse default — its router
* guard skipped a hardcoded `noneAuthUrl` list — which meant any route added
* without a `meta.role` was silently unprotected.
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
/** Enforces @SuperAdminOnly() and @RequirePermission(rid) against the expanded permission list. */
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const targets = [context.getHandler(), context.getClass()];
const superAdminOnly = this.reflector.getAllAndOverride<SuperAdminOnlyOptions | undefined>(
SUPER_ADMIN_ONLY_KEY,
targets,
);
const requiredRids = this.reflector.getAllAndOverride<string[] | undefined>(REQUIRED_RID_KEY, targets);
if (!superAdminOnly && !requiredRids) return true;
const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
if (!user) throw new ForbiddenException();
if (superAdminOnly && !isSuperAdmin(user.mobile)) {
if (!(superAdminOnly.allowBranch && user.branchId)) {
throw new ForbiddenException('این بخش فقط در دسترس مدیر ارشد سامانه است');
}
}
if (!requiredRids) return true;
if (!requiredRids.some((rid) => user.permissions.includes(rid))) {
throw new ForbiddenException(`Missing permission: ${requiredRids.join(' | ')}`);
}
return true;
}
}
+105
View File
@@ -0,0 +1,105 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
import { envOr } from '../../common/env';
import { AdminSessionService } from '../sessions/admin-session.service';
import { expandWithParents } from './acl.constants';
import { withSuperAdminRids } from './super-admin';
export interface JwtPayload {
sub: string;
mobile: string;
/**
* Present only on tokens issued while session tracking is on. It identifies the
* AdminSession row, which is what makes revoking a token before it expires
* possible. A token without it predates tracking and stays valid until it
* expires, so switching the feature on does not sign everyone out.
*/
jti?: string;
/**
* Set on APP tokens only ('customer' or 'sales-expert'). An admin-panel
* token has no `typ`. Checked in validate() below — see the note there.
*/
typ?: string;
}
/**
* App token types, which must NEVER authenticate an admin-panel route.
*
* This matters because a sales expert's app token carries their AdminUser id
* in `sub` (D21). The two token families are signed with different secrets, so
* normally the signature alone stops it — but `JWT_CUSTOMER_SECRET` falls back
* to `JWT_SECRET` when unset, and in that configuration an expert's app token
* WOULD verify here and be handed full admin-panel permissions. This check
* closes that regardless of how the secrets are configured.
*/
const APP_TOKEN_TYPES = new Set(['customer', 'sales-expert']);
/**
* The authenticated principal attached to `req.user`.
*
* `permissions` is already expanded with ancestors, so guards can test a rid with
* a plain `includes()` — the same shape the legacy frontend held in its Vuex store
* after checkParent().
*/
export interface AuthenticatedAdmin {
id: string;
fullName: string;
mobile: string;
role: string;
userTypeId: number | null;
branchId: string | null;
permissions: string[];
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly prisma: PrismaService,
private readonly sessions: AdminSessionService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
});
}
async validate(payload: JwtPayload): Promise<AuthenticatedAdmin> {
// An app token is never an admin-panel token, whatever it is signed with.
if (payload?.typ && APP_TOKEN_TYPES.has(payload.typ)) {
throw new UnauthorizedException();
}
// Revoked centrally (IAM back-channel logout) or locally? While tracking is
// off this returns true without touching the database, so the request costs
// exactly what it did before.
if (!(await this.sessions.isActive(payload.jti))) {
throw new UnauthorizedException();
}
const user = await this.prisma.adminUser.findUnique({
where: { id: payload.sub },
include: { userType: { include: { permissions: true } } },
});
if (!user || !user.isActive) {
throw new UnauthorizedException();
}
// Permissions are re-read on every request rather than baked into the token,
// so a permission change takes effect immediately instead of at next login.
const rids = withSuperAdminRids(user.mobile, user.userType?.permissions.map((p) => p.rid) ?? []);
return {
id: user.id,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
userTypeId: user.userTypeId,
branchId: user.branchId,
permissions: expandWithParents(rids),
};
}
}
@@ -0,0 +1,40 @@
import * as bcrypt from 'bcryptjs';
/**
* Value written into `passwordHash` for accounts that must never log in with a
* password — the ~1,590 legacy customers who only ever used OTP
* (docs/DATA-MIGRATION-MAP.md §10.4). The `!` prefix is the Django/Rails
* "unusable password" convention: readable by anyone looking at the row, and
* matched by no password.
*
* Kept in this dependency-free module (bcryptjs only) so the data-import
* script can use it without loading NestJS.
*/
export const OTP_ONLY_PASSWORD_HASH = '!OTP_ONLY_NO_PASSWORD_LOGIN';
/**
* Same idea for an admin account created before anyone has issued it a
* password (e.g. a workshop supervisor added from the branch form): it cannot
* sign in until a password is set through the users screen.
*/
export const NO_PASSWORD_YET_HASH = '!NO_PASSWORD_SET_YET';
/**
* A real bcrypt hash always starts with `$2` ($2a$, $2b$, or PHP's $2y$ from
* the legacy server — all three verify with bcryptjs). Anything else is not a
* password: '' on accounts created without one, or OTP_ONLY_PASSWORD_HASH.
*
* Such hashes are refused here, BEFORE bcrypt is called, so the outcome never
* depends on how a particular bcrypt build treats a malformed hash. bcryptjs 3
* happens to return false today; a future version or a native binding might
* throw and turn a wrong password into a 500 (owner ruling 2026-10-07).
*/
export async function compareLocalHash(password: string, passwordHash: string | null | undefined) {
if (typeof passwordHash !== 'string' || !passwordHash.startsWith('$2')) return false;
return bcrypt.compare(password, passwordHash);
}
/** True when a stored legacy hash can be carried over as a working password. */
export function isPasswordHash(hash: unknown): hash is string {
return typeof hash === 'string' && hash.startsWith('$2');
}
+48
View File
@@ -0,0 +1,48 @@
/**
* The one account allowed to see the super-admin-only features (retail
* customers and orders, bankers, walk-in shopkeepers, branches and workshops,
* retail promotions, wallet management, trays). Matched on mobile, not on
* AdminRole.SUPER_ADMIN: that role can be assigned to any admin from the
* users form, this account cannot.
*/
export const SUPER_ADMIN_MOBILE = '09128081392';
/**
* Rids of those features. The admin panel no longer offers them on the
* permission screen (except to the super admin), so the super admin is granted
* them here whatever its user type holds.
*/
export const SUPER_ADMIN_RIDS = [
'persons.create.single',
'persons.list.client.single',
'persons.list.client.single.view',
'persons.list.client.single.edit',
'branch',
'branch.create',
'branch.list',
'workshop',
'workshop.create',
'workshop.list',
'order.create',
'order.list',
'order.list.view',
'order.list.sts',
'order.list.sts.view',
'order.list.sts.remove',
'order.list.sts.submit',
'order.list.view.edit',
'order.list.pdf',
'offers.create.tak',
'offers.list.tak.edit',
'offers.list.tak.remove',
'config.wallet',
] as const;
export function isSuperAdmin(mobile: string | null | undefined): boolean {
return mobile?.trim() === SUPER_ADMIN_MOBILE;
}
/** The raw rids an admin holds, plus SUPER_ADMIN_RIDS for the super admin. */
export function withSuperAdminRids(mobile: string | null | undefined, rids: string[]): string[] {
return isSuperAdmin(mobile) ? [...new Set([...rids, ...SUPER_ADMIN_RIDS])] : rids;
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { CatalogCacheService } from './catalog-cache.service';
/**
* Global so the accounting module can invalidate the storefront catalogue
* without importing CatalogModule (which itself depends on the accounting
* module).
*/
@Global()
@Module({
providers: [CatalogCacheService],
exports: [CatalogCacheService],
})
export class CatalogCacheModule {}
@@ -0,0 +1,97 @@
import { CATALOG_CACHE_MS, CatalogCacheService } from './catalog-cache.service';
type Middleware = (params: Record<string, unknown>, next: (p: unknown) => Promise<unknown>) => Promise<unknown>;
function setup() {
let middleware: Middleware | undefined;
const prisma = { $use: jest.fn((fn: Middleware) => (middleware = fn)) };
const cache = new CatalogCacheService(prisma as never);
cache.onModuleInit();
const runQuery = (params: Record<string, unknown>) => middleware!(params, async () => 'ok');
return { cache, runQuery };
}
describe('CatalogCacheService', () => {
afterEach(() => jest.useRealTimers());
it('serves a fresh copy without rebuilding', async () => {
const { cache } = setup();
const build = jest.fn().mockResolvedValue(['a']);
expect(await cache.get(build)).toEqual(['a']);
expect(await cache.get(build)).toEqual(['a']);
expect(build).toHaveBeenCalledTimes(1);
});
it('rebuilds once the TTL has passed', async () => {
jest.useFakeTimers({ now: 1_000_000 });
const { cache } = setup();
const build = jest.fn().mockResolvedValueOnce(['old']).mockResolvedValueOnce(['new']);
await cache.get(build);
jest.setSystemTime(1_000_000 + CATALOG_CACHE_MS);
expect(await cache.get(build)).toEqual(['new']);
expect(build).toHaveBeenCalledTimes(2);
});
it('shares one build between concurrent callers', async () => {
const { cache } = setup();
let resolve!: (v: string[]) => void;
const build = jest.fn(() => new Promise<string[]>((r) => (resolve = r)));
const a = cache.get(build);
const b = cache.get(build);
resolve(['x']);
expect(await a).toEqual(['x']);
expect(await b).toEqual(['x']);
expect(build).toHaveBeenCalledTimes(1);
});
it('invalidate() forces the next read to rebuild', async () => {
const { cache } = setup();
const build = jest.fn().mockResolvedValueOnce(['old']).mockResolvedValueOnce(['new']);
await cache.get(build);
cache.invalidate();
expect(await cache.get(build)).toEqual(['new']);
});
it('does not keep a build that started before an invalidation', async () => {
const { cache } = setup();
let resolve!: (v: string[]) => void;
const slow = jest.fn(() => new Promise<string[]>((r) => (resolve = r)));
const pending = cache.get(slow);
cache.invalidate();
resolve(['stale']);
await pending;
const fresh = jest.fn().mockResolvedValue(['fresh']);
expect(await cache.get(fresh)).toEqual(['fresh']);
});
it('invalidates on a write to a catalogue model', async () => {
const { cache, runQuery } = setup();
const build = jest.fn().mockResolvedValue(['x']);
await cache.get(build);
await runQuery({ model: 'ProductVariant', action: 'updateMany', runInTransaction: false });
await cache.get(build);
expect(build).toHaveBeenCalledTimes(2);
});
it('ignores reads and writes to other models', async () => {
const { cache, runQuery } = setup();
const build = jest.fn().mockResolvedValue(['x']);
await cache.get(build);
await runQuery({ model: 'Product', action: 'findMany', runInTransaction: false });
await runQuery({ model: 'Setting', action: 'upsert', runInTransaction: false });
await cache.get(build);
expect(build).toHaveBeenCalledTimes(1);
});
it('invalidates again after a transactional write has had time to commit', async () => {
jest.useFakeTimers();
const { cache, runQuery } = setup();
await runQuery({ model: 'Product', action: 'update', runInTransaction: true });
// Rebuilt from the pre-commit state before the transaction finished.
const build = jest.fn().mockResolvedValue(['x']);
await cache.get(build);
jest.advanceTimersByTime(2_000);
await cache.get(build);
expect(build).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,83 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
/** How long a built storefront catalogue is served before it is rebuilt. */
export const CATALOG_CACHE_MS = 30_000;
/** Models whose rows appear in GET /catalog/products. */
const CATALOG_MODELS = new Set(['Product', 'ProductVariant', 'ProductImage', 'Category', 'Brand', 'Collection', 'CollectionProduct']);
const WRITE_ACTIONS = new Set([
'create',
'createMany',
'createManyAndReturn',
'update',
'updateMany',
'upsert',
'delete',
'deleteMany',
]);
/**
* A write inside an interactive transaction is not visible until the commit,
* which the middleware cannot see. A second invalidation this long after the
* write drops anything rebuilt from the pre-commit state in between.
*/
const TX_SETTLE_MS = 2_000;
/**
* In-memory copy of the storefront catalogue (GET /catalog/products).
*
* Building it is one wide findMany plus a stock check per product, and every
* app screen used to ask for it. Any Prisma write to a catalogue model — admin
* product/category/brand/collection screens, the accounting product and stock
* pull, wage changes — invalidates it through the middleware below, wherever
* the write comes from. Accounting settings (which decide the stock rule) are
* not a catalogue model; AccountingConfigService invalidates on save.
*
* Per process: correct while the API runs as a single instance. With several
* instances this moves to a shared store (Redis) behind the same two methods.
*/
@Injectable()
export class CatalogCacheService implements OnModuleInit {
private value: { at: number; data: unknown } | null = null;
private inflight: Promise<unknown> | null = null;
/** Bumped by invalidate(), so a build started before it is never stored. */
private generation = 0;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
this.prisma.$use(async (params, next) => {
const result = await next(params);
if (params.model && CATALOG_MODELS.has(params.model) && WRITE_ACTIONS.has(params.action)) {
this.invalidate();
if (params.runInTransaction) setTimeout(() => this.invalidate(), TX_SETTLE_MS).unref?.();
}
return result;
});
}
async get<T>(build: () => Promise<T>): Promise<T> {
if (this.value && Date.now() - this.value.at < CATALOG_CACHE_MS) return this.value.data as T;
if (this.inflight) return this.inflight as Promise<T>;
const gen = this.generation;
const p = build()
.then((data) => {
if (gen === this.generation) this.value = { at: Date.now(), data };
return data;
})
.finally(() => {
if (this.inflight === p) this.inflight = null;
});
this.inflight = p;
return p;
}
invalidate() {
this.generation++;
this.value = null;
this.inflight = null;
}
}

Some files were not shown because too many files have changed in this diff Show More