Files
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

311 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(' ') };
}