- 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>
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
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(' ') };
|
||
}
|