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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user