import { MAX_CARD_NAME_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH, } from '../domain/bank-card-limits'; import { BadRequestException, ConflictException, NotFoundException, } from '@nestjs/common'; import { createHash } from 'node:crypto'; import { bankPaymentSystems, BankPeriodQueryDto, CreateBankCardDto, CreateBankOperationDto, } from '../presentation/bank-card.dto'; import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; import { parseRubMoney } from '../domain/rub-money'; export const BankErrorCode = { InvalidInput: 'BANK_INVALID_INPUT', VersionConflict: 'BANK_VERSION_CONFLICT', NotFound: 'BANK_NOT_FOUND', InvalidMoney: 'BANK_INVALID_MONEY', InvalidDate: 'BANK_INVALID_DATE', ImmutableOperation: 'BANK_IMMUTABLE_OPERATION', InvalidRefund: 'BANK_INVALID_REFUND', RefundLimit: 'BANK_REFUND_LIMIT', DependentRefunds: 'BANK_DEPENDENT_REFUNDS', IntentConflict: 'BANK_INTENT_CONFLICT', OperationDeleted: 'BANK_OPERATION_DELETED', CardArchived: 'BANK_CARD_ARCHIVED', OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT', WriteConflict: 'BANK_WRITE_CONFLICT', DuplicateCategory: 'BANK_DUPLICATE_CATEGORY', } as const; export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never { throw new BadRequestException({ code }); } export function periodBounds(query: BankPeriodQueryDto) { if (query.month && !query.from && !query.to) { const from = `${query.month}-01`; if (!isCalendarDate(from)) return fail(); const end = new Date(`${from}T00:00:00Z`); end.setUTCMonth(end.getUTCMonth() + 1, 0); return { from, to: end.toISOString().slice(0, 10) }; } if ( query.month || !query.from || !query.to || !isCalendarDate(query.from) || !isCalendarDate(query.to) || query.from > query.to ) return fail(); return { from: query.from, to: query.to }; } export function conflict( code: BankErrorCode = BankErrorCode.VersionConflict, ): never { throw new ConflictException({ code }); } export function missing(): never { throw new NotFoundException({ code: BankErrorCode.NotFound }); } export const fingerprint = (value: unknown) => createHash('sha256') .update( JSON.stringify(value, (_key, item: unknown) => typeof item === 'bigint' ? item.toString() : item, ), ) .digest('hex'); export function amount( value: Parameters[0], positive = false, ) { try { return parseRubMoney(value, positive); } catch { return fail(BankErrorCode.InvalidMoney); } } export function calendar(value: string) { if (!isCalendarDate(value)) fail(BankErrorCode.InvalidDate); return value; } export function currentDate(value: string) { calendar(value); if (value > moscowToday()) fail(BankErrorCode.InvalidDate); } export function name(value: string, max: number) { if (typeof value !== 'string' || !value.trim() || value.trim().length > max) fail(); return value.trim(); } export function cardInput(dto: CreateBankCardDto) { const bankName = dto.bankName?.trim() || null; if (bankName && bankName.length > MAX_CARD_NAME_LENGTH) fail(); if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail(); if ( dto.paymentSystem != null && !bankPaymentSystems.some((system) => system === dto.paymentSystem) ) fail(); return { ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), name: name(dto.name, MAX_CARD_NAME_LENGTH), bankName, lastFour: dto.lastFour || null, openingBalanceKopecks: amount(dto.openingBalance), openingDate: calendar(dto.openingDate), }; } export function operationInput(dto: CreateBankOperationDto) { if ( dto.description != null && (typeof dto.description !== 'string' || dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH) ) fail(); return { kind: dto.kind, cardId: dto.cardId, targetCardId: dto.targetCardId ?? null, categoryId: dto.categoryId ?? null, originalExpenseId: dto.originalExpenseId ?? null, amountKopecks: amount(dto.amount, true), date: calendar(dto.date), description: dto.description || null, }; }