Add Tahesab jewelry accounting integration with multi-DB connections.
Encrypt tokens server-side, sync customers both ways, show balances/documents on the person Assets tab, and push sanads with idempotent Factor_Code logging. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabCryptoService } from './tahesab-crypto.service';
|
||||
import { TahesabHttpClient } from './tahesab-http.client';
|
||||
import { TahesabApi } from './tahesab-methods';
|
||||
import {
|
||||
CreateTahesabConnectionDto,
|
||||
UpdateTahesabConnectionDto,
|
||||
} from './dto';
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
let u = url.trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
throw new BadRequestException('baseUrl باید با http:// یا https:// شروع شود.');
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabConnectionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: TahesabCryptoService,
|
||||
private readonly http: TahesabHttpClient,
|
||||
) {}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
dbName: string;
|
||||
tokenLast4: string;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
allowInsecureTls: boolean;
|
||||
lastHealthAt: Date | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
provider: row.provider,
|
||||
label: row.label,
|
||||
baseUrl: row.baseUrl,
|
||||
dbName: row.dbName,
|
||||
tokenMasked: row.tokenLast4 ? `••••••${row.tokenLast4}` : '••••',
|
||||
enabled: row.enabled,
|
||||
isDefault: row.isDefault,
|
||||
allowInsecureTls: row.allowInsecureTls,
|
||||
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
|
||||
lastHealthOk: row.lastHealthOk,
|
||||
lastError: row.lastError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async list(organizationId: string) {
|
||||
const rows = await this.prisma.accountingConnection.findMany({
|
||||
where: { organizationId, provider: 'tahesab' },
|
||||
orderBy: [{ isDefault: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
async getOrThrow(organizationId: string, id: string) {
|
||||
const row = await this.prisma.accountingConnection.findFirst({
|
||||
where: { id, organizationId, provider: 'tahesab' },
|
||||
});
|
||||
if (!row) throw new NotFoundException('اتصال تهحساب یافت نشد.');
|
||||
return row;
|
||||
}
|
||||
|
||||
async getApi(organizationId: string, connectionId: string) {
|
||||
const row = await this.getOrThrow(organizationId, connectionId);
|
||||
if (!row.enabled) {
|
||||
throw new BadRequestException('این اتصال غیرفعال است.');
|
||||
}
|
||||
const token = this.crypto.decrypt({
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
tag: row.tokenTag,
|
||||
});
|
||||
return {
|
||||
row,
|
||||
api: new TahesabApi(this.http, {
|
||||
baseUrl: row.baseUrl,
|
||||
token,
|
||||
dbName: row.dbName,
|
||||
allowInsecureTls: row.allowInsecureTls,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async resolveConnectionId(
|
||||
organizationId: string,
|
||||
connectionId?: string | null,
|
||||
) {
|
||||
if (connectionId) {
|
||||
await this.getOrThrow(organizationId, connectionId);
|
||||
return connectionId;
|
||||
}
|
||||
const def = await this.prisma.accountingConnection.findFirst({
|
||||
where: { organizationId, provider: 'tahesab', enabled: true, isDefault: true },
|
||||
});
|
||||
if (def) return def.id;
|
||||
const any = await this.prisma.accountingConnection.findFirst({
|
||||
where: { organizationId, provider: 'tahesab', enabled: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!any) {
|
||||
throw new BadRequestException(
|
||||
'هیچ اتصال فعالی به تهحساب تعریف نشده است. از تنظیمات یک اتصال اضافه کنید.',
|
||||
);
|
||||
}
|
||||
return any.id;
|
||||
}
|
||||
|
||||
private async clearOtherDefaults(organizationId: string, exceptId?: string) {
|
||||
await this.prisma.accountingConnection.updateMany({
|
||||
where: {
|
||||
organizationId,
|
||||
provider: 'tahesab',
|
||||
...(exceptId ? { id: { not: exceptId } } : {}),
|
||||
},
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
async create(organizationId: string, dto: CreateTahesabConnectionDto) {
|
||||
const baseUrl = normalizeBaseUrl(dto.baseUrl);
|
||||
const existing = await this.prisma.accountingConnection.findFirst({
|
||||
where: { organizationId, baseUrl, dbName: dto.dbName.trim() },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
'اتصالی با همین آدرس و نام دیتابیس از قبل وجود دارد.',
|
||||
);
|
||||
}
|
||||
|
||||
const count = await this.prisma.accountingConnection.count({
|
||||
where: { organizationId, provider: 'tahesab' },
|
||||
});
|
||||
const makeDefault = dto.isDefault === true || count === 0;
|
||||
if (makeDefault) await this.clearOtherDefaults(organizationId);
|
||||
|
||||
const enc = this.crypto.encrypt(dto.token.trim());
|
||||
const row = await this.prisma.accountingConnection.create({
|
||||
data: {
|
||||
organizationId,
|
||||
provider: 'tahesab',
|
||||
label: dto.label.trim(),
|
||||
baseUrl,
|
||||
dbName: dto.dbName.trim(),
|
||||
tokenCiphertext: enc.ciphertext,
|
||||
tokenIv: enc.iv,
|
||||
tokenTag: enc.tag,
|
||||
tokenLast4: this.crypto.last4(dto.token),
|
||||
enabled: dto.enabled ?? true,
|
||||
isDefault: makeDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? false,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateTahesabConnectionDto,
|
||||
) {
|
||||
const row = await this.getOrThrow(organizationId, id);
|
||||
const baseUrl = dto.baseUrl ? normalizeBaseUrl(dto.baseUrl) : row.baseUrl;
|
||||
const dbName = dto.dbName?.trim() ?? row.dbName;
|
||||
|
||||
if (baseUrl !== row.baseUrl || dbName !== row.dbName) {
|
||||
const clash = await this.prisma.accountingConnection.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
baseUrl,
|
||||
dbName,
|
||||
id: { not: id },
|
||||
},
|
||||
});
|
||||
if (clash) {
|
||||
throw new ConflictException(
|
||||
'اتصالی با همین آدرس و نام دیتابیس از قبل وجود دارد.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
|
||||
|
||||
let enc = {
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
tag: row.tokenTag,
|
||||
};
|
||||
let tokenLast4 = row.tokenLast4;
|
||||
if (dto.token?.trim()) {
|
||||
enc = this.crypto.encrypt(dto.token.trim());
|
||||
tokenLast4 = this.crypto.last4(dto.token);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.accountingConnection.update({
|
||||
where: { id },
|
||||
data: {
|
||||
label: dto.label?.trim() ?? row.label,
|
||||
baseUrl,
|
||||
dbName,
|
||||
tokenCiphertext: enc.ciphertext,
|
||||
tokenIv: enc.iv,
|
||||
tokenTag: enc.tag,
|
||||
tokenLast4,
|
||||
enabled: dto.enabled ?? row.enabled,
|
||||
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
|
||||
},
|
||||
});
|
||||
return this.toDto(updated);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string) {
|
||||
const row = await this.getOrThrow(organizationId, id);
|
||||
await this.prisma.accountingConnection.delete({ where: { id: row.id } });
|
||||
if (row.isDefault) {
|
||||
const next = await this.prisma.accountingConnection.findFirst({
|
||||
where: { organizationId, provider: 'tahesab', enabled: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (next) {
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: next.id },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async test(organizationId: string, id: string) {
|
||||
const { row, api } = await this.getApi(organizationId, id);
|
||||
try {
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await api.checkHealthSsl();
|
||||
} catch {
|
||||
result = await api.checkHealthNoSsl();
|
||||
}
|
||||
const updated = await this.prisma.accountingConnection.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
lastHealthAt: new Date(),
|
||||
lastHealthOk: true,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return { ok: true, connection: this.toDto(updated), health: result };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? 'خطای ناشناخته';
|
||||
const updated = await this.prisma.accountingConnection.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
lastHealthAt: new Date(),
|
||||
lastHealthOk: false,
|
||||
lastError: message.slice(0, 500),
|
||||
},
|
||||
});
|
||||
return { ok: false, connection: this.toDto(updated), error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user