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>; listCustomerGroups(s: ProviderSettings): Promise; /** 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; 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; /** * 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; getStatement(s: ProviderSettings, q: StatementQuery): Promise; listKarSakhteGroups(s: ProviderSettings): Promise; listKarSakhteTypes(s: ProviderSettings): Promise; listKarSakhte(s: ProviderSettings): Promise; getKarSakhteStock(s: ProviderSettings, item: { code: string; name: string }): Promise; getKarSakhteMovement(s: ProviderSettings, from: JalaliDate, to: JalaliDate): Promise; 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, ): Promise; /** True when a document with this key exists. */ documentExists(s: ProviderSettings, doc: { factorCode: string; ref: string | null }): Promise; 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(' ') }; }