import { BadRequestException, ConflictException, NotFoundException, } from '@nestjs/common'; import { createHash } from 'node:crypto'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time'; import { MAX_CARD_NAME_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH, } from '../domain/bank-card-limits'; import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; import { parseRubMoney } from '../domain/rub-money'; import { bankPaymentSystems, BankPeriodQueryDto, CreateBankCardDto, CreateBankOperationDto, } from '../presentation/bank-card.dto'; const NEXT_MONTH_OFFSET = 1; const PREVIOUS_MONTH_LAST_DAY = 0; export enum BankErrorCode { CardArchived = 'BANK_CARD_ARCHIVED', DependentRefunds = 'BANK_DEPENDENT_REFUNDS', DuplicateCategory = 'BANK_DUPLICATE_CATEGORY', ImmutableOperation = 'BANK_IMMUTABLE_OPERATION', IntentConflict = 'BANK_INTENT_CONFLICT', InvalidDate = 'BANK_INVALID_DATE', InvalidInput = 'BANK_INVALID_INPUT', InvalidMoney = 'BANK_INVALID_MONEY', InvalidRefund = 'BANK_INVALID_REFUND', NotFound = 'BANK_NOT_FOUND', OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT', OperationDeleted = 'BANK_OPERATION_DELETED', RefundLimit = 'BANK_REFUND_LIMIT', VersionConflict = 'BANK_VERSION_CONFLICT', WriteConflict = 'BANK_WRITE_CONFLICT', } 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() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY); return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) }; } 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, }; }