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; } export interface AccountingState { jobs: Partial>; } /** 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 { 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): 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 | 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 { 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 { const a = await this.active(); return a && a.config.features[feature] ? a : null; } // ── job bookkeeping (shown on the admin screen) ─────────────────────── async getState(): Promise { const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_STATE_KEY } }); const v = (row?.value ?? {}) as Partial; 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 }, }); } }