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,298 @@
|
||||
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
ACCOUNTING_CONFIG_KEY,
|
||||
ACCOUNTING_STATE_KEY,
|
||||
AccountingConfig,
|
||||
StoredConnection,
|
||||
cleanBool,
|
||||
cleanCa,
|
||||
cleanGroup,
|
||||
cleanGroups,
|
||||
cleanInt,
|
||||
cleanUrl,
|
||||
documentStatusRank,
|
||||
withDefaults,
|
||||
} from './accounting-config';
|
||||
import { ACCOUNTING_PROVIDERS, AccountingProvider, ProviderName, ProviderSettings, isProviderName } from './providers';
|
||||
import { openSecret, sealSecret, secretKeySource } from './secret-box';
|
||||
import { parsePemCertificates } from './accounting-http';
|
||||
|
||||
/** The first certificate of a pasted bundle, for display; null when none parses. */
|
||||
function describeCertificate(pem: string | null) {
|
||||
const [c] = parsePemCertificates(pem);
|
||||
if (!c) return null;
|
||||
return {
|
||||
subject: c.subject,
|
||||
subjectAltName: c.subjectAltName ?? null,
|
||||
validTo: c.validTo,
|
||||
expired: new Date(c.validTo).getTime() < Date.now(),
|
||||
fingerprint256: c.fingerprint256,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActiveProvider {
|
||||
provider: AccountingProvider;
|
||||
settings: ProviderSettings;
|
||||
config: AccountingConfig;
|
||||
}
|
||||
|
||||
export type JobName = 'customers' | 'balances' | 'products' | 'outbox';
|
||||
|
||||
export interface JobState {
|
||||
lastRunAt?: string;
|
||||
lastOkAt?: string;
|
||||
lastError?: string | null;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AccountingState {
|
||||
jobs: Partial<Record<JobName, JobState>>;
|
||||
}
|
||||
|
||||
/** How long a read config is reused before going back to the database. */
|
||||
const CACHE_MS = 5_000;
|
||||
|
||||
@Injectable()
|
||||
export class AccountingConfigService implements OnModuleInit {
|
||||
private readonly logger = new Logger('Accounting');
|
||||
private cache: { at: number; config: AccountingConfig } | null = null;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (secretKeySource() !== 'ACCOUNTING_SECRET_KEY') {
|
||||
this.logger.warn(
|
||||
'⚠️ ACCOUNTING_SECRET_KEY تنظیم نشده است. توکن نرمافزار حسابداری با کلیدی مشتق از JWT_SECRET رمز میشود؛ ' +
|
||||
'با عوضشدن JWT_SECRET توکن ذخیرهشده قابلخواندن نمیماند و باید دوباره وارد شود. (D27)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async get(): Promise<AccountingConfig> {
|
||||
if (this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.config;
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_CONFIG_KEY } });
|
||||
const config = withDefaults(row?.value);
|
||||
this.cache = { at: Date.now(), config };
|
||||
return config;
|
||||
}
|
||||
|
||||
/** The configuration as the admin screen sees it: tokens reduced to flags. */
|
||||
async getPublic() {
|
||||
const c = await this.get();
|
||||
const publicConn = (conn: StoredConnection) => {
|
||||
const { tokenSealed, ...rest } = conn;
|
||||
return {
|
||||
...rest,
|
||||
tokenSet: !!tokenSealed,
|
||||
tokenReadable: !!tokenSealed && openSecret(tokenSealed) !== null,
|
||||
// What the pinned certificate is, so the screen can show it instead of PEM.
|
||||
caInfo: describeCertificate(rest.caCert),
|
||||
};
|
||||
};
|
||||
return {
|
||||
...c,
|
||||
tahesab: publicConn(c.tahesab),
|
||||
zarrin: publicConn(c.zarrin),
|
||||
secretKeySource: secretKeySource(),
|
||||
providers: Object.values(ACCOUNTING_PROVIDERS).map((p) => ({
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
capabilities: p.capabilities,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an admin's edit. Anything not sent is kept. A token is replaced
|
||||
* only by a non-empty string and removed only by an explicit null — so the
|
||||
* form can be saved without ever having seen the token.
|
||||
*/
|
||||
async save(input: Record<string, any>): Promise<{ providerChanged: boolean }> {
|
||||
const current = await this.get();
|
||||
const next: AccountingConfig = JSON.parse(JSON.stringify(current));
|
||||
|
||||
if (input.provider !== undefined) {
|
||||
if (input.provider !== 'none' && !isProviderName(input.provider)) {
|
||||
throw new BadRequestException('نرمافزار حسابداری نامعتبر است');
|
||||
}
|
||||
next.provider = input.provider;
|
||||
}
|
||||
|
||||
const conn = (key: ProviderName) => {
|
||||
const src = input[key];
|
||||
if (!src || typeof src !== 'object') return;
|
||||
const label = ACCOUNTING_PROVIDERS[key].label;
|
||||
const dst: StoredConnection = next[key];
|
||||
if (src.baseUrl !== undefined) dst.baseUrl = cleanUrl(src.baseUrl, `آدرس ${label}`);
|
||||
if (src.caCert !== undefined) dst.caCert = cleanCa(src.caCert);
|
||||
if (src.timeoutMs !== undefined) dst.timeoutMs = cleanInt(src.timeoutMs, 'مهلت پاسخ (میلیثانیه)', 3000, 120000, dst.timeoutMs);
|
||||
if (src.token === null) dst.tokenSealed = null;
|
||||
else if (typeof src.token === 'string' && src.token.trim()) {
|
||||
try {
|
||||
dst.tokenSealed = sealSecret(src.token.trim());
|
||||
} catch (err) {
|
||||
throw new BadRequestException((err as Error).message);
|
||||
}
|
||||
}
|
||||
};
|
||||
conn('tahesab');
|
||||
conn('zarrin');
|
||||
|
||||
if (input.tahesab?.dbName !== undefined) {
|
||||
const db = String(input.tahesab.dbName ?? '').trim();
|
||||
if (!/^[A-Za-z0-9_]{1,64}$/.test(db)) throw new BadRequestException('نام دیتابیس تهحساب نامعتبر است');
|
||||
next.tahesab.dbName = db;
|
||||
}
|
||||
if (input.zarrin?.balanceOwesWhen !== undefined) {
|
||||
if (input.zarrin.balanceOwesWhen !== 'negative' && input.zarrin.balanceOwesWhen !== 'positive') {
|
||||
throw new BadRequestException('علامت ماندهی زرین نامعتبر است');
|
||||
}
|
||||
next.zarrin.balanceOwesWhen = input.zarrin.balanceOwesWhen;
|
||||
}
|
||||
|
||||
if (input.features && typeof input.features === 'object') {
|
||||
for (const k of Object.keys(next.features) as (keyof AccountingConfig['features'])[]) {
|
||||
next.features[k] = cleanBool(input.features[k], next.features[k]);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.intervals && typeof input.intervals === 'object') {
|
||||
const i = input.intervals;
|
||||
next.intervals.customersMinutes = cleanInt(i.customersMinutes, 'بازهی سینک مشتری', 5, 1440, next.intervals.customersMinutes);
|
||||
next.intervals.balancesMinutes = cleanInt(i.balancesMinutes, 'بازهی سینک مانده', 1, 1440, next.intervals.balancesMinutes);
|
||||
next.intervals.productsMinutes = cleanInt(i.productsMinutes, 'بازهی سینک کارساخته', 5, 1440, next.intervals.productsMinutes);
|
||||
}
|
||||
|
||||
if (input.mapping && typeof input.mapping === 'object') {
|
||||
const m = input.mapping;
|
||||
if (m.importCustomerGroups !== undefined) next.mapping.importCustomerGroups = cleanGroups(m.importCustomerGroups);
|
||||
if (m.newCustomerGroup !== undefined) next.mapping.newCustomerGroup = cleanGroup(m.newCustomerGroup);
|
||||
if (m.newKarSakhteGroup !== undefined) next.mapping.newKarSakhteGroup = cleanGroup(m.newKarSakhteGroup);
|
||||
if (m.newKarSakhteType !== undefined) next.mapping.newKarSakhteType = cleanGroup(m.newKarSakhteType);
|
||||
if (m.categoryGroups !== undefined) {
|
||||
next.mapping.categoryGroups = (Array.isArray(m.categoryGroups) ? m.categoryGroups : [])
|
||||
.map((x: any) => ({ categoryId: String(x?.categoryId ?? '').trim(), group: cleanGroup(x?.group) }))
|
||||
.filter((x: any) => x.categoryId && x.group);
|
||||
}
|
||||
if (m.karat !== undefined) next.mapping.karat = cleanInt(m.karat, 'عیار پایه', 1, 1000, next.mapping.karat);
|
||||
}
|
||||
|
||||
if (input.documents && typeof input.documents === 'object' && input.documents.triggerStatus !== undefined) {
|
||||
if (!documentStatusRank(String(input.documents.triggerStatus))) {
|
||||
throw new BadRequestException('وضعیت ثبت سند سفارش نامعتبر است');
|
||||
}
|
||||
next.documents.triggerStatus = input.documents.triggerStatus;
|
||||
}
|
||||
|
||||
if (input.report && typeof input.report === 'object') {
|
||||
next.report.windowDays = cleanInt(input.report.windowDays, 'بازهی گزارش (روز)', 7, 3650, next.report.windowDays);
|
||||
}
|
||||
|
||||
// Selecting a software that has no address/token would switch the sync
|
||||
// off without a word (the worker finds nothing usable).
|
||||
if (next.provider !== 'none' && (!next[next.provider].baseUrl || !next[next.provider].tokenSealed)) {
|
||||
const label = ACCOUNTING_PROVIDERS[next.provider].label;
|
||||
throw new BadRequestException(`«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`);
|
||||
}
|
||||
|
||||
await this.prisma.setting.upsert({
|
||||
where: { key: ACCOUNTING_CONFIG_KEY },
|
||||
update: { value: next as unknown as Prisma.InputJsonValue },
|
||||
create: { key: ACCOUNTING_CONFIG_KEY, value: next as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
this.cache = null;
|
||||
return { providerChanged: current.provider !== next.provider };
|
||||
}
|
||||
|
||||
/** Decrypted settings for one provider; null when it is not usable. */
|
||||
settingsFor(config: AccountingConfig, name: ProviderName): ProviderSettings | null {
|
||||
const conn = config[name];
|
||||
if (!conn.baseUrl) return null;
|
||||
const token = openSecret(conn.tokenSealed);
|
||||
if (!token) return null;
|
||||
return {
|
||||
baseUrl: conn.baseUrl,
|
||||
token,
|
||||
caCert: conn.caCert,
|
||||
timeoutMs: conn.timeoutMs,
|
||||
dbName: name === 'tahesab' ? config.tahesab.dbName : undefined,
|
||||
balanceOwesWhen: name === 'zarrin' ? config.zarrin.balanceOwesWhen : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings as the admin form currently shows them, saved or not, so «تست
|
||||
* اتصال» checks what is on screen. A blank token means "the saved one".
|
||||
* Validated like a save; nothing is stored.
|
||||
*/
|
||||
draftSettings(config: AccountingConfig, name: ProviderName, input: Record<string, any> | undefined): ProviderSettings | null {
|
||||
const src = input && typeof input === 'object' ? input : {};
|
||||
const label = ACCOUNTING_PROVIDERS[name].label;
|
||||
const saved = config[name];
|
||||
const baseUrl = src.baseUrl !== undefined ? cleanUrl(src.baseUrl, `آدرس ${label}`) : saved.baseUrl;
|
||||
const token = typeof src.token === 'string' && src.token.trim() ? src.token.trim() : openSecret(saved.tokenSealed);
|
||||
if (!baseUrl || !token) return null;
|
||||
let dbName = config.tahesab.dbName;
|
||||
if (name === 'tahesab' && src.dbName !== undefined) {
|
||||
dbName = String(src.dbName ?? '').trim();
|
||||
if (!/^[A-Za-z0-9_]{1,64}$/.test(dbName)) throw new BadRequestException('نام دیتابیس تهحساب نامعتبر است');
|
||||
}
|
||||
let balanceOwesWhen = config.zarrin.balanceOwesWhen;
|
||||
if (name === 'zarrin' && (src.balanceOwesWhen === 'negative' || src.balanceOwesWhen === 'positive')) {
|
||||
balanceOwesWhen = src.balanceOwesWhen;
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
caCert: src.caCert !== undefined ? cleanCa(src.caCert) : saved.caCert,
|
||||
timeoutMs: cleanInt(src.timeoutMs, 'مهلت پاسخ (میلیثانیه)', 3000, 120000, saved.timeoutMs),
|
||||
dbName: name === 'tahesab' ? dbName : undefined,
|
||||
balanceOwesWhen: name === 'zarrin' ? balanceOwesWhen : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Why nothing syncs, in words for the admin screen; null when a provider is usable. */
|
||||
inactiveReason(config: AccountingConfig): string | null {
|
||||
if (config.provider === 'none') return 'سوییچ روی «بدون اتصال» است';
|
||||
const label = ACCOUNTING_PROVIDERS[config.provider].label;
|
||||
const conn = config[config.provider];
|
||||
if (!conn.baseUrl || !conn.tokenSealed) return `«${label}» انتخاب شده ولی آدرس یا توکن آن وارد نشده است`;
|
||||
if (!this.settingsFor(config, config.provider)) return `توکن ذخیرهشدهی «${label}» قابلخواندن نیست؛ دوباره وارد کنید`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The selected, fully configured provider — or null when the switch is off. */
|
||||
async active(): Promise<ActiveProvider | null> {
|
||||
const config = await this.get();
|
||||
if (config.provider === 'none') return null;
|
||||
const settings = this.settingsFor(config, config.provider);
|
||||
if (!settings) return null;
|
||||
return { provider: ACCOUNTING_PROVIDERS[config.provider], settings, config };
|
||||
}
|
||||
|
||||
/** Active provider with this feature switched on, or null. */
|
||||
async activeFor(feature: keyof AccountingConfig['features']): Promise<ActiveProvider | null> {
|
||||
const a = await this.active();
|
||||
return a && a.config.features[feature] ? a : null;
|
||||
}
|
||||
|
||||
// ── job bookkeeping (shown on the admin screen) ───────────────────────
|
||||
|
||||
async getState(): Promise<AccountingState> {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: ACCOUNTING_STATE_KEY } });
|
||||
const v = (row?.value ?? {}) as Partial<AccountingState>;
|
||||
return { jobs: v.jobs ?? {} };
|
||||
}
|
||||
|
||||
async recordJob(job: JobName, patch: JobState) {
|
||||
const state = await this.getState();
|
||||
state.jobs[job] = { ...(state.jobs[job] ?? {}), ...patch };
|
||||
await this.prisma.setting.upsert({
|
||||
where: { key: ACCOUNTING_STATE_KEY },
|
||||
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||
create: { key: ACCOUNTING_STATE_KEY, value: state as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user