Files
tehran_taj/apps/api/src/modules/accounting-sync/accounting-sync.service.ts
T
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

950 lines
44 KiB
TypeScript

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<string, { at: number; rows: StatementRow[] }>();
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<string, ExternalCustomer[]>();
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<string> {
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<ExternalCustomer>;
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<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(
product: T,
): Promise<T> {
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<string>();
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<string, unknown>,
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<AccountingConfig> {
return this.config.get();
}
}