- 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>
182 lines
7.0 KiB
TypeScript
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;
|
|
}
|