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:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
Reference in New Issue
Block a user