Files
hedena_gold/apps/api/src/modules/accounting-sync/accounting-config.service.ts
T

305 lines
13 KiB
TypeScript

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';
import { CatalogCacheService } from '../catalog/catalog-cache.service';
/** 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,
private readonly catalogCache: CatalogCacheService,
) {}
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;
// The provider and product-sync switch decide which products read as out of stock.
this.catalogCache.invalidate();
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 },
});
}
}