Use owner enums for error and T-Bank protocol values with ESLint regression checks. Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma. Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
144 lines
4.3 KiB
TypeScript
144 lines
4.3 KiB
TypeScript
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<typeof parseRubMoney>[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,
|
|
};
|
|
}
|