Files
s.esmaeiliandClaude Opus 4.6 19d490e790 feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 16:48:36 +03:30

512 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}