From a80bd5080b6cdb1337e03ecd5d1bcb74b88289c6 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Fri, 31 Jul 2026 10:44:57 +0000 Subject: [PATCH] feat(transactions): add transactions.service --- src/transactions/transactions.service.ts | 129 +++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/transactions/transactions.service.ts diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts new file mode 100644 index 0000000..7a622cd --- /dev/null +++ b/src/transactions/transactions.service.ts @@ -0,0 +1,129 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; +import { db } from '../db/client'; +import { transactions } from '../db/schema'; +import { AuditService } from '../audit/audit.service'; +import { OutboxService } from '../events/outbox.service'; +import type { AuthenticatedSession } from '../auth/tenant.guard'; +import type { CreateTransactionDto, UpdateTransactionDto } from './dto'; + +@Injectable() +export class TransactionsService { + constructor( + private readonly outbox: OutboxService, + private readonly audit: AuditService, + ) {} + + async list(session: AuthenticatedSession, organizationId?: string) { + return db.query.transactions.findMany({ + where: and( + eq(transactions.tenantId, session.tenantId), + isNull(transactions.deletedAt), + ...(organizationId ? [eq(transactions.organizationId, organizationId)] : []), + ), + orderBy: desc(transactions.transactionDate), + }); + } + + async getById(session: AuthenticatedSession, id: string) { + const row = await db.query.transactions.findFirst({ + where: and( + eq(transactions.id, id), + eq(transactions.tenantId, session.tenantId), + isNull(transactions.deletedAt), + ), + }); + if (!row) throw new NotFoundException('Transaction not found'); + return row; + } + + async create(session: AuthenticatedSession, dto: CreateTransactionDto) { + return this.outbox.withTransaction(async (tx) => { + const [row] = await tx + .insert(transactions) + .values({ + tenantId: session.tenantId, + organizationId: dto.organizationId, + type: dto.type, + amountMinorUnits: String(dto.amountMinorUnits), + currency: dto.currency.toUpperCase(), + transactionDate: new Date(dto.transactionDate), + evidenceStatus: dto.evidenceStatus ?? 'missing', + source: dto.source, + }) + .returning(); + await this.audit.record(tx, { + tenantId: session.tenantId, + actorId: session.userId, + action: 'transaction.created', + resource: `transaction:${row.id}`, + }); + await this.outbox.record(tx, { + tenantId: session.tenantId, + workspaceId: session.workspaceId, + actorId: session.userId, + eventType: 'transaction.created', + aggregateType: 'transaction', + subjectId: row.id, + correlationId: session.correlationId, + payload: { type: row.type, currency: row.currency, evidenceStatus: row.evidenceStatus }, + }); + return row; + }); + } + + async update(session: AuthenticatedSession, id: string, dto: UpdateTransactionDto) { + await this.getById(session, id); + return this.outbox.withTransaction(async (tx) => { + const [row] = await tx + .update(transactions) + .set({ + ...(dto.evidenceStatus !== undefined ? { evidenceStatus: dto.evidenceStatus } : {}), + ...(dto.type !== undefined ? { type: dto.type } : {}), + ...(dto.source !== undefined ? { source: dto.source } : {}), + updatedAt: new Date(), + version: sql`${transactions.version} + 1`, + }) + .where(and(eq(transactions.id, id), eq(transactions.tenantId, session.tenantId))) + .returning(); + await this.audit.record(tx, { + tenantId: session.tenantId, + actorId: session.userId, + action: dto.evidenceStatus !== undefined + ? `transaction.evidence_updated:${dto.evidenceStatus}` + : 'transaction.updated', + resource: `transaction:${id}`, + }); + if (dto.evidenceStatus !== undefined) { + await this.outbox.record(tx, { + tenantId: session.tenantId, + workspaceId: session.workspaceId, + actorId: session.userId, + eventType: 'transaction.evidence_updated', + aggregateType: 'transaction', + subjectId: id, + correlationId: session.correlationId, + payload: { evidenceStatus: dto.evidenceStatus }, + }); + } + return row; + }); + } + + async softDelete(session: AuthenticatedSession, id: string) { + await this.getById(session, id); + await this.outbox.withTransaction(async (tx) => { + await tx + .update(transactions) + .set({ deletedAt: new Date() }) + .where(and(eq(transactions.id, id), eq(transactions.tenantId, session.tenantId))); + await this.audit.record(tx, { + tenantId: session.tenantId, + actorId: session.userId, + action: 'transaction.deleted', + resource: `transaction:${id}`, + }); + }); + return { deleted: true }; + } +}