diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 645448bd..ee69c45b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: echo "Contents of .env file:" cat .env + - name: Create data directory for SQLite + run: mkdir -p apps/backend/data + - name: TypeScript type check (backend only) run: pnpm --filter backend run type-check diff --git a/apps/backend/src/application/interfaces/EntryRepository.interface.ts b/apps/backend/src/application/interfaces/EntryRepository.interface.ts index c366e2f4..74f37825 100644 --- a/apps/backend/src/application/interfaces/EntryRepository.interface.ts +++ b/apps/backend/src/application/interfaces/EntryRepository.interface.ts @@ -8,4 +8,8 @@ export type EntryRepositoryInterface = { userId: UUID, transactionId: UUID, ): Promise; + deleteByTransactionId( + userId: UUID, + transactionId: UUID, + ): Promise; }; diff --git a/apps/backend/src/application/interfaces/OperationRepository.interface.ts b/apps/backend/src/application/interfaces/OperationRepository.interface.ts index 47a1cddb..a45dbb89 100644 --- a/apps/backend/src/application/interfaces/OperationRepository.interface.ts +++ b/apps/backend/src/application/interfaces/OperationRepository.interface.ts @@ -8,4 +8,5 @@ export type OperationRepositoryInterface = { userId: UUID, entryIds: UUID[], ): Promise; + deleteByEntryIds(userId: UUID, entryIds: UUID[]): Promise; }; diff --git a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts index 7c57e013..f50700ae 100644 --- a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts +++ b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts @@ -54,6 +54,7 @@ export class UpdateTransactionUseCase { // For now, we will just delete all existing entries and create new ones // let's think about a better approach later // If entries are undefined, treat as 'no update to entries' + // we should compare with existing entries and update accordingly if (data.entries === undefined) { return this.transactionMapper.toResponseDTO( transaction, @@ -61,7 +62,7 @@ export class UpdateTransactionUseCase { ); } - await this.softDeleteEntriesByTransactionId( + await this.deleteEntriesByTransactionId( user.getId().valueOf(), transactionId, ); @@ -82,22 +83,21 @@ export class UpdateTransactionUseCase { }); } - private async softDeleteEntriesByTransactionId( + private async deleteEntriesByTransactionId( userId: UUID, transactionId: UUID, ): Promise { - const softDeletedEntries = - await this.entryRepository.softDeleteByTransactionId( - userId, - transactionId, - ); + const deletedEntries = await this.entryRepository.deleteByTransactionId( + userId, + transactionId, + ); - const entryIds = softDeletedEntries.map((entry) => entry.id); + const entryIds = deletedEntries.map((entry) => entry.id); if (entryIds.length === 0) { return; } - await this.operationRepository.softDeleteByEntryIds(userId, entryIds); + await this.operationRepository.deleteByEntryIds(userId, entryIds); } } diff --git a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts index 757912e7..655af924 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts @@ -54,11 +54,11 @@ describe('UpdateTransactionUseCase', () => { }; const mockEntryRepository = { - softDeleteByTransactionId: vi.fn(), + deleteByTransactionId: vi.fn(), }; const mockOperationRepository = { - softDeleteByEntryIds: vi.fn(), + deleteByEntryIds: vi.fn(), }; const mockEnsureEntityExistsAndOwned = vi.fn(); @@ -264,7 +264,7 @@ describe('UpdateTransactionUseCase', () => { const softDeletedEntries = entries.map((e) => e.toPersistence()); - mockEntryRepository.softDeleteByTransactionId.mockResolvedValue( + mockEntryRepository.deleteByTransactionId.mockResolvedValue( softDeletedEntries, ); @@ -289,16 +289,14 @@ describe('UpdateTransactionUseCase', () => { }), ); - expect(mockEntryRepository.softDeleteByTransactionId).toHaveBeenCalledWith( + expect(mockEntryRepository.deleteByTransactionId).toHaveBeenCalledWith( user.getId().valueOf(), transactionDBRow.id, ); - expect(mockOperationRepository.softDeleteByEntryIds).toHaveBeenCalledTimes( - 1, - ); + expect(mockOperationRepository.deleteByEntryIds).toHaveBeenCalledTimes(1); - expect(mockOperationRepository.softDeleteByEntryIds).toHaveBeenCalledWith( + expect(mockOperationRepository.deleteByEntryIds).toHaveBeenCalledWith( user.getId().valueOf(), softDeletedEntries.map((e) => e.id), ); diff --git a/apps/backend/src/db/index.ts b/apps/backend/src/db/index.ts index 01282417..798da57b 100644 --- a/apps/backend/src/db/index.ts +++ b/apps/backend/src/db/index.ts @@ -8,27 +8,17 @@ import * as schemas from './schemas'; dotenv.config(); -const isTestEnvironment = - process.env.NODE_ENV === 'test' || process.env.VITEST === 'true'; - export type DataBase = ReturnType>; export type TxType = Parameters[0]>[0]; -let db: DataBase; - -if (isTestEnvironment) { - const client = createClient({ url: 'file::memory:' }); - db = drizzle(client, { schema: schemas }); -} else { - const dbUrl = config.dbUrl; +const dbUrl = config.dbUrl; - if (!dbUrl) { - throw new Error('Database URL is not defined'); - } - - const client = createClient({ url: dbUrl }); - db = drizzle(client, { schema: schemas }); +if (!dbUrl) { + throw new Error('Database URL is not defined'); } +const client = createClient({ url: dbUrl }); +const db = drizzle(client, { schema: schemas }); + export { db }; diff --git a/apps/backend/src/db/schemas/transactions.ts b/apps/backend/src/db/schemas/transactions.ts index ea6649fd..e78cd220 100644 --- a/apps/backend/src/db/schemas/transactions.ts +++ b/apps/backend/src/db/schemas/transactions.ts @@ -46,7 +46,7 @@ export type TransactionDbInsert = InferInsertModel; export type TransactionRepoInsert = TransactionDbInsert; export type TransactionDbUpdate = Partial< - Omit + Omit >; // Type for transaction with nested relations (operations as array) diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index 420d2ebb..1eb4386b 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -18,6 +18,7 @@ import { migrate } from 'drizzle-orm/libsql/migrator'; import { DataBase } from 'src/db'; import { Amount, Currency, DateValue } from 'src/domain/domain-core'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; +import { AmountFormatter } from 'src/presentation/formatters'; import { EntryDbRow, @@ -27,12 +28,15 @@ import { accountsTable, usersTable, UserDbRow, + TransactionWithRelations, } from './schema'; import * as schema from './schemas'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const FILE_PREFIX = 'file:/tmp/test-'; + class Counter { private count = 0; @@ -62,6 +66,8 @@ export class TestDB { transactionCounter = new Counter('transaction'); operationCounter = new Counter('operation'); userCounter = new Counter('user'); + private testDbFile?: string; + private client?: ReturnType; constructor(db?: DataBase) { if (db) { @@ -69,11 +75,15 @@ export class TestDB { return; } - const client = createClient({ - url: 'file::memory:', + this.testDbFile = `${FILE_PREFIX}${Date.now()}-${crypto.randomUUID()}.db`; + + // To use an in-memory database for faster, ephemeral tests, set url to 'file::memory:'. + // The default below uses a file-based database, which persists data across test runs and can aid debugging. + this.client = createClient({ + url: this.testDbFile, }); - this.db = drizzle(client, { schema }); + this.db = drizzle(this.client, { schema }); } static get createTimestamps() { @@ -117,24 +127,23 @@ export class TestDB { } async cleanupTestDb() { - console.info('Cleaning up test database...'); - await this.db.run(sql`PRAGMA foreign_keys = OFF;`); - - const tables = [ - 'entries', - 'operations', - 'transactions', - 'settings', - 'accounts', - 'currencies', - 'users', - ]; - - for (const table of tables) { - await this.db.run(sql.raw(`DELETE FROM ${table};`)); + if (this.client) { + try { + this.client.close(); + } catch { + /* empty */ + } } - await this.db.run(sql`PRAGMA foreign_keys = ON;`); + if (this.testDbFile?.startsWith(FILE_PREFIX)) { + try { + const fs = await import('fs/promises'); + const filePath = this.testDbFile.replace('file:', ''); + await fs.unlink(filePath); + } catch { + /* empty */ + } + } } createUser = async (params?: { @@ -243,6 +252,106 @@ export class TestDB { return transaction; }; + getTransactionById = async ( + transactionId: UUID, + ): Promise => { + const transaction = await this.db + .select() + .from(schema.transactionsTable) + .where(sql`${schema.transactionsTable.id} = ${transactionId}`) + .get(); + + return transaction ?? null; + }; + + getTransactionWithRelations = async ( + transactionId: UUID, + ): Promise => { + const transaction = await this.getTransactionById(transactionId); + if (!transaction) return null; + + const entries = await this.db + .select() + .from(schema.entriesTable) + .where(sql`${schema.entriesTable.transactionId} = ${transactionId}`); + + const entriesWithOperations = await Promise.all( + entries.map(async (entry) => { + const operations = await this.db + .select() + .from(schema.operationsTable) + .where(sql`${schema.operationsTable.entryId} = ${entry.id}`); + + return { + ...entry, + operations, + }; + }), + ); + + return { entries: entriesWithOperations, ...transaction }; + }; + + getTransactionInPTAFormat = async ( + transactionId: UUID, + ): Promise => { + const transaction = await this.getTransactionById(transactionId); + if (!transaction) return null; + + const entries = await this.db + .select() + .from(schema.entriesTable) + .where(sql`${schema.entriesTable.transactionId} = ${transactionId}`); + + const entriesWithOperations = await Promise.all( + entries.map(async (entry) => { + const operations = await this.db + .select() + .from(schema.operationsTable) + .where(sql`${schema.operationsTable.entryId} = ${entry.id}`); + + const operationsWithAccounts = await Promise.all( + operations.map(async (operation) => { + const account = await this.db + .select() + .from(schema.accountsTable) + .where(sql`${schema.accountsTable.id} = ${operation.accountId}`) + .get(); + + return { ...operation, account }; + }), + ); + + return { + ...entry, + operations: operationsWithAccounts, + }; + }), + ); + + // Format in PTA (Plain Text Accounting) style + let output = `${transaction.transactionDate} ${transaction.description}\n`; + + for (const entry of entriesWithOperations) { + output += ` Entry ID: ${entry.id}\n`; + for (const operation of entry.operations) { + if (!operation.account) continue; + + const formatter = new AmountFormatter(); + + const amount = Amount.fromPersistence(operation.amount); + const userFriendlyAmount = formatter.formatForTable(amount, 'en-US'); + const accountName = operation.account.name; + const currency = operation.account.currency; + const systemMarker = operation.isSystem ? ' [system]' : ''; + + output += ` ${accountName.padEnd(40)} ${operation.description} ${userFriendlyAmount} ${currency}${systemMarker}\n`; + } + } + + return output; + }; + softDeleteTransaction = async (transactionId: UUID) => { return await this.db .update(schema.transactionsTable) @@ -334,6 +443,16 @@ export class TestDB { return operations; }; + getOperationById = async (operationId: UUID) => { + const operation = await this.db + .select() + .from(schema.operationsTable) + .where(sql`${schema.operationsTable.id} = ${operationId}`) + .get(); + + return operation ?? null; + }; + deleteData = async () => { await this.db.delete(transactionsTable); await this.db.delete(accountsTable); @@ -430,4 +549,30 @@ export class TestDB { user, }; }; + + getDatabaseInfo = async () => { + const tables = await this.db.all<{ name: string }>( + sql`SELECT name + FROM sqlite_schema + WHERE type = 'table' AND name NOT LIKE 'sqlite_%';`, + ); + + const info: Record = {}; + + for (const table of tables) { + const countResult = await this.db + .select({ count: sql`count(*)` }) + .from(sql.raw(table.name)) + .get(); + + if (countResult && table.name) { + info[table.name] = Number(countResult.count); + } + } + console.info(info); + }; + + getAllOperations = async () => { + return this.db.select().from(schema.operationsTable); + }; } diff --git a/apps/backend/src/domain/domain-core/value-objects/Amount.ts b/apps/backend/src/domain/domain-core/value-objects/Amount.ts index ee5fea56..a164bb23 100644 --- a/apps/backend/src/domain/domain-core/value-objects/Amount.ts +++ b/apps/backend/src/domain/domain-core/value-objects/Amount.ts @@ -56,4 +56,12 @@ export class Amount { isZero(): boolean { return this.minor === BigInt(0); } + + isPositive(): boolean { + return this.minor > BigInt(0); + } + + isNegative(): boolean { + return this.minor < BigInt(0); + } } diff --git a/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts b/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts index bad537dd..b524e280 100644 --- a/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts +++ b/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts @@ -174,4 +174,31 @@ describe('EntryRepository', () => { expect(entries).toEqual([]); }); }); + + describe('deleteByTransactionId', () => { + it('should delete entries by transaction ID', async () => { + const createdEntries = await Promise.all([ + testDB.createEntry(user.id, { + transactionId: transaction.id, + }), + testDB.createEntry(user.id, { + transactionId: transaction.id, + }), + ]); + + await entryRepository.deleteByTransactionId(user.id, transaction.id); + + const entries = await entryRepository.getByTransactionId( + user.id, + transaction.id, + ); + + expect(entries).toHaveLength(0); + + for (const createdEntry of createdEntries) { + const deletedEntry = await testDB.getEntryById(createdEntry.id); + expect(deletedEntry).toBeNull(); + } + }); + }); }); diff --git a/apps/backend/src/infrastructure/db/entries/entry.repository.ts b/apps/backend/src/infrastructure/db/entries/entry.repository.ts index ac9b182a..0b02d588 100644 --- a/apps/backend/src/infrastructure/db/entries/entry.repository.ts +++ b/apps/backend/src/infrastructure/db/entries/entry.repository.ts @@ -61,4 +61,30 @@ export class EntryRepository }, ); } + + async deleteByTransactionId( + userId: UUID, + transactionId: UUID, + ): Promise { + return this.executeDatabaseOperation( + () => { + return this.db + .delete(entriesTable) + .where( + and( + eq(entriesTable.transactionId, transactionId), + eq(entriesTable.userId, userId), + ), + ) + .returning() + .all(); + }, + 'EntryRepository.deleteByTransactionId', + { + field: 'transactionId', + tableName: 'entries', + value: transactionId, + }, + ); + } } diff --git a/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts b/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts index 6a954b10..9805fe66 100644 --- a/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts +++ b/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts @@ -195,4 +195,40 @@ describe('OperationRepository', () => { expect(softDeletedOperations).toHaveLength(0); }); }); + + describe('deleteByEntryIds', () => { + it('should delete operations by entry IDs', async () => { + const operations = await Promise.all([ + testDB.createOperation(user.id, { + accountId: account1.id, + amount: Amount.create('700').valueOf(), + description: 'Operation to be deleted', + entryId: entry.id, + }), + + testDB.createOperation(user.id, { + accountId: account2.id, + amount: Amount.create('300').valueOf(), + description: 'Another operation to be deleted', + entryId: entry.id, + }), + ]); + + const fetchedOperationsBefore = await operationRepository.getByEntryId( + user.id, + entry.id, + ); + + expect(fetchedOperationsBefore).toHaveLength(operations.length); + + await operationRepository.deleteByEntryIds(user.id, [entry.id]); + + const fetchedOperationsAfter = await operationRepository.getByEntryId( + user.id, + entry.id, + ); + + expect(fetchedOperationsAfter).toHaveLength(0); + }); + }); }); diff --git a/apps/backend/src/infrastructure/db/operations/operation.repository.ts b/apps/backend/src/infrastructure/db/operations/operation.repository.ts index 7a4beb3b..d16d19e2 100644 --- a/apps/backend/src/infrastructure/db/operations/operation.repository.ts +++ b/apps/backend/src/infrastructure/db/operations/operation.repository.ts @@ -43,6 +43,27 @@ export class OperationRepository ); } + async deleteByEntryIds(userId: UUID, entryIds: UUID[]): Promise { + return this.executeDatabaseOperation( + async () => { + await this.db + .delete(operationsTable) + .where( + and( + inArray(operationsTable.entryId, entryIds), + eq(operationsTable.userId, userId), + ), + ); + }, + 'OperationRepository.deleteByEntryIds', + { + field: 'entryId', + tableName: 'operations', + value: entryIds.join(', '), + }, + ); + } + async softDeleteByEntryIds( userId: UUID, entryIds: UUID[], diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts index c4c1cbcc..4636be91 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts @@ -123,6 +123,7 @@ export class TransactionRepository 'description', 'postingDate', 'transactionDate', + 'updatedAt', ]); const updated = await this.db diff --git a/apps/backend/src/interfaces/transactions/transaction.integration.test.ts b/apps/backend/src/interfaces/transactions/transaction.integration.test.ts index 35806285..42a6d316 100644 --- a/apps/backend/src/interfaces/transactions/transaction.integration.test.ts +++ b/apps/backend/src/interfaces/transactions/transaction.integration.test.ts @@ -1,5 +1,6 @@ import { ROUTES } from '@ledgerly/shared/routes'; import { UUID } from '@ledgerly/shared/types'; +import { TransactionUpdateInput } from '@ledgerly/shared/validation'; import { TransactionResponseDTO } from 'src/application'; import { EntryDbRow, @@ -10,7 +11,7 @@ import { import { TestDB } from 'src/db/test-db'; import { Amount, Currency, DateValue, Id } from 'src/domain/domain-core'; import { createServer } from 'src/presentation/server'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; const testUser = { email: 'test@example.com', @@ -47,6 +48,10 @@ describe('Transactions Integration Tests', () => { userId = Id.fromPersistence(decoded.userId).valueOf(); }); + afterEach(async () => { + await testDB.cleanupTestDb(); + }); + describe('POST /api/transactions', () => { it('should create a new transaction', async () => { const account1 = await testDB.createAccount(userId, { @@ -583,18 +588,90 @@ describe('Transactions Integration Tests', () => { }); describe('PUT /api/transactions/:id', () => { - it('should update an existing transaction', async () => { + it('should update an existing transaction and all related entries and operations', async () => { + const accounts = await Promise.all([ + testDB.createAccount(userId, { + currency: Currency.create('USD').valueOf(), + name: 'Account 1 USD', + }), + testDB.createAccount(userId, { + currency: Currency.create('EUR').valueOf(), + name: 'Account 2 EUR', + }), + testDB.createAccount(userId, { + currency: Currency.create('USD').valueOf(), + name: 'Account 3 USD', + }), + testDB.createAccount(userId, { + currency: Currency.create('EUR').valueOf(), + name: 'Account 4 EUR', + }), + ]); + const transaction = await testDB.createTransaction(userId, { description: 'Initial description', postingDate: DateValue.restore('2025-11-01').valueOf(), transactionDate: DateValue.restore('2025-11-01').valueOf(), }); - const payload = { + const entries = await Promise.all([ + testDB.createEntry(userId, { + transactionId: transaction.id, + }), + testDB.createEntry(userId, { + transactionId: transaction.id, + }), + ]); + + const operations = await Promise.all([ + testDB.createOperation(userId, { + accountId: accounts[0].id, + amount: Amount.create('-10000').valueOf(), + description: 'Initial operation 1 USD', + entryId: entries[0].id, + isSystem: false, + }), + testDB.createOperation(userId, { + accountId: accounts[1].id, + amount: Amount.create('10000').valueOf(), + description: 'Initial operation 2 EUR', + entryId: entries[0].id, + isSystem: false, + }), + testDB.createOperation(userId, { + accountId: accounts[2].id, + amount: Amount.create('5000').valueOf(), + description: 'Initial operation 3 USD', + entryId: entries[1].id, + isSystem: false, + }), + testDB.createOperation(userId, { + accountId: accounts[3].id, + amount: Amount.create('-5000').valueOf(), + description: 'Initial operation 4 EUR', + entryId: entries[1].id, + isSystem: false, + }), + ]); + + const payload: TransactionUpdateInput = { description: 'Updated description', - entries: [], - postingDate: '2025-11-10', - transactionDate: '2025-11-10', + entries: [ + [ + { + accountId: Id.fromPersistence(accounts[0].id).valueOf(), + amount: Amount.create('-70000').valueOf(), + description: 'Updated operation 1', + }, + { + accountId: Id.fromPersistence(accounts[1].id).valueOf(), + amount: Amount.create('10000').valueOf(), + description: 'Updated operation 2', + }, + ], + ], + postingDate: DateValue.restore('2025-11-10').valueOf(), + transactionDate: DateValue.restore('2025-11-10').valueOf(), }; const response = await server.inject({ @@ -614,6 +691,27 @@ describe('Transactions Integration Tests', () => { expect(updatedTransaction.description).toBe(payload.description); expect(updatedTransaction.postingDate).toBe(payload.postingDate); expect(updatedTransaction.transactionDate).toBe(payload.transactionDate); + + await Promise.all( + operations.map((op) => + testDB.getOperationById(op.id).then((fetchedOp) => { + expect(fetchedOp).toBeNull(); + }), + ), + ); + + expect(updatedTransaction.entries.length).toBe(payload.entries.length); + + updatedTransaction.entries.forEach((entry, index) => { + expect(entry.operations.length).toBe(payload.entries[index].length); + + entry.operations.forEach((op, opIndex) => { + const payloadOp = payload.entries[index][opIndex]; + expect(op.accountId).toBe(payloadOp.accountId); + expect(op.amount).toBe(payloadOp.amount); + expect(op.description).toBe(payloadOp.description); + }); + }); }); it('should return 404 when updating non-existent transaction', async () => { diff --git a/apps/backend/src/presentation/formatters/AmountFormatter.ts b/apps/backend/src/presentation/formatters/AmountFormatter.ts new file mode 100644 index 00000000..926e3a53 --- /dev/null +++ b/apps/backend/src/presentation/formatters/AmountFormatter.ts @@ -0,0 +1,78 @@ +import { MoneyString } from '@ledgerly/shared/types'; + +import { Amount } from '../../domain/domain-core/value-objects/Amount'; + +export class AmountFormatter { + private minorToMajor(minorUnits: MoneyString): { + major: string; + minor: string; + } { + const isNegative = minorUnits.startsWith('-'); + const absoluteValue = isNegative ? minorUnits.slice(1) : minorUnits; + + const paddedValue = absoluteValue.padStart(3, '0'); + + const majorPart = paddedValue.slice(0, -2) || '0'; + const minorPart = paddedValue.slice(-2); + + return { + major: isNegative ? `-${majorPart}` : majorPart, + minor: minorPart, + }; + } + + format(amount: Amount, locale = 'en-US'): string { + const minorUnits = amount.valueOf(); + const { major, minor } = this.minorToMajor(minorUnits); + + const formattedMajor = new Intl.NumberFormat(locale, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + }).format(Number(major)); + + const decimalSeparator = + new Intl.NumberFormat(locale) + .formatToParts(1.1) + .find((part) => part.type === 'decimal')?.value ?? '.'; + + return `${formattedMajor}${decimalSeparator}${minor}`; + } + + formatWithCurrency( + amount: Amount, + currencyCode: string, + locale = 'en-US', + ): string { + const minorUnits = amount.valueOf(); + const { major, minor } = this.minorToMajor(minorUnits); + + const formatted = new Intl.NumberFormat(locale, { + currency: currencyCode, + style: 'currency', + }).format(Number(`${major}.${minor}`)); + + return formatted; + } + + formatCompact(amount: Amount, locale = 'en-US'): string { + const minorUnits = amount.valueOf(); + const { major, minor } = this.minorToMajor(minorUnits); + + return new Intl.NumberFormat(locale, { + maximumFractionDigits: 1, + minimumFractionDigits: 0, + notation: 'compact', + }).format(Number(`${major}.${minor}`)); + } + + formatForTable(amount: Amount, locale = 'en-US'): string { + const formatted = this.formatWithSign(amount, locale); + return formatted.padStart(15); + } + + formatWithSign(amount: Amount, locale = 'en-US'): string { + const formatted = this.format(amount, locale); + const sign = amount.isPositive() ? '+' : ''; + return `${sign}${formatted}`; + } +} diff --git a/apps/backend/src/presentation/formatters/index.ts b/apps/backend/src/presentation/formatters/index.ts new file mode 100644 index 00000000..cec31da7 --- /dev/null +++ b/apps/backend/src/presentation/formatters/index.ts @@ -0,0 +1 @@ +export { AmountFormatter } from './AmountFormatter'; diff --git a/restAPI/transactions/getById.bru b/restAPI/transactions/getById.bru index b6ab4ed0..240c0484 100644 --- a/restAPI/transactions/getById.bru +++ b/restAPI/transactions/getById.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{baseUrl}}/transactions + url: {{baseUrl}}/transactions/cd6ee47a-7d47-4ec7-824d-3f94b064d668 body: none auth: bearer }