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>
This commit is contained in:
s.esmaeili
2026-10-06 16:48:36 +03:30
co-authored by Claude Opus 4.6
parent a72fa607a5
commit 19d490e790
179 changed files with 12538 additions and 370 deletions
+3
View File
@@ -9,6 +9,7 @@ import { CustomerReportModule } from './modules/customer-report/customer-report.
import { SmsModule } from './modules/sms/sms.module';
import { OrdersModule } from './modules/orders/orders.module';
import { AccountingModule } from './modules/accounting/accounting.module';
import { AccountingSyncModule } from './modules/accounting-sync/accounting-sync.module';
import { PricingModule } from './modules/pricing/pricing.module';
import { AuthModule } from './modules/auth/auth.module';
import { IamModule } from './modules/iam/iam.module';
@@ -66,6 +67,8 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
SalesAgentModule,
OrdersModule,
AccountingModule,
// Tahesab / Zarrin integration (D27) — inert until switched on in the admin panel
AccountingSyncModule,
PricingModule,
],
})
@@ -0,0 +1,107 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { AccountingAdminService } from './accounting-admin.service';
/**
* «اتصال حسابداری» (D27) — every route behind `config.accounting`. Nothing
* here ever returns a token: the configuration comes back with tokens reduced
* to `tokenSet` / `tokenReadable` flags.
*/
@Controller('admin/accounting')
@RequirePermission('config.accounting')
export class AccountingAdminController {
constructor(private readonly admin: AccountingAdminService) {}
@Get()
overview() {
return this.admin.overview();
}
@Put('config')
save(@Body() body: Record<string, unknown>) {
return this.admin.save(body);
}
@Post('test')
@HttpCode(200)
test(@Body() body: { provider?: string; connection?: Record<string, unknown> }) {
return this.admin.test(body?.provider, body?.connection);
}
@Post('certificate')
@HttpCode(200)
certificate(@Body() body: { baseUrl?: string }) {
return this.admin.certificate(body?.baseUrl);
}
@Get('options')
options(@Query('provider') provider?: string) {
return this.admin.options(provider);
}
@Post('run/:job')
@HttpCode(200)
run(@Param('job') job: string) {
return this.admin.run(job);
}
@Get('outbox')
outbox(@Query() q: Record<string, unknown>) {
return this.admin.outbox(q);
}
@Post('outbox/:id/retry')
@HttpCode(200)
retry(@Param('id') id: string) {
return this.admin.retry(id);
}
@Post('outbox/:id/resolve')
@HttpCode(200)
resolve(@Param('id') id: string, @CurrentAdmin() me: AuthenticatedAdmin, @Body() body: { note?: string }) {
return this.admin.resolve(id, me.id, body?.note);
}
@Get('issues')
issues(@Query() q: Record<string, unknown>) {
return this.admin.issues(q);
}
@Post('issues/:id/resolve')
@HttpCode(200)
resolveIssue(@Param('id') id: string) {
return this.admin.resolveIssue(id);
}
@Get('links')
links(@Query() q: Record<string, unknown>) {
return this.admin.links(q);
}
@Post('links')
@HttpCode(200)
link(@Body() body: Record<string, unknown>) {
return this.admin.manualLink(body);
}
@Delete('links/:id')
unlink(@Param('id') id: string) {
return this.admin.unlink(id);
}
@Get('customers/:id')
customer(@Param('id') id: string) {
return this.admin.customer(id);
}
@Get('products/status')
productsStatus(@Query('ids') ids: string) {
return this.admin.productsStatus(ids);
}
@Get('movement')
movement(@Query() q: Record<string, unknown>) {
return this.admin.movement(q);
}
}
@@ -0,0 +1,366 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AccountingEntityType, AccountingOutboxStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { cleanUrl } from './accounting-config';
import { AccountingConfigService, JobName } from './accounting-config.service';
import { fetchServerCertificate } from './accounting-http';
import { AccountingSyncService } from './accounting-sync.service';
import { AccountingWorkerService, PULL_FEATURE } from './accounting-worker.service';
import { parseJalali, toJalali } from './jalali';
import { ACCOUNTING_PROVIDERS, ProviderName, isProviderName } from './providers';
const JOBS: JobName[] = ['customers', 'balances', 'products', 'outbox'];
const STATUSES: AccountingOutboxStatus[] = ['PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED'];
function page(v: unknown) {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function limit(v: unknown, max = 100) {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? Math.min(n, max) : 25;
}
/** Backs the «اتصال حسابداری» admin screen (plan §5). */
@Injectable()
export class AccountingAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly config: AccountingConfigService,
private readonly sync: AccountingSyncService,
private readonly worker: AccountingWorkerService,
) {}
async overview() {
const [config, state, counts, openIssues, raw] = await Promise.all([
this.config.getPublic(),
this.config.getState(),
this.prisma.accountingOutbox.groupBy({ by: ['status'], _count: { _all: true } }),
this.prisma.accountingIssue.count({ where: { resolvedAt: null } }),
this.config.get(),
]);
return {
config,
inactiveReason: this.config.inactiveReason(raw),
state,
busy: this.worker.busy,
realtime: this.worker.realtime,
outbox: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
openIssues,
};
}
async save(body: Record<string, unknown>) {
const { providerChanged } = await this.config.save(body);
return { ...(await this.overview()), providerChanged };
}
private async settingsOf(name: unknown) {
const config = await this.config.get();
const provider = isProviderName(name) ? name : config.provider;
if (provider === 'none') throw new BadRequestException('نرم‌افزار حسابداری انتخاب نشده است');
const settings = this.config.settingsFor(config, provider);
if (!settings) throw new BadRequestException('آدرس یا توکن این نرم‌افزار تنظیم نشده (یا توکن قابل‌خواندن نیست)');
return { provider: ACCOUNTING_PROVIDERS[provider], settings, config };
}
/** For the sync buttons on the lists: connected or not, nothing more. */
async connectionStatus() {
const config = await this.config.get();
const reason = this.config.inactiveReason(config);
return {
connected: !reason,
provider: reason || config.provider === 'none' ? null : ACCOUNTING_PROVIDERS[config.provider].label,
};
}
/** The list-page sync buttons (plan §12): run now, wait, report. */
async syncNow(jobs: ('customers' | 'balances' | 'products')[]) {
const reason = this.config.inactiveReason(await this.config.get());
if (reason) throw new BadRequestException(`سینک با حسابداری انجام نمی‌شود: ${reason}`);
const results = await this.worker.runPullsNow(jobs);
if (!results) throw new BadRequestException('یک همگام‌سازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید');
return { ok: Object.values(results).every((r) => r.ok), results };
}
/** Tests the connection as typed in the form (`connection`), falling back to what is saved. */
async test(name: unknown, connection?: Record<string, unknown>) {
if (!isProviderName(name)) throw new BadRequestException('نرم‌افزار حسابداری نامعتبر است');
const provider = ACCOUNTING_PROVIDERS[name];
const settings = this.config.draftSettings(await this.config.get(), name, connection);
if (!settings) throw new BadRequestException('آدرس و توکن را وارد کنید');
const started = Date.now();
try {
const info = await provider.health(settings);
return { ok: true, provider: provider.label, ms: Date.now() - started, info };
} catch (err) {
return { ok: false, provider: provider.label, ms: Date.now() - started, error: this.sync.describeError(err) };
}
}
/**
* The certificate the server at `baseUrl` presents, for the admin to compare
* and pin (a self-signed Tahesab install cannot be verified otherwise).
*/
async certificate(baseUrl: unknown) {
const url = cleanUrl(baseUrl, 'آدرس');
if (!url) throw new BadRequestException('آدرس را وارد کنید');
try {
return await fetchServerCertificate(url, 10_000);
} catch (err) {
throw new BadRequestException(`گواهی دریافت نشد: ${this.sync.describeError(err)}`);
}
}
/** Dropdown options for the mapping tab; each list fails on its own. */
async options(name: unknown) {
const { provider, settings } = await this.settingsOf(name);
const safe = async <T>(fn: () => Promise<T>) => {
try {
return { ok: true as const, items: await fn() };
} catch (err) {
return { ok: false as const, items: [] as never[], error: this.sync.describeError(err) };
}
};
const [customerGroups, karSakhteGroups, karSakhteTypes] = await Promise.all([
safe(() => provider.listCustomerGroups(settings)),
safe(() => provider.listKarSakhteGroups(settings)),
safe(() => provider.listKarSakhteTypes(settings)),
]);
const categories = await this.prisma.category.findMany({ select: { id: true, title: true }, orderBy: { title: 'asc' } });
return { provider: provider.name, customerGroups, karSakhteGroups, karSakhteTypes, categories };
}
async run(job: string) {
if (!JOBS.includes(job as JobName)) throw new BadRequestException('کار نامعتبر است');
const config = await this.config.get();
const reason = this.config.inactiveReason(config);
if (reason) throw new BadRequestException(`همگام‌سازی انجام نمی‌شود: ${reason}`);
if (job !== 'outbox' && !config.features[PULL_FEATURE[job as Exclude<JobName, 'outbox'>]]) {
throw new BadRequestException('این قابلیت در تب «قابلیت‌ها و زمان‌بندی» خاموش است؛ اول روشن و ذخیره کنید');
}
const started = this.worker.runNow(job as JobName);
return { started, message: started ? 'شروع شد' : 'یک همگام‌سازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید' };
}
// ── outbox log ──────────────────────────────────────────────────────
async outbox(q: Record<string, unknown>) {
const where: Prisma.AccountingOutboxWhereInput = {};
if (typeof q.status === 'string' && STATUSES.includes(q.status as AccountingOutboxStatus)) {
where.status = q.status as AccountingOutboxStatus;
}
if (typeof q.kind === 'string' && q.kind) where.kind = q.kind as Prisma.AccountingOutboxWhereInput['kind'];
if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId;
const p = page(q.page);
const l = limit(q.limit);
const [rows, total] = await Promise.all([
this.prisma.accountingOutbox.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }),
this.prisma.accountingOutbox.count({ where }),
]);
const labels = await this.labelsFor(rows.map((r) => ({ kind: r.kind, entityId: r.entityId })));
return { rows: rows.map((r) => ({ ...r, entityLabel: labels.get(r.entityId) ?? null })), total, page: p, limit: l };
}
private async labelsFor(items: { kind: string; entityId: string }[]) {
const orderIds = items.filter((i) => i.kind.startsWith('ORDER')).map((i) => i.entityId);
const customerIds = items.filter((i) => i.kind === 'CUSTOMER_PUSH').map((i) => i.entityId);
const productIds = items.filter((i) => i.kind === 'PRODUCT_PUSH').map((i) => i.entityId);
const [orders, customers, products] = await Promise.all([
orderIds.length ? this.prisma.order.findMany({ where: { id: { in: orderIds } }, select: { id: true, orderNumber: true, customer: { select: { fullName: true } } } }) : [],
customerIds.length ? this.prisma.customer.findMany({ where: { id: { in: customerIds } }, select: { id: true, fullName: true, mobile: true } }) : [],
productIds.length ? this.prisma.product.findMany({ where: { id: { in: productIds } }, select: { id: true, title: true } }) : [],
]);
const m = new Map<string, string>();
for (const o of orders) m.set(o.id, `سفارش ${o.orderNumber} — ${o.customer.fullName}`);
for (const c of customers) m.set(c.id, `${c.fullName} (${c.mobile})`);
for (const p of products) m.set(p.id, p.title);
return m;
}
async retry(id: string) {
const row = await this.prisma.accountingOutbox.findUnique({ where: { id } });
if (!row) throw new NotFoundException('ردیف پیدا نشد');
if (row.status === 'SENT' || row.status === 'SENDING') throw new BadRequestException('این ردیف قابل تلاش دوباره نیست');
return this.prisma.accountingOutbox.update({
where: { id },
data: { status: 'PENDING', nextAttemptAt: new Date(), attempts: row.status === 'PENDING' ? row.attempts : 0 },
});
}
async resolve(id: string, adminId: string, note?: string) {
const row = await this.prisma.accountingOutbox.findUnique({ where: { id } });
if (!row) throw new NotFoundException('ردیف پیدا نشد');
if (row.status === 'SENDING') throw new BadRequestException('در حال ارسال است');
return this.prisma.accountingOutbox.update({
where: { id },
data: { status: 'RESOLVED', resolvedById: adminId, lastError: note ? `حل‌شده: ${note}` : row.lastError },
});
}
// ── issues ──────────────────────────────────────────────────────────
async issues(q: Record<string, unknown>) {
const where: Prisma.AccountingIssueWhereInput = q.resolved === '1' ? { resolvedAt: { not: null } } : { resolvedAt: null };
const p = page(q.page);
const l = limit(q.limit);
const [rows, total] = await Promise.all([
this.prisma.accountingIssue.findMany({ where, orderBy: { updatedAt: 'desc' }, skip: (p - 1) * l, take: l }),
this.prisma.accountingIssue.count({ where }),
]);
return { rows, total, page: p, limit: l };
}
resolveIssue(id: string) {
return this.prisma.accountingIssue.update({ where: { id }, data: { resolvedAt: new Date() } });
}
// ── links ───────────────────────────────────────────────────────────
async links(q: Record<string, unknown>) {
const config = await this.config.get();
const where: Prisma.AccountingLinkWhereInput = {};
if (q.entityType === 'CUSTOMER' || q.entityType === 'PRODUCT') where.entityType = q.entityType;
where.provider = isProviderName(q.provider) ? q.provider : config.provider === 'none' ? undefined : config.provider;
if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId;
if (typeof q.q === 'string' && q.q.trim()) {
where.OR = [{ externalCode: { contains: q.q.trim() } }, { externalName: { contains: q.q.trim() } }];
}
const p = page(q.page);
const l = limit(q.limit);
const [rows, total] = await Promise.all([
this.prisma.accountingLink.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }),
this.prisma.accountingLink.count({ where }),
]);
return { rows, total, page: p, limit: l };
}
async manualLink(body: Record<string, unknown>) {
const config = await this.config.get();
if (config.provider === 'none') throw new BadRequestException('نرم‌افزار حسابداری انتخاب نشده است');
const provider = config.provider as ProviderName;
const entityType = body.entityType === 'PRODUCT' ? AccountingEntityType.PRODUCT : AccountingEntityType.CUSTOMER;
const entityId = String(body.entityId ?? '').trim();
const externalCode = String(body.externalCode ?? '').trim();
if (!entityId || !externalCode) throw new BadRequestException('شناسه و کد حسابداری الزامی است');
const exists =
entityType === 'CUSTOMER'
? await this.prisma.customer.findUnique({ where: { id: entityId }, select: { id: true } })
: await this.prisma.product.findUnique({ where: { id: entityId }, select: { id: true } });
if (!exists) throw new NotFoundException('ردیف اپ پیدا نشد');
const taken = await this.prisma.accountingLink.findUnique({
where: { provider_entityType_externalCode: { provider, entityType, externalCode } },
});
if (taken && taken.entityId !== entityId) throw new BadRequestException('این کد به ردیف دیگری لینک است');
return this.prisma.accountingLink.upsert({
where: { provider_entityType_entityId: { provider, entityType, entityId } },
update: { externalCode, origin: 'MANUAL', syncedAt: new Date() },
create: { provider, entityType, entityId, externalCode, origin: 'MANUAL', externalName: (body.externalName as string) || null },
});
}
async unlink(id: string) {
await this.prisma.accountingLink.delete({ where: { id } }).catch(() => {
throw new NotFoundException('لینک پیدا نشد');
});
return { success: true };
}
// ── per-row panels ──────────────────────────────────────────────────
async customer(id: string) {
const config = await this.config.get();
const c = await this.prisma.customer.findUnique({
where: { id },
select: {
id: true,
goldDebtGrams: true,
fiatDebtRial: true,
accountingGoldBalance: true,
accountingRialBalance: true,
accountingBalanceAt: true,
},
});
if (!c) throw new NotFoundException('مشتری یافت نشد');
const link =
config.provider === 'none'
? null
: await this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: config.provider, entityType: 'CUSTOMER', entityId: id } },
});
const fresh = link ? await this.sync.customerBalance(id) : null;
const jobs = await this.prisma.accountingOutbox.findMany({
where: { entityId: id, kind: 'CUSTOMER_PUSH' },
orderBy: { createdAt: 'desc' },
take: 5,
});
return {
provider: config.provider,
link,
internal: { gold: c.goldDebtGrams, rial: c.fiatDebtRial },
accounting: fresh
? { gold: fresh.gold, rial: fresh.rial, at: fresh.at }
: c.accountingBalanceAt
? { gold: c.accountingGoldBalance, rial: c.accountingRialBalance, at: c.accountingBalanceAt }
: null,
jobs,
};
}
async productsStatus(idsCsv: string) {
const ids = String(idsCsv ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 500);
const config = await this.config.get();
if (ids.length === 0) return {};
const [products, links] = await Promise.all([
this.prisma.product.findMany({
where: { id: { in: ids } },
select: {
id: true,
accountingStockWeight: true,
accountingStockCount: true,
accountingStockAt: true,
_count: { select: { images: true } },
},
}),
config.provider === 'none'
? []
: this.prisma.accountingLink.findMany({ where: { provider: config.provider, entityType: 'PRODUCT', entityId: { in: ids } } }),
]);
const linkBy = new Map(links.map((l) => [l.entityId, l]));
return Object.fromEntries(
products.map((p) => {
const l = linkBy.get(p.id);
return [
p.id,
{
linked: !!l,
locked: !!l && config.features.products,
code: l?.externalCode ?? null,
origin: l?.origin ?? null,
stockWeight: p.accountingStockWeight,
stockCount: p.accountingStockCount,
stockAt: p.accountingStockAt,
hasImage: p._count.images > 0,
},
];
}),
);
}
async movement(q: Record<string, unknown>) {
const a = await this.config.active();
if (!a) throw new BadRequestException('اتصال حسابداری فعال نیست');
const to = parseJalali(q.to as string) ?? toJalali(new Date());
const from = parseJalali(q.from as string) ?? toJalali(new Date(Date.now() - 30 * 24 * 3600 * 1000));
const rows = await a.provider.getKarSakhteMovement(a.settings, from, to);
return { provider: a.provider.label, scope: a.provider.capabilities.movementScope, rows };
}
}
@@ -0,0 +1,298 @@
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import {
ACCOUNTING_CONFIG_KEY,
ACCOUNTING_STATE_KEY,
AccountingConfig,
StoredConnection,
cleanBool,
cleanCa,
cleanGroup,
cleanGroups,
cleanInt,
cleanUrl,
documentStatusRank,
withDefaults,
} from './accounting-config';
import { ACCOUNTING_PROVIDERS, AccountingProvider, ProviderName, ProviderSettings, isProviderName } from './providers';
import { openSecret, sealSecret, secretKeySource } from './secret-box';
import { parsePemCertificates } from './accounting-http';
/** The first certificate of a pasted bundle, for display; null when none parses. */
function describeCertificate(pem: string | null) {
const [c] = parsePemCertificates(pem);
if (!c) return null;
return {
subject: c.subject,
subjectAltName: c.subjectAltName ?? null,
validTo: c.validTo,
expired: new Date(c.validTo).getTime() < Date.now(),
fingerprint256: c.fingerprint256,
};
}
export interface ActiveProvider {
provider: AccountingProvider;
settings: ProviderSettings;
config: AccountingConfig;
}
export type JobName = 'customers' | 'balances' | 'products' | 'outbox';
export interface JobState {
lastRunAt?: string;
lastOkAt?: string;
lastError?: string | null;
summary?: Record<string, unknown>;
}
export interface AccountingState {
jobs: Partial<Record<JobName, JobState>>;
}
/** How long a read config is reused before going back to the database. */
const CACHE_MS = 5_000;
@Injectable()
export class AccountingConfigService implements OnModuleInit {
private readonly logger = new Logger('Accounting');
private cache: { at: number; config: AccountingConfig } | null = null;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
if (secretKeySource() !== 'ACCOUNTING_SECRET_KEY') {
this.logger.warn(
'⚠️ ACCOUNTING_SECRET_KEY تنظیم نشده است. توکن نرم‌افزار حسابداری با کلیدی مشتق از JWT_SECRET رمز می‌شود؛ ' +
'با عوض‌شدن JWT_SECRET توکن ذخیره‌شده قابل‌خواندن نمی‌ماند و باید دوباره وارد شود. (D27)',
);
}
}
async get(): Promise<AccountingConfig> {
if (this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.config;
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_CONFIG_KEY } });
const config = withDefaults(row?.value);
this.cache = { at: Date.now(), config };
return config;
}
/** The configuration as the admin screen sees it: tokens reduced to flags. */
async getPublic() {
const c = await this.get();
const publicConn = (conn: StoredConnection) => {
const { tokenSealed, ...rest } = conn;
return {
...rest,
tokenSet: !!tokenSealed,
tokenReadable: !!tokenSealed && openSecret(tokenSealed) !== null,
// What the pinned certificate is, so the screen can show it instead of PEM.
caInfo: describeCertificate(rest.caCert),
};
};
return {
...c,
tahesab: publicConn(c.tahesab),
zarrin: publicConn(c.zarrin),
secretKeySource: secretKeySource(),
providers: Object.values(ACCOUNTING_PROVIDERS).map((p) => ({
name: p.name,
label: p.label,
capabilities: p.capabilities,
})),
};
}
/**
* Applies an admin's edit. Anything not sent is kept. A token is replaced
* only by a non-empty string and removed only by an explicit null — so the
* form can be saved without ever having seen the token.
*/
async save(input: Record<string, any>): Promise<{ providerChanged: boolean }> {
const current = await this.get();
const next: AccountingConfig = JSON.parse(JSON.stringify(current));
if (input.provider !== undefined) {
if (input.provider !== 'none' && !isProviderName(input.provider)) {
throw new BadRequestException('نرم‌افزار حسابداری نامعتبر است');
}
next.provider = input.provider;
}
const conn = (key: ProviderName) => {
const src = input[key];
if (!src || typeof src !== 'object') return;
const label = ACCOUNTING_PROVIDERS[key].label;
const dst: StoredConnection = next[key];
if (src.baseUrl !== undefined) dst.baseUrl = cleanUrl(src.baseUrl, `آدرس ${label}`);
if (src.caCert !== undefined) dst.caCert = cleanCa(src.caCert);
if (src.timeoutMs !== undefined) dst.timeoutMs = cleanInt(src.timeoutMs, 'مهلت پاسخ (میلی‌ثانیه)', 3000, 120000, dst.timeoutMs);
if (src.token === null) dst.tokenSealed = null;
else if (typeof src.token === 'string' && src.token.trim()) {
try {
dst.tokenSealed = sealSecret(src.token.trim());
} catch (err) {
throw new BadRequestException((err as Error).message);
}
}
};
conn('tahesab');
conn('zarrin');
if (input.tahesab?.dbName !== undefined) {
const db = String(input.tahesab.dbName ?? '').trim();
if (!/^[A-Za-z0-9_]{1,64}$/.test(db)) throw new BadRequestException('نام دیتابیس ته‌حساب نامعتبر است');
next.tahesab.dbName = db;
}
if (input.zarrin?.balanceOwesWhen !== undefined) {
if (input.zarrin.balanceOwesWhen !== 'negative' && input.zarrin.balanceOwesWhen !== 'positive') {
throw new BadRequestException('علامت مانده‌ی زرین نامعتبر است');
}
next.zarrin.balanceOwesWhen = input.zarrin.balanceOwesWhen;
}
if (input.features && typeof input.features === 'object') {
for (const k of Object.keys(next.features) as (keyof AccountingConfig['features'])[]) {
next.features[k] = cleanBool(input.features[k], next.features[k]);
}
}
if (input.intervals && typeof input.intervals === 'object') {
const i = input.intervals;
next.intervals.customersMinutes = cleanInt(i.customersMinutes, 'بازه‌ی سینک مشتری', 5, 1440, next.intervals.customersMinutes);
next.intervals.balancesMinutes = cleanInt(i.balancesMinutes, 'بازه‌ی سینک مانده', 1, 1440, next.intervals.balancesMinutes);
next.intervals.productsMinutes = cleanInt(i.productsMinutes, 'بازه‌ی سینک کارساخته', 5, 1440, next.intervals.productsMinutes);
}
if (input.mapping && typeof input.mapping === 'object') {
const m = input.mapping;
if (m.importCustomerGroups !== undefined) next.mapping.importCustomerGroups = cleanGroups(m.importCustomerGroups);
if (m.newCustomerGroup !== undefined) next.mapping.newCustomerGroup = cleanGroup(m.newCustomerGroup);
if (m.newKarSakhteGroup !== undefined) next.mapping.newKarSakhteGroup = cleanGroup(m.newKarSakhteGroup);
if (m.newKarSakhteType !== undefined) next.mapping.newKarSakhteType = cleanGroup(m.newKarSakhteType);
if (m.categoryGroups !== undefined) {
next.mapping.categoryGroups = (Array.isArray(m.categoryGroups) ? m.categoryGroups : [])
.map((x: any) => ({ categoryId: String(x?.categoryId ?? '').trim(), group: cleanGroup(x?.group) }))
.filter((x: any) => x.categoryId && x.group);
}
if (m.karat !== undefined) next.mapping.karat = cleanInt(m.karat, 'عیار پایه', 1, 1000, next.mapping.karat);
}
if (input.documents && typeof input.documents === 'object' && input.documents.triggerStatus !== undefined) {
if (!documentStatusRank(String(input.documents.triggerStatus))) {
throw new BadRequestException('وضعیت ثبت سند سفارش نامعتبر است');
}
next.documents.triggerStatus = input.documents.triggerStatus;
}
if (input.report && typeof input.report === 'object') {
next.report.windowDays = cleanInt(input.report.windowDays, 'بازه‌ی گزارش (روز)', 7, 3650, next.report.windowDays);
}
// Selecting a software that has no address/token would switch the sync
// off without a word (the worker finds nothing usable).
if (next.provider !== 'none' && (!next[next.provider].baseUrl || !next[next.provider].tokenSealed)) {
const label = ACCOUNTING_PROVIDERS[next.provider].label;
throw new BadRequestException(`«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`);
}
await this.prisma.setting.upsert({
where: { key: ACCOUNTING_CONFIG_KEY },
update: { value: next as unknown as Prisma.InputJsonValue },
create: { key: ACCOUNTING_CONFIG_KEY, value: next as unknown as Prisma.InputJsonValue },
});
this.cache = null;
return { providerChanged: current.provider !== next.provider };
}
/** Decrypted settings for one provider; null when it is not usable. */
settingsFor(config: AccountingConfig, name: ProviderName): ProviderSettings | null {
const conn = config[name];
if (!conn.baseUrl) return null;
const token = openSecret(conn.tokenSealed);
if (!token) return null;
return {
baseUrl: conn.baseUrl,
token,
caCert: conn.caCert,
timeoutMs: conn.timeoutMs,
dbName: name === 'tahesab' ? config.tahesab.dbName : undefined,
balanceOwesWhen: name === 'zarrin' ? config.zarrin.balanceOwesWhen : undefined,
};
}
/**
* Settings as the admin form currently shows them, saved or not, so «تست
* اتصال» checks what is on screen. A blank token means "the saved one".
* Validated like a save; nothing is stored.
*/
draftSettings(config: AccountingConfig, name: ProviderName, input: Record<string, any> | undefined): ProviderSettings | null {
const src = input && typeof input === 'object' ? input : {};
const label = ACCOUNTING_PROVIDERS[name].label;
const saved = config[name];
const baseUrl = src.baseUrl !== undefined ? cleanUrl(src.baseUrl, `آدرس ${label}`) : saved.baseUrl;
const token = typeof src.token === 'string' && src.token.trim() ? src.token.trim() : openSecret(saved.tokenSealed);
if (!baseUrl || !token) return null;
let dbName = config.tahesab.dbName;
if (name === 'tahesab' && src.dbName !== undefined) {
dbName = String(src.dbName ?? '').trim();
if (!/^[A-Za-z0-9_]{1,64}$/.test(dbName)) throw new BadRequestException('نام دیتابیس ته‌حساب نامعتبر است');
}
let balanceOwesWhen = config.zarrin.balanceOwesWhen;
if (name === 'zarrin' && (src.balanceOwesWhen === 'negative' || src.balanceOwesWhen === 'positive')) {
balanceOwesWhen = src.balanceOwesWhen;
}
return {
baseUrl,
token,
caCert: src.caCert !== undefined ? cleanCa(src.caCert) : saved.caCert,
timeoutMs: cleanInt(src.timeoutMs, 'مهلت پاسخ (میلی‌ثانیه)', 3000, 120000, saved.timeoutMs),
dbName: name === 'tahesab' ? dbName : undefined,
balanceOwesWhen: name === 'zarrin' ? balanceOwesWhen : undefined,
};
}
/** Why nothing syncs, in words for the admin screen; null when a provider is usable. */
inactiveReason(config: AccountingConfig): string | null {
if (config.provider === 'none') return 'سوییچ روی «بدون اتصال» است';
const label = ACCOUNTING_PROVIDERS[config.provider].label;
const conn = config[config.provider];
if (!conn.baseUrl || !conn.tokenSealed) return `«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`;
if (!this.settingsFor(config, config.provider)) return `توکن ذخیره‌شده‌ی «${label}» قابل‌خواندن نیست؛ دوباره وارد کنید`;
return null;
}
/** The selected, fully configured provider — or null when the switch is off. */
async active(): Promise<ActiveProvider | null> {
const config = await this.get();
if (config.provider === 'none') return null;
const settings = this.settingsFor(config, config.provider);
if (!settings) return null;
return { provider: ACCOUNTING_PROVIDERS[config.provider], settings, config };
}
/** Active provider with this feature switched on, or null. */
async activeFor(feature: keyof AccountingConfig['features']): Promise<ActiveProvider | null> {
const a = await this.active();
return a && a.config.features[feature] ? a : null;
}
// ── job bookkeeping (shown on the admin screen) ───────────────────────
async getState(): Promise<AccountingState> {
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_STATE_KEY } });
const v = (row?.value ?? {}) as Partial<AccountingState>;
return { jobs: v.jobs ?? {} };
}
async recordJob(job: JobName, patch: JobState) {
const state = await this.getState();
state.jobs[job] = { ...(state.jobs[job] ?? {}), ...patch };
await this.prisma.setting.upsert({
where: { key: ACCOUNTING_STATE_KEY },
update: { value: state as unknown as Prisma.InputJsonValue },
create: { key: ACCOUNTING_STATE_KEY, value: state as unknown as Prisma.InputJsonValue },
});
}
}
@@ -0,0 +1,181 @@
import { BadRequestException } from '@nestjs/common';
import { ExternalGroup, ProviderName } from './providers/accounting-provider';
/**
* The admin-editable configuration of the accounting integration (D27),
* stored as one Setting row. Every `accounting.*` Setting key is refused by
* the generic `const` routes (admin-misc), so the only way in or out is the
* dedicated, `config.accounting`-guarded screen — and even that never returns
* a token.
*/
export const ACCOUNTING_SETTING_PREFIX = 'accounting.';
export const ACCOUNTING_CONFIG_KEY = 'accounting.config';
export const ACCOUNTING_STATE_KEY = 'accounting.state';
/** Keys the generic `const` settings routes must never read or write. */
export function isAccountingSettingKey(key: string) {
return key.trim().toLowerCase().startsWith(ACCOUNTING_SETTING_PREFIX);
}
export type ProviderChoice = 'none' | ProviderName;
export interface StoredConnection {
baseUrl: string;
/** AES-GCM sealed (secret-box.ts). Never leaves the server. */
tokenSealed: string | null;
/** PEM CA of a self-signed installation; verification is never turned off. */
caCert: string | null;
timeoutMs: number;
}
export interface AccountingFeatures {
/** F1 — customer import/link and push. */
customers: boolean;
/** F2 — balance pull; the customer sees the accounting balance. */
balances: boolean;
/** F6 — «گزارش» reads the accounting statement. */
report: boolean;
/** F4 — کارساخته ↔ product sync and the out-of-stock lock. */
products: boolean;
/** F3 — order documents. */
documents: boolean;
}
export interface AccountingConfig {
provider: ProviderChoice;
tahesab: StoredConnection & { dbName: string };
zarrin: StoredConnection & { balanceOwesWhen: 'negative' | 'positive' };
features: AccountingFeatures;
intervals: {
customersMinutes: number;
balancesMinutes: number;
productsMinutes: number;
};
mapping: {
/** Account groups imported into the app; empty = every group. */
importCustomerGroups: ExternalGroup[];
/** Group a customer created from the app is put in. */
newCustomerGroup: ExternalGroup | null;
/** Zarrin: group (گروه ساخته) for a product created in the admin… */
newKarSakhteGroup: ExternalGroup | null;
/** …overridden per app category. */
categoryGroups: { categoryId: string; group: ExternalGroup }[];
/** Zarrin: item type (گرمی/تعدادی…) for a product created in the admin. */
newKarSakhteType: ExternalGroup | null;
/** Purity the app's grams are in. */
karat: number;
};
/** How far back «گزارش» reads the accounting statement. */
report: { windowDays: number };
/** F3 — the order status at (or past) which the temporary document (پیش‌فاکتور) is posted. */
documents: { triggerStatus: DocumentTriggerStatus };
}
/** Order statuses in the order an order passes them; SUSPENDED/CANCELLED never trigger. */
export const DOCUMENT_TRIGGER_STATUSES = [
'PLACED',
'ADMIN_APPROVED',
'SOURCING',
'PREPARING',
'DELIVERED_TO_AGENT',
'DELIVERED_TO_CUSTOMER',
] as const;
export type DocumentTriggerStatus = (typeof DOCUMENT_TRIGGER_STATUSES)[number];
/** 1-based place of a status in DOCUMENT_TRIGGER_STATUSES; 0 = never triggers. */
export function documentStatusRank(status: string): number {
return DOCUMENT_TRIGGER_STATUSES.indexOf(status as DocumentTriggerStatus) + 1;
}
export const DEFAULT_TIMEOUT_MS = 20_000;
export function defaultConfig(): AccountingConfig {
return {
provider: 'none',
tahesab: { baseUrl: '', tokenSealed: null, caCert: null, timeoutMs: DEFAULT_TIMEOUT_MS, dbName: 'TahesabDB' },
zarrin: { baseUrl: '', tokenSealed: null, caCert: null, timeoutMs: DEFAULT_TIMEOUT_MS, balanceOwesWhen: 'negative' },
features: { customers: false, balances: false, report: false, products: false, documents: false },
intervals: { customersMinutes: 60, balancesMinutes: 10, productsMinutes: 30 },
mapping: {
importCustomerGroups: [],
newCustomerGroup: null,
newKarSakhteGroup: null,
categoryGroups: [],
newKarSakhteType: null,
karat: 750,
},
report: { windowDays: 365 },
// The point the debt is recognised — the behaviour before the rule existed.
documents: { triggerStatus: 'DELIVERED_TO_AGENT' },
};
}
/** Stored JSON → a complete config, tolerating rows written by older code. */
export function withDefaults(raw: unknown): AccountingConfig {
const d = defaultConfig();
const r = (raw && typeof raw === 'object' ? raw : {}) as Partial<AccountingConfig>;
return {
provider: r.provider === 'tahesab' || r.provider === 'zarrin' ? r.provider : 'none',
tahesab: { ...d.tahesab, ...(r.tahesab ?? {}) },
zarrin: { ...d.zarrin, ...(r.zarrin ?? {}) },
features: { ...d.features, ...(r.features ?? {}) },
intervals: { ...d.intervals, ...(r.intervals ?? {}) },
mapping: { ...d.mapping, ...(r.mapping ?? {}) },
report: { ...d.report, ...(r.report ?? {}) },
documents: { ...d.documents, ...(r.documents ?? {}) },
};
}
// ── input validation ────────────────────────────────────────────────────
export function cleanUrl(v: unknown, label: string): string {
const s = typeof v === 'string' ? v.trim() : '';
if (!s) return '';
let u: URL;
try {
u = new URL(s);
} catch {
throw new BadRequestException(`${label}: آدرس معتبر نیست`);
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new BadRequestException(`${label}: آدرس باید با http:// یا https:// شروع شود`);
}
if (u.username || u.password) throw new BadRequestException(`${label}: آدرس نباید نام کاربری/رمز داشته باشد`);
return s;
}
export function cleanCa(v: unknown): string | null {
const s = typeof v === 'string' ? v.trim() : '';
if (!s) return null;
if (!s.includes('-----BEGIN CERTIFICATE-----')) {
throw new BadRequestException('گواهی CA باید با قالب PEM (BEGIN CERTIFICATE) باشد');
}
return s;
}
export function cleanInt(v: unknown, label: string, min: number, max: number, fallback: number): number {
if (v === undefined || v === null || v === '') return fallback;
const n = Number(v);
if (!Number.isInteger(n) || n < min || n > max) {
throw new BadRequestException(`${label}: عدد صحیح بین ${min} و ${max}`);
}
return n;
}
export function cleanGroup(v: unknown): ExternalGroup | null {
if (!v || typeof v !== 'object') return null;
const g = v as Record<string, unknown>;
const id = g.id === undefined || g.id === null ? '' : String(g.id).trim();
const name = typeof g.name === 'string' ? g.name.trim() : '';
if (!id && !name) return null;
return { id, name };
}
export function cleanGroups(v: unknown): ExternalGroup[] {
return Array.isArray(v) ? v.map(cleanGroup).filter((g): g is ExternalGroup => !!g) : [];
}
export function cleanBool(v: unknown, fallback: boolean): boolean {
return typeof v === 'boolean' ? v : fallback;
}
@@ -0,0 +1,106 @@
import { AccountingHooksService } from './accounting-hooks.service';
import { AccountingConfig, defaultConfig } from './accounting-config';
type Row = { id: string; provider: string; kind: string; entityId: string; status: string };
/** Just enough of a Prisma transaction client for the outbox rules. */
function fakeTx(rows: Row[]) {
const match = (r: Row, where: any) =>
(!where.provider || r.provider === where.provider) &&
(!where.kind || r.kind === where.kind) &&
(!where.entityId || r.entityId === where.entityId) &&
(!where.status ||
(where.status.in ? where.status.in.includes(r.status) : true) &&
(where.status.notIn ? !where.status.notIn.includes(r.status) : true) &&
(where.status.not ? r.status !== where.status.not : true));
return {
accountingOutbox: {
findFirst: async ({ where }: any) => rows.find((r) => match(r, where)) ?? null,
findMany: async ({ where }: any) => rows.filter((r) => match(r, where)),
create: async ({ data }: any) => {
rows.push({ id: String(rows.length + 1), status: 'PENDING', ...data });
},
update: async ({ where, data }: any) => {
Object.assign(rows.find((r) => r.id === where.id)!, data);
},
},
};
}
function hooks(trigger: AccountingConfig['documents']['triggerStatus'], provider: 'tahesab' | 'zarrin' = 'tahesab') {
const c = defaultConfig();
c.provider = provider;
c.features.documents = true;
c.documents.triggerStatus = trigger;
const config = { get: async () => c } as any;
const worker = { kickSoon: jest.fn() } as any;
return new AccountingHooksService({} as any, config, {} as any, worker);
}
const docs = (rows: Row[], kind = 'ORDER_DOCUMENT') => rows.filter((r) => r.kind === kind);
describe('document rule (پیش‌فاکتور at a chosen status)', () => {
it('rule «ثبت سفارش»: the document is queued as the order is placed', async () => {
const rows: Row[] = [];
await hooks('PLACED').orderStatusReached(fakeTx(rows) as any, 'o1', 'PLACED');
expect(docs(rows)).toHaveLength(1);
});
it('default rule: nothing before «تحویل مسئول فروش», then once', async () => {
const rows: Row[] = [];
const h = hooks('DELIVERED_TO_AGENT');
for (const s of ['PLACED', 'ADMIN_APPROVED', 'PREPARING']) await h.orderStatusReached(fakeTx(rows) as any, 'o1', s);
expect(docs(rows)).toHaveLength(0);
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_AGENT');
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_CUSTOMER');
expect(docs(rows)).toHaveLength(1);
});
it('an order that jumps past the rule status still gets its document', async () => {
const rows: Row[] = [];
await hooks('PREPARING').orderStatusReached(fakeTx(rows) as any, 'o1', 'DELIVERED_TO_CUSTOMER');
expect(docs(rows)).toHaveLength(1);
});
it('a suspended order never triggers', async () => {
const rows: Row[] = [];
await hooks('PLACED').orderStatusReached(fakeTx(rows) as any, 'o1', 'SUSPENDED');
expect(rows).toHaveLength(0);
});
it('rolled back below the rule: a waiting document is dropped, a sent one gets a delete job', async () => {
const waiting: Row[] = [{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'PENDING' }];
await hooks('PREPARING').orderStatusReached(fakeTx(waiting) as any, 'o1', 'ADMIN_APPROVED');
expect(waiting[0].status).toBe('RESOLVED');
const sent: Row[] = [{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' }];
await hooks('PREPARING').orderStatusReached(fakeTx(sent) as any, 'o1', 'ADMIN_APPROVED');
expect(docs(sent, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
});
it('Tahesab: reaching the rule again after a withdrawal queues a new document behind the delete', async () => {
const rows: Row[] = [
{ id: '1', provider: 'tahesab', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' },
{ id: '2', provider: 'tahesab', kind: 'ORDER_DOCUMENT_DELETE', entityId: 'o1', status: 'PENDING' },
];
await hooks('PREPARING').orderStatusReached(fakeTx(rows) as any, 'o1', 'PREPARING');
expect(docs(rows)).toHaveLength(2);
});
it('Zarrin: never queued twice (no duplicate guard in its API)', async () => {
const rows: Row[] = [
{ id: '1', provider: 'zarrin', kind: 'ORDER_DOCUMENT', entityId: 'o1', status: 'SENT' },
{ id: '2', provider: 'zarrin', kind: 'ORDER_DOCUMENT_DELETE', entityId: 'o1', status: 'PENDING' },
];
await hooks('PREPARING', 'zarrin').orderStatusReached(fakeTx(rows) as any, 'o1', 'PREPARING');
expect(docs(rows)).toHaveLength(1);
});
it('does nothing while the document feature is off', async () => {
const rows: Row[] = [];
const h = hooks('PLACED');
((await (h as any).config.get()) as AccountingConfig).features.documents = false;
await h.orderStatusReached(fakeTx(rows) as any, 'o1', 'PLACED');
expect(rows).toHaveLength(0);
});
});
@@ -0,0 +1,164 @@
import { Injectable } from '@nestjs/common';
import { AccountingEntityType, AccountingOutboxKind, AccountingOutboxStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingConfigService } from './accounting-config.service';
import { AccountingFeatures, documentStatusRank } from './accounting-config';
import { AccountingSyncService } from './accounting-sync.service';
import { AccountingWorkerService } from './accounting-worker.service';
import { ACCOUNTING_PROVIDERS } from './providers';
type Tx = Prisma.TransactionClient | PrismaService;
const OPEN: AccountingOutboxStatus[] = ['PENDING', 'SENDING'];
/**
* The narrow surface the rest of the app calls into (D27): orders, customer
* admin, product admin, catalog. Every write goes through the caller's own
* transaction client, so the outbox row commits or rolls back with the change
* that caused it. Nothing here talks to the accounting software.
*/
@Injectable()
export class AccountingHooksService {
constructor(
private readonly prisma: PrismaService,
private readonly config: AccountingConfigService,
private readonly sync: AccountingSyncService,
private readonly worker: AccountingWorkerService,
) {}
private async enqueue(tx: Tx, feature: keyof AccountingFeatures, kind: AccountingOutboxKind, entityId: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features[feature]) return false;
const open = await tx.accountingOutbox.findFirst({
where: { provider: c.provider, kind, entityId, status: { in: OPEN } },
select: { id: true },
});
if (open) return false;
await tx.accountingOutbox.create({ data: { provider: c.provider, kind, entityId } });
this.worker.kickSoon();
return true;
}
/**
* F3 — the order is now in `status` (created, advanced, or rolled back).
* The owner's rule (`documents.triggerStatus`, «اتصال حسابداری») says from
* which status on the temporary document (پیش‌فاکتور) belongs in the books:
* at or past it the document is queued once; back below it, a document
* already sent is withdrawn like a cancellation.
*/
async orderStatusReached(tx: Tx, orderId: string, status: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.documents) return;
const rank = documentStatusRank(status);
if (!rank) return;
if (rank >= documentStatusRank(c.documents.triggerStatus)) await this.queueOrderDocument(tx, c.provider, orderId);
else await this.orderCancelled(tx, orderId);
}
private async queueOrderDocument(tx: Tx, provider: string, orderId: string) {
const existing = await tx.accountingOutbox.findFirst({
where: { provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { notIn: ['RESOLVED'] } },
select: { id: true },
});
if (existing) {
// Withdrawn and wanted again: Tahesab deletes first (older job), then
// the new one posts — and a duplicate is refused by its Factor_Code key.
// Zarrin has no such key, so its document is never queued twice.
const deleting = await tx.accountingOutbox.findFirst({
where: { provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { in: ['PENDING', 'SENDING'] } },
select: { id: true },
});
if (!(provider === 'tahesab' && deleting)) return;
}
await this.enqueue(tx, 'documents', 'ORDER_DOCUMENT', orderId);
}
/**
* F3 — the order was cancelled. A document still waiting is simply dropped;
* one that may already be in the books gets a delete job, which itself only
* deletes a document that is still temporary (worker).
*/
async orderCancelled(tx: Tx, orderId: string) {
const docs = await tx.accountingOutbox.findMany({
where: { kind: 'ORDER_DOCUMENT', entityId: orderId, status: { not: 'RESOLVED' } },
});
for (const doc of docs) {
if (doc.status === 'PENDING') {
await tx.accountingOutbox.update({
where: { id: doc.id },
data: { status: 'RESOLVED', lastError: 'سفارش پیش از ارسال سند لغو شد' },
});
continue;
}
const open = await tx.accountingOutbox.findFirst({
where: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { not: 'RESOLVED' } },
select: { id: true },
});
if (!open) {
await tx.accountingOutbox.create({
data: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId },
});
}
}
}
/** F1 — an admin marked the customer VERIFIED. */
async customerVerified(tx: Tx, customerId: string) {
await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
}
/** F1 — a linked customer's contact details changed. */
async customerEdited(tx: Tx, customerId: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.customers) return;
const link = await tx.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: c.provider, entityType: 'CUSTOMER', entityId: customerId } },
select: { id: true },
});
if (link) await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
}
/** F4 — a product was created in the admin panel. */
async productCreated(tx: Tx, productId: string) {
const c = await this.config.get();
if (c.provider === 'none' || !ACCOUNTING_PROVIDERS[c.provider].capabilities.createKarSakhte) return;
await this.enqueue(tx, 'products', 'PRODUCT_PUSH', productId);
}
/**
* F4 — title and code of a linked product belong to the accounting software
* (D27); the admin form locks them. Null when the product is not linked.
*/
async productLink(productId: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.products) return null;
return this.prisma.accountingLink.findUnique({
where: { provider_entityType_entityId: { provider: c.provider, entityType: AccountingEntityType.PRODUCT, entityId: productId } },
});
}
/**
* Order time: the product with its stock read live from the accounting
* software, or as last stored when that cannot answer in a moment.
*/
liveStock<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(product: T) {
return this.sync.liveStock(product);
}
/**
* F4 — is this product out of stock in the accounting software? Only
* meaningful while the product sync is on; stock fields of products that are
* not linked under the active provider are cleared by every product pull.
*/
async stockBlocked(product: {
accountingStockAt: Date | null;
accountingStockWeight: Prisma.Decimal | null;
accountingStockCount: Prisma.Decimal | null;
}) {
if (!product.accountingStockAt) return false;
const c = await this.config.get();
if (c.provider === 'none' || !c.features.products) return false;
const stock = product.accountingStockWeight ?? product.accountingStockCount;
return stock !== null && new Prisma.Decimal(stock).lte(0);
}
}
@@ -0,0 +1,87 @@
import * as https from 'https';
import { AddressInfo } from 'net';
import { AccountingHttpError, fetchServerCertificate, httpRequest, pinnedFingerprints } from './accounting-http';
// Throwaway self-signed test certificates (EC P-256, valid to 2126), issued
// for *.invalid names so connecting to 127.0.0.1 never matches them — the
// same situation as a Tahesab install whose certificate says `localhost`.
const CERT_A = `-----BEGIN CERTIFICATE-----
MIIBxTCCAWugAwIBAgIUNaqVnWRRUyN8hOGPQtARjZymRcUwCgYIKoZIzj0EAwIw
JDEiMCAGA1UEAwwZYWNjb3VudGluZy10ZXN0LWEuaW52YWxpZDAgFw0yNjEwMDUx
MjA3NDJaGA8yMTI2MDkxMTEyMDc0MlowJDEiMCAGA1UEAwwZYWNjb3VudGluZy10
ZXN0LWEuaW52YWxpZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFC+h8KB1h41
56L9+l8nHbMZ5hrrp9nwqmz5RoAqrkVI8Ii5RFYUjssaH2Y0P9+B9L9SvijfB5Xi
Wwk6eVzGKtSjeTB3MB0GA1UdDgQWBBSokIrPzA6ur33vyVeBY+2/NWHx1TAfBgNV
HSMEGDAWgBSokIrPzA6ur33vyVeBY+2/NWHx1TAPBgNVHRMBAf8EBTADAQH/MCQG
A1UdEQQdMBuCGWFjY291bnRpbmctdGVzdC1hLmludmFsaWQwCgYIKoZIzj0EAwID
SAAwRQIgCLZVDlrE2aO8poWAdZMOd+obOeB23TU1Jfh3nnbEDF0CIQCB5+8PNRQe
aWzW6sgCcDWdiga81Nhm7FxYj4AorbPO1w==
-----END CERTIFICATE-----`;
const KEY_A = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgOQ//h9ZRCK1KaiXW
Ij3FN+77RDPYSWgY6DwZqC5x0A6hRANCAARQvofCgdYeNeei/fpfJx2zGeYa66fZ
8Kps+UaAKq5FSPCIuURWFI7LGh9mND/fgfS/Ur4o3weV4lsJOnlcxirU
-----END PRIVATE KEY-----`;
const CERT_B = `-----BEGIN CERTIFICATE-----
MIIBxjCCAWugAwIBAgIUMO+HE/U7Z4EXTnw9ucoKjRz/u7MwCgYIKoZIzj0EAwIw
JDEiMCAGA1UEAwwZYWNjb3VudGluZy10ZXN0LWIuaW52YWxpZDAgFw0yNjEwMDUx
MjA3NDJaGA8yMTI2MDkxMTEyMDc0MlowJDEiMCAGA1UEAwwZYWNjb3VudGluZy10
ZXN0LWIuaW52YWxpZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABI2Naohejia1
fQnFx33/5jasQJudFTmAdPEHxlIyBlUUi2IoaMWbSyMBBfgy+dX6gM7QetK/99ex
rt/d5OQNyvqjeTB3MB0GA1UdDgQWBBSgpkkpJptOajy+1aSgPZ8gre559TAfBgNV
HSMEGDAWgBSgpkkpJptOajy+1aSgPZ8gre559TAPBgNVHRMBAf8EBTADAQH/MCQG
A1UdEQQdMBuCGWFjY291bnRpbmctdGVzdC1iLmludmFsaWQwCgYIKoZIzj0EAwID
SQAwRgIhALJMbkGHT256tdNcVJfnB0eqxivGD7hooljD6epmvTgbAiEA1r/cX59l
0bu56aMEi0TeoQAAQRiEpdBIwe0F/YjWXL4=
-----END CERTIFICATE-----`;
describe('accounting HTTP client — TLS with a pinned certificate', () => {
let server: https.Server;
let url: string;
beforeAll(async () => {
server = https.createServer({ cert: CERT_A, key: KEY_A }, (_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"Status":"OK"}');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
url = `https://127.0.0.1:${(server.address() as AddressInfo).port}/`;
});
afterAll(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
const call = (ca: string | null) => httpRequest({ method: 'POST', url, body: '{}', timeoutMs: 5000, ca });
it('rejects a self-signed server when nothing is pinned, without claiming it may have been received', async () => {
const err = await call(null).catch((e) => e);
expect(err).toBeInstanceOf(AccountingHttpError);
expect(err.ambiguous).toBe(false);
expect(err.message).toContain('بررسی گواهی سرور');
});
it('accepts exactly the pinned certificate even though it names another host', async () => {
await expect(call(CERT_A)).resolves.toEqual({ status: 200, body: '{"Status":"OK"}' });
});
it('still rejects when a different certificate is pinned', async () => {
const err = await call(CERT_B).catch((e) => e);
expect(err).toBeInstanceOf(AccountingHttpError);
expect(err.message).toContain('یکی نیست');
});
it('reads the presented certificate for the admin to compare and pin', async () => {
const c = await fetchServerCertificate(url, 5000);
expect(c.fingerprint256).toBe([...pinnedFingerprints(CERT_A)][0]);
expect(c.selfSigned).toBe(true);
expect(c.trustedBySystem).toBe(false);
expect(c.subjectAltName).toContain('accounting-test-a.invalid');
expect(pinnedFingerprints(c.pem).has(c.fingerprint256)).toBe(true);
});
it('refuses to fetch a certificate from a plain-http address', async () => {
await expect(fetchServerCertificate('http://127.0.0.1:1/', 1000)).rejects.toThrow('https');
});
});
@@ -0,0 +1,281 @@
import { X509Certificate } from 'crypto';
import * as http from 'http';
import * as https from 'https';
import * as net from 'net';
import * as tls from 'tls';
/**
* The one HTTP client both accounting drivers use.
*
* Built on node:http(s) rather than global fetch because a self-hosted
* Tahesab/Zarrin often serves a self-signed certificate, and fetch cannot be
* given a CA without an extra dependency. The reference "solved" that with
* `Http::withoutVerifying()` (SEC-17); here verification is ALWAYS on and the
* admin can paste the installation's certificate instead.
*
* A pasted certificate is trusted two ways: as a CA (the normal chain and
* hostname checks), and as a pin — when the server presents exactly that
* certificate it is accepted whatever name it was issued for. Tahesab's own
* certificate is self-signed for `localhost`, so a remote install reached by
* IP can only be verified by the pin.
*
* Errors say whether the request may have reached the server. That decides
* what the outbox may do next: a refused connection is safe to retry, a
* timeout after the request went out is not (Zarrin has no idempotency key).
*/
export interface HttpRequest {
method: 'GET' | 'POST';
url: string;
headers?: Record<string, string>;
body?: string;
timeoutMs: number;
/** PEM CA certificate(s) to trust in addition to the system store. */
ca?: string | null;
}
export interface HttpResponse {
status: number;
body: string;
}
export class AccountingHttpError extends Error {
constructor(
message: string,
/** True when the request may have been received and acted on. */
readonly ambiguous: boolean,
readonly code?: string,
) {
super(message);
this.name = 'AccountingHttpError';
}
}
const MAX_BODY_BYTES = 20 * 1024 * 1024;
const TLS_CERT_ERRORS = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/;
/** The PEM blocks in a pasted bundle, parsed; unparsable blocks are skipped. */
export function parsePemCertificates(pem: string | null | undefined): X509Certificate[] {
const blocks = (pem ?? '').match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
const out: X509Certificate[] = [];
for (const block of blocks) {
try {
out.push(new X509Certificate(block));
} catch {
// not a certificate — the CA list below will reject it too
}
}
return out;
}
/** SHA-256 fingerprints, as Node prints them (`AB:CD:…`), of the pasted certificates. */
export function pinnedFingerprints(pem: string | null | undefined): Set<string> {
return new Set(parsePemCertificates(pem).map((c) => c.fingerprint256));
}
function tlsErrorMessage(err: NodeJS.ErrnoException, pinned: boolean): string {
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
return pinned
? `گواهی سرور برای این آدرس صادر نشده و با گواهی ثبت‌شده در تنظیمات هم یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
: `گواهی TLS سرور برای این آدرس صادر نشده (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
}
return pinned
? `گواهی فعلی سرور با گواهی ثبت‌شده در تنظیمات یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید`
: `گواهی TLS سرور قابل‌اعتماد نیست (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`;
}
export function httpRequest(req: HttpRequest): Promise<HttpResponse> {
let url: URL;
try {
url = new URL(req.url);
} catch {
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
}
const isHttps = url.protocol === 'https:';
if (!isHttps && url.protocol !== 'http:') {
return Promise.reject(new AccountingHttpError('آدرس باید با http یا https شروع شود', false, 'BAD_URL'));
}
const headers: Record<string, string | number> = { ...(req.headers ?? {}) };
if (req.body !== undefined) headers['Content-Length'] = Buffer.byteLength(req.body);
const options: https.RequestOptions = {
method: req.method,
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: `${url.pathname}${url.search}`,
headers,
};
const pins = isHttps ? pinnedFingerprints(req.ca) : new Set<string>();
if (isHttps) {
options.rejectUnauthorized = true;
if (req.ca && req.ca.trim()) {
// Extra CA on top of the system store, never instead of verification.
options.ca = [...tls.rootCertificates, req.ca];
// Runs only after the chain verified. The exact pasted certificate is
// accepted under any name; anything else gets the normal name check.
// (Pooled sockets are keyed by `ca`, so a reused one had the same pins.)
options.checkServerIdentity = (host, cert) =>
pins.has(cert.fingerprint256) ? undefined : tls.checkServerIdentity(host, cert);
}
}
return new Promise<HttpResponse>((resolve, reject) => {
let connected = false;
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
const client = isHttps ? https : http;
const request = client.request(options, (res) => {
const chunks: Buffer[] = [];
let size = 0;
res.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
request.destroy();
finish(() => reject(new AccountingHttpError('پاسخ سرور حسابداری بیش از حد بزرگ است', true, 'TOO_LARGE')));
return;
}
chunks.push(chunk);
});
res.on('end', () =>
finish(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })),
);
res.on('error', (err) => finish(() => reject(new AccountingHttpError(err.message, true, 'RESPONSE'))));
});
request.on('socket', (socket) => {
// A kept-alive socket is already connected; listening on it again would
// pile up a listener per request.
if (!(socket as { connecting?: boolean }).connecting) {
connected = true;
return;
}
socket.once(isHttps ? 'secureConnect' : 'connect', () => {
connected = true;
});
});
const timer = setTimeout(() => {
request.destroy();
finish(() =>
reject(
new AccountingHttpError(
connected ? 'پاسخ سرور حسابداری در زمان مقرر نرسید' : 'اتصال به سرور حسابداری برقرار نشد (timeout)',
connected,
'TIMEOUT',
),
),
);
}, req.timeoutMs);
request.on('error', (err: NodeJS.ErrnoException) => {
// Node's own text for these suggests flags that do not apply here.
const text = isHttps && err.code && TLS_CERT_ERRORS.test(err.code) ? tlsErrorMessage(err, pins.size > 0) : err.message;
finish(() => reject(new AccountingHttpError(text, connected, err.code)));
});
if (req.body !== undefined) request.write(req.body);
request.end();
});
}
export interface ServerCertificate {
pem: string;
subject: string;
issuer: string;
subjectAltName: string | null;
validFrom: string;
validTo: string;
expired: boolean;
fingerprint256: string;
selfSigned: boolean;
/** Valid chain to a system CA and issued for this host: no pin needed. */
trustedBySystem: boolean;
}
/**
* Reads the certificate an https server presents, WITHOUT trusting it, so the
* admin can compare its fingerprint with the one on the accounting server and
* pin it. Nothing but the TLS handshake is sent.
*/
export function fetchServerCertificate(baseUrl: string, timeoutMs: number): Promise<ServerCertificate> {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL'));
}
if (url.protocol !== 'https:') {
return Promise.reject(new AccountingHttpError('این آدرس https نیست؛ گواهی ندارد', false, 'NOT_HTTPS'));
}
const host = url.hostname.replace(/^\[|\]$/g, '');
return new Promise<ServerCertificate>((resolve, reject) => {
const socket = tls.connect({
host,
port: Number(url.port || 443),
servername: net.isIP(host) ? undefined : host,
rejectUnauthorized: false,
});
const timer = setTimeout(() => {
socket.destroy();
reject(new AccountingHttpError('اتصال به سرور حسابداری برقرار نشد (timeout)', false, 'TIMEOUT'));
}, timeoutMs);
socket.once('secureConnect', () => {
clearTimeout(timer);
const peer = socket.getPeerCertificate();
const chainOk = socket.authorized;
socket.end();
if (!peer?.raw) {
reject(new AccountingHttpError('سرور گواهی ارسال نکرد', false, 'NO_CERT'));
return;
}
const cert = new X509Certificate(peer.raw);
const nameOk = net.isIP(host) ? !!cert.checkIP(host) : !!cert.checkHost(host);
const b64 = peer.raw.toString('base64').match(/.{1,64}/g)!.join('\n');
resolve({
pem: `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`,
subject: cert.subject,
issuer: cert.issuer,
subjectAltName: cert.subjectAltName ?? null,
validFrom: cert.validFrom,
validTo: cert.validTo,
expired: new Date(cert.validTo).getTime() < Date.now(),
fingerprint256: cert.fingerprint256,
// checkIssued() also wants keyCertSign, which Tahesab's certificate lacks.
selfSigned: cert.subject === cert.issuer && cert.verify(cert.publicKey),
trustedBySystem: chainOk && nameOk,
});
});
socket.once('error', (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
reject(new AccountingHttpError(err.message, false, err.code));
});
});
}
/**
* Parses a JSON body that may carry junk around it. Tahesab replies have been
* seen with a BOM, raw control characters and unescaped backslashes inside
* strings (henza_server TahesabController::test had to scrub them the same
* way before json_decode would accept the body).
*/
export function parseLooseJson(body: string): unknown {
try {
return JSON.parse(body);
} catch {
// fall through to the scrubbed attempt
}
let raw = body.replace(/^/, '');
raw = raw.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
raw = raw.replace(/\\(?!["\\/bfnrtu])/g, '-');
const start = raw.search(/[{[]/);
if (start > 0) raw = raw.slice(start);
return JSON.parse(raw);
}
@@ -0,0 +1,48 @@
import { Controller, Get, HttpCode, Post } from '@nestjs/common';
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
import { RequirePermission } from '../auth/guards';
import { AccountingAdminService } from './accounting-admin.service';
import { AccountingSyncService } from './accounting-sync.service';
/**
* D27, plan §12 — sync where people work, on top of the «اتصال حسابداری»
* screen: a button on the wholesale-customer list and on the product list,
* and the customer's own balance read live each time the app's profile opens.
* Each is behind the permission of the screen it sits on, and works whenever a
* provider is connected, whatever the feature switches say (owner's ruling).
*/
@Controller()
export class AccountingQuickController {
constructor(
private readonly admin: AccountingAdminService,
private readonly sync: AccountingSyncService,
) {}
/** Whether to show the sync buttons. Any signed-in admin; reveals no settings. */
@Get('admin/accounting-status')
status() {
return this.admin.connectionStatus();
}
@Post('admin/customers-omde/accounting-sync')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
syncCustomers() {
return this.admin.syncNow(['customers', 'balances']);
}
@Post('admin/products/accounting-sync')
@HttpCode(200)
@RequirePermission('product.list')
syncProducts() {
return this.admin.syncNow(['products']);
}
/** The app's profile: this customer's balance, read from the software now. */
@CustomerAuth()
@Get('customer/me/accounting-balance')
myBalance(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.sync.customerBalanceLive(me.id);
}
}
@@ -0,0 +1,27 @@
import { Global, Module } from '@nestjs/common';
import { AccountingAdminController } from './accounting-admin.controller';
import { AccountingAdminService } from './accounting-admin.service';
import { AccountingConfigService } from './accounting-config.service';
import { AccountingHooksService } from './accounting-hooks.service';
import { AccountingQuickController } from './accounting-quick.controller';
import { AccountingSyncService } from './accounting-sync.service';
import { AccountingWorkerService } from './accounting-worker.service';
/**
* Accounting software integration (D27). Global so orders, customer admin,
* product admin, the catalog and the report can call the hooks without each
* re-importing it — the same reason SmsModule is global.
*/
@Global()
@Module({
controllers: [AccountingAdminController, AccountingQuickController],
providers: [
AccountingConfigService,
AccountingHooksService,
AccountingSyncService,
AccountingWorkerService,
AccountingAdminService,
],
exports: [AccountingConfigService, AccountingHooksService, AccountingSyncService],
})
export class AccountingSyncModule {}
@@ -0,0 +1,949 @@
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();
}
}
@@ -0,0 +1,128 @@
import { Prisma } from '@prisma/client';
import { formatJalali, jalaliToIsoDate, parseJalali, toIsoDateTehran, toJalali } from './jalali';
import { openSecret, sealSecret } from './secret-box';
import { dec, normalizeMobile, splitName } from './providers/accounting-provider';
import { factorCodeFor, validNationalCode } from './accounting-sync.service';
import { parseLooseJson } from './accounting-http';
import { isAccountingSettingKey, withDefaults } from './accounting-config';
describe('jalali', () => {
it('converts a Tehran calendar day', () => {
// The Tahesab test server reported 1405/07/13 on 2026-10-05.
expect(formatJalali(toJalali(new Date('2026-10-05T08:00:00Z')), '/')).toBe('1405/07/13');
// Nowruz 1404: 2025-03-21 00:30 in Tehran is still 20 March in UTC.
expect(formatJalali(toJalali(new Date('2025-03-20T21:00:00Z')))).toBe('1404-01-01');
});
it('goes back to Gregorian', () => {
expect(jalaliToIsoDate({ jy: 1405, jm: 7, jd: 13 })).toBe('2026-10-05');
expect(jalaliToIsoDate({ jy: 1403, jm: 12, jd: 30 })).toBe('2025-03-20');
});
it('parses the forms the apps and Tahesab use, and rejects impossible days', () => {
expect(parseJalali('1405/7/13')).toEqual({ jy: 1405, jm: 7, jd: 13 });
expect(parseJalali('۱۴۰۵-۰۷-۱۳')).toEqual({ jy: 1405, jm: 7, jd: 13 });
expect(parseJalali('1403/12/30')).not.toBeNull(); // leap year
expect(parseJalali('1404/12/30')).toBeNull(); // common year
expect(parseJalali('1405/07/31')).toBeNull();
expect(parseJalali('2026-10-05x')).toBeNull();
});
it('formats a Tehran ISO day', () => {
expect(toIsoDateTehran(new Date('2026-10-04T21:00:00Z'))).toBe('2026-10-05');
});
});
describe('secret-box', () => {
const saved = { ...process.env };
afterEach(() => {
process.env = { ...saved };
});
it('round-trips and never stores the plain token', () => {
process.env.ACCOUNTING_SECRET_KEY = 'k1';
const sealed = sealSecret('M3Y3D2Y7');
expect(sealed.startsWith('enc:v1:')).toBe(true);
expect(sealed).not.toContain('M3Y3D2Y7');
expect(openSecret(sealed)).toBe('M3Y3D2Y7');
});
it('cannot be opened with another key', () => {
process.env.ACCOUNTING_SECRET_KEY = 'k1';
const sealed = sealSecret('secret');
process.env.ACCOUNTING_SECRET_KEY = 'k2';
expect(openSecret(sealed)).toBeNull();
expect(openSecret('plain-text')).toBeNull();
});
it('falls back to JWT_SECRET when the dedicated key is blank', () => {
process.env.ACCOUNTING_SECRET_KEY = ' ';
process.env.JWT_SECRET = 'jwt';
expect(openSecret(sealSecret('x'))).toBe('x');
});
});
describe('helpers', () => {
it('normalises every mobile form the two programs store', () => {
expect(normalizeMobile('9121234567')).toBe('09121234567');
expect(normalizeMobile('09121234567')).toBe('09121234567');
expect(normalizeMobile('+98 912 123 4567')).toBe('09121234567');
expect(normalizeMobile('00989121234567')).toBe('09121234567');
expect(normalizeMobile('۰۹۱۲۱۲۳۴۵۶۷')).toBe('09121234567');
expect(normalizeMobile('02155662233')).toBeNull();
expect(normalizeMobile('')).toBeNull();
expect(normalizeMobile(null)).toBeNull();
});
it('reads the numbers Tahesab sends as strings, and its literal "null"', () => {
expect(dec('-177.431')?.toString()).toBe('-177.431');
expect(dec('1,234')?.toString()).toBe('1234');
expect(dec('null')).toBeNull();
expect(dec(null)).toBeNull();
expect(dec('abc')).toBeNull();
});
it('accepts only real national codes', () => {
expect(validNationalCode('0499370899')).toBe('0499370899');
expect(validNationalCode('0000000000')).toBeNull(); // seen on the Tahesab test server
expect(validNationalCode('1234567890')).toBeNull();
expect(validNationalCode(null)).toBeNull();
});
it('builds a Tahesab-safe Factor_Code from an order id', () => {
const code = factorCodeFor('cm1abcdefghijklmnopqrstuv');
expect(code).toBe(code.toLowerCase());
expect(code).toMatch(/^[a-z0-9]+$/);
expect(code.length).toBeGreaterThanOrEqual(20);
});
it('splits a name the way the DB trigger does', () => {
expect(splitName('علی رضا محمدی')).toEqual({ firstName: 'علی', lastName: 'رضا محمدی' });
expect(splitName('masoud')).toEqual({ firstName: 'masoud', lastName: '' });
});
it('scrubs the junk Tahesab replies have carried', () => {
expect(parseLooseJson('{"a":"x\\y"}')).toEqual({ a: 'x-y' });
expect(parseLooseJson('garbage [1,2]')).toEqual([1, 2]);
});
it('fences off every accounting Setting key from the const routes', () => {
expect(isAccountingSettingKey('accounting.config')).toBe(true);
expect(isAccountingSettingKey('Accounting.lease')).toBe(true);
expect(isAccountingSettingKey('wallet.config')).toBe(false);
});
it('fills a partial stored config with defaults', () => {
const c = withDefaults({ provider: 'tahesab', features: { balances: true } });
expect(c.provider).toBe('tahesab');
expect(c.features).toEqual({ customers: false, balances: true, report: false, products: false, documents: false });
expect(c.tahesab.dbName).toBe('TahesabDB');
expect(c.mapping.karat).toBe(750);
expect(withDefaults({ provider: 'other' }).provider).toBe('none');
});
it('keeps Decimal maths exact for the wage-as-karat rule', () => {
const w = new Prisma.Decimal(18.5);
expect(new Prisma.Decimal(750).mul(new Prisma.Decimal(1).add(w.div(100))).toString()).toBe('888.75');
});
});
@@ -0,0 +1,375 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { AccountingOutbox, Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { envOr } from '../../common/env';
import { PrismaService } from '../../prisma/prisma.service';
import { ActiveProvider, AccountingConfigService, JobName } from './accounting-config.service';
import { AccountingSyncService, NeedsReviewError } from './accounting-sync.service';
import { AccountingNotSupportedError, AccountingProviderError } from './providers';
/** Scheduled pulls (customers, full balances, full products): the safety net. */
const TICK_MS = 30_000;
/** Outbox and change poll, the real-time path: checked this often. */
const FAST_TICK_MS = 2_000;
/** How often the change feed is polled. */
const CHANGES_POLL_MS = 5_000;
/** Long enough for a full customer import; renewed before every step. */
const LEASE_MS = 10 * 60_000;
const LEASE_KEY = 'accounting.lease';
const OUTBOX_BATCH = 25;
const MAX_ATTEMPTS = 8;
type PullJob = Exclude<JobName, 'outbox'>;
export const PULL_FEATURE: Record<PullJob, 'customers' | 'balances' | 'products'> = {
customers: 'customers',
balances: 'balances',
products: 'products',
};
function toJson(v: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
if (v === undefined || v === null) return Prisma.JsonNull;
return JSON.parse(JSON.stringify(v)) as Prisma.InputJsonValue;
}
/**
* Runs the accounting integration in the background (D27, plan §3.4). Two
* loops: a fast one (every couple of seconds) for the outbox and the change
* feed, the real-time path; and the 30-second one for the scheduled pulls that
* reconcile what the feed cannot see (new customers, edits and deletions,
* overflow).
*
* In-process, no queue dependency. A lease row in the Setting table
* (`accounting.lease`) keeps a second API instance from running the same jobs
* at the same time; the in-memory flag keeps one instance's ticks from
* overlapping. Set ACCOUNTING_WORKER=off to keep an instance out entirely.
*/
@Injectable()
export class AccountingWorkerService implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger('Accounting');
private readonly owner = randomUUID();
private timer: NodeJS.Timeout | null = null;
private running = false;
private fastRunning = false;
private fastTimer: NodeJS.Timeout | null = null;
private kickTimer: NodeJS.Timeout | null = null;
private changes: { provider: string; cursor: string | null } = { provider: '', cursor: null };
private lastChangePollAt = 0;
private lastChangeWarnAt = 0;
private forced = new Set<PullJob>();
private lastForcedCustomersAt = 0;
/** Shown on the admin screen. */
readonly realtime: {
lastPollAt?: string;
lastOkAt?: string;
lastError?: string | null;
lastChangeAt?: string;
lastSummary?: Record<string, unknown>;
} = {};
private recovered = new Set<string>();
constructor(
private readonly prisma: PrismaService,
private readonly config: AccountingConfigService,
private readonly sync: AccountingSyncService,
) {}
onApplicationBootstrap() {
if (envOr('ACCOUNTING_WORKER', 'on') === 'off' || process.env.NODE_ENV === 'test') return;
this.timer = setInterval(() => void this.tick(), TICK_MS);
this.timer.unref?.();
this.fastTimer = setInterval(() => void this.fastTick(), FAST_TICK_MS);
this.fastTimer.unref?.();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
if (this.fastTimer) clearInterval(this.fastTimer);
if (this.kickTimer) clearTimeout(this.kickTimer);
}
/**
* Something was just queued. Hooks run inside the caller's transaction, so
* wake the outbox a moment later, once it has committed, instead of waiting
* for the next tick.
*/
kickSoon() {
if (this.kickTimer || !this.fastTimer) return;
this.kickTimer = setTimeout(() => {
this.kickTimer = null;
void this.fastTick(true);
}, 700);
this.kickTimer.unref?.();
}
/** True when this instance holds (or just took) the lease. */
private async acquireLease(): Promise<boolean> {
const value = JSON.stringify({ owner: this.owner, until: new Date(Date.now() + LEASE_MS).toISOString() });
await this.prisma.$executeRaw`
INSERT INTO "Setting" ("key", "value", "updatedAt") VALUES (${LEASE_KEY}, ${value}::jsonb, now())
ON CONFLICT ("key") DO NOTHING`;
const n = await this.prisma.$executeRaw`
UPDATE "Setting" SET "value" = ${value}::jsonb, "updatedAt" = now()
WHERE "key" = ${LEASE_KEY}
AND ("value"->>'owner' = ${this.owner} OR ("value"->>'until')::timestamptz < now())`;
return n > 0;
}
async tick(only?: JobName): Promise<boolean> {
if (this.running) return false;
this.running = true;
try {
if (!(await this.acquireLease())) return false;
const a = await this.config.active();
if (!a) return false;
if (!this.recovered.has(a.provider.name)) {
await this.recoverInterrupted(a);
this.recovered.add(a.provider.name);
}
if (only === 'outbox') await this.processOutbox(a);
for (const job of ['balances', 'customers', 'products'] as PullJob[]) {
if (only && only !== job) continue;
if (!a.config.features[PULL_FEATURE[job]]) continue;
const wasForced = this.forced.delete(job);
if (!only && !wasForced && !(await this.isDue(job, a))) continue;
await this.acquireLease();
await this.runPull(job, a);
}
return true;
} catch (err) {
this.logger.error(`Accounting tick failed: ${(err as Error).message}`);
return false;
} finally {
this.running = false;
}
}
/**
* The real-time path, every couple of seconds: send queued documents and
* edits at once, and poll the software's change feed so a balance or a stock
* figure changed there is changed here moments later. Never waits behind a
* long scheduled pull (it has its own guard, apart from tick()).
*/
async fastTick(force = false): Promise<void> {
if (this.fastRunning) return;
this.fastRunning = true;
try {
if (!(await this.acquireLease())) return;
const a = await this.config.active();
if (!a) return;
if (!this.recovered.has(a.provider.name)) {
await this.recoverInterrupted(a);
this.recovered.add(a.provider.name);
}
await this.processOutbox(a);
if (!force && Date.now() - this.lastChangePollAt < CHANGES_POLL_MS) return;
if (!a.provider.capabilities.changeFeed || !(a.config.features.balances || a.config.features.products)) return;
this.lastChangePollAt = Date.now();
await this.pollChanges(a);
} catch (err) {
this.logger.error(`Accounting fast tick failed: ${(err as Error).message}`);
} finally {
this.fastRunning = false;
}
}
private async pollChanges(a: ActiveProvider) {
if (this.changes.provider !== a.provider.name) this.changes = { provider: a.provider.name, cursor: null };
this.realtime.lastPollAt = new Date().toISOString();
try {
const baseline = this.changes.cursor === null;
const r = await this.sync.pollChanges(a, this.changes.cursor);
this.changes.cursor = r.cursor;
this.realtime.lastOkAt = new Date().toISOString();
this.realtime.lastError = null;
if (r.balances > 0 || r.stocks > 0) {
this.realtime.lastChangeAt = this.realtime.lastOkAt;
this.realtime.lastSummary = { balances: r.balances, stocks: r.stocks };
}
if (baseline) return;
// What the feed cannot describe goes to the scheduled jobs right away.
if (r.fullBalances) this.forced.add('balances');
if (r.fullProducts) this.forced.add('products');
if (r.newAccounts && a.config.features.customers && Date.now() - this.lastForcedCustomersAt > 60_000) {
this.lastForcedCustomersAt = Date.now();
this.forced.add('customers');
}
if (this.forced.size > 0) void this.tick();
} catch (err) {
const msg = this.sync.describeError(err);
this.realtime.lastError = msg;
if (Date.now() - this.lastChangeWarnAt > 60_000) {
this.lastChangeWarnAt = Date.now();
this.logger.warn(`Accounting change poll failed: ${msg}`);
}
}
}
/**
* A sync button (plan §12): runs these pulls now and waits for them. Needs
* only a connected provider — the feature switches do not apply. Each job
* reports on its own, so one failing does not hide the others.
*/
async runPullsNow(jobs: PullJob[]): Promise<Record<string, { ok: boolean; summary?: unknown; error?: string }> | null> {
if (this.running) return null;
this.running = true;
try {
const a = await this.config.active();
if (!a) return null;
const out: Record<string, { ok: boolean; summary?: unknown; error?: string }> = {};
for (const job of jobs) {
await this.acquireLease();
const startedAt = new Date().toISOString();
await this.config.recordJob(job, { lastRunAt: startedAt });
try {
const summary =
job === 'customers'
? await this.sync.pullCustomers(a)
: job === 'balances'
? await this.sync.pullBalances(a)
: await this.sync.pullProducts(a);
await this.config.recordJob(job, { lastOkAt: new Date().toISOString(), lastError: null, summary });
out[job] = { ok: true, summary };
} catch (err) {
const error = this.sync.describeError(err);
await this.config.recordJob(job, { lastError: error });
out[job] = { ok: false, error };
}
}
return out;
} finally {
this.running = false;
}
}
/** «سینک الان» from the admin screen: runs in the background, returns at once. */
runNow(job: JobName): boolean {
if (job === 'outbox') {
if (this.fastRunning) return false;
void this.fastTick(true);
return true;
}
if (this.running) return false;
void this.tick(job);
return true;
}
get busy() {
return this.running;
}
private async isDue(job: PullJob, a: ActiveProvider) {
const minutes = {
customers: a.config.intervals.customersMinutes,
balances: a.config.intervals.balancesMinutes,
products: a.config.intervals.productsMinutes,
}[job];
const state = await this.config.getState();
const last = state.jobs[job]?.lastRunAt;
return !last || Date.now() - new Date(last).getTime() >= minutes * 60_000;
}
private async runPull(job: PullJob, a: ActiveProvider) {
const startedAt = new Date().toISOString();
await this.config.recordJob(job, { lastRunAt: startedAt });
try {
const summary =
job === 'customers'
? await this.sync.pullCustomers(a)
: job === 'balances'
? await this.sync.pullBalances(a)
: await this.sync.pullProducts(a);
await this.config.recordJob(job, { lastOkAt: new Date().toISOString(), lastError: null, summary });
} catch (err) {
const msg = this.sync.describeError(err);
this.logger.warn(`Accounting ${job} pull failed: ${msg}`);
await this.config.recordJob(job, { lastError: msg });
}
}
/**
* A job left in SENDING means the process stopped mid-call: its outcome is
* unknown. Safe kinds go back to the queue; a Zarrin document goes to a
* person, since sending it again could post it twice.
*/
private async recoverInterrupted(a: ActiveProvider) {
const stuck = await this.prisma.accountingOutbox.findMany({ where: { provider: a.provider.name, status: 'SENDING' } });
for (const job of stuck) {
const safe = this.sync.isRetrySafe(a.provider.name, job.kind);
await this.prisma.accountingOutbox.update({
where: { id: job.id },
data: safe
? { status: 'PENDING', nextAttemptAt: new Date() }
: {
status: 'NEEDS_REVIEW',
lastError: 'سرور هنگام ارسال متوقف شد؛ معلوم نیست سند ثبت شده یا نه. پیش از «تلاش دوباره» در حسابداری بررسی کنید.',
},
});
}
}
private async processOutbox(a: ActiveProvider) {
const jobs = await this.prisma.accountingOutbox.findMany({
where: { provider: a.provider.name, status: 'PENDING', nextAttemptAt: { lte: new Date() } },
orderBy: { createdAt: 'asc' },
take: OUTBOX_BATCH,
});
const startedAt = new Date().toISOString();
let sent = 0;
let failed = 0;
for (const job of jobs) {
await this.acquireLease();
const ok = await this.runOne(a, job);
if (ok) sent += 1;
else failed += 1;
}
if (jobs.length > 0) {
await this.config.recordJob('outbox', { lastRunAt: startedAt, lastOkAt: new Date().toISOString(), summary: { picked: jobs.length, sent, failed } });
}
}
private async runOne(a: ActiveProvider, job: AccountingOutbox): Promise<boolean> {
const attempts = job.attempts + 1;
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { status: 'SENDING', attempts } });
const fresh = { ...job, attempts };
try {
const res = await this.sync.runJob(a, fresh);
await this.prisma.accountingOutbox.update({
where: { id: job.id },
data: {
status: 'SENT',
sentAt: new Date(),
externalRef: res.externalRef ?? null,
lastRequest: toJson(res.request),
lastResponse: toJson(res.response),
lastError: null,
},
});
return true;
} catch (err) {
const msg = this.sync.describeError(err);
const response = err instanceof AccountingProviderError ? err.response : undefined;
let data: Prisma.AccountingOutboxUpdateInput;
if (err instanceof NeedsReviewError || err instanceof AccountingNotSupportedError) {
data = { status: 'NEEDS_REVIEW', lastError: msg };
} else if (err instanceof AccountingProviderError && err.ambiguous && !this.sync.isRetrySafe(a.provider.name, job.kind)) {
data = {
status: 'NEEDS_REVIEW',
lastError: `${msg} — ممکن است سند ثبت شده باشد؛ پیش از «تلاش دوباره» در حسابداری بررسی کنید.`,
};
} else if (attempts >= MAX_ATTEMPTS) {
data = { status: 'FAILED', lastError: msg };
} else {
const delayMin = Math.min(2 ** (attempts - 1), 60);
data = { status: 'PENDING', lastError: msg, nextAttemptAt: new Date(Date.now() + delayMin * 60_000) };
}
if (response !== undefined) data.lastResponse = toJson(response);
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data });
this.logger.warn(`Accounting job ${job.kind} ${job.entityId} → ${String(data.status)}: ${msg}`);
return false;
}
}
}
@@ -0,0 +1,152 @@
/**
* Jalali (Shamsi) ⇄ Gregorian conversion.
*
* Tahesab wants a document date as separate Shamsi year/month/day integers and
* its list filters as `1405-04-24`; the customer app sends report ranges as
* `1405/04/24`; Zarrin wants Gregorian `2024-10-12` on most calls. One small
* converter covers all three without a dependency.
*
* The arithmetic is the well-known jalaali-js algorithm (Borkowski's leap-year
* break table), valid for Jalali years -61 … 3177.
*/
export interface JalaliDate {
jy: number;
jm: number;
jd: number;
}
const BREAKS = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456,
3178,
];
function div(a: number, b: number) {
return ~~(a / b);
}
function mod(a: number, b: number) {
return a - ~~(a / b) * b;
}
function jalCal(jy: number) {
const gy = jy + 621;
let leapJ = -14;
let jp = BREAKS[0];
let jump = 0;
if (jy < jp || jy >= BREAKS[BREAKS.length - 1]) throw new RangeError(`Invalid Jalali year ${jy}`);
for (let i = 1; i < BREAKS.length; i += 1) {
const jm = BREAKS[i];
jump = jm - jp;
if (jy < jm) break;
leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
jp = jm;
}
let n = jy - jp;
leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
const march = 20 + leapJ - leapG;
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
let leap = mod(mod(n + 1, 33) - 1, 4);
if (leap === -1) leap = 4;
return { leap, gy, march };
}
function g2d(gy: number, gm: number, gd: number) {
let d =
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + div(153 * mod(gm + 9, 12) + 2, 5) + gd - 34840408;
d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
return d;
}
function d2g(jdn: number) {
let j = 4 * jdn + 139361631;
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
const i = div(mod(j, 1461), 4) * 5 + 308;
const gd = div(mod(i, 153), 5) + 1;
const gm = mod(div(i, 153), 12) + 1;
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
return { gy, gm, gd };
}
function j2d(jy: number, jm: number, jd: number) {
const r = jalCal(jy);
return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
}
function d2j(jdn: number): JalaliDate {
const gy = d2g(jdn).gy;
let jy = gy - 621;
const r = jalCal(jy);
const jdn1f = g2d(gy, 3, r.march);
let k = jdn - jdn1f;
if (k >= 0) {
if (k <= 185) return { jy, jm: 1 + div(k, 31), jd: mod(k, 31) + 1 };
k -= 186;
} else {
jy -= 1;
k += 179;
if (r.leap === 1) k += 1;
}
return { jy, jm: 7 + div(k, 30), jd: mod(k, 30) + 1 };
}
/** Iran has had no DST since 2022, so a fixed +03:30 gives the local calendar day. */
const TEHRAN_OFFSET_MS = (3 * 60 + 30) * 60 * 1000;
/** The Jalali calendar day of an instant, as seen in Tehran. */
export function toJalali(date: Date): JalaliDate {
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
return d2j(g2d(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate()));
}
/** Gregorian calendar day for a Jalali day: `{ gy, gm, gd }`. */
export function jalaliToGregorian(jy: number, jm: number, jd: number) {
return d2g(j2d(jy, jm, jd));
}
/**
* Parses `1405/04/24`, `1405-4-24` or Persian digits. Returns null for anything
* that is not a real Jalali date.
*/
export function parseJalali(input: string | null | undefined): JalaliDate | null {
if (!input) return null;
const ascii = String(input)
.trim()
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
const m = ascii.match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/);
if (!m) return null;
const jy = Number(m[1]);
const jm = Number(m[2]);
const jd = Number(m[3]);
if (jm < 1 || jm > 12 || jd < 1 || jd > 31 || (jm > 6 && jd > 30)) return null;
try {
// Round-trip rejects 30 Esfand in a common year.
const back = d2j(j2d(jy, jm, jd));
return back.jy === jy && back.jm === jm && back.jd === jd ? { jy, jm, jd } : null;
} catch {
return null;
}
}
const pad = (n: number) => String(n).padStart(2, '0');
/** `1405-04-24` (or with another separator). */
export function formatJalali(d: JalaliDate, sep = '-') {
return `${d.jy}${sep}${pad(d.jm)}${sep}${pad(d.jd)}`;
}
/** `2026-07-15` for a Jalali day. */
export function jalaliToIsoDate(d: JalaliDate) {
const g = jalaliToGregorian(d.jy, d.jm, d.jd);
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}`;
}
/** `2026-07-15` for an instant, Tehran calendar day. */
export function toIsoDateTehran(date: Date) {
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
return `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`;
}
@@ -0,0 +1,310 @@
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<Record<string, unknown>>;
listCustomerGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
/** 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<ExternalCustomer[]>;
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<ExternalBalance[]>;
/**
* 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<ChangeBatch>;
getStatement(s: ProviderSettings, q: StatementQuery): Promise<StatementRow[]>;
listKarSakhteGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
listKarSakhteTypes(s: ProviderSettings): Promise<ExternalGroup[]>;
listKarSakhte(s: ProviderSettings): Promise<ExternalKarSakhte[]>;
getKarSakhteStock(s: ProviderSettings, item: { code: string; name: string }): Promise<KarSakhteStock>;
getKarSakhteMovement(s: ProviderSettings, from: JalaliDate, to: JalaliDate): Promise<MovementRow[]>;
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<void>,
): Promise<DocumentPostResult>;
/** True when a document with this key exists. */
documentExists(s: ProviderSettings, doc: { factorCode: string; ref: string | null }): Promise<boolean>;
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(' ') };
}
@@ -0,0 +1,18 @@
import { AccountingProvider, ProviderName } from './accounting-provider';
import { tahesabProvider } from './tahesab.provider';
import { zarrinProvider } from './zarrin.provider';
/**
* Every accounting software, keyed by the admin switch's value. A new one is a
* driver file plus one line here — the same shape as the SMS drivers (D26).
*/
export const ACCOUNTING_PROVIDERS: Readonly<Record<ProviderName, AccountingProvider>> = {
tahesab: tahesabProvider,
zarrin: zarrinProvider,
};
export function isProviderName(v: unknown): v is ProviderName {
return typeof v === 'string' && Object.prototype.hasOwnProperty.call(ACCOUNTING_PROVIDERS, v);
}
export * from './accounting-provider';
@@ -0,0 +1,298 @@
import { Prisma } from '@prisma/client';
import { httpRequest } from '../accounting-http';
import { AccountingProviderError, OrderDocumentInput, ProviderSettings } from './accounting-provider';
import { karatWithWage, tahesabProvider as t } from './tahesab.provider';
jest.mock('../accounting-http', () => ({
...jest.requireActual('../accounting-http'),
httpRequest: jest.fn(),
}));
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
const s: ProviderSettings = { baseUrl: 'https://tahesab.local:8081/', token: 'TOK', caCert: null, timeoutMs: 5000, dbName: 'TahesabDB' };
const reply = (status: number, body: unknown) =>
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
/** The body of the n-th request, parsed. */
const sent = (n = 0) => JSON.parse(http.mock.calls[n][0].body as string);
beforeEach(() => http.mockReset());
describe('wire format', () => {
it('posts {"Method": [args]} to the root with the bearer token and DBname', async () => {
http.mockReturnValueOnce(reply(200, [{ Name: 'بنکدار', GID: 2 }]));
const groups = await t.listCustomerGroups(s);
const req = http.mock.calls[0][0];
expect(req.method).toBe('POST');
expect(req.url).toBe('https://tahesab.local:8081/');
expect(req.headers).toMatchObject({ Authorization: 'Bearer TOK', DBname: 'TahesabDB' });
expect(sent()).toEqual({ DoListGorooh: [] });
// GID 0 (accounts in no group) is offered too.
expect(groups).toEqual([
{ id: '2', name: 'بنکدار' },
{ id: '0', name: 'بدون گروه (GID 0)' },
]);
});
it('turns {"ERROR": …} into a provider error, flagging "not found"', async () => {
http.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.', Command: 'POST', SSL: '1' }));
await expect(t.updateCustomer(s, '5', { firstName: 'a', lastName: 'b', fullName: 'a b', mobile: '09120000000', phone: null, address: null, nationalCode: null, group: { id: null, name: null } })).rejects.toMatchObject({
notFound: true,
response: { ERROR: 'هیچ رکوردی یافت نشد.' },
});
});
it('reports a timeout after the request went out as ambiguous', async () => {
const { AccountingHttpError } = jest.requireActual('../accounting-http');
http.mockRejectedValueOnce(new AccountingHttpError('timeout', true, 'TIMEOUT'));
await expect(t.listCustomerGroups(s)).rejects.toMatchObject({ ambiguous: true });
});
});
describe('customers', () => {
it('pages through code windows and stops after empty ones', async () => {
http.mockReturnValueOnce(
reply(200, {
'1': { Code: 1, Name: 'masoud', GID: '2', GoroupName: 'بنکدار', Mobile: '9121234567', CodeMelli: '', Address: 'آدرس' },
}),
);
const page1 = await t.listCustomers(s, null);
expect(sent()).toEqual({ Dolistmoshtari: [1, 500] });
expect(page1.items[0]).toMatchObject({ code: '1', mobile: '09121234567', groupId: '2', nationalCode: null });
expect(page1.next).toBe('501:0');
http.mockReturnValue(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
let cursor: string | null = page1.next;
let calls = 0;
while (cursor) {
cursor = (await t.listCustomers(s, cursor)).next;
calls += 1;
}
expect(calls).toBe(4);
});
it('looks a mobile up in both stored forms and keeps exact matches only', async () => {
http
.mockReturnValueOnce(reply(200, { '1': { Code: 1, Name: 'a', Mobile: '9121234567' }, '790': { Code: 790, Name: 'b', Mobile: '9121234567' } }))
.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
const found = await t.findCustomersByMobile(s, '09121234567');
expect(sent(0)).toEqual({ DoListMoshtari: [9121234567] });
expect(sent(1)).toEqual({ DoListMoshtari: ['09121234567'] });
expect(found.map((c) => c.code)).toEqual(['1', '790']);
});
it('creates with an auto code and returns it', async () => {
http.mockReturnValueOnce(reply(200, { OK: 35 }));
const res = await t.createCustomer(s, {
firstName: 'علی',
lastName: 'محمدی',
fullName: 'علی محمدی',
mobile: '09121112233',
phone: null,
address: null,
nationalCode: null,
group: { id: '13', name: 'مشتري' },
});
expect(res.code).toBe('35');
const args = sent().DoNewMoshtari;
expect(args[0]).toBe('علی محمدی');
expect(args[1]).toBe('مشتري');
expect(args[2]).toBe('09121112233');
expect(args[8]).toBe(-1);
});
});
describe('balances and statement', () => {
it('flips the sign: in Tahesab negative means the customer owes', async () => {
http.mockReturnValueOnce(
reply(200, { MandeHesab: [{ Code: '1', MandeyeMali: '-238999230', MandeyeVazni: '-177.431' }, { Code: '2', MandeyeMali: '5', MandeyeVazni: '1.5' }] }),
);
const b = await t.getBalances(s, ['1', '2', '999']);
expect(sent()).toEqual({ getmandehesabbycode: ['1', '2', '999'] });
expect(b).toEqual([
{ code: '1', goldGrams: new Prisma.Decimal('177.431'), rial: new Prisma.Decimal('238999230') },
{ code: '2', goldGrams: new Prisma.Decimal('-1.5'), rial: new Prisma.Decimal('-5') },
]);
});
it('maps DoListAsnad rows (real test-server rows)', async () => {
http.mockReturnValueOnce(
reply(200, {
'11558': {
ID: 11558, Radif: 1, Factor_Code: '123456789123421578191111985', MCode: 1, Tarikh: '1405/07/11', Tarikh_Time: '19:47:44',
NO: 'ورود کارساخته', Sh_Factor: 228, Vazn: 1.25, No_Kar: 'testmm', Ayar: 750, Fi: 10000, Mazaneh: 'null', Count: 1,
TabdilVazn: 1.25, Mali: 100000, Sharh1: 'sharhtest', TahesabVazni: 11526.174, TahesabMali: -209358396276,
Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: false, DarsadSood: 5.5,
},
'12261': {
ID: 12261, Radif: 1, Factor_Code: 'GBORDX', MCode: 1, Tarikh: '1405/07/12', NO: 'خروج کارساخته', Vazn: 2,
TabdilVazn: -2, Mali: 'null', TahesabVazni: 0, TahesabMali: 0, Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: true,
},
}),
);
const rows = await t.getStatement(s, { code: '1', from: { jy: 1405, jm: 6, jd: 1 }, to: { jy: 1405, jm: 7, jd: 13 } });
expect(sent()).toEqual({ DoListAsnad: [-1, 1, '1405-06-01', '1405-07-13', '', 0, 'IRR'] });
expect(rows[0]).toMatchObject({
documentRef: '123456789123421578191111985',
accountCode: '1',
title: 'ورود کارساخته',
itemName: 'testmm',
metal: 'طلا',
temporary: false,
});
expect(rows[0].goldIn.toString()).toBe('1.25');
expect(rows[0].goldOut.toString()).toBe('0');
expect(rows[0].rialIn.toString()).toBe('100000');
expect(rows[0].goldBalance?.toString()).toBe('-11526.174');
expect(rows[0].rialBalance?.toString()).toBe('209358396276');
// A temporary row moves nothing yet: no balance of its own.
expect(rows[1]).toMatchObject({ documentRef: 'gbordx', temporary: true, goldBalance: null });
expect(rows[1].goldOut.toString()).toBe('2');
});
});
describe('order documents', () => {
const doc: OrderDocumentInput = {
factorCode: 'gbordcm1abcdefghijklmnopqrstuv',
customerCode: '2',
date: new Date('2026-10-05T08:00:00Z'),
description: 'سفارش 1001 از اپ',
temporary: true,
lines: [
{ itemCode: 'انگشتر', itemName: 'انگشتر', weightGrams: new Prisma.Decimal('4.200'), count: 2, wagePercent: new Prisma.Decimal(10), flatRial: new Prisma.Decimal(0), karat: 750, description: 'line 1' },
{ itemCode: 'النگو', itemName: 'النگو', weightGrams: new Prisma.Decimal('10'), count: 1, wagePercent: new Prisma.Decimal('18.5'), flatRial: new Prisma.Decimal(500000), karat: 750, description: 'line 2' },
],
};
it('raises the karat by the making charge — the only field that moves the gold balance', () => {
expect(karatWithWage(750, new Prisma.Decimal(10)).toString()).toBe('825');
expect(karatWithWage(750, new Prisma.Decimal('18.5')).toString()).toBe('888.75');
});
it('posts one temporary exit row per line under one Factor_Code', async () => {
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
const progress: unknown[] = [];
const res = await t.postOrderDocument(s, doc, {}, async (p) => {
progress.push(p);
});
const r1 = sent(0).DoNewSanadVKHKar;
const r2 = sent(1).DoNewSanadVKHKar;
expect(r1).toHaveLength(27);
expect(r1[0]).toBe(0); // temporary
expect(r1[1]).toBe(2); // Moshtari_Code
expect([r1[3], r2[3]]).toEqual([1, 2]); // Radif_Number
expect([r1[4], r1[5], r1[6]]).toEqual([1405, 7, 13]);
expect(r1[7]).toBe(4.2); // physical weight
expect(r1[8]).toBe(825); // karat with the charge
expect(r1[9]).toBe(2);
expect(r1[10]).toBe('انگشتر');
expect(r1[11]).toBe(1); // exit
expect(r1[16]).toBe(0); // Darsad_Talayee never double-counts
expect(r2[13]).toBe(500000); // stones in MablaghKol
expect(r2[20]).toBe(0); // not PoolSang
expect(r1[22]).toBe(doc.factorCode);
expect(r1[24]).toBe(1); // MultiRadif
expect(progress).toEqual([{ doneLines: 1 }, { doneLines: 2 }]);
expect(res.ref).toBe(doc.factorCode);
});
it('resumes after the last confirmed line', async () => {
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
await t.postOrderDocument(s, doc, { doneLines: 1 }, async () => undefined);
expect(http).toHaveBeenCalledTimes(1);
expect(sent(0).DoNewSanadVKHKar[3]).toBe(2);
});
it('treats a duplicate-row rejection as "already there", which makes a retry safe', async () => {
http
.mockReturnValueOnce(
reply(400, {
ERROR:
"[EUniError] The statement has been terminated.\r\nCannot insert duplicate key row in object 'dbo.hesab_detail' with unique index 'UX_hesab_detail_UniqRowID'.",
}),
)
.mockReturnValueOnce(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
const res = await t.postOrderDocument(s, doc, {}, async () => undefined);
expect(res.progress).toEqual({ doneLines: 2 });
});
it('does not swallow other errors', async () => {
http.mockReturnValueOnce(reply(400, { ERROR: 'کد حساب نامعتبر است' }));
await expect(t.postOrderDocument(s, doc, {}, async () => undefined)).rejects.toBeInstanceOf(AccountingProviderError);
});
it('inquires and deletes by Factor_Code', async () => {
http.mockReturnValueOnce(reply(200, { Found: doc.factorCode })).mockReturnValueOnce(reply(404, { ERROR: 'Not_Found' }));
expect(await t.documentExists(s, { factorCode: doc.factorCode, ref: null })).toBe(true);
expect(await t.documentExists(s, { factorCode: 'x', ref: null })).toBe(false);
http.mockReturnValueOnce(reply(404, { ERROR: 'سند یافت نشد.' }));
expect((await t.deleteDocument(s, { factorCode: 'x', ref: null, customerCode: '2' })).response).toEqual({ alreadyDeleted: true });
});
});
describe('items', () => {
it('skips item names that lost their encoding and reads stock per name', async () => {
http.mockReturnValueOnce(reply(200, [{ Name: 'انگشتر', Ayar: 750 }, { Name: '???????0.400????' }]));
const items = await t.listKarSakhte(s);
expect(items.map((i) => i.name)).toEqual(['انگشتر']);
http.mockReturnValueOnce(reply(200, { Name: 'انگشتر', VaznMojood: 12.5, TedadMojood: 3, MaxAyar: 750 }));
const stock = await t.getKarSakhteStock(s, { code: 'انگشتر', name: 'انگشتر' });
expect(sent(1)).toEqual({ GetMojoodiKarSakhte: ['انگشتر', 0] });
expect(stock.weight?.toString()).toBe('12.5');
expect(stock.count?.toString()).toBe('3');
});
it('cannot create items through the API', async () => {
await expect(t.createKarSakhte(s, { name: 'x', groupId: null, typeId: null })).rejects.toThrow('وجود ندارد');
});
});
describe('change feed (real-time sync)', () => {
const row = (ID: number, MCode: number, extra: Record<string, unknown> = {}) => ({
ID,
MCode,
NO: 'فروش طلا',
No_Kar: 'null',
ZamanEdit: null,
...extra,
});
const feed = (rows: ReturnType<typeof row>[]) => reply(200, Object.fromEntries(rows.map((r) => [String(r.ID), r])));
it('the first poll only returns the starting cursor', async () => {
http.mockReturnValueOnce(feed([row(10, 1), row(11, 2)]));
expect(await t.getChanges(s, null)).toEqual({ cursor: '11|', accountCodes: [], itemNames: [], overflow: false });
expect(sent()).toEqual({ DoListUpdatedAsnad: [200] });
});
it('later polls report the accounts and کارساخته of new documents only', async () => {
http.mockReturnValueOnce(
feed([row(11, 2), row(12, 7), row(13, 7), row(14, 9, { NO: 'خروج کارساخته', No_Kar: 'انگشتر' })]),
);
const r = await t.getChanges(s, '11|');
expect(r.accountCodes.sort()).toEqual(['7', '9']);
expect(r.itemNames).toEqual(['انگشتر']);
expect(r.cursor).toBe('14|');
expect(r.overflow).toBe(false);
});
it('sees an edit of an old document through ZamanEdit', async () => {
http.mockReturnValueOnce(feed([row(11, 2, { ZamanEdit: '1405/07/12 18:00:00' }), row(12, 3)]));
const r = await t.getChanges(s, '12|1405/07/12 17:00:00');
expect(r.accountCodes).toEqual(['2']);
});
it('says overflow when every row of the window is new', async () => {
const rows = Array.from({ length: 200 }, (_, i) => row(100 + i, i));
http.mockReturnValueOnce(feed(rows));
expect((await t.getChanges(s, '50|')).overflow).toBe(true);
});
it('reports nothing when nothing changed', async () => {
http.mockReturnValueOnce(feed([row(11, 2), row(12, 3)]));
expect(await t.getChanges(s, '12|')).toMatchObject({ accountCodes: [], itemNames: [], overflow: false });
});
});
@@ -0,0 +1,511 @@
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,
};
}
@@ -0,0 +1,215 @@
import { Prisma } from '@prisma/client';
import { httpRequest } from '../accounting-http';
import { OrderDocumentInput, ProviderSettings } from './accounting-provider';
import { __zarrinInternals, zarrinProvider as z } from './zarrin.provider';
jest.mock('../accounting-http', () => ({
...jest.requireActual('../accounting-http'),
httpRequest: jest.fn(),
}));
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
const s: ProviderSettings = {
baseUrl: 'http://zarrin.local:8000/ZarrinService/accounting',
token: 'SECRET',
caCert: null,
timeoutMs: 5000,
balanceOwesWhen: 'negative',
};
const reply = (body: unknown, status = 200) =>
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
const url = (n = 0) => new URL(http.mock.calls[n][0].url);
beforeEach(() => http.mockReset());
describe('wire format', () => {
it('sends GET ?kind=…&token=… with the parameters, and nothing blank', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '4', name: 'مشتری (خریدار)' }] }));
const groups = await z.listCustomerGroups(s);
const u = url();
expect(http.mock.calls[0][0].method).toBe('GET');
expect(u.pathname).toBe('/ZarrinService/accounting');
expect(u.searchParams.get('kind')).toBe('listGorooheHesabha');
expect(u.searchParams.get('token')).toBe('SECRET');
expect(groups).toEqual([{ id: '4', name: 'مشتری (خریدار)' }]);
});
it('unwraps the JSON-in-a-string replies the documentation shows', async () => {
const inner = JSON.stringify({ state: true, msg: 'حساب با موفقیت ایجاد شد', id: 132 });
http.mockReturnValueOnce(reply(JSON.stringify(inner)));
const res = await z.createCustomer(s, {
firstName: 'تست',
lastName: 'تست',
fullName: 'تست تست',
mobile: '09121112233',
phone: null,
address: null,
nationalCode: null,
group: { id: '4', name: 'مشتری' },
});
expect(res.code).toBe('132');
expect(url().searchParams.get('fkNoe')).toBe('4');
expect(url().searchParams.has('telephone')).toBe(false);
});
it('turns state:false into an error carrying the message', async () => {
http.mockReturnValueOnce(reply({ state: false, msg: 'دسترسی مجاز نیست' }));
await expect(z.listCustomerGroups(s)).rejects.toThrow('دسترسی مجاز نیست');
});
it('lists the different envelopes Zarrin uses', () => {
const { listOf, fullJalali } = __zarrinInternals;
expect(listOf({ data: [1] })).toEqual([1]);
expect(listOf({ date: [2] })).toEqual([2]); // Get100Account's typo
expect(listOf([3])).toEqual([3]);
expect(listOf({ id: '84', nam: 'x' })).toEqual([{ id: '84', nam: 'x' }]);
expect(fullJalali('04/05/29')).toBe('1404/05/29');
});
});
describe('customers', () => {
it('pages with Get100Account and fills groups from listhesabha', async () => {
const page = Array.from({ length: 100 }, (_, i) => ({ code: 81 + i, name: 'n', namKhanevadegi: `f${i}`, mobile: i === 0 ? '09121112233' : '' }));
http
.mockReturnValueOnce(reply({ state: true, msg: '', date: page }))
.mockReturnValueOnce(reply({ data: [{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' }] }));
const res = await z.listCustomers(s, null);
expect(url(0).searchParams.get('id')).toBe('0');
expect(url(1).searchParams.get('az')).toBe('81');
expect(url(1).searchParams.get('ta')).toBe('180');
expect(res.items[0]).toMatchObject({ code: '81', name: 'حسین اصغری', mobile: '09121112233', groupName: 'مشتری (خریدار)', phone: '021' });
expect(res.next).toBe('181');
});
it('keeps the account in its group on edit', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'ویرایش اطلاعات با موفقیت انجام شد' }));
await z.updateCustomer(s, '101', {
firstName: 'حسین',
lastName: 'اصغری',
fullName: 'حسین اصغری',
mobile: '09121427233',
phone: null,
address: 'خیابان مولوی',
nationalCode: null,
group: { id: null, name: 'واسطه' },
});
expect(url().searchParams.get('nameG')).toBe('واسطه');
expect(url().searchParams.get('id')).toBe('101');
});
});
describe('balances and statement', () => {
it('applies the configured sign', async () => {
http.mockReturnValue(reply({ state: true, msg: 'OK', data: [{ id: '108', name: 'x', geram: '-75.409', pool: '-3.211960271E9' }, { id: '9', geram: '1', pool: '1' }] }));
const neg = await z.getBalances(s, ['108', '5']);
expect(neg).toHaveLength(1);
expect(neg[0].goldGrams.toString()).toBe('75.409');
expect(neg[0].rial.toString()).toBe('3211960271');
const pos = await z.getBalances({ ...s, balanceOwesWhen: 'positive' }, ['108', '9']);
expect(pos[0].goldGrams.toString()).toBe('-75.409');
});
it('folds a running balance from «مانده از قبل»', async () => {
http.mockReturnValueOnce(
reply({
state: true,
detail: 'OK',
data: [
{ idRadif: '0', idFactor: '0', idHesab: '81', sharh: 'مانده از قبل', vaznTarazooPardakht: '10.0', vaznTarazooDaryaft: '0.0', poolPardakht: '0', poolDaryaft: '0' },
{ idRadif: '470', idFactor: '221', idHesab: '81', noeRadif: 'چک', tarikh: '04/05/29', vaznTarazooPardakht: '0.0', vaznTarazooDaryaft: '2.5', poolPardakht: '810000000', poolDaryaft: '0', ayar: '0' },
],
}),
);
const rows = await z.getStatement(s, { code: '81', from: { jy: 1404, jm: 5, jd: 1 }, to: { jy: 1404, jm: 5, jd: 31 } });
expect(url().searchParams.get('dateAz')).toBe('2025-07-23');
expect(rows[0].goldBalance?.toString()).toBe('10');
expect(rows[1]).toMatchObject({ documentRef: '221', date: '1404/05/29', title: 'چک' });
expect(rows[1].goldBalance?.toString()).toBe('7.5');
expect(rows[1].rialBalance?.toString()).toBe('810000000');
});
});
describe('order documents', () => {
const line = (code: string, wage: number) => ({
itemCode: code,
itemName: code,
weightGrams: new Prisma.Decimal('10'),
count: 1,
wagePercent: new Prisma.Decimal(wage),
flatRial: new Prisma.Decimal(0),
karat: 750,
description: 'd',
});
const doc: OrderDocumentInput = {
factorCode: 'gbordx00000000000000000',
customerCode: '81',
date: new Date(),
description: 'سفارش',
temporary: true,
lines: [line('19', 12), line('20', 8)],
};
it('opens the invoice with the account, appends with the invoice id, in bonak mode', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } })).mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } }));
const progress: unknown[] = [];
const res = await z.postOrderDocument(s, doc, {}, async (p) => {
progress.push(p);
});
const a = url(0).searchParams;
const b = url(1).searchParams;
expect(a.get('kind')).toBe('foroushSakhte');
expect(a.get('idHesab')).toBe('81');
expect(a.has('idFactor')).toBe(false);
expect(a.get('idSakhte')).toBe('19');
expect(a.get('darsad')).toBe('12');
expect(a.get('vazn')).toBe('10.000');
expect(a.get('pd')).toBe('-1');
expect(a.get('bonak')).toBe('1');
expect(a.get('movaghat')).toBe('1');
expect(b.get('idFactor')).toBe('117764');
expect(b.has('idHesab')).toBe(false);
expect(b.get('movaghat')).toBe('1'); // temporary, as the owner ruled
expect(progress).toEqual([
{ factorId: '117764', doneLines: 1 },
{ factorId: '117764', doneLines: 2 },
]);
expect(res.ref).toBe('117764');
});
it('resumes into the same invoice', async () => {
http.mockReturnValueOnce(reply({ state: true, data: { idFactor: 5 } }));
await z.postOrderDocument(s, doc, { factorId: '5', doneLines: 1 }, async () => undefined);
expect(http).toHaveBeenCalledTimes(1);
expect(url().searchParams.get('idFactor')).toBe('5');
expect(url().searchParams.get('idSakhte')).toBe('20');
});
it('finds the row to delete in the ledger, since foroushSakhte never returned it', async () => {
http
.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '0', idFactor: '0' }, { idRadif: '731', idFactor: '390' }] }))
.mockReturnValueOnce(reply({ state: true, msg: 'حذف با موفقیت انجام شد :)' }));
await z.deleteDocument(s, { factorCode: 'x', ref: '390', customerCode: '81' });
expect(url(1).searchParams.get('kind')).toBe('hazfRadifSanad');
expect(url(1).searchParams.get('pkRadifSanad')).toBe('731');
expect(url(1).searchParams.get('kol')).toBe('1');
});
});
describe('change feed (real-time sync)', () => {
it('reads the newest journal rows and reports accounts above the cursor', async () => {
http.mockReturnValueOnce(
reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }, { idRadif: '733', idHesab: '40' }, { idRadif: '734', idHesab: '40' }] }),
);
const r = await z.getChanges(s, '731');
expect(url().searchParams.get('kind')).toBe('parLastTenRadif');
expect(url().searchParams.has('idHesab')).toBe(false);
expect(r).toMatchObject({ cursor: '734', accountCodes: ['40'], overflow: false });
});
it('first poll only returns the cursor', async () => {
http.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }] }));
expect(await z.getChanges(s, null)).toEqual({ cursor: '731', accountCodes: [], itemNames: [], overflow: false });
});
});
@@ -0,0 +1,466 @@
import { Prisma } from '@prisma/client';
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
import { jalaliToIsoDate, toIsoDateTehran } from '../jalali';
import {
AccountingProvider,
AccountingProviderError,
ExternalCustomer,
ProviderSettings,
StatementRow,
ZERO,
dec,
normalizeMobile,
splitName,
str,
} from './accounting-provider';
/**
* Zarrin (زرین) — https://zarrin.ir/api-docs/ (the page renders
* /wp-content/themes/Hirad/data/api.json, 82 entries).
*
* Wire format: GET <base>?kind=<api>&token=<token>&<params>, where <base> is
* the installation's `http://host:8000/ZarrinService/accounting`. The token
* travels in the query string, so a URL built here must never be logged.
*
* NOT verified against a live installation — the owner has none to test on
* (plan, decision 11). Everything below follows the documentation and its
* sample outputs, and is covered by unit tests built from those samples. The
* open points (balance sign, which rial field the stones belong in) are
* configurable or flagged, and the integration is to be switched on for real
* only after a trial period on temporary documents.
*
* Differences from Tahesab that shape the sync:
* * No idempotency key on foroushSakhte. A timed-out document call cannot be
* retried blindly — the worker parks it as NEEDS_REVIEW.
* * foroushSakhte does not return the row id needed to delete a document;
* deleteDocument looks it up from the account's ledger (parvande).
* * کارساخته can be created (addFelezsakhteModel) but never edited.
*/
const STATEMENT_PAGE = 250;
const CHANGE_WINDOW = 50;
const MAX_STATEMENT_PAGES = 20;
function endpoint(s: ProviderSettings) {
return s.baseUrl.trim();
}
async function call(
s: ProviderSettings,
kind: string,
params: Record<string, string | number | null | undefined> = {},
): Promise<Record<string, unknown> | unknown[]> {
let url: URL;
try {
url = new URL(endpoint(s));
} catch {
throw new AccountingProviderError('زرین: آدرس سرور نامعتبر است');
}
url.searchParams.set('kind', kind);
url.searchParams.set('token', s.token);
for (const [k, v] of Object.entries(params)) {
if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, String(v));
}
let res;
try {
res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: s.timeoutMs, ca: s.caCert });
} catch (err) {
if (err instanceof AccountingHttpError) {
throw new AccountingProviderError(`زرین (${kind}): ${err.message}`, err.ambiguous);
}
throw err;
}
if (res.status < 200 || res.status >= 300) {
throw new AccountingProviderError(`زرین (${kind}): HTTP ${res.status}`, false, res.body.slice(0, 500), res.status === 404);
}
let json: unknown;
try {
json = parseLooseJson(res.body);
// The documented outputs are JSON-encoded strings of JSON ("{\"state\":…}").
if (typeof json === 'string' && /^\s*[{[]/.test(json)) json = parseLooseJson(json);
} catch {
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, res.body.slice(0, 500));
}
if (!json || typeof json !== 'object') {
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, json);
}
const o = json as Record<string, unknown>;
if (o.state === false || o.status === false) {
const msg = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? 'خطای نامشخص';
throw new AccountingProviderError(`زرین (${kind}): ${msg}`, false, json, /یافت نشد|موجود نیست/.test(msg));
}
return json as Record<string, unknown> | unknown[];
}
/** Zarrin wraps lists as `data`, once as `date` (Get100Account), or not at all. */
function listOf(json: unknown): Record<string, unknown>[] {
if (Array.isArray(json)) return json as Record<string, unknown>[];
if (json && typeof json === 'object') {
const o = json as Record<string, unknown>;
for (const key of ['data', 'date', 'mande']) {
if (Array.isArray(o[key])) return o[key] as Record<string, unknown>[];
}
if ('id' in o || 'code' in o) return [o];
}
return [];
}
function dataOf(json: unknown): Record<string, unknown> {
const o = (json ?? {}) as Record<string, unknown>;
const d = o.data;
if (d && typeof d === 'object' && !Array.isArray(d)) return d as Record<string, unknown>;
return o;
}
function joinName(first: unknown, last: unknown) {
return [str(first), str(last)].filter(Boolean).join(' ').trim();
}
/** Zarrin prints dates as yy/mm/dd of the 1400s: "04/05/29" → "1404/05/29". */
function fullJalali(v: unknown): string {
const s = str(v) ?? '';
const m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/);
if (!m) return s;
return `14${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`;
}
function signed(s: ProviderSettings, v: Prisma.Decimal) {
// Until checked on a real installation (plan §9) the sign is a setting.
return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v;
}
export const zarrinProvider: AccountingProvider = {
name: 'zarrin',
label: 'زرین',
capabilities: { createKarSakhte: true, documentInquiry: false, movementScope: 'all', changeFeed: true },
async health(s) {
const v = (await call(s, 'version')) as Record<string, unknown>;
// `version` may not check the token; a group listing does.
const groups = listOf(await call(s, 'listGorooheHesabha'));
return { version: v.version, message: v.message, customerGroups: groups.length };
},
async listCustomerGroups(s) {
return listOf(await call(s, 'listGorooheHesabha')).map((g) => ({
id: String(g.id),
name: str(g.name) ?? String(g.id),
}));
},
async listCustomers(s, cursor) {
const from = Number(cursor ?? 0) || 0;
// Get100Account pages by code (100 accounts from `id` on) but carries no
// group or address; listhesabha over the same code span fills those in.
const page = listOf(await call(s, 'Get100Account', { id: from }));
if (page.length === 0) return { items: [], next: null };
const codes = page.map((r) => Number(r.code)).filter(Number.isFinite);
const lo = Math.min(...codes);
const hi = Math.max(...codes);
const details = new Map<string, Record<string, unknown>>();
for (const r of listOf(await call(s, 'listhesabha', { az: lo, ta: hi }))) details.set(String(r.id), r);
const items: ExternalCustomer[] = page.map((r) => {
const code = String(r.code);
const d = details.get(code) ?? {};
return {
code,
name: str(d.nam) ?? joinName(r.name, r.namKhanevadegi),
mobile: normalizeMobile(d.mobile ?? r.mobile),
phone: str(d.tell),
nationalCode: str(d.codeMeli),
address: str(d.address),
groupId: null,
groupName: str(d.gorooh),
};
});
const next = page.length < 100 ? null : String(hi + 1);
return { items, next };
},
async findCustomersByMobile(s, mobile) {
const target = normalizeMobile(mobile);
if (!target) return [];
let rows: Record<string, unknown>[];
try {
rows = listOf(await call(s, 'getinfoByPhone', { number: target }));
} catch (err) {
if (err instanceof AccountingProviderError && err.notFound) return [];
throw err;
}
return rows
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
.map((r) => ({
code: String(r.code),
name: joinName(r.nam, r.namKhanevadegi),
mobile: target,
phone: str(r.tel),
nationalCode: null,
address: str(r.address),
groupId: null,
groupName: null,
}));
},
async createCustomer(s, p) {
const json = (await call(s, 'addAccount', {
firstName: p.firstName || p.fullName,
lastName: p.lastName || '-',
fkNoe: p.group.id,
mobile: p.mobile,
telephone: p.phone,
address: p.address,
codeMelli: p.nationalCode,
})) as Record<string, unknown>;
const id = json.id ?? dataOf(json).id;
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد حساب جدید برنگشت', false, json);
return { code: String(id), response: json };
},
async updateCustomer(s, code, p) {
const json = await call(s, 'edithesab', {
id: code,
name: p.firstName || p.fullName,
family: p.lastName,
mobile: p.mobile,
tel: p.phone,
address: p.address,
codeMeli: p.nationalCode,
// Without nameG Zarrin moves the account into its customers group, so the
// current group is always sent back.
nameG: p.group.name,
});
return { response: json };
},
async getBalances(s, codes) {
if (codes.length === 0) return [];
const wanted = new Set(codes);
const rows =
codes.length === 1
? listOf(await call(s, 'listMandeHesabha', { id: codes[0], isRial: 0 }))
: listOf(await call(s, 'listMandeHesabha', { isRial: 0 }));
return rows
.filter((r) => wanted.has(String(r.id)))
.map((r) => ({
code: String(r.id),
goldGrams: signed(s, dec(r.geram) ?? ZERO),
rial: signed(s, dec(r.pool) ?? ZERO),
}));
},
async getChanges(s, cursor) {
// parLastTenRadif without an account is the newest rows of the whole
// journal; each row names its account (idHesab) and has an ascending idRadif.
// Not yet seen on a real install (ACC-01).
const rows = listOf(await call(s, 'parLastTenRadif', { limit: CHANGE_WINDOW }));
let maxId = 0;
for (const r of rows) maxId = Math.max(maxId, Number(r.idRadif) || 0);
if (cursor === null) return { cursor: String(maxId), accountCodes: [], itemNames: [], overflow: false };
const seen = Number(cursor) || 0;
const fresh = rows.filter((r) => (Number(r.idRadif) || 0) > seen);
const accounts = new Set<string>();
for (const r of fresh) {
const code = str(r.idHesab);
if (code) accounts.add(code);
}
return {
cursor: String(Math.max(maxId, seen)),
accountCodes: [...accounts],
itemNames: [],
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
};
},
async getStatement(s, q) {
const rows: Record<string, unknown>[] = [];
for (let page = 1; page <= MAX_STATEMENT_PAGES; page += 1) {
const batch = listOf(
await call(s, 'parvande', {
id: q.code,
dateAz: jalaliToIsoDate(q.from),
dateTa: jalaliToIsoDate(q.to),
pageNum: page,
detailCount: STATEMENT_PAGE,
}),
);
rows.push(...batch);
if (batch.length < STATEMENT_PAGE) break;
}
// parvande has no running balance; it opens with «مانده از قبل» and the
// balance is folded from there. پرداخت = we gave = the customer owes more.
let gold = ZERO;
let rial = ZERO;
return rows.map((r): StatementRow => {
const goldIn = dec(r.vaznTarazooDaryaft) ?? ZERO;
const goldOut = dec(r.vaznTarazooPardakht) ?? ZERO;
const rialIn = dec(r.poolDaryaft) ?? ZERO;
const rialOut = dec(r.poolPardakht) ?? ZERO;
gold = gold.add(goldOut).sub(goldIn);
rial = rial.add(rialOut).sub(rialIn);
const factor = str(r.idFactor);
return {
key: String(r.idRadif ?? ''),
documentRef: factor && factor !== '0' ? factor : '',
accountCode: String(r.idHesab ?? q.code),
documentNo: factor && factor !== '0' ? factor : '',
date: fullJalali(r.tarikh),
time: null,
title: str(r.noeRadif) ?? str(r.noeFactor) ?? str(r.sharh) ?? '',
description: str(r.sharh),
itemName: str(r.namFelez),
weight: dec(r.vaznDaryaft)?.gt(0) ? dec(r.vaznDaryaft) : dec(r.vaznPardakht),
karat: dec(r.ayar)?.toNumber() || null,
count: null,
unitPrice: null,
wagePercent: null,
goldIn,
goldOut,
rialIn,
rialOut,
goldBalance: gold,
rialBalance: rial,
metal: null,
temporary: false,
};
});
},
async listKarSakhteGroups(s) {
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
},
async listKarSakhteTypes(s) {
const types = new Map<string, string>();
for (const r of listOf(await call(s, 'listKarSakhte'))) {
if (r.fkNoe !== undefined) types.set(String(r.fkNoe), str(r.NoeName) ?? String(r.fkNoe));
}
return [...types].map(([id, name]) => ({ id, name }));
},
async listKarSakhte(s) {
return listOf(await call(s, 'listKarSakhte'))
.filter((r) => r.id !== undefined && str(r.namFelezsakhthe))
.map((r) => ({
code: String(r.id),
name: str(r.namFelezsakhthe) as string,
// listKarSakhte carries the item's type (گرمی/تعدادی…) but not its
// group (النگو/انگشتر…); category stays the app's call (D27).
groupId: null,
groupName: null,
typeId: str(r.fkNoe),
typeName: str(r.NoeName),
saleWagePercent: dec(r.darsadForoosh),
stockWeight: dec(r.vazn),
stockCount: dec(r.tedad),
}));
},
async getKarSakhteStock(s, item) {
const d = dataOf(await call(s, 'mojoodiKala', { id: item.code, dateTa: toIsoDateTehran(new Date()) }));
const qty = dec(d.mojoodi);
const byWeight = /گرم/.test(String(d.noeName ?? 'گرمی'));
return { weight: byWeight ? qty : null, count: byWeight ? null : qty };
},
async getKarSakhteMovement(s, from, to) {
return listOf(await call(s, 'infogardeshsakhte', { az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) })).map(
(r) => ({
itemCode: str(r.code_kar),
itemName: str(r.nam_kar) ?? '—',
date: str(r.tarikh_akharin_gardesh) ?? '',
description: str(r.sharh_akharin_gardesh),
weight: dec(r.vazn_akharin_gardesh),
amount: dec(r.mablagh_akharin_gardesh),
count: null,
}),
);
},
async createKarSakhte(s, item) {
const json = (await call(s, 'addFelezsakhteModel', {
nameKar: item.name,
noeKar: item.typeId,
gorooheKar: item.groupId,
})) as Record<string, unknown>;
const id = json.idKar ?? dataOf(json).idKar;
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد کارساخته‌ی جدید برنگشت', false, json);
return { code: String(id), response: json };
},
async postOrderDocument(s, doc, progress, onProgress) {
let factorId = progress.factorId;
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 isLast = i === doc.lines.length - 1;
last = await call(s, 'foroushSakhte', {
// The first call opens the invoice for the account; the rest append.
...(factorId ? { idFactor: factorId } : { idHesab: doc.customerCode }),
idSakhte: line.itemCode,
// Bonak mode: mablagh is the rial charge, darsad the per-gram making charge.
mablagh: line.flatRial.toFixed(0),
sharh: line.description,
vazn: line.weightGrams.toFixed(3),
vaznSang: 0,
mablaghSang: 0,
pd: -1, // پرداخت: goods leave to the customer
darsad: line.wagePercent.toString(),
// The invoice has to stay open while lines are still to come; the last
// line closes it unless the document is to stay temporary (D27: it is).
movaghat: doc.temporary || !isLast ? 1 : 0,
bonak: 1,
});
const id = (dataOf(last).idFactor ?? (last as Record<string, unknown>).idFactor) as unknown;
if (!factorId) {
if (id === undefined || id === null) throw new AccountingProviderError('زرین: شماره فاکتور برنگشت', true, last);
factorId = String(id);
}
done = i + 1;
await onProgress({ factorId, doneLines: done });
}
return { ref: String(factorId ?? ''), progress: { factorId, doneLines: done }, response: last };
},
async documentExists() {
// No lookup by our own key exists in Zarrin's API.
return false;
},
async deleteDocument(s, doc) {
if (!doc.ref) throw new AccountingProviderError('زرین: شماره فاکتور سند معلوم نیست');
// hazfRadifSanad needs a row id, which foroushSakhte never returned; find
// one in the account's ledger for the last year.
const today = new Date();
const yearAgo = new Date(today.getTime() - 366 * 24 * 3600 * 1000);
const rows = listOf(
await call(s, 'parvande', {
id: doc.customerCode,
dateAz: toIsoDateTehran(yearAgo),
dateTa: toIsoDateTehran(today),
pageNum: 1,
detailCount: STATEMENT_PAGE,
}),
);
const row = rows.find((r) => String(r.idFactor) === doc.ref && str(r.idRadif) && String(r.idRadif) !== '0');
if (!row) return { response: { alreadyDeleted: true } };
const json = await call(s, 'hazfRadifSanad', {
pkRadifSanad: String(row.idRadif),
kol: 1,
commentUser: 'لغو سفارش در اپ',
});
return { response: json };
},
};
/** Exposed for tests. */
export const __zarrinInternals = { listOf, fullJalali };
@@ -0,0 +1,56 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
import { envOrNull } from '../../common/env';
/**
* Encrypts the accounting-software token at rest (D27).
*
* The token sits in the Setting table, so a database dump or a backup would
* otherwise hand out a live credential to the shop's books. AES-256-GCM with a
* key derived from `ACCOUNTING_SECRET_KEY`.
*
* When that variable is unset the key falls back to one derived from
* `JWT_SECRET`, with a loud warning at boot — the same "warn, don't refuse"
* trade-off D23 made — so an admin can configure the integration before ops
* add the variable. The cost: rotating JWT_SECRET then makes the stored token
* unreadable and the admin has to enter it again (the screen says so).
*/
const PREFIX = 'enc:v1:';
export function secretKeySource(): 'ACCOUNTING_SECRET_KEY' | 'JWT_SECRET' | null {
if (envOrNull('ACCOUNTING_SECRET_KEY')) return 'ACCOUNTING_SECRET_KEY';
if (envOrNull('JWT_SECRET')) return 'JWT_SECRET';
return null;
}
function key(): Buffer {
const material = envOrNull('ACCOUNTING_SECRET_KEY') ?? envOrNull('JWT_SECRET');
if (!material) {
throw new Error('ACCOUNTING_SECRET_KEY تنظیم نشده است؛ ذخیره‌ی توکن حسابداری ممکن نیست.');
}
return createHash('sha256').update(`goldbank-accounting:${material}`).digest();
}
export function sealSecret(plain: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key(), iv);
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return PREFIX + Buffer.concat([iv, tag, body]).toString('base64');
}
/** Null when the value cannot be decrypted (wrong key, tampered, malformed). */
export function openSecret(sealed: string | null | undefined): string | null {
if (!sealed || !sealed.startsWith(PREFIX)) return null;
try {
const raw = Buffer.from(sealed.slice(PREFIX.length), 'base64');
const iv = raw.subarray(0, 12);
const tag = raw.subarray(12, 28);
const body = raw.subarray(28);
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
} catch {
return null;
}
}
@@ -6,6 +6,7 @@ import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { faToEn } from '../customer-auth/digits';
import { SmsService } from '../sms/sms.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
/**
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
@@ -97,6 +98,7 @@ export class AdminCustomersService {
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly sms: SmsService,
private readonly accounting: AccountingHooksService,
) {}
/**
@@ -802,7 +804,16 @@ export class AdminCustomersService {
patch.shopImageUrl = (data.shop_image as string) || null;
}
return this.prisma.customer.update({ where: { id }, data: patch });
// D27 — the fields the accounting account carries. A change to one of them
// is pushed to a linked account.
const contactChanged = ['fname', 'lname', 'name', 'mobile', 'national_code', 'phone', 'address'].some(
(k) => data[k] !== undefined,
);
return this.prisma.$transaction(async (tx) => {
const updated = await tx.customer.update({ where: { id }, data: patch });
if (contactChanged) await this.accounting.customerEdited(tx, id);
return updated;
});
}
/**
@@ -957,7 +968,15 @@ export class AdminCustomersService {
throw new BadRequestException('مرحله‌ی تایید مشخص نشده است');
}
return this.prisma.customer.update({ where: { id }, data: { verificationStep: next } });
return this.prisma.$transaction(async (tx) => {
const updated = await tx.customer.update({ where: { id }, data: { verificationStep: next } });
// D27 — a verified customer gets an account in the accounting software
// (queued; searched by mobile first, so an existing account is linked).
if (next === VerificationStep.VERIFIED && c.verificationStep !== VerificationStep.VERIFIED) {
await this.accounting.customerVerified(tx, id);
}
return updated;
});
}
/** Legacy `GET major/archive/{id}` — MajorCustomerController::archive. */
@@ -13,6 +13,7 @@ import { AdminMiscService } from './admin-misc.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { isAccountingSettingKey } from '../accounting-sync/accounting-config';
/**
* Cities, SMS, notifications, newsletter, activity log and settings — the
@@ -227,7 +228,7 @@ export class AdminMiscController {
// `custom.config.statics.slider`) simply by calling `const/mobile_main_slider`
// directly, since the underlying Setting row is keyed the same either way.
private assertNotSliderKey(key: string) {
if (key === 'mobile_main_slider') {
if (key === 'mobile_main_slider' || isAccountingSettingKey(key)) {
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
}
}
@@ -3,6 +3,7 @@ import { MessageAudience, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
/**
* The remaining admin screens — legacy views/city, views/sms, views/notify,
@@ -477,7 +478,13 @@ export class AdminMiscService {
}
async listConsts() {
const rows = await this.prisma.setting.findMany({ orderBy: { key: 'asc' } });
// The accounting integration's rows (connection, encrypted token, worker
// lease) have their own `config.accounting` screen and never appear here
// (D27); the single-key routes refuse them in the controller.
const rows = await this.prisma.setting.findMany({
where: { NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } },
orderBy: { key: 'asc' },
});
return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt }));
}
@@ -3,6 +3,7 @@ import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { OrdersService } from '../orders/orders.service';
import type { UpdateOrderStatusDto } from './update-status.dto';
@@ -40,6 +41,7 @@ export class AdminOrdersService {
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly orders: OrdersService,
private readonly accounting: AccountingHooksService,
) {}
/** Fields the XTable filter bar and sorter may reference. */
@@ -220,11 +222,14 @@ export class AdminOrdersService {
);
}
await this.prisma.$transaction([
this.prisma.orderStatusEvent.deleteMany({ where: { orderId: id } }),
this.prisma.orderItem.deleteMany({ where: { orderId: id } }),
this.prisma.order.delete({ where: { id } }),
]);
await this.prisma.$transaction(async (tx) => {
// D27 — a temporary document may already be in the books (the rule can
// post it at placement); withdraw it like a cancellation.
await this.accounting.orderCancelled(tx, id);
await tx.orderStatusEvent.deleteMany({ where: { orderId: id } });
await tx.orderItem.deleteMany({ where: { orderId: id } });
await tx.order.delete({ where: { id } });
});
return { success: true };
}
@@ -405,9 +410,12 @@ export class AdminOrdersService {
where: { orderId },
orderBy: { createdAt: 'desc' },
});
return this.prisma.order.update({
where: { id: orderId },
data: { status: latest?.toStatus ?? OrderStatus.PLACED },
const rolledBackTo = latest?.toStatus ?? OrderStatus.PLACED;
return this.prisma.$transaction(async (tx) => {
const updated = await tx.order.update({ where: { id: orderId }, data: { status: rolledBackTo } });
// D27 — rolled back below the document rule: the پیش‌فاکتور is withdrawn.
await this.accounting.orderStatusReached(tx, orderId, rolledBackTo);
return updated;
});
}
}
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
/**
* Product administration — legacy views/apps/product/* served by
@@ -24,6 +25,7 @@ export class AdminProductsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly accounting: AccountingHooksService,
) {}
private static readonly ALLOWED = [
@@ -115,6 +117,11 @@ export class AdminProductsService {
: null,
// legacy grid slot reads `ProductImage`
ProductImage: p.images.map((i) => ({ id: i.id, image: i.url, full: i.url, url: i.url })),
// D27 — stock as last read from the accounting software (null = not
// linked / never synced).
accounting_stock: p.accountingStockAt
? { weight: p.accountingStockWeight, count: p.accountingStockCount, at: p.accountingStockAt }
: null,
images: p.images.map((i) => ({ id: i.id, url: i.url, sortOrder: i.sortOrder, role: i.role })),
// The order builder's grid reads `thumb.full` and `variants_all` directly
// off each row, so both travel with the list as they did in legacy. Legacy's
@@ -159,8 +166,16 @@ export class AdminProductsService {
});
if (!p) throw new NotFoundException('محصول یافت نشد');
// D27 — the form locks title/code of a linked product, and offers the
// accounting software's sale making charge as the default for new variants.
const link = await this.accounting.productLink(p.id);
const snapshot = (link?.snapshot ?? {}) as { saleWagePercent?: string | null };
return {
...this.toRow(p),
accounting: link
? { locked: true, code: link.externalCode, origin: link.origin, sale_wage_percent: snapshot.saleWagePercent ?? null }
: null,
body_html: p.description,
brand_id: p.brandId,
cats: p.categoryId ? [p.categoryId] : [],
@@ -446,6 +461,10 @@ export class AdminProductsService {
}
}
// D27 — create the matching کارساخته in the accounting software (queued;
// only where the software's API can create items).
await this.accounting.productCreated(this.prisma, created.id);
return this.findOne(created.id);
}
@@ -454,6 +473,21 @@ export class AdminProductsService {
const existing = await this.prisma.product.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('محصول یافت نشد');
// D27 — title and code of a product linked to the accounting software are
// that software's; the form shows them locked. Re-posting the same values
// is fine, changing them is not.
const link = await this.accounting.productLink(id);
if (link) {
const titleChanged = data.title !== undefined && String(data.title).trim() !== existing.title;
const codeChanged =
data.product_code !== undefined && ((data.product_code as string) || null) !== existing.productCode;
if (titleChanged || codeChanged) {
throw new BadRequestException(
'عنوان و کد این محصول از نرم‌افزار حسابداری می‌آید؛ برای تغییر، آن را در حسابداری ویرایش کنید.',
);
}
}
const patch: Prisma.ProductUpdateInput = {};
if (data.title !== undefined) patch.title = String(data.title);
if (data.body_html !== undefined) patch.description = String(data.body_html);
@@ -32,6 +32,8 @@ export const ALL_RIDS = [
'comment.state',
'comment.view',
'comment.view.edit',
// D27 — «اتصال حسابداری» (Tahesab / Zarrin). Not a legacy rid.
'config.accounting',
'config.report',
'config.statics',
'config.statics.brand',
@@ -1,13 +1,20 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly accounting: AccountingHooksService,
) {}
listProducts() {
return this.prisma.product.findMany({
where: { isActive: true },
async listProducts() {
const products = await this.prisma.product.findMany({
// D27 — a product with no image is never shown in the app. Products now
// also arrive from the accounting software, which carries no pictures;
// they appear once an admin photographs them.
where: { isActive: true, images: { some: {} } },
include: {
// Only orderable variants reach the storefront. An inactive one used to
// be listed and then rejected by getVariantOrThrow at checkout, so the
@@ -26,13 +33,49 @@ export class CatalogService {
collections: { include: { collection: true } },
},
});
const out = [];
for (const p of products) {
const { accountingStockWeight, accountingStockCount, accountingStockAt, ...product } = p;
// D27 — out of stock in the accounting software: every variant reads as
// qty 0, which the apps already render as «ناموجود» and refuse to add to
// the cart unless it is a pre-order variant. The stock figures themselves
// stay server-side; this endpoint is public.
const blocked = await this.accounting.stockBlocked({ accountingStockWeight, accountingStockCount, accountingStockAt });
out.push(blocked ? { ...product, variants: product.variants.map((v) => (v.isPreOrder ? v : { ...v, qty: 0 })) } : product);
}
return out;
}
async getVariantOrThrow(variantId: string) {
const variant = await this.prisma.productVariant.findUnique({ where: { id: variantId } });
const variant = await this.prisma.productVariant.findUnique({
where: { id: variantId },
include: {
product: {
select: {
id: true,
accountingStockWeight: true,
accountingStockCount: true,
accountingStockAt: true,
_count: { select: { images: true } },
},
},
},
});
if (!variant || !variant.isActive) {
throw new NotFoundException(`Product variant ${variantId} not found or inactive`);
}
return variant;
// The same two rules the listing applies, so a stale client cannot order
// around them.
if (variant.product._count.images === 0) {
throw new BadRequestException('این محصول هنوز تصویر ندارد و قابل سفارش نیست');
}
// Live from the accounting software at order time; on no answer the last
// stored figure decides (liveStock).
if (!variant.isPreOrder && (await this.accounting.stockBlocked(await this.accounting.liveStock(variant.product)))) {
throw new BadRequestException('این محصول در انبار موجود نیست');
}
const { product: _product, ...plain } = variant;
return plain;
}
}
@@ -558,11 +558,11 @@ export class CustomerAuthService {
}
/**
* Hands the code to the SMS gateway (SmsService -> Trez, per Sms.php:57-82).
* Hands the code to the SMS gateway (SmsService -> the SMS_PROVIDER driver,
* relay by default per D26).
*
* When no credentials are configured, SmsService does not send and the code
* is logged instead — development only; production refuses to boot without
* credentials (assertSmsConfigured in main.ts).
* When no gateway is configured, SmsService does not send and the code is
* logged instead — outside production only (D20).
*
* A failed send does NOT throw. The reference ignored Sms::Send's result, so
* a gateway outage never blocked the flow that triggered it; the customer
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
import { jalaliToGregorian, parseJalali } from '../accounting-sync/jalali';
import type { StatementRow } from '../accounting-sync/providers';
/**
* "گزارش" — the customer's account statement.
@@ -30,7 +33,26 @@ import { PrismaService } from '../../prisma/prisma.service';
*/
@Injectable()
export class CustomerReportService {
constructor(private readonly prisma: PrismaService) {}
private readonly logger = new Logger(CustomerReportService.name);
constructor(
private readonly prisma: PrismaService,
private readonly accounting: AccountingSyncService,
) {}
/**
* The accounting statement, or null to fall back to the internal ledger —
* when the integration is off, the customer is not linked, or the software
* cannot be reached (the report must still open).
*/
private async accountingStatement(customerId: string) {
try {
return await this.accounting.customerStatement(customerId);
} catch (err) {
this.logger.warn(`Accounting statement unavailable for ${customerId}, using the ledger: ${(err as Error).message}`);
return null;
}
}
/** The reference's page size (report/index.vue:96). */
private static readonly LIMIT = 50;
@@ -51,6 +73,21 @@ export class CustomerReportService {
* display (report/index.vue:191), so the app shows newest first. Same here.
*/
async summary(customerId: string, page = 1) {
// D27 — from the accounting software when the integration covers this
// customer, so the statement agrees with the balance they are shown.
const statement = await this.accountingStatement(customerId);
if (statement) {
const rows = statementToSummary(statement.rows).reverse();
const start = (Math.max(1, page) - 1) * CustomerReportService.LIMIT;
return {
result: rows.slice(start, start + CustomerReportService.LIMIT),
count: rows.length,
page: Math.max(1, page),
limit: CustomerReportService.LIMIT,
source: 'accounting',
};
}
const entries = await this.prisma.ledgerEntry.findMany({
// Only confirmed entries move a balance — the same rule the cached
// Customer.goldDebtGrams / fiatDebtRial follow, so the statement's final
@@ -129,6 +166,22 @@ export class CustomerReportService {
* from the client beyond being a filter.
*/
async detail(customerId: string, entryId?: string, page = 1) {
const statement = await this.accountingStatement(customerId);
if (statement) {
// SEC-16 holds: the rows are already limited to this customer's own
// account (AccountingSyncService.customerStatement), and the id is only a
// filter over them — another customer's document id matches nothing.
let rows = statement.rows.map(statementToDetail).reverse();
if (entryId) rows = rows.filter((r) => r.factor_Code === entryId);
const start = (Math.max(1, page) - 1) * CustomerReportService.LIMIT;
return {
result: entryId ? rows : rows.slice(start, start + CustomerReportService.LIMIT),
page: Math.max(1, page),
limit: CustomerReportService.LIMIT,
source: 'accounting',
};
}
const where: Prisma.LedgerEntryWhereInput = {
customerId,
confirmed: true,
@@ -215,6 +268,22 @@ export class CustomerReportService {
* flip is applied and the figure matches every other screen.
*/
async balances(customerId: string) {
// D27 — the accounting software's balance when the integration covers
// this customer; the same figure /customer/me shows.
const accounting = await this.accounting.customerBalance(customerId);
if (accounting) {
return {
status: 200,
message: '',
source: 'accounting',
at: accounting.at,
result: [
{ arz: 'IRR', mandeh: accounting.rial.toFixed(2) },
{ arz: 'gold', mandeh: accounting.gold.toFixed(3) },
],
};
}
const c = await this.prisma.customer.findUnique({
where: { id: customerId },
select: { goldDebtGrams: true, fiatDebtRial: true },
@@ -242,3 +311,82 @@ const SOURCE_LABELS: Record<string, string> = {
WALLET_BONUS: 'هدیه',
ADJUSTMENT: 'اصلاحیه',
};
// ── D27: accounting statement → the report's existing shape ─────────────
//
// The apps bind the reference's field names (henza_pwa/src/type/accounting.ts)
// and format `tarikh` as an ISO instant, so the accounting rows are mapped onto
// exactly what the ledger-based report already returns.
/** `1405/07/12` + `20:20:41` → ISO in Tehran time, which the apps' jdate() reads. */
function isoFromJalali(date: string, time: string | null): string {
const j = parseJalali(date);
if (!j) return date;
const g = jalaliToGregorian(j.jy, j.jm, j.jd);
const pad = (n: number) => String(n).padStart(2, '0');
const t = time && /^\d{1,2}:\d{2}(:\d{2})?$/.test(time) ? (time.length === 5 ? `${time}:00` : time) : '12:00:00';
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}T${t.padStart(8, '0')}+03:30`;
}
const fixed = (d: Prisma.Decimal | null, places: number) => (d ?? new Prisma.Decimal(0)).toFixed(places);
/**
* Oldest first, with a running balance on every row. The software's own
* running balance wins where it reports one; a row without one (a temporary
* Tahesab document, which moves nothing yet) carries the previous figure.
*/
function statementToSummary(rows: StatementRow[]) {
let gold = new Prisma.Decimal(0);
let rial = new Prisma.Decimal(0);
return rows.map((r, index) => {
if (r.goldBalance) gold = r.goldBalance;
else if (!r.temporary) gold = gold.add(r.goldOut).sub(r.goldIn);
if (r.rialBalance) rial = r.rialBalance;
else if (!r.temporary) rial = rial.add(r.rialOut).sub(r.rialIn);
return {
id: index + 1,
factor_Code: r.documentRef || r.key,
sh_factor: r.documentNo,
tarikh: isoFromJalali(r.date, r.time),
geram_v: fixed(r.goldIn, 3),
geram_kh: fixed(r.goldOut, 3),
rial_v: fixed(r.rialIn, 2),
rial_kh: fixed(r.rialOut, 2),
tahesab_T: gold.toFixed(3),
tahesab_P: rial.toFixed(2),
rial_V2: '0.00',
rial_Kh2: '0.00',
tahesabP2: '0.00',
sourceType: 'ACCOUNTING',
note: [r.title, r.description, r.temporary ? '(موقت — هنوز در مانده اثر ندارد)' : null].filter(Boolean).join(' — '),
};
});
}
function statementToDetail(r: StatementRow) {
return {
factor_Code: r.documentRef || r.key,
sh_factor: r.documentNo,
tarikh: isoFromJalali(r.date, r.time),
vazn: fixed(r.weight, 3),
ayar: r.karat,
no: r.title,
sh_sharti: null,
name_az: null,
tabdil750_v: fixed(r.goldIn, 3),
tabdil750_kh: fixed(r.goldOut, 3),
fi: r.unitPrice ? r.unitPrice.toFixed(2) : null,
fiF: r.rialIn.gt(0) ? r.rialIn.toFixed(2) : r.rialOut.gt(0) ? r.rialOut.toFixed(2) : null,
Darsad_Talayee: r.wagePercent ? r.wagePercent.toString() : null,
accountName: null,
bankName: null,
transferTo: null,
receivedAt: null,
note: [r.description, r.temporary ? 'سند موقت' : null].filter(Boolean).join(' — ') || null,
// REP-01: the accounting software does carry these.
nO_kar: r.itemName,
jens_felez: r.metal,
Darsad: null,
maliatDarsad: null,
};
}
@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
// passwordHash must never leave the API — every customer read goes through this.
const PUBLIC_CUSTOMER_SELECT = {
@@ -35,7 +36,10 @@ const PUBLIC_CUSTOMER_SELECT = {
*/
@Injectable()
export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly accounting: AccountingSyncService,
) {}
async getOrThrow(customerId: string) {
const customer = await this.prisma.customer.findUnique({
@@ -43,7 +47,21 @@ export class CustomersService {
select: PUBLIC_CUSTOMER_SELECT,
});
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
return customer;
// D27 — with the accounting integration on, the customer sees the balance
// the accounting software holds. The apps only display these two fields;
// the credit-limit check runs server-side on the internal ledger, untouched.
const accounting = await this.accounting.customerBalance(customerId);
if (accounting) {
return {
...customer,
goldDebtGrams: accounting.gold,
fiatDebtRial: accounting.rial,
balanceSource: 'accounting' as const,
balanceAt: accounting.at,
};
}
return { ...customer, balanceSource: 'app' as const, balanceAt: null };
}
getOrders(customerId: string) {
@@ -3,6 +3,7 @@ import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CatalogService } from '../catalog/catalog.service';
import { LedgerService } from '../accounting/ledger.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { CreateOrderDto } from './dto/create-order.dto';
interface ComputedItem {
@@ -23,6 +24,7 @@ export class OrdersService {
private readonly prisma: PrismaService,
private readonly catalog: CatalogService,
private readonly ledger: LedgerService,
private readonly accounting: AccountingHooksService,
) {}
/**
@@ -121,6 +123,9 @@ export class OrdersService {
include: { items: true, statusEvents: true },
});
// D27 — the document rule may say «at order placement».
await this.accounting.orderStatusReached(tx, order.id, initialStatus);
return order;
});
}
@@ -151,6 +156,14 @@ export class OrdersService {
await this.ledger.postOrderDebt(orderId, tx);
}
// D27 — the accounting document (پیش‌فاکتور) follows the owner's rule on
// which status posts it, queued in this same transaction.
if (newStatus === OrderStatus.CANCELLED && order.status !== OrderStatus.CANCELLED) {
await this.accounting.orderCancelled(tx, orderId);
} else if (newStatus !== order.status) {
await this.accounting.orderStatusReached(tx, orderId, newStatus);
}
return updated;
});
}
+17
View File
@@ -0,0 +1,17 @@
import { relayDriver } from './relay.driver';
import { SmsDriver } from './sms-driver';
import { trezDriver } from './trez.driver';
/**
* Every SMS gateway, keyed by its `SMS_PROVIDER` value. A new provider is one
* driver file plus one line here.
*/
export const SMS_DRIVERS: Readonly<Record<string, SmsDriver>> = {
[relayDriver.name]: relayDriver,
[trezDriver.name]: trezDriver,
};
/** Used when `SMS_PROVIDER` is unset or blank (D26). */
export const DEFAULT_SMS_PROVIDER = relayDriver.name;
export type { SmsDriver, SmsSendResult } from './sms-driver';
@@ -0,0 +1,110 @@
import { networkInterfaces } from 'node:os';
import { envOr, envOrNull } from '../../../common/env';
import { SmsDriver, postWithTimeout } from './sms-driver';
/**
* The owner's SMS gateway (`/api/createsms`) — a relay that serves the SMS of
* several panels and apps and forwards to the actual provider. The default
* driver since D26.
*
* Shape, from the owner's PHP client:
* * POST JSON, 15s timeout, auth header `secret_key`
* * body: { phone, host, text, platform }
* * `host` identifies the sending server: a configured value, else the
* server's own IP (`gethostbyname(gethostname())`)
* * `platform: "kave"` routes through Kavenegar; null lets the gateway pick.
* The owner wants every message through Kavenegar.
*
* The secret is in source on the owner's decision (D26): the gateway has a
* second verification factor held elsewhere. `SMS_RELAY_SECRET` overrides it.
*
* The PHP client forced IPv4 (CURLOPT_IPRESOLVE); the URL is an IP literal, so
* there is nothing to resolve and fetch needs no equivalent.
*/
const DEFAULT_RELAY_URL = 'http://185.231.112.101/api/createsms';
const DEFAULT_RELAY_SECRET = 'f21debfb-83ac-4624-a2f4-f08375043181';
const DEFAULT_RELAY_PLATFORM = 'kave';
/** `Http::timeout(15)` in the owner's client. */
const REQUEST_TIMEOUT_MS = 15_000;
/** The first external IPv4 of this machine — Node's `gethostbyname(gethostname())`. */
export function serverIpv4(): string | null {
for (const addresses of Object.values(networkInterfaces())) {
for (const a of addresses ?? []) {
if (a.family === 'IPv4' && !a.internal) return a.address;
}
}
return null;
}
export function relayConfig() {
const platform = envOr('SMS_RELAY_PLATFORM', DEFAULT_RELAY_PLATFORM);
return {
url: envOr('SMS_RELAY_URL', DEFAULT_RELAY_URL),
secret: envOr('SMS_RELAY_SECRET', DEFAULT_RELAY_SECRET),
host: envOrNull('SMS_RELAY_HOST') ?? serverIpv4(),
// "default" sends null, so the gateway chooses the line itself.
};
}
/**
* The gateway's reply format is not documented, so a 2xx is trusted unless the
* body plainly says otherwise. Returns the reason, or null when the reply
* reads as accepted.
*
* A non-empty body that is not JSON counts as a failure: the gateway is a JSON
* API, and its own PHP errors arrive as bare text with HTTP 200 — seen live on
* 2026-10-05 as "cURL library is not loaded", with nothing delivered.
*/
export function relayRejection(body: string): string | null {
const text = body.trim();
if (text === '') return null;
let data: unknown;
try {
data = JSON.parse(text);
} catch {
return text.slice(0, 200);
}
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
const d = data as Record<string, unknown>;
const failedStatus =
d.status === false ||
(typeof d.status === 'string' && /^(error|fail|failed|failure)$/i.test(d.status));
const failed = failedStatus || d.success === false || d.ok === false || Boolean(d.error);
if (!failed) return null;
if (typeof d.error === 'string' && d.error) return d.error;
if (typeof d.message === 'string' && d.message) return d.message;
return 'rejected-by-gateway';
}
export const relayDriver: SmsDriver = {
name: 'relay',
missingConfig() {
// The URL and secret always have defaults; only the host can be missing,
// on a machine with no external IPv4.
return relayConfig().host ? [] : ['SMS_RELAY_HOST'];
},
async deliver(mobile, text) {
const c = relayConfig();
const reply = await postWithTimeout(
c.url,
{ 'Content-Type': 'application/json', secret_key: c.secret },
JSON.stringify({ phone: mobile, host: c.host, text }),
REQUEST_TIMEOUT_MS,
);
if (!reply.ok) return { ok: false, response: reply.body, error: `http-${reply.status}` };
const rejection = relayRejection(reply.body);
if (rejection) return { ok: false, response: reply.body, error: rejection };
return { ok: true, response: reply.body };
},
};
@@ -0,0 +1,55 @@
/**
* One SMS gateway. SmsService picks a driver by `SMS_PROVIDER` and owns
* everything around the call — trimming, persistence, logging and the
* never-throw guarantee — so a driver only has to talk to its provider.
*/
export interface SmsSendResult {
ok: boolean;
/** Provider response body, when one was received. */
response?: string;
error?: string;
}
export interface SmsDriver {
/** The `SMS_PROVIDER` value that selects this driver. */
readonly name: string;
/** Names of the env variables this driver still needs; empty when it can send. */
missingConfig(): string[];
/**
* Sends one already-trimmed message. May throw on a network error or
* timeout — SmsService turns that into `{ ok: false }`.
*/
deliver(mobile: string, text: string): Promise<SmsSendResult>;
}
export interface HttpReply {
ok: boolean;
status: number;
body: string;
}
/** POST with an abort-based timeout. Throws on network failure or timeout. */
export async function postWithTimeout(
url: string,
headers: Record<string, string>,
body: string,
timeoutMs: number,
): Promise<HttpReply> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers,
body,
signal: controller.signal,
});
return { ok: response.ok, status: response.status, body: await response.text() };
} finally {
clearTimeout(timer);
}
}
@@ -0,0 +1,66 @@
import { envOr } from '../../../common/env';
import { SmsDriver, postWithTimeout } from './sms-driver';
/**
* Trez panel — port of henza_server/app/Models/Sms.php.
*
* Reference behaviour (`Sms::Send`, `Sms.php:57-82`):
* * POST, form-urlencoded, 20s timeout
* * fields: PhoneNumber (sender line), Username, Password, Mobile, Message
*
* Credentials come from the environment. The reference hardcodes live
* production credentials in the source (SEC-12); they are not copied here.
*
* No longer the default since D26, kept selectable with `SMS_PROVIDER=trez`.
*/
/** Reference endpoint (`Sms.php:69`). Overridable for staging. */
export const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx';
/** `Http::timeout(20)` in the reference (`Sms.php:72`). */
const REQUEST_TIMEOUT_MS = 20_000;
export function trezConfig() {
return {
url: envOr('SMS_TREZ_URL', DEFAULT_TREZ_URL),
username: process.env.SMS_TREZ_USERNAME ?? '',
password: process.env.SMS_TREZ_PASSWORD ?? '',
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
};
}
export const trezDriver: SmsDriver = {
name: 'trez',
missingConfig() {
const c = trezConfig();
return (
[
['SMS_TREZ_USERNAME', c.username],
['SMS_TREZ_PASSWORD', c.password],
['SMS_TREZ_SENDER_LINE', c.senderLine],
] as const
)
.filter(([, v]) => !v)
.map(([name]) => name);
},
async deliver(mobile, text) {
const c = trezConfig();
const reply = await postWithTimeout(
c.url,
{ 'Content-Type': 'application/x-www-form-urlencoded' },
new URLSearchParams({
PhoneNumber: c.senderLine,
Username: c.username,
Password: c.password,
Mobile: mobile,
Message: text,
}).toString(),
REQUEST_TIMEOUT_MS,
);
if (!reply.ok) return { ok: false, response: reply.body, error: `http-${reply.status}` };
return { ok: true, response: reply.body };
},
};
+261 -78
View File
@@ -1,125 +1,308 @@
import { NetworkInterfaceInfo, networkInterfaces } from 'node:os';
import { Logger } from '@nestjs/common';
import { assertSmsConfigured, smsConfig, smsConfigured } from './sms.service';
import { PrismaService } from '../../prisma/prisma.service';
import { DEFAULT_TREZ_URL, trezConfig } from './drivers/trez.driver';
import { relayConfig, relayRejection } from './drivers/relay.driver';
import {
SmsService,
assertSmsConfigured,
smsConfigured,
smsProvider,
} from './sms.service';
const SMS_ENV = [
'SMS_PROVIDER',
'SMS_RELAY_URL',
'SMS_RELAY_SECRET',
'SMS_RELAY_HOST',
'SMS_RELAY_PLATFORM',
'SMS_TREZ_URL',
'SMS_TREZ_USERNAME',
'SMS_TREZ_PASSWORD',
'SMS_TREZ_SENDER_LINE',
'NODE_ENV',
];
// Node's own module namespace cannot be spied on, so the lookup is mocked.
jest.mock('node:os', () => ({ ...jest.requireActual('node:os'), networkInterfaces: jest.fn() }));
const interfaces = networkInterfaces as jest.MockedFunction<typeof networkInterfaces>;
const iface = (address: string, internal: boolean) =>
({ address, internal, family: 'IPv4' }) as NetworkInterfaceInfo;
/** A machine with only loopback, so the relay has no host to report. */
const noExternalIp = () => interfaces.mockReturnValue({ lo: [iface('127.0.0.1', true)] });
const saved = { ...process.env };
beforeEach(() => {
for (const key of SMS_ENV) delete process.env[key];
jest.restoreAllMocks();
interfaces.mockReturnValue({ eth0: [iface('10.9.9.9', false)] });
});
afterAll(() => {
process.env = { ...saved };
});
/**
* Covers the boot-time contract as REVISED by D20:
* * while no key is set, development behaviour is unchanged;
* Covers the boot-time contract as REVISED by D20 and the driver choice of D26:
* * a missing key NEVER stops the boot, in any environment — it warns, and
* sending silently declines. (D14's "fail to boot in production" rule was
* removed by the owner for the delivery.)
* sending silently declines;
* * `relay` is the default provider; `trez` stays selectable.
*/
describe('SMS configuration', () => {
const saved = { ...process.env };
// Silence the warnings these tests deliberately provoke.
const logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() } as unknown as Logger;
const warned = () => (logger.warn as jest.Mock).mock.calls.flat().join(' ');
beforeEach(() => {
for (const key of [
'SMS_PROVIDER',
'SMS_TREZ_URL',
'SMS_TREZ_USERNAME',
'SMS_TREZ_PASSWORD',
'SMS_TREZ_SENDER_LINE',
'NODE_ENV',
]) {
delete process.env[key];
}
jest.clearAllMocks();
});
beforeEach(() => jest.clearAllMocks());
afterAll(() => {
process.env = { ...saved };
});
const configureFully = () => {
process.env.SMS_TREZ_USERNAME = 'u';
process.env.SMS_TREZ_PASSWORD = 'p';
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
};
describe('smsConfig', () => {
it('defaults the provider to trez and the URL to the reference endpoint', () => {
const c = smsConfig();
expect(c.provider).toBe('trez');
expect(c.url).toBe('https://smspanel.trez.ir/SendMessageWithCode.ashx');
describe('provider selection (D26)', () => {
it('defaults to relay', () => {
expect(smsProvider()).toBe('relay');
});
it('lets the URL be overridden for staging', () => {
process.env.SMS_TREZ_URL = 'https://staging.example/send';
expect(smsConfig().url).toBe('https://staging.example/send');
it('treats a blank SMS_PROVIDER, as docker-compose passes it, as the default', () => {
process.env.SMS_PROVIDER = '';
expect(smsProvider()).toBe('relay');
});
it('still selects trez on request', () => {
process.env.SMS_PROVIDER = 'Trez';
expect(smsProvider()).toBe('trez');
});
});
describe('smsConfigured', () => {
it('is false when nothing is set', () => {
expect(smsConfigured()).toBe(false);
});
it('is false when only some credentials are set', () => {
process.env.SMS_TREZ_USERNAME = 'u';
process.env.SMS_TREZ_PASSWORD = 'p';
expect(smsConfigured()).toBe(false);
});
it('is true once all three are set', () => {
configureFully();
describe('relay', () => {
it('works out of the box, sending through Kavenegar', () => {
process.env.SMS_RELAY_HOST = '10.0.0.5';
const c = relayConfig();
expect(c.url).toBe('http://185.231.112.101/api/createsms');
expect(c.secret).toBeTruthy();
expect(c.platform).toBe('kave');
expect(smsConfigured()).toBe(true);
});
it('is false when the provider is explicitly disabled', () => {
configureFully();
process.env.SMS_PROVIDER = 'none';
it('falls back to the server IP for host, skipping loopback', () => {
interfaces.mockReturnValue({
lo: [iface('127.0.0.1', true)],
eth0: [iface('192.168.1.20', false)],
});
expect(relayConfig().host).toBe('192.168.1.20');
});
it('lets SMS_RELAY_HOST override the server IP', () => {
process.env.SMS_RELAY_HOST = 'gold.example';
expect(relayConfig().host).toBe('gold.example');
});
it('sends platform null when SMS_RELAY_PLATFORM=default', () => {
process.env.SMS_RELAY_PLATFORM = 'default';
expect(relayConfig().platform).toBeNull();
});
it('is not configured, and warns by name, when no host can be found', () => {
noExternalIp();
expect(smsConfigured()).toBe(false);
expect(() => assertSmsConfigured(logger)).not.toThrow();
expect(warned()).toMatch(/SMS_RELAY_HOST/);
});
it('passes silently once a host is known', () => {
process.env.SMS_RELAY_HOST = '10.0.0.5';
assertSmsConfigured(logger);
expect(logger.warn).not.toHaveBeenCalled();
});
});
/**
* D20 REVERSED the original boot contract. SMS must never stop the server
* from starting, in any environment — an earlier revision of these tests
* asserted the opposite (a throw in production) and is intentionally gone.
*/
describe('assertSmsConfigured (D20 — never throws)', () => {
it('warns outside production when keys are missing', () => {
process.env.NODE_ENV = 'development';
expect(() => assertSmsConfigured(logger)).not.toThrow();
expect(logger.warn).toHaveBeenCalled();
describe('trez', () => {
beforeEach(() => {
process.env.SMS_PROVIDER = 'trez';
});
const configureFully = () => {
process.env.SMS_TREZ_USERNAME = 'u';
process.env.SMS_TREZ_PASSWORD = 'p';
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
};
it('defaults the URL to the reference endpoint and lets staging override it', () => {
expect(trezConfig().url).toBe(DEFAULT_TREZ_URL);
process.env.SMS_TREZ_URL = 'https://staging.example/send';
expect(trezConfig().url).toBe('https://staging.example/send');
});
it('is configured only once all three credentials are set', () => {
expect(smsConfigured()).toBe(false);
process.env.SMS_TREZ_USERNAME = 'u';
process.env.SMS_TREZ_PASSWORD = 'p';
expect(smsConfigured()).toBe(false);
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
expect(smsConfigured()).toBe(true);
});
it('warns in production when keys are missing, naming them, and still boots', () => {
process.env.NODE_ENV = 'production';
expect(() => assertSmsConfigured(logger)).not.toThrow();
const warned = (logger.warn as jest.Mock).mock.calls.flat().join(' ');
expect(warned).toMatch(/SMS_TREZ_USERNAME/);
expect(warned).toMatch(/SMS_TREZ_PASSWORD/);
expect(warned).toMatch(/SMS_TREZ_SENDER_LINE/);
expect(warned()).toMatch(/SMS_TREZ_USERNAME/);
expect(warned()).toMatch(/SMS_TREZ_PASSWORD/);
expect(warned()).toMatch(/SMS_TREZ_SENDER_LINE/);
});
it('passes silently in production once everything is set', () => {
process.env.NODE_ENV = 'production';
configureFully();
expect(() => assertSmsConfigured(logger)).not.toThrow();
assertSmsConfigured(logger);
expect(logger.warn).not.toHaveBeenCalled();
});
});
it('allows SMS to be explicitly disabled in production', () => {
describe('none and unknown providers (D20 — never throws)', () => {
it('allows SMS to be explicitly disabled, in production too', () => {
process.env.NODE_ENV = 'production';
process.env.SMS_PROVIDER = 'none';
process.env.SMS_RELAY_HOST = '10.0.0.5';
expect(smsConfigured()).toBe(false);
expect(() => assertSmsConfigured(logger)).not.toThrow();
expect(logger.warn).toHaveBeenCalled();
});
it('allows SMS to be disabled outside production', () => {
process.env.NODE_ENV = 'test';
process.env.SMS_PROVIDER = 'none';
expect(() => assertSmsConfigured(logger)).not.toThrow();
});
it('warns about an unknown provider name instead of refusing to boot', () => {
process.env.SMS_PROVIDER = 'kavenegar';
expect(smsConfigured()).toBe(false);
expect(() => assertSmsConfigured(logger)).not.toThrow();
expect((logger.warn as jest.Mock).mock.calls.flat().join(' ')).toMatch(
/Unknown SMS_PROVIDER/,
);
expect(warned()).toMatch(/Unknown SMS_PROVIDER/);
expect(warned()).toMatch(/"relay", "trez", "none"/);
});
});
});
describe('relayRejection', () => {
it('accepts empty and neutral JSON bodies', () => {
expect(relayRejection('')).toBeNull();
expect(relayRejection('{"status":true,"id":12}')).toBeNull();
expect(relayRejection('{"status":"success"}')).toBeNull();
expect(relayRejection('[1,2]')).toBeNull();
});
it('rejects bodies that say they failed', () => {
expect(relayRejection('{"status":false}')).toBe('rejected-by-gateway');
expect(relayRejection('{"success":false,"message":"bad phone"}')).toBe('bad phone');
expect(relayRejection('{"status":"error"}')).toBe('rejected-by-gateway');
expect(relayRejection('{"error":"invalid key"}')).toBe('invalid key');
});
it('rejects a bare-text body — the gateway reports its own PHP errors that way', () => {
expect(relayRejection('cURL library is not loaded')).toBe('cURL library is not loaded');
});
});
describe('SmsService.send', () => {
const create = jest.fn();
const service = new SmsService({ smsMessage: { create } } as unknown as PrismaService);
let fetchMock: jest.SpyInstance;
const reply = (status: number, body: string) =>
fetchMock.mockResolvedValue(new Response(body, { status }));
beforeEach(() => {
create.mockReset().mockResolvedValue({});
fetchMock = jest.spyOn(globalThis, 'fetch');
// Keep the deliberate failure logs out of the test output.
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
process.env.SMS_RELAY_HOST = '10.0.0.5';
});
it('posts the shape of the owner PHP client to the relay', async () => {
reply(200, '{"status":true}');
const result = await service.send('09120000000', ' سلام ');
expect(result).toEqual({ ok: true, response: '{"status":true}' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://185.231.112.101/api/createsms');
expect(init.method).toBe('POST');
expect(init.headers).toEqual({
'Content-Type': 'application/json',
secret_key: relayConfig().secret,
});
expect(JSON.parse(init.body)).toEqual({
phone: '09120000000',
host: '10.0.0.5',
text: 'سلام',
platform: 'kave',
});
expect(create).toHaveBeenCalledWith({
data: expect.objectContaining({ recipients: '09120000000', body: 'سلام' }),
});
});
it('reports an HTTP error without throwing, and still records the message', async () => {
reply(500, 'boom');
await expect(service.send('09120000000', 'x')).resolves.toEqual({
ok: false,
response: 'boom',
error: 'http-500',
});
expect(create).toHaveBeenCalled();
});
it('reports a 200 that the gateway marks as failed', async () => {
reply(200, '{"status":false,"message":"no credit"}');
const result = await service.send('09120000000', 'x');
expect(result.ok).toBe(false);
expect(result.error).toBe('no credit');
});
it('turns a network failure into ok:false', async () => {
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(service.send('09120000000', 'x')).resolves.toEqual({
ok: false,
error: 'ECONNREFUSED',
});
});
it('reports the gateway bare-text PHP error, as seen live', async () => {
reply(200, 'cURL library is not loaded');
await expect(service.send('09120000000', 'x')).resolves.toEqual({
ok: false,
response: 'cURL library is not loaded',
error: 'cURL library is not loaded',
});
});
it('honours persist:false', async () => {
reply(200, '{}');
await service.send('09120000000', 'x', { persist: false });
expect(create).not.toHaveBeenCalled();
});
it('sends nothing when the provider is disabled', async () => {
process.env.SMS_PROVIDER = 'none';
const result = await service.send('09120000000', 'x');
expect(result).toEqual({ ok: false, error: 'sms-not-configured' });
expect(fetchMock).not.toHaveBeenCalled();
});
it('still drives trez when selected', async () => {
process.env.SMS_PROVIDER = 'trez';
process.env.SMS_TREZ_USERNAME = 'u';
process.env.SMS_TREZ_PASSWORD = 'p';
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
reply(200, '1');
await service.send('09120000000', 'x');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(DEFAULT_TREZ_URL);
expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({
PhoneNumber: '9998882797',
Username: 'u',
Password: 'p',
Mobile: '09120000000',
Message: 'x',
});
});
});
+54 -94
View File
@@ -2,22 +2,22 @@ import { Injectable, Logger } from '@nestjs/common';
import { MessageAudience } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { envOr } from '../../common/env';
import { DEFAULT_SMS_PROVIDER, SMS_DRIVERS, SmsDriver, SmsSendResult } from './drivers';
/**
* SMS delivery — port of henza_server/app/Models/Sms.php.
* SMS delivery — port of henza_server/app/Models/Sms.php, with the gateway
* pulled out into drivers (`./drivers`) selected by `SMS_PROVIDER`:
* * `relay` (default, D26) — the owner's createsms gateway
* * `trez` — the reference's Trez panel
* * `none` — send nothing
*
* Reference behaviour (`Sms::Send`, `Sms.php:57-82`):
* * POST, form-urlencoded, to the Trez panel, 20s timeout
* * fields: PhoneNumber (sender line), Username, Password, Mobile, Message
* * the message is trim()ed before sending
* Reference behaviour kept for every driver:
* * the message is trim()ed before sending (`Sms.php:77`)
* * EVERY message is persisted, via `Sms::set()` (`:80`)
* * the response body is returned and the callers ignore it
*
* Two things differ, both deliberate:
* 1. Credentials come from the environment. The reference hardcodes live
* production credentials in the source (SEC-12); they are not copied here.
* 2. The OTP message body is NOT persisted in readable form — see
* CustomerAuthService.deliver() and SEC-11.
* The OTP message body is NOT persisted in readable form — see
* CustomerAuthService.deliver() and SEC-11.
*
* Failure handling matches the reference: `Sms::Send` is called inline and its
* result is discarded, so a failed send never aborted the order, the document
@@ -25,18 +25,7 @@ import { envOr } from '../../common/env';
* never throws — it logs and reports failure in its return value.
*/
/** Reference endpoint (`Sms.php:69`). Overridable for staging. */
const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx';
/** `Http::timeout(20)` in the reference (`Sms.php:72`). */
const REQUEST_TIMEOUT_MS = 20_000;
export interface SmsSendResult {
ok: boolean;
/** Provider response body, when one was received. */
response?: string;
error?: string;
}
export type { SmsSendResult } from './drivers';
export interface SmsSendOptions {
/**
@@ -47,15 +36,14 @@ export interface SmsSendOptions {
audience?: MessageAudience;
}
/** Reads the provider config. Exported so main.ts can validate it at boot. */
export function smsConfig() {
return {
provider: envOr('SMS_PROVIDER', 'trez').toLowerCase(),
url: envOr('SMS_TREZ_URL', DEFAULT_TREZ_URL),
username: process.env.SMS_TREZ_USERNAME ?? '',
password: process.env.SMS_TREZ_PASSWORD ?? '',
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
};
/** The selected `SMS_PROVIDER`, lower-cased. */
export function smsProvider(): string {
return envOr('SMS_PROVIDER', DEFAULT_SMS_PROVIDER).toLowerCase();
}
/** The selected driver, or null for `none` and unknown names. */
export function smsDriver(): SmsDriver | null {
return SMS_DRIVERS[smsProvider()] ?? null;
}
/**
@@ -63,9 +51,8 @@ export function smsConfig() {
* development mode: nothing is sent, and OTP codes are logged locally.
*/
export function smsConfigured(): boolean {
const c = smsConfig();
if (c.provider === 'none') return false;
return Boolean(c.username && c.password && c.senderLine);
const driver = smsDriver();
return driver !== null && driver.missingConfig().length === 0;
}
/**
@@ -80,35 +67,30 @@ export function smsConfigured(): boolean {
* login code, which is why `OTP_MASTER_CODE` exists for the delivery.
*/
export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void {
const c = smsConfig();
const provider = smsProvider();
if (c.provider === 'none') {
if (provider === 'none') {
logger.warn('SMS_PROVIDER=none — no SMS will be sent.');
return;
}
if (c.provider !== 'trez') {
const driver = smsDriver();
if (!driver) {
const supported = [...Object.keys(SMS_DRIVERS), 'none'].map((n) => `"${n}"`).join(', ');
logger.warn(
`Unknown SMS_PROVIDER "${c.provider}" (supported: "trez", "none"). ` +
'No SMS will be sent.',
`Unknown SMS_PROVIDER "${provider}" (supported: ${supported}). No SMS will be sent.`,
);
return;
}
const missing = (
[
['SMS_TREZ_USERNAME', c.username],
['SMS_TREZ_PASSWORD', c.password],
['SMS_TREZ_SENDER_LINE', c.senderLine],
] as const
)
.filter(([, v]) => !v)
.map(([name]) => name);
if (missing.length === 0) return;
const missing = driver.missingConfig();
if (missing.length === 0) {
logger.log(`SMS provider: ${driver.name}`);
return;
}
logger.warn(
`SMS provider "trez" is selected but these are not set: ${missing.join(', ')}. ` +
`SMS provider "${driver.name}" is selected but these are not set: ${missing.join(', ')}. ` +
'Messages are logged, not sent. Customers cannot receive a login code.',
);
}
@@ -128,14 +110,8 @@ export class SmsService {
// The reference trims before sending (`Sms.php:77`).
const text = body.trim();
let result: SmsSendResult;
if (!smsConfigured()) {
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
result = { ok: false, error: 'sms-not-configured' };
} else {
result = await this.deliverViaTrez(mobile, text);
}
console.log("mobile ==>" ,mobile)
const result = await this.deliver(mobile, text);
if (persist) {
// `Sms::set()` records every message, delivered or not.
@@ -145,42 +121,26 @@ export class SmsService {
return result;
}
/** POST to the Trez panel, shaped exactly as the reference's form post. */
private async deliverViaTrez(mobile: string, text: string): Promise<SmsSendResult> {
const c = smsConfig();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(c.url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
PhoneNumber: c.senderLine,
Username: c.username,
Password: c.password,
Mobile: mobile,
Message: text,
}).toString(),
signal: controller.signal,
});
const responseBody = await response.text();
if (!response.ok) {
// Logged, not thrown — see the class comment.
this.logger.error(`SMS to ${mobile} failed: HTTP ${response.status} ${responseBody}`);
return { ok: false, response: responseBody, error: `http-${response.status}` };
}
return { ok: true, response: responseBody };
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
this.logger.error(`SMS to ${mobile} failed: ${error}`);
return { ok: false, error };
} finally {
clearTimeout(timer);
private async deliver(mobile: string, text: string): Promise<SmsSendResult> {
const driver = smsDriver();
if (!driver || driver.missingConfig().length > 0) {
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
return { ok: false, error: 'sms-not-configured' };
}
let result: SmsSendResult;
try {
result = await driver.deliver(mobile, text);
} catch (e) {
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
}
if (!result.ok) {
// Logged, not thrown — see the class comment.
const detail = result.response ? ` ${result.response}` : '';
this.logger.error(`SMS to ${mobile} via ${driver.name} failed: ${result.error}${detail}`);
}
return result;
}
/** The `messages` row the reference writes for every send (`Sms.php:120-129`). */