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

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

182 lines
7.0 KiB
TypeScript

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;
}