305 lines
13 KiB
TypeScript
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 },
|
|
});
|
|
}
|
|
}
|