import { Injectable, Logger } from '@nestjs/common'; import { AccountingOutbox, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; 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(); 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(); 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 the mobile — the fallback admin-customers and customer-auth already use. * Credit limit 0 and UNVERIFIED: everything beyond identity is the app's. */ private async importCustomer(provider: ProviderName, ext: ExternalCustomer): Promise { const mobile = ext.mobile as string; const candidates = [validNationalCode(ext.nationalCode), mobile, `${mobile}-${provider}-${ext.code}`].filter( (v): v is string => !!v, ); let nationalCode = candidates[candidates.length - 1]; for (const c of candidates) { const taken = await this.prisma.customer.findUnique({ where: { nationalCode: c }, select: { id: true } }); if (!taken) { nationalCode = c; break; } } 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: '', 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( c: { firstName: string | null; lastName: string | null; fullName: string; mobile: string; phone: string | null; address: string | null; nationalCode: string }, 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; 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( product: T, ): Promise { 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(); 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, 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 { return this.config.get(); } }