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; 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; 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; }