feat(bank): multi-bank chequera — required bankAccountId, per-account scoping
The office keeps more than one operating account (Utilities banks in MXN, Seguros in USD), but bank_transactions was a single implicit MXN register by design. Adds Bank/BankAccount and makes every read and write in the module scoped to exactly one account. Schema: - Bank / BankAccount. Currency is fixed per account and BankTransaction has no currency column of its own — a movement inherits its account's, the way a real bank account doesn't mix currencies. - BankTransaction.bankAccountId, required. A movement with no known account isn't reconcilable against a statement. - @@index([bankAccountId, transactionDate]): every read now filters by account and orders/groups by date. Migration: - backfill_bank_accounts.py seeds Scotiabank + "Utilities — Scotiabank (MXN)" and backfills all 22,669 existing rows onto it, then promotes the column to NOT NULL and attaches the FK. Standalone because prisma db push cannot add a required column to a populated table. Idempotent; re-running once a second account exists does not re-point rows. - run_all.py runs it (both modes) before transform_bank.py, which now resolves the account by label and fails fast if it is missing. API: - ?bankAccountId= required on list/stats/facets/summary — not optional with an "all accounts" default, since summing an MXN and a USD register repeats the currency-collapsing mistake the billing module exists to prevent. Missing is 400, unknown is 404. - facets() had no account clause at all and summary() has two raw-SQL rollups; all three are now parameterised. Scoping only one of summary's queries would leave the year list and its drill-down describing different books. - New bank/accounts + bank/banks sub-resource under a MANAGER bank:manage-accounts ability. currency is absent from the update DTO: booked movements are denominated in it, so editing would re-denominate history. Capture into a closed account is rejected. Web: - /banco gains an account picker (remembered per browser) and reads every figure in the selected account's currency; the "single currency (MXN)" doc-comment and the hardcoded MXN formatting are gone. - New /banco/cuentas for banks and accounts. Accounts are closed, never deleted — the FK is required, so deleting one would destroy its register. - /inicio's chequera card names the account it is reading instead of implying a single register. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged (22,669 movements, net 1,014,266.97). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ export type Ability =
|
||||
| "ledger:void"
|
||||
| "bank:create"
|
||||
| "bank:void"
|
||||
| "bank:manage-accounts"
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
@@ -50,6 +51,9 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"ledger:void": "MANAGER",
|
||||
"bank:create": "STAFF",
|
||||
"bank:void": "MANAGER",
|
||||
// Opening or renaming a chequera is rarer and higher-stakes than posting a
|
||||
// movement into one — a wrong account silently mixes two sets of books.
|
||||
"bank:manage-accounts": "MANAGER",
|
||||
"lookup:manage": "MANAGER",
|
||||
"user:manage": "ADMIN",
|
||||
"db:manage": "ADMIN",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
|
||||
/** Mirrors the Prisma `Currency` enum; a chequera's is fixed at creation. */
|
||||
export const BANK_CURRENCIES = ["MXN", "USD"] as const;
|
||||
export type BankAccountCurrency = (typeof BANK_CURRENCIES)[number];
|
||||
|
||||
/** Mirrors `TransactionDomain`. A soft hint on the account, never enforced. */
|
||||
export const BANK_BUSINESS_LINES = ["UTILITY", "INSURANCE", "TRUST"] as const;
|
||||
export type BankBusinessLine = (typeof BANK_BUSINESS_LINES)[number];
|
||||
|
||||
export class CreateBankDto {
|
||||
@IsString() @MinLength(1) name!: string;
|
||||
/** "MX" | "US" — free text, informational only. */
|
||||
@IsOptional() @IsString() country?: string;
|
||||
}
|
||||
|
||||
export class UpdateBankDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsString() country?: string;
|
||||
}
|
||||
|
||||
export class CreateBankAccountDto {
|
||||
@IsString() @MinLength(1) bankId!: string;
|
||||
@IsString() @MinLength(1) label!: string;
|
||||
/**
|
||||
* Immutable after creation (no field for it on the update DTO): every
|
||||
* movement already booked into the account is denominated in it, so
|
||||
* changing it would silently re-denominate history.
|
||||
*/
|
||||
@IsIn(BANK_CURRENCIES) currency!: BankAccountCurrency;
|
||||
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateBankAccountDto {
|
||||
@IsOptional() @IsString() @MinLength(1) bankId?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) label?: string;
|
||||
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
|
||||
/** Closing an account hides it from the picker; its movements stay readable. */
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
@@ -2,10 +2,14 @@ import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-vali
|
||||
|
||||
/**
|
||||
* A new bank-register movement. `amount` is signed: positive = ingreso,
|
||||
* negative = egreso (the module's sign convention). Single currency (MXN).
|
||||
* negative = egreso (the module's sign convention). The currency is the
|
||||
* account's, not the movement's — `bankAccountId` decides it.
|
||||
* Booked rows are never edited — a mistake is corrected by voiding + re-capture.
|
||||
*/
|
||||
export class CreateBankMovementDto {
|
||||
/** Which chequera this lands in. Required — see BankAccount in the schema. */
|
||||
@IsString() @MinLength(1) bankAccountId!: string;
|
||||
|
||||
@IsNumber() amount!: number;
|
||||
@IsString() @MinLength(1) transactionDate!: string;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
@@ -20,6 +21,12 @@ import {
|
||||
BankSort,
|
||||
} from "./bank.service";
|
||||
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||
import {
|
||||
CreateBankAccountDto,
|
||||
CreateBankDto,
|
||||
UpdateBankAccountDto,
|
||||
UpdateBankDto,
|
||||
} from "./bank-account.dto";
|
||||
|
||||
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
||||
@@ -54,28 +61,108 @@ export class BankController {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
// --- accounts -------------------------------------------------------------
|
||||
// Declared before the parameterised routes below so `/bank/accounts` can
|
||||
// never be swallowed by a `:id`-shaped path.
|
||||
|
||||
/**
|
||||
* The account picker. Readable by any authenticated user, VIEWER included —
|
||||
* nothing else on this page can render until an account is chosen.
|
||||
*/
|
||||
@Get("accounts")
|
||||
accounts() {
|
||||
return this.bank.listAccounts();
|
||||
}
|
||||
|
||||
@Get("banks")
|
||||
banks() {
|
||||
return this.bank.listBanks();
|
||||
}
|
||||
|
||||
@Post("banks")
|
||||
@RequireAbility("bank:manage-accounts")
|
||||
async createBank(@Body() dto: CreateBankDto, @Req() req: Request) {
|
||||
const row = await this.bank.createBank(dto);
|
||||
void this.audit.log(this.actingId(req), "bank.bank.create", {
|
||||
bankId: row.id,
|
||||
name: row.name,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
@Patch("banks/:id")
|
||||
@RequireAbility("bank:manage-accounts")
|
||||
async updateBank(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateBankDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const row = await this.bank.updateBank(id, dto);
|
||||
void this.audit.log(this.actingId(req), "bank.bank.update", { bankId: id });
|
||||
return row;
|
||||
}
|
||||
|
||||
@Post("accounts")
|
||||
@RequireAbility("bank:manage-accounts")
|
||||
async createAccount(
|
||||
@Body() dto: CreateBankAccountDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const row = await this.bank.createAccount(dto);
|
||||
void this.audit.log(this.actingId(req), "bank.account.create", {
|
||||
bankAccountId: row.id,
|
||||
label: row.label,
|
||||
currency: row.currency,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
@Patch("accounts/:id")
|
||||
@RequireAbility("bank:manage-accounts")
|
||||
async updateAccount(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateBankAccountDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const row = await this.bank.updateAccount(id, dto);
|
||||
void this.audit.log(this.actingId(req), "bank.account.update", {
|
||||
bankAccountId: id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// --- register reads (all scoped to one account) ---------------------------
|
||||
|
||||
@Get("stats")
|
||||
stats() {
|
||||
return this.bank.stats();
|
||||
async stats(@Query("bankAccountId") bankAccountId?: string) {
|
||||
const account = await this.bank.requireAccount(bankAccountId);
|
||||
return this.bank.stats(account.id);
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.bank.facets();
|
||||
async facets(@Query("bankAccountId") bankAccountId?: string) {
|
||||
const account = await this.bank.requireAccount(bankAccountId);
|
||||
return this.bank.facets(account.id);
|
||||
}
|
||||
|
||||
/** Year and month rollups with a running net-movement figure. */
|
||||
@Get("summary")
|
||||
summary(@Query("year") year?: string) {
|
||||
async summary(
|
||||
@Query("bankAccountId") bankAccountId?: string,
|
||||
@Query("year") year?: string,
|
||||
) {
|
||||
const account = await this.bank.requireAccount(bankAccountId);
|
||||
const y = Number(year);
|
||||
return this.bank.summary(
|
||||
account.id,
|
||||
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** The register browser. */
|
||||
@Get()
|
||||
list(
|
||||
async list(
|
||||
@Query("bankAccountId") bankAccountId?: string,
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@@ -85,7 +172,9 @@ export class BankController {
|
||||
@Query("to") to?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
const account = await this.bank.requireAccount(bankAccountId);
|
||||
return this.bank.list({
|
||||
bankAccountId: account.id,
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
@@ -105,6 +194,7 @@ export class BankController {
|
||||
const row = await this.bank.createMovement(dto);
|
||||
void this.audit.log(this.actingId(req), "bank.create", {
|
||||
bankTransactionId: row.id,
|
||||
bankAccountId: row.bankAccountId,
|
||||
amount: dto.amount,
|
||||
});
|
||||
return row;
|
||||
|
||||
@@ -2,6 +2,12 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||
import {
|
||||
CreateBankAccountDto,
|
||||
CreateBankDto,
|
||||
UpdateBankAccountDto,
|
||||
UpdateBankDto,
|
||||
} from "./bank-account.dto";
|
||||
|
||||
/**
|
||||
* App-voided rows (voidedAt set) are reversed and must leave every
|
||||
@@ -28,9 +34,18 @@ const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null };
|
||||
* expense and are excluded from both sides, the way the ~193 zero rows are
|
||||
* in the customer ledger.
|
||||
*
|
||||
* SINGLE CURRENCY. Unlike the customer ledger there is no currency column here:
|
||||
* `bank_transactions` has none, and every `amountInWords` on the egreso side is
|
||||
* spelled out in PESOS. All figures in this module are MXN.
|
||||
* ONE ACCOUNT AT A TIME, CURRENCY FROM THE ACCOUNT. The office now keeps more
|
||||
* than one chequera (Utilities banks in MXN, Seguros in USD), so every read
|
||||
* path here is scoped to exactly one `bankAccountId` — never "all accounts".
|
||||
* There is deliberately no currency column on `bank_transactions`: a movement
|
||||
* inherits its account's, the way a real bank account doesn't mix currencies.
|
||||
* Callers must therefore pass an account id; an unscoped total would sum MXN
|
||||
* and USD into a figure that never existed, the same mistake the billing
|
||||
* module's per-currency rule exists to prevent.
|
||||
*
|
||||
* The 22,669 migrated rows are all SCOTHIA = the Utilities MXN account
|
||||
* (backfilled by `migration/backfill_bank_accounts.py`), and their
|
||||
* `amountInWords` on the egreso side is spelled out in PESOS accordingly.
|
||||
*
|
||||
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
|
||||
* rows and this module does not filter or group by it, because the data cannot
|
||||
@@ -64,6 +79,8 @@ export type BankSort =
|
||||
| "reference";
|
||||
|
||||
export interface BankListParams {
|
||||
/** Which chequera to read. Required — see the module header. */
|
||||
bankAccountId: string;
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
@@ -98,7 +115,9 @@ export class BankService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
||||
const and: Prisma.BankTransactionWhereInput[] = [];
|
||||
const and: Prisma.BankTransactionWhereInput[] = [
|
||||
{ bankAccountId: p.bankAccountId },
|
||||
];
|
||||
|
||||
if (p.query && p.query.trim()) {
|
||||
const q = p.query.trim();
|
||||
@@ -124,7 +143,9 @@ export class BankService {
|
||||
});
|
||||
}
|
||||
|
||||
return and.length ? { AND: and } : {};
|
||||
// Never empty: the account clause above is always present, so no read can
|
||||
// accidentally span every chequera.
|
||||
return { AND: and };
|
||||
}
|
||||
|
||||
private orderBy(
|
||||
@@ -233,22 +254,25 @@ export class BankService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Top-line figures for the bank page header. */
|
||||
async stats() {
|
||||
/** Top-line figures for the bank page header, for one chequera. */
|
||||
async stats(bankAccountId: string) {
|
||||
const account = { bankAccountId };
|
||||
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
||||
this.prisma.bankTransaction.count({ where: NOT_VOIDED }),
|
||||
this.prisma.bankTransaction.count({
|
||||
where: { AND: [account, NOT_VOIDED] },
|
||||
}),
|
||||
this.prisma.bankTransaction.aggregate({
|
||||
where: NOT_VOIDED,
|
||||
where: { AND: [account, NOT_VOIDED] },
|
||||
_min: { transactionDate: true },
|
||||
_max: { transactionDate: true },
|
||||
}),
|
||||
this.prisma.bankTransaction.count({
|
||||
where: { AND: [{ cleared: false }, NOT_VOIDED] },
|
||||
where: { AND: [account, { cleared: false }, NOT_VOIDED] },
|
||||
}),
|
||||
this.prisma.bankTransaction.count({
|
||||
where: { AND: [{ transferred: true }, NOT_VOIDED] },
|
||||
where: { AND: [account, { transferred: true }, NOT_VOIDED] },
|
||||
}),
|
||||
this.totalsFor({}),
|
||||
this.totalsFor(account),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -261,14 +285,16 @@ export class BankService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Year list for the period filter, newest first. */
|
||||
async facets() {
|
||||
/** Year list for the period filter, newest first, for one chequera. */
|
||||
async facets(bankAccountId: string) {
|
||||
// Tagged-template `$queryRaw`: the interpolation below is a bound
|
||||
// parameter, not string concatenation.
|
||||
const years = await this.prisma.$queryRaw<
|
||||
{ year: number; count: bigint | number | string }[]
|
||||
>`
|
||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||
FROM bank_transactions
|
||||
WHERE voidedAt IS NULL
|
||||
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||
GROUP BY year
|
||||
ORDER BY year DESC
|
||||
`;
|
||||
@@ -287,8 +313,12 @@ export class BankService {
|
||||
* `BAN` table holds only the bank's name), so the register starts at zero on
|
||||
* its first row in 2013 and the running figure is the net movement since
|
||||
* then. Labelled as such in the UI so it is never read as a statement balance.
|
||||
*
|
||||
* Both rollups take the SAME `bankAccountId`. Scoping only one of them would
|
||||
* leave the year list and its month drill-down describing different books —
|
||||
* wrong in a way that still looks right.
|
||||
*/
|
||||
async summary(year?: number) {
|
||||
async summary(bankAccountId: string, year?: number) {
|
||||
const years = await this.prisma.$queryRaw<PeriodRow[]>`
|
||||
SELECT
|
||||
YEAR(transactionDate) AS period,
|
||||
@@ -297,7 +327,7 @@ export class BankService {
|
||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||
SUM(amount) AS net
|
||||
FROM bank_transactions
|
||||
WHERE voidedAt IS NULL
|
||||
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||
GROUP BY period
|
||||
ORDER BY period ASC
|
||||
`;
|
||||
@@ -311,7 +341,9 @@ export class BankService {
|
||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||
SUM(amount) AS net
|
||||
FROM bank_transactions
|
||||
WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL
|
||||
WHERE YEAR(transactionDate) = ${year}
|
||||
AND voidedAt IS NULL
|
||||
AND bankAccountId = ${bankAccountId}
|
||||
GROUP BY period
|
||||
ORDER BY period ASC
|
||||
`
|
||||
@@ -365,13 +397,135 @@ export class BankService {
|
||||
};
|
||||
}
|
||||
|
||||
// --- accounts -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every chequera, closed ones included — a closed account still has to be
|
||||
* selectable to read its history, it just isn't offered for new captures.
|
||||
*/
|
||||
async listAccounts() {
|
||||
const rows = await this.prisma.bankAccount.findMany({
|
||||
orderBy: [{ active: "desc" }, { label: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
currency: true,
|
||||
businessLine: true,
|
||||
active: true,
|
||||
bank: { select: { id: true, name: true, country: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((a) => ({
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
currency: a.currency,
|
||||
businessLine: a.businessLine,
|
||||
active: a.active,
|
||||
bankId: a.bank.id,
|
||||
bankName: a.bank.name,
|
||||
bankCountry: a.bank.country,
|
||||
}));
|
||||
}
|
||||
|
||||
async listBanks() {
|
||||
return this.prisma.bank.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, country: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an account id from a request, or reject. Every read route funnels
|
||||
* through this so a bad/missing id is a 400 rather than a silently empty
|
||||
* register that reads as "this account has no movements".
|
||||
*/
|
||||
async requireAccount(bankAccountId: string | undefined) {
|
||||
if (!bankAccountId || !bankAccountId.trim())
|
||||
throw new BadRequestException("Falta la cuenta bancaria (bankAccountId)");
|
||||
const account = await this.prisma.bankAccount.findUnique({
|
||||
where: { id: bankAccountId },
|
||||
select: { id: true, label: true, currency: true, active: true },
|
||||
});
|
||||
if (!account)
|
||||
throw new NotFoundException(`Cuenta bancaria ${bankAccountId} no existe`);
|
||||
return account;
|
||||
}
|
||||
|
||||
async createBank(dto: CreateBankDto) {
|
||||
return this.prisma.bank.create({
|
||||
data: { name: dto.name.trim(), country: dto.country?.trim() || null },
|
||||
});
|
||||
}
|
||||
|
||||
async updateBank(id: string, dto: UpdateBankDto) {
|
||||
await this.getBankOr404(id);
|
||||
return this.prisma.bank.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(dto.country !== undefined
|
||||
? { country: dto.country.trim() || null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getBankOr404(id: string) {
|
||||
const bank = await this.prisma.bank.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!bank) throw new NotFoundException(`Banco ${id} no existe`);
|
||||
return bank;
|
||||
}
|
||||
|
||||
async createAccount(dto: CreateBankAccountDto) {
|
||||
await this.getBankOr404(dto.bankId);
|
||||
return this.prisma.bankAccount.create({
|
||||
data: {
|
||||
bankId: dto.bankId,
|
||||
label: dto.label.trim(),
|
||||
currency: dto.currency,
|
||||
businessLine: dto.businessLine ?? null,
|
||||
active: dto.active ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `currency` is intentionally absent from the update DTO: the movements
|
||||
* already booked in this account are denominated in it, so changing it would
|
||||
* silently re-denominate history rather than convert it.
|
||||
*/
|
||||
async updateAccount(id: string, dto: UpdateBankAccountDto) {
|
||||
await this.requireAccount(id);
|
||||
if (dto.bankId !== undefined) await this.getBankOr404(dto.bankId);
|
||||
return this.prisma.bankAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.bankId !== undefined ? { bankId: dto.bankId } : {}),
|
||||
...(dto.label !== undefined ? { label: dto.label.trim() } : {}),
|
||||
...(dto.businessLine !== undefined
|
||||
? { businessLine: dto.businessLine }
|
||||
: {}),
|
||||
...(dto.active !== undefined ? { active: dto.active } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- writes (append + void) -----------------------------------------------
|
||||
|
||||
async createMovement(dto: CreateBankMovementDto) {
|
||||
const date = new Date(dto.transactionDate);
|
||||
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
||||
const account = await this.requireAccount(dto.bankAccountId);
|
||||
if (!account.active)
|
||||
throw new BadRequestException(
|
||||
`La cuenta "${account.label}" está cerrada; no admite movimientos nuevos.`,
|
||||
);
|
||||
return this.prisma.bankTransaction.create({
|
||||
data: {
|
||||
bankAccountId: account.id,
|
||||
amount: dto.amount,
|
||||
transactionDate: date,
|
||||
concept: dto.concept,
|
||||
|
||||
Reference in New Issue
Block a user