diff --git a/.agents/skills/code-style/SKILL.md b/.agents/skills/code-style/SKILL.md index 5eafd82..dd777cd 100644 --- a/.agents/skills/code-style/SKILL.md +++ b/.agents/skills/code-style/SKILL.md @@ -1,6 +1,6 @@ --- name: code-style -description: "Use when writing, editing, or reviewing TypeScript/JavaScript code in packages/* and src/ of this repository. Enforces the project's coding style — snake_case filenames, camelCase functions/variables, PascalCase classes/types, SOLID and DRY, no code comments, constructor dependency injection via one grouped params object marked private readonly, functions taking a single named-params object, strategy/factory patterns, and pure immutable functions. Do not use for Go code in shield/ or TF2 cfg/variant files." +description: "Use when writing, editing, or reviewing TypeScript/JavaScript code in packages/* and src/ of this repository. Enforces the project's coding style — PascalCase filenames for class/type modules and camelCase for function modules (snake_case only for migrations), camelCase functions/variables, PascalCase classes/types, SOLID and DRY, no code comments, constructor dependency injection via one grouped params object marked private readonly, functions taking a single named-params object, strategy/factory patterns, and pure immutable functions. Do not use for Go code in shield/ or TF2 cfg/variant files." --- # TypeScript Code Style @@ -17,7 +17,7 @@ code patterns. - Avoid writing code that isn't needed; favor built-in functions and libraries. - Use camelCase for variable and function names. - Use PascalCase for class and type names. -- Use snake_case for file names (e.g. `server-manager.ts`). +- Use **PascalCase** for file names whose main export is a class or a type (`SQLiteLedgerRepository.ts`, `MonthlyCostSettlement.ts`), and **camelCase** for modules whose main export is a function (`maskIp.ts`, `commandErrorHandler.ts`). Use snake_case **only** for migrations: `YYYYMMDDHHMMSS_descriptive_name.ts`. - Favor composition over inheritance. - Write pure functions whenever possible; favor immutability over mutating state. - For complex logic, break it down into smaller functions with descriptive names. diff --git a/AGENTS.md b/AGENTS.md index 410357b..66db764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ image" server variants. ## Local Norms - **Clean Architecture**: keep `core` free of infra/framework imports; implement core interfaces in `providers`; wire it all together in `entrypoints`. -- **Naming**: snake_case filenames, camelCase functions/variables, PascalCase classes/types. Strict TS (`strict: true`), NodeNext modules, imports via `@tf2qs/*` workspace aliases. +- **Naming**: PascalCase filenames when the main export is a class or type (`SQLiteLedgerRepository.ts`, `MonthlyCostSettlement.ts`), camelCase when it is a function (`maskIp.ts`, `commandErrorHandler.ts`), snake_case only for migrations (`YYYYMMDDHHMMSS_descriptive_name.ts`). camelCase functions/variables, PascalCase classes/types. Strict TS (`strict: true`), NodeNext modules, imports via `@tf2qs/*` workspace aliases. - **Style**: SOLID, DRY, no code comments, DI via one grouped constructor-params object marked `private readonly`, functions take a single named-params object, prefer composition and strategy/factory patterns, favor pure immutable functions. Activate **`.agents/skills/code-style/SKILL.md`**. - **Layer rules**: when editing a layer, activate its skill — `core-layer`, `providers-layer`, `entrypoints-layer` (under `.agents/skills/`). - **Tests**: Vitest, Given/When/Then `it` names, a `makeSut` factory per file, mocks via `vitest-mock-extended`/`vitest-when` (`aws-sdk-client-mock` for AWS, `msw` for fetch). Mock setup lives inside `makeSut`/tests, never in global `beforeEach`. Activate **`.agents/skills/tests/SKILL.md`**. diff --git a/config/default.json b/config/default.json index cc60eb2..2029715 100644 --- a/config/default.json +++ b/config/default.json @@ -500,6 +500,12 @@ "135608307065946112" ] }, + "money": { + "fixedMonthlyInfrastructureCost": { + "amount": "5.05", + "currency": "USD" + } + }, "github": { "repository": "sonikro/TF2-QuickServer", "workflowFileName": "check-tf2-update.yaml", diff --git a/migrations/20261011000000_ledger_transactions.ts b/migrations/20261011000000_ledger_transactions.ts new file mode 100644 index 0000000..25735ec --- /dev/null +++ b/migrations/20261011000000_ledger_transactions.ts @@ -0,0 +1,29 @@ +import type { Knex } from "knex"; + + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('ledger_transactions', (table) => { + table.increments('id').primary(); + table.text('occurred_at').notNullable(); + table.text('type').notNullable(); + table.float('amount_original').notNullable(); + table.text('currency_original').notNullable(); + table.integer('amount_brl_cents').notNullable(); + table.float('fx_rate').notNullable(); + table.text('fx_source').notNullable(); + table.text('source').notNullable(); + table.text('actor_discord_id').notNullable(); + table.text('dedupe_key').notNullable(); + table.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + table.unique('dedupe_key', { indexName: 'uq_ledger_dedupe' }); + table.check("type IN ('OPENING', 'COST', 'DONATION', 'ADJUSTMENT')"); + table.check("currency_original IN ('BRL', 'USD')"); + table.check("fx_source IN ('PTAX', 'NONE')"); + table.check("typeof(amount_brl_cents) = 'integer'"); + }); +} + + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('ledger_transactions'); +} diff --git a/packages/core/index.ts b/packages/core/index.ts index 4fcb4d3..5332d0b 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -18,6 +18,7 @@ export * from './src/repository/UserBanRepository'; export * from './src/repository/ServerStatusMetricsRepository'; export * from './src/repository/PlayerConnectionHistoryRepository'; export * from './src/repository/ScheduledServerRepository'; +export * from './src/repository/LedgerRepository'; // Services export * from './src/services/BackgroundTaskQueue'; @@ -36,6 +37,7 @@ export * from './src/services/SteamUpdateChecker'; export * from './src/services/Tf2VersionReader'; export * from './src/services/WorkflowDispatcher'; export * from './src/services/StatusUpdater'; +export * from './src/services/ExchangeRateProvider'; export * from './src/services/TF2ServerReadinessService'; // Use cases @@ -59,6 +61,10 @@ export * from './src/usecase/SetUserData'; export * from './src/usecase/TerminateEmptyServers'; export * from './src/usecase/TerminateLongRunningServers'; export * from './src/usecase/TerminatePendingServers'; +export * from './src/usecase/RegisterDonation'; +export * from './src/usecase/RegisterCost'; +export * from './src/usecase/GetBalance'; +export * from './src/usecase/MonthlyCostSettlement'; // Utils export * from './src/utils/ConfigManager'; diff --git a/packages/core/src/domain/LedgerTransaction.test.ts b/packages/core/src/domain/LedgerTransaction.test.ts new file mode 100644 index 0000000..015ceac --- /dev/null +++ b/packages/core/src/domain/LedgerTransaction.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { buildCostDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl, toMajorUnits } from './LedgerTransaction'; + +describe('LedgerTransaction helpers', () => { + it.each([ + { amount: 10, fxRate: 5.4321, expected: 5432 }, + { amount: 10, fxRate: 5.5, expected: 5500 }, + { amount: 130, fxRate: 1, expected: 13000 }, + { amount: 0.335, fxRate: 1, expected: 34 }, + { amount: 3, fxRate: 1.234, expected: 370 }, + ])('should convert $amount at rate $fxRate to $expected cents', ({ amount, fxRate, expected }) => { + // Given + const params = { amount, fxRate }; + + // When + const cents = toBrlCents(params); + + // Then + expect(cents).toBe(expected); + }); + + it('should derive balance reais from cents', () => { + // Given + const params = { balanceBrlCents: 18500 }; + + // When + const balance = toBalanceBrl(params); + + // Then + expect(balance).toBe(185); + }); + + it.each([ + { amountInMinorUnits: 505, expected: 5.05 }, + { amountInMinorUnits: 999, expected: 9.99 }, + { amountInMinorUnits: 1, expected: 0.01 }, + { amountInMinorUnits: 0, expected: 0 }, + { amountInMinorUnits: -250, expected: -2.5 }, + ])('should convert $amountInMinorUnits minor units to $expected major units', ({ amountInMinorUnits, expected }) => { + // When + const majorUnits = toMajorUnits({ amountInMinorUnits }); + + // Then + expect(majorUnits).toBe(expected); + }); + + it.each([ + { balanceBrlCents: 13000, expected: '130.00' }, + { balanceBrlCents: 18500, expected: '185.00' }, + { balanceBrlCents: -250, expected: '-2.50' }, + { balanceBrlCents: 0, expected: '0.00' }, + ])('should display $balanceBrlCents cents as $expected BRL', ({ balanceBrlCents, expected }) => { + // When + const display = toDisplayBrl({ balanceBrlCents }); + + // Then + expect(display).toBe(expected); + }); + + it('should build zero-padded monthly cost dedupe keys', () => { + // When + const key = buildCostDedupeKey({ month: 9, year: 2026, segment: 'sa-saopaulo-1' }); + + // Then + expect(key).toBe('cost:2026-09:sa-saopaulo-1'); + }); + + it('should settle on the last day of the month at noon UTC', () => { + // When + const occurredAt = buildSettlementOccurredAt({ month: 9, year: 2026 }); + + // Then + expect(occurredAt.toISOString()).toBe('2026-09-30T12:00:00.000Z'); + }); +}); diff --git a/packages/core/src/domain/LedgerTransaction.ts b/packages/core/src/domain/LedgerTransaction.ts new file mode 100644 index 0000000..9d0ee2c --- /dev/null +++ b/packages/core/src/domain/LedgerTransaction.ts @@ -0,0 +1,63 @@ +export type LedgerType = 'OPENING' | 'COST' | 'DONATION' | 'ADJUSTMENT'; + +export type LedgerCurrency = 'BRL' | 'USD'; + +export type LedgerFxSource = 'PTAX' | 'NONE'; + +export type LedgerTransaction = { + id: number; + occurredAt: Date; + type: LedgerType; + amountOriginal: number; + currencyOriginal: LedgerCurrency; + amountBrlCents: number; + fxRate: number; + fxSource: LedgerFxSource; + source: string; + actorDiscordId: string; + dedupeKey: string; + createdAt: Date; +}; + +export type NewLedgerEntry = { + occurredAt: Date; + type: LedgerType; + amountOriginal: number; + currencyOriginal: LedgerCurrency; + amountBrlCents: number; + fxRate: number; + fxSource: LedgerFxSource; + source: string; + actorDiscordId: string; + dedupeKey: string; +}; + +export function toBrlCents(params: { amount: number; fxRate: number }): number { + const { amount, fxRate } = params; + return Math.round(amount * fxRate * 100); +} + +export function toMajorUnits(params: { amountInMinorUnits: number }): number { + const { amountInMinorUnits } = params; + return amountInMinorUnits / 100; +} + +export function toBalanceBrl(params: { balanceBrlCents: number }): number { + const { balanceBrlCents } = params; + return toMajorUnits({ amountInMinorUnits: balanceBrlCents }); +} + +export function toDisplayBrl(params: { balanceBrlCents: number }): string { + const { balanceBrlCents } = params; + return (balanceBrlCents / 100).toFixed(2); +} + +export function buildCostDedupeKey(params: { month: number; year: number; segment: string }): string { + const { month, year, segment } = params; + return `cost:${year}-${String(month).padStart(2, '0')}:${segment}`; +} + +export function buildSettlementOccurredAt(params: { month: number; year: number }): Date { + const { month, year } = params; + return new Date(Date.UTC(year, month, 0, 12, 0, 0)); +} diff --git a/packages/core/src/domain/MoneyConfig.test.ts b/packages/core/src/domain/MoneyConfig.test.ts new file mode 100644 index 0000000..2f900c0 --- /dev/null +++ b/packages/core/src/domain/MoneyConfig.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { getMoneyConfig, parseMoneyConfig } from './MoneyConfig'; + +describe('MoneyConfig', () => { + describe('getMoneyConfig', () => { + it('should read the fixed monthly infrastructure cost from the application config', () => { + // When + const moneyConfig = getMoneyConfig(); + + // Then + expect(moneyConfig).toEqual({ + fixedMonthlyInfrastructureCost: { amountInMinorUnits: 505, currency: 'USD' }, + }); + expect(Number.isInteger(moneyConfig.fixedMonthlyInfrastructureCost.amountInMinorUnits)).toBe(true); + }); + }); + + describe('parseMoneyConfig', () => { + it.each([ + { amount: '5.05', currency: 'USD', expected: 505 }, + { amount: '5', currency: 'USD', expected: 500 }, + { amount: '9.99', currency: 'USD', expected: 999 }, + { amount: '1234.5', currency: 'USD', expected: 123450 }, + { amount: '0.01', currency: 'USD', expected: 1 }, + { amount: '0', currency: 'USD', expected: 0 }, + { amount: '5.05', currency: 'BRL', expected: 505 }, + { amount: '90071992547409.91', currency: 'USD', expected: 9007199254740991 }, + ])('should parse amount $amount in $currency into $expected minor units', ({ amount, currency, expected }) => { + // Given + const source = { fixedMonthlyInfrastructureCost: { amount, currency } }; + + // When + const moneyConfig = parseMoneyConfig({ source }); + + // Then + expect(moneyConfig).toEqual({ + fixedMonthlyInfrastructureCost: { amountInMinorUnits: expected, currency }, + }); + }); + + it.each([ + { amount: '5.055', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: 'abc', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: '-1', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: '', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: '5.', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: '.5', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: '1,50', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: ' ', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: 505, currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: undefined, currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' }, + { amount: '5.05', currency: 'EUR', expectedMessage: 'expected "BRL" or "USD"' }, + { amount: '5.05', currency: 'brl', expectedMessage: 'expected "BRL" or "USD"' }, + { amount: '5.05', currency: '', expectedMessage: 'expected "BRL" or "USD"' }, + { amount: '5.05', currency: undefined, expectedMessage: 'expected "BRL" or "USD"' }, + { amount: '5.05', currency: null, expectedMessage: 'expected "BRL" or "USD"' }, + { amount: '5.05', currency: 42, expectedMessage: 'expected "BRL" or "USD"' }, + { amount: '9007199254740992', currency: 'USD', expectedMessage: 'expected minor units within the safe integer range' }, + { amount: '99999999999999999999.99', currency: 'USD', expectedMessage: 'expected minor units within the safe integer range' }, + { amount: '99999999999999999999999999999999999999999', currency: 'USD', expectedMessage: 'expected minor units within the safe integer range' }, + ])('should reject the invalid money config amount $amount and currency $currency', ({ amount, currency, expectedMessage }) => { + // Given + const source = { fixedMonthlyInfrastructureCost: { amount, currency } }; + + // When + const attempt = () => parseMoneyConfig({ source }); + + // Then + expect(attempt).toThrow(expectedMessage); + }); + + it.each([ + { source: undefined }, + { source: {} }, + ])('should reject a missing money block', ({ source }) => { + // When + const attempt = () => parseMoneyConfig({ source }); + + // Then + expect(attempt).toThrow('The "money.fixedMonthlyInfrastructureCost" configuration block is required.'); + }); + }); +}); diff --git a/packages/core/src/domain/MoneyConfig.ts b/packages/core/src/domain/MoneyConfig.ts new file mode 100644 index 0000000..85e939a --- /dev/null +++ b/packages/core/src/domain/MoneyConfig.ts @@ -0,0 +1,59 @@ +import config from "config"; +import { LedgerCurrency } from "./LedgerTransaction"; + +export type MoneyConfig = { + fixedMonthlyInfrastructureCost: { + amountInMinorUnits: number; + currency: LedgerCurrency; + }; +}; + +type MoneyConfigSource = { + fixedMonthlyInfrastructureCost?: { + amount?: unknown; + currency?: unknown; + }; +}; + +const AMOUNT_PATTERN = /^\d+(\.\d{1,2})?$/; + +type ParseMoneyConfigParams = { + source: MoneyConfigSource | undefined; +}; + +export function parseMoneyConfig(params: ParseMoneyConfigParams): MoneyConfig { + const { source } = params; + const fixedMonthlyInfrastructureCost = source?.fixedMonthlyInfrastructureCost; + if (!fixedMonthlyInfrastructureCost) { + throw new Error('The "money.fixedMonthlyInfrastructureCost" configuration block is required.'); + } + + const { amount, currency } = fixedMonthlyInfrastructureCost; + if (typeof amount !== "string" || !AMOUNT_PATTERN.test(amount)) { + throw new Error(`Invalid fixed monthly infrastructure cost amount ${JSON.stringify(amount)}: expected a non-negative decimal string with at most two fraction digits.`); + } + + const amountInMinorUnits = Math.round(Number(amount) * 100); + if (!Number.isSafeInteger(amountInMinorUnits)) { + throw new Error(`Invalid fixed monthly infrastructure cost amount ${JSON.stringify(amount)}: expected minor units within the safe integer range.`); + } + + if (!isLedgerCurrency(currency)) { + throw new Error(`Invalid fixed monthly infrastructure cost currency ${JSON.stringify(currency)}: expected "BRL" or "USD".`); + } + + return { + fixedMonthlyInfrastructureCost: { + amountInMinorUnits, + currency, + }, + }; +} + +function isLedgerCurrency(value: unknown): value is LedgerCurrency { + return value === "BRL" || value === "USD"; +} + +export function getMoneyConfig(): MoneyConfig { + return parseMoneyConfig({ source: config.has("money") ? config.get("money") : undefined }); +} diff --git a/packages/core/src/domain/index.ts b/packages/core/src/domain/index.ts index 4da1818..cb077e2 100644 --- a/packages/core/src/domain/index.ts +++ b/packages/core/src/domain/index.ts @@ -11,6 +11,8 @@ export * from "./MonthlyUsageReport"; export * from "./User"; export * from "./GuildParameters"; export * from "./Cost"; +export * from "./MoneyConfig"; +export * from "./LedgerTransaction"; export * from "./DateRange"; export * from "./ServerActivity"; export * from "./PlayerConnectionHistory"; diff --git a/packages/core/src/repository/LedgerRepository.ts b/packages/core/src/repository/LedgerRepository.ts new file mode 100644 index 0000000..4e66282 --- /dev/null +++ b/packages/core/src/repository/LedgerRepository.ts @@ -0,0 +1,8 @@ +import { LedgerTransaction, NewLedgerEntry } from "../domain/LedgerTransaction"; + +export interface LedgerRepository { + insert(params: { entry: NewLedgerEntry }): Promise; + existsByDedupeKey(params: { dedupeKey: string }): Promise; + getBalance(): Promise; + listRecent(params: { limit: number }): Promise; +} diff --git a/packages/core/src/services/ExchangeRateProvider.ts b/packages/core/src/services/ExchangeRateProvider.ts new file mode 100644 index 0000000..a623fab --- /dev/null +++ b/packages/core/src/services/ExchangeRateProvider.ts @@ -0,0 +1,3 @@ +export interface ExchangeRateProvider { + getBrlPerUsd(params: { date: Date }): Promise<{ rate: number; source: 'PTAX'; quotedAt: Date }>; +} diff --git a/packages/core/src/usecase/GetBalance.test.ts b/packages/core/src/usecase/GetBalance.test.ts new file mode 100644 index 0000000..991a05f --- /dev/null +++ b/packages/core/src/usecase/GetBalance.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { when } from 'vitest-when'; +import { LedgerRepository } from '../repository/LedgerRepository'; +import { GetBalance } from './GetBalance'; + +describe('GetBalance', () => { + function makeSut() { + const ledgerRepository = mock(); + const sut = new GetBalance({ ledgerRepository }); + return { sut, ledgerRepository }; + } + + it('should return the summed balance in BRL cents', async () => { + // Given + const { sut, ledgerRepository } = makeSut(); + when(ledgerRepository.getBalance).calledWith().thenResolve(18500); + + // When + const result = await sut.execute(); + + // Then + expect(result).toEqual({ balanceBrlCents: 18500 }); + }); + + it('should return zero when the ledger is empty', async () => { + // Given + const { sut, ledgerRepository } = makeSut(); + when(ledgerRepository.getBalance).calledWith().thenResolve(0); + + // When + const result = await sut.execute(); + + // Then + expect(result).toEqual({ balanceBrlCents: 0 }); + }); +}); diff --git a/packages/core/src/usecase/GetBalance.ts b/packages/core/src/usecase/GetBalance.ts new file mode 100644 index 0000000..8da13af --- /dev/null +++ b/packages/core/src/usecase/GetBalance.ts @@ -0,0 +1,19 @@ +import { LedgerRepository } from "../repository/LedgerRepository"; + +type GetBalanceDependencies = { + ledgerRepository: LedgerRepository; +}; + +type GetBalanceResult = { + balanceBrlCents: number; +}; + +export class GetBalance { + constructor(private readonly dependencies: GetBalanceDependencies) {} + + async execute(): Promise { + const { ledgerRepository } = this.dependencies; + const balanceBrlCents = await ledgerRepository.getBalance(); + return { balanceBrlCents }; + } +} diff --git a/packages/core/src/usecase/MonthlyCostSettlement.test.ts b/packages/core/src/usecase/MonthlyCostSettlement.test.ts new file mode 100644 index 0000000..9856753 --- /dev/null +++ b/packages/core/src/usecase/MonthlyCostSettlement.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { when } from 'vitest-when'; +import { getRegions } from '../domain/Region'; +import { LedgerTransaction } from '../domain/LedgerTransaction'; +import { MoneyConfig } from '../domain/MoneyConfig'; +import { CostProvider } from '../services/CostProvider'; +import { EventLogger } from '../services/EventLogger'; +import { ExchangeRateProvider } from '../services/ExchangeRateProvider'; +import { MonthlyCostSettlement } from './MonthlyCostSettlement'; +import { RegisterCost } from './RegisterCost'; + +const DEFAULT_MONEY_CONFIG: MoneyConfig = { + fixedMonthlyInfrastructureCost: { amountInMinorUnits: 505, currency: 'USD' }, +}; + +const SETTLEMENT_DATE = new Date('2026-09-30T12:00:00.000Z'); + +describe('MonthlyCostSettlement', () => { + function makeSut(moneyConfig: MoneyConfig = DEFAULT_MONEY_CONFIG) { + const costProvider = mock(); + const exchangeRateProvider = mock(); + const registerCost = mock(); + const eventLogger = mock(); + const sut = new MonthlyCostSettlement({ costProvider, exchangeRateProvider, registerCost, moneyConfig, eventLogger }); + return { sut, costProvider, exchangeRateProvider, registerCost, eventLogger }; + } + + function givenExchangeRate(exchangeRateProvider: ExchangeRateProvider) { + when(exchangeRateProvider.getBrlPerUsd) + .calledWith({ date: SETTLEMENT_DATE }) + .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: SETTLEMENT_DATE }); + } + + function makeCostEntry(dedupeKey: string): LedgerTransaction { + return { + id: 1, + occurredAt: SETTLEMENT_DATE, + type: 'COST', + amountOriginal: 10, + currencyOriginal: 'USD', + amountBrlCents: -5500, + fxRate: 5.5, + fxSource: 'PTAX', + source: 'Monthly infrastructure cost', + actorDiscordId: 'system', + dedupeKey, + createdAt: new Date('2026-10-01T12:00:01.000Z'), + }; + } + + it('should insert one cost per region plus bot infrastructure', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + givenExchangeRate(exchangeRateProvider); + when(costProvider.fetchCost).calledWith(expect.anything()).thenResolve({ currency: 'USD', value: 10 }); + registerCost.execute.mockImplementation(async (params) => makeCostEntry(params.dedupeKey)); + const expectedSegments = getRegions().length + 1; + + // When + const result = await sut.execute({ month: 9, year: 2026 }); + + // Then + expect(result).toEqual({ inserted: expectedSegments, skipped: 0, failed: 0 }); + expect(registerCost.execute).toHaveBeenCalledTimes(expectedSegments); + expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ + dedupeKey: 'cost:2026-09:bot-infra', + fxRate: 5.5, + fxSource: 'PTAX', + occurredAt: SETTLEMENT_DATE, + })); + }); + + it('should skip already-settled segments without double-inserting', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + givenExchangeRate(exchangeRateProvider); + when(costProvider.fetchCost).calledWith(expect.anything()).thenResolve({ currency: 'USD', value: 10 }); + registerCost.execute.mockResolvedValue(null); + const expectedSegments = getRegions().length + 1; + + // When + const result = await sut.execute({ month: 9, year: 2026 }); + + // Then + expect(result).toEqual({ inserted: 0, skipped: expectedSegments, failed: 0 }); + }); + + it('should skip zero-value region costs without registering them', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + givenExchangeRate(exchangeRateProvider); + when(costProvider.fetchCost).calledWith(expect.anything()).thenResolve({ currency: 'USD', value: 0 }); + registerCost.execute.mockImplementation(async (params) => makeCostEntry(params.dedupeKey)); + + // When + const result = await sut.execute({ month: 9, year: 2026 }); + + // Then + expect(result).toEqual({ inserted: 1, skipped: getRegions().length, failed: 0 }); + expect(registerCost.execute).toHaveBeenCalledTimes(1); + expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ + dedupeKey: 'cost:2026-09:bot-infra', + })); + }); + + it('should settle bot infrastructure from the injected money config', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + givenExchangeRate(exchangeRateProvider); + when(costProvider.fetchCost).calledWith(expect.anything()).thenResolve({ currency: 'USD', value: 0 }); + registerCost.execute.mockImplementation(async (params) => makeCostEntry(params.dedupeKey)); + + // When + await sut.execute({ month: 9, year: 2026 }); + + // Then + expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ + amount: 5.05, + currency: 'USD', + fxSource: 'PTAX', + dedupeKey: 'cost:2026-09:bot-infra', + })); + }); + + it('should settle bot infrastructure in BRL without FX conversion when configured in BRL', async () => { + // Given + const moneyConfig: MoneyConfig = { fixedMonthlyInfrastructureCost: { amountInMinorUnits: 999, currency: 'BRL' } }; + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(moneyConfig); + givenExchangeRate(exchangeRateProvider); + when(costProvider.fetchCost).calledWith(expect.anything()).thenResolve({ currency: 'USD', value: 0 }); + registerCost.execute.mockImplementation(async (params) => makeCostEntry(params.dedupeKey)); + + // When + await sut.execute({ month: 9, year: 2026 }); + + // Then + expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ + amount: 9.99, + currency: 'BRL', + fxSource: 'NONE', + fxRate: 1, + dedupeKey: 'cost:2026-09:bot-infra', + })); + }); + + it('should count a failing segment and log it without aborting the rest', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost, eventLogger } = makeSut(); + givenExchangeRate(exchangeRateProvider); + const regions = getRegions(); + costProvider.fetchCost.mockImplementation(async ({ region }) => { + if (region === regions[0]) { + throw new Error('Oracle cost API down'); + } + return { currency: 'USD', value: 10 }; + }); + registerCost.execute.mockImplementation(async (params) => makeCostEntry(params.dedupeKey)); + + // When + const result = await sut.execute({ month: 9, year: 2026 }); + + // Then + expect(result).toEqual({ inserted: regions.length, skipped: 0, failed: 1 }); + expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ + dedupeKey: 'cost:2026-09:bot-infra', + })); + expect(eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ + actorId: 'system', + eventMessage: expect.stringContaining(`2026-09 ${regions[0]}`), + })); + }); + + it('should count a segment with an unexpected currency as failed without aborting the rest', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + givenExchangeRate(exchangeRateProvider); + const regions = getRegions(); + costProvider.fetchCost.mockImplementation(async ({ region }) => { + if (region === regions[0]) { + return { currency: 'EUR', value: 10 }; + } + return { currency: 'USD', value: 10 }; + }); + registerCost.execute.mockImplementation(async (params) => { + if (params.currency !== 'BRL' && params.currency !== 'USD') { + throw new Error('Cost currency must be BRL or USD.'); + } + return makeCostEntry(params.dedupeKey); + }); + + // When + const result = await sut.execute({ month: 9, year: 2026 }); + + // Then + expect(result).toEqual({ inserted: regions.length, skipped: 0, failed: 1 }); + expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ + dedupeKey: 'cost:2026-09:bot-infra', + })); + }); + + it('should fetch region costs for the UTC month range', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + givenExchangeRate(exchangeRateProvider); + when(costProvider.fetchCost).calledWith(expect.anything()).thenResolve({ currency: 'USD', value: 10 }); + registerCost.execute.mockImplementation(async (params) => makeCostEntry(params.dedupeKey)); + + // When + await sut.execute({ month: 9, year: 2026 }); + + // Then + expect(costProvider.fetchCost).toHaveBeenCalledWith(expect.objectContaining({ + dateRange: { + startDate: new Date(Date.UTC(2026, 8, 1)), + endDate: new Date(Date.UTC(2026, 9, 0)), + }, + })); + }); + + it('should reject the whole settlement when the exchange rate is unavailable', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + exchangeRateProvider.getBrlPerUsd.mockRejectedValue(new Error('PTAX unavailable')); + + // When + const attempt = sut.execute({ month: 9, year: 2026 }); + + // Then + await expect(attempt).rejects.toThrow('PTAX unavailable'); + expect(costProvider.fetchCost).not.toHaveBeenCalled(); + expect(registerCost.execute).not.toHaveBeenCalled(); + }); + + it.each([{ month: 0 }, { month: 13 }])('should reject settlement month $month', async ({ month }) => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + givenExchangeRate(exchangeRateProvider); + + // When + const attempt = sut.execute({ month, year: 2026 }); + + // Then + await expect(attempt).rejects.toThrow(); + expect(exchangeRateProvider.getBrlPerUsd).not.toHaveBeenCalled(); + expect(costProvider.fetchCost).not.toHaveBeenCalled(); + expect(registerCost.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/usecase/MonthlyCostSettlement.ts b/packages/core/src/usecase/MonthlyCostSettlement.ts new file mode 100644 index 0000000..10a39ad --- /dev/null +++ b/packages/core/src/usecase/MonthlyCostSettlement.ts @@ -0,0 +1,122 @@ +import { Region, getRegions } from "../domain/Region"; +import { LedgerCurrency, buildCostDedupeKey, buildSettlementOccurredAt, toMajorUnits } from "../domain/LedgerTransaction"; +import { MoneyConfig } from "../domain/MoneyConfig"; +import { CostProvider } from "../services/CostProvider"; +import { EventLogger } from "../services/EventLogger"; +import { ExchangeRateProvider } from "../services/ExchangeRateProvider"; +import { UserError } from "../errors/UserError"; +import { RegisterCost } from "./RegisterCost"; + +type MonthlyCostSettlementDependencies = { + costProvider: CostProvider; + exchangeRateProvider: ExchangeRateProvider; + registerCost: RegisterCost; + moneyConfig: MoneyConfig; + eventLogger?: EventLogger; +}; + +type MonthlyCostSettlementExecuteParams = { + month: number; + year: number; +}; + +type MonthlyCostSettlementResult = { + inserted: number; + skipped: number; + failed: number; +}; + +export class MonthlyCostSettlement { + constructor(private readonly dependencies: MonthlyCostSettlementDependencies) {} + + async execute(params: MonthlyCostSettlementExecuteParams): Promise { + const { costProvider, exchangeRateProvider, registerCost, moneyConfig } = this.dependencies; + const { month, year } = params; + + validateSettlement({ month }); + + const occurredAt = buildSettlementOccurredAt({ month, year }); + const dateRange = { + startDate: new Date(Date.UTC(year, month - 1, 1)), + endDate: new Date(Date.UTC(year, month, 0)), + }; + const fx = await exchangeRateProvider.getBrlPerUsd({ date: occurredAt }); + + const segments = [ + ...getRegions().map((region) => ({ segment: region as string, region })), + ...[{ segment: 'bot-infra', region: null }], + ]; + + let inserted = 0; + let skipped = 0; + let failed = 0; + + for (const { segment, region } of segments) { + try { + const settled = await settleSegment({ segment, region, costProvider, registerCost, moneyConfig, fxRate: fx.rate, month, year, occurredAt, dateRange }); + if (settled) { + inserted += 1; + } else { + skipped += 1; + } + } catch (error) { + failed += 1; + await this.dependencies.eventLogger?.log({ + eventMessage: `Monthly cost settlement failed for ${year}-${String(month).padStart(2, '0')} ${segment}: ${error instanceof Error ? error.message : String(error)}`, + actorId: 'system', + }); + } + } + + return { inserted, skipped, failed }; + } +} + +type SettleSegmentParams = { + segment: string; + region: Region | null; + costProvider: CostProvider; + registerCost: RegisterCost; + moneyConfig: MoneyConfig; + fxRate: number; + month: number; + year: number; + occurredAt: Date; + dateRange: { startDate: Date; endDate: Date }; +}; + +async function settleSegment(params: SettleSegmentParams): Promise { + const { segment, region, costProvider, registerCost, moneyConfig, fxRate, month, year, occurredAt, dateRange } = params; + const { fixedMonthlyInfrastructureCost } = moneyConfig; + const cost = region + ? await costProvider.fetchCost({ region, dateRange }) + : { + currency: fixedMonthlyInfrastructureCost.currency, + value: toMajorUnits({ amountInMinorUnits: fixedMonthlyInfrastructureCost.amountInMinorUnits }), + }; + if (cost.value <= 0) { + return false; + } + const currency = cost.currency as LedgerCurrency; + const settled = await registerCost.execute({ + amount: cost.value, + currency, + source: `Monthly infrastructure cost ${year}-${String(month).padStart(2, '0')} ${segment}`, + dedupeKey: buildCostDedupeKey({ month, year, segment }), + occurredAt, + fxRate: currency === 'BRL' ? 1 : fxRate, + fxSource: currency === 'BRL' ? 'NONE' : 'PTAX', + }); + return settled !== null; +} + +type ValidateSettlementParams = { + month: number; +}; + +function validateSettlement(params: ValidateSettlementParams): void { + const { month } = params; + if (!Number.isInteger(month) || month < 1 || month > 12) { + throw new UserError('Settlement month must be an integer between 1 and 12.'); + } +} diff --git a/packages/core/src/usecase/RegisterCost.test.ts b/packages/core/src/usecase/RegisterCost.test.ts new file mode 100644 index 0000000..8ccdee9 --- /dev/null +++ b/packages/core/src/usecase/RegisterCost.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { when } from 'vitest-when'; +import { LedgerCurrency, LedgerFxSource, LedgerTransaction } from '../domain/LedgerTransaction'; +import { LedgerRepository } from '../repository/LedgerRepository'; +import { RegisterCost } from './RegisterCost'; + +describe('RegisterCost', () => { + function makeSut() { + const ledgerRepository = mock(); + const sut = new RegisterCost({ ledgerRepository }); + return { sut, ledgerRepository }; + } + + function makeParams() { + return { + amount: 5.05, + currency: 'USD' as LedgerCurrency, + source: 'Monthly infrastructure cost 2026-09 bot-infra', + dedupeKey: 'cost:2026-09:bot-infra', + occurredAt: new Date('2026-09-30T12:00:00.000Z'), + fxRate: 5.5, + fxSource: 'PTAX' as LedgerFxSource, + }; + } + + function makeStoredEntry(): LedgerTransaction { + return { + id: 2, + occurredAt: new Date('2026-09-30T12:00:00.000Z'), + type: 'COST', + amountOriginal: 5.05, + currencyOriginal: 'USD', + amountBrlCents: -2778, + fxRate: 5.5, + fxSource: 'PTAX', + source: 'Monthly infrastructure cost 2026-09 bot-infra', + actorDiscordId: 'system', + dedupeKey: 'cost:2026-09:bot-infra', + createdAt: new Date('2026-10-01T12:00:01.000Z'), + }; + } + + it('should insert a negative-cent cost entry when the dedupe key is new', async () => { + // Given + const { sut, ledgerRepository } = makeSut(); + when(ledgerRepository.existsByDedupeKey).calledWith({ dedupeKey: 'cost:2026-09:bot-infra' }).thenResolve(false); + when(ledgerRepository.insert).calledWith(expect.anything()).thenResolve(makeStoredEntry()); + + // When + const result = await sut.execute(makeParams()); + + // Then + expect(result?.amountBrlCents).toBe(-2778); + expect(result?.actorDiscordId).toBe('system'); + expect(result?.type).toBe('COST'); + }); + + it('should return null without inserting when the dedupe key already exists', async () => { + // Given + const { sut, ledgerRepository } = makeSut(); + when(ledgerRepository.existsByDedupeKey).calledWith({ dedupeKey: 'cost:2026-09:bot-infra' }).thenResolve(true); + + // When + const result = await sut.execute(makeParams()); + + // Then + expect(result).toBeNull(); + expect(ledgerRepository.insert).not.toHaveBeenCalled(); + }); + + it.each([ + { amount: 0, dedupeKey: 'cost:2026-09:bot-infra', fxRate: 5.5, source: 'Monthly cost' }, + { amount: -3, dedupeKey: 'cost:2026-09:bot-infra', fxRate: 5.5, source: 'Monthly cost' }, + { amount: 5.05, dedupeKey: '', fxRate: 5.5, source: 'Monthly cost' }, + { amount: 5.05, dedupeKey: 'cost:2026-09:bot-infra', fxRate: 0, source: 'Monthly cost' }, + { amount: 5.05, dedupeKey: 'cost:2026-09:bot-infra', fxRate: 5.5, source: ' ' }, + ])('should reject invalid cost $amount $dedupeKey', async ({ amount, dedupeKey, fxRate, source }) => { + // Given + const { sut, ledgerRepository } = makeSut(); + + // When + const attempt = sut.execute({ ...makeParams(), amount, dedupeKey, fxRate, source }); + + // Then + await expect(attempt).rejects.toThrow(); + expect(ledgerRepository.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/usecase/RegisterCost.ts b/packages/core/src/usecase/RegisterCost.ts new file mode 100644 index 0000000..e423309 --- /dev/null +++ b/packages/core/src/usecase/RegisterCost.ts @@ -0,0 +1,75 @@ +import { LedgerCurrency, LedgerFxSource, LedgerTransaction, toBrlCents } from "../domain/LedgerTransaction"; +import { LedgerRepository } from "../repository/LedgerRepository"; +import { UserError } from "../errors/UserError"; + +type RegisterCostDependencies = { + ledgerRepository: LedgerRepository; +}; + +type RegisterCostExecuteParams = { + amount: number; + currency: LedgerCurrency; + source: string; + dedupeKey: string; + occurredAt: Date; + fxRate: number; + fxSource: LedgerFxSource; +}; + +export class RegisterCost { + constructor(private readonly dependencies: RegisterCostDependencies) {} + + async execute(params: RegisterCostExecuteParams): Promise { + const { ledgerRepository } = this.dependencies; + const { amount, currency, source, dedupeKey, occurredAt, fxRate, fxSource } = params; + + validateCost({ amount, currency, source, dedupeKey, fxRate }); + + const alreadySettled = await ledgerRepository.existsByDedupeKey({ dedupeKey }); + if (alreadySettled) { + return null; + } + + return ledgerRepository.insert({ + entry: { + occurredAt, + type: 'COST', + amountOriginal: amount, + currencyOriginal: currency, + amountBrlCents: -toBrlCents({ amount, fxRate }), + fxRate, + fxSource, + source, + actorDiscordId: 'system', + dedupeKey, + }, + }); + } +} + +type ValidateCostParams = { + amount: number; + currency: LedgerCurrency; + source: string; + dedupeKey: string; + fxRate: number; +}; + +function validateCost(params: ValidateCostParams): void { + const { amount, currency, source, dedupeKey, fxRate } = params; + if (!Number.isFinite(amount) || amount <= 0) { + throw new UserError('Cost amount must be greater than zero.'); + } + if (currency !== 'BRL' && currency !== 'USD') { + throw new UserError('Cost currency must be BRL or USD.'); + } + if (source.trim().length === 0) { + throw new UserError('Cost source is required.'); + } + if (dedupeKey.trim().length === 0) { + throw new UserError('Cost dedupe key is required.'); + } + if (!Number.isFinite(fxRate) || fxRate <= 0) { + throw new UserError('Cost FX rate must be greater than zero.'); + } +} diff --git a/packages/core/src/usecase/RegisterDonation.test.ts b/packages/core/src/usecase/RegisterDonation.test.ts new file mode 100644 index 0000000..8234092 --- /dev/null +++ b/packages/core/src/usecase/RegisterDonation.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { when } from 'vitest-when'; +import { LedgerCurrency, LedgerTransaction } from '../domain/LedgerTransaction'; +import { LedgerRepository } from '../repository/LedgerRepository'; +import { ExchangeRateProvider } from '../services/ExchangeRateProvider'; +import { RegisterDonation } from './RegisterDonation'; + +describe('RegisterDonation', () => { + function makeSut() { + const ledgerRepository = mock(); + const exchangeRateProvider = mock(); + const sut = new RegisterDonation({ ledgerRepository, exchangeRateProvider }); + return { sut, ledgerRepository, exchangeRateProvider }; + } + + function makeStoredEntry(overrides: Partial = {}): LedgerTransaction { + return { + id: 1, + occurredAt: new Date('2026-10-06T12:00:00.000Z'), + type: 'DONATION', + amountOriginal: 130, + currencyOriginal: 'BRL', + amountBrlCents: 13000, + fxRate: 1, + fxSource: 'NONE', + source: 'Monthly support', + actorDiscordId: '226828637586849795', + dedupeKey: 'donation:seed', + createdAt: new Date('2026-10-06T12:00:01.000Z'), + ...overrides, + }; + } + + it('should insert a BRL donation without calling the FX provider', async () => { + // Given + const { sut, ledgerRepository, exchangeRateProvider } = makeSut(); + const stored = makeStoredEntry(); + when(ledgerRepository.existsByDedupeKey).calledWith(expect.anything()).thenResolve(false); + when(ledgerRepository.insert).calledWith(expect.anything()).thenResolve(stored); + + // When + const result = await sut.execute({ + amount: 130, + currency: 'BRL', + source: 'Monthly support', + actorDiscordId: '226828637586849795', + occurredAt: new Date('2026-10-06T12:00:00.000Z'), + }); + + // Then + expect(result).toEqual(stored); + expect(exchangeRateProvider.getBrlPerUsd).not.toHaveBeenCalled(); + const inserted = ledgerRepository.insert.mock.calls[0][0].entry; + expect(inserted.amountBrlCents).toBe(13000); + expect(inserted.fxRate).toBe(1); + expect(inserted.fxSource).toBe('NONE'); + expect(inserted.type).toBe('DONATION'); + }); + + it('should convert a USD donation via the FX provider', async () => { + // Given + const { sut, ledgerRepository, exchangeRateProvider } = makeSut(); + const occurredAt = new Date('2026-10-06T12:00:00.000Z'); + when(ledgerRepository.existsByDedupeKey).calledWith(expect.anything()).thenResolve(false); + when(exchangeRateProvider.getBrlPerUsd) + .calledWith({ date: occurredAt }) + .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: occurredAt }); + const stored = makeStoredEntry({ currencyOriginal: 'USD', amountOriginal: 10, amountBrlCents: 5500, fxRate: 5.5, fxSource: 'PTAX' }); + when(ledgerRepository.insert).calledWith(expect.anything()).thenResolve(stored); + + // When + const result = await sut.execute({ + amount: 10, + currency: 'USD', + source: 'Patreon payout', + actorDiscordId: '226828637586849795', + occurredAt, + }); + + // Then + expect(result.amountBrlCents).toBe(5500); + const inserted = ledgerRepository.insert.mock.calls[0][0].entry; + expect(inserted.fxRate).toBe(5.5); + expect(inserted.fxSource).toBe('PTAX'); + }); + + it('should build a stable dedupe key without the occurrence timestamp', async () => { + // Given + const { sut, ledgerRepository } = makeSut(); + when(ledgerRepository.existsByDedupeKey).calledWith(expect.anything()).thenResolve(false); + when(ledgerRepository.insert).calledWith(expect.anything()).thenResolve(makeStoredEntry()); + + // When + await sut.execute({ + amount: 130, + currency: 'BRL', + source: ' Monthly support ', + actorDiscordId: '226828637586849795', + occurredAt: new Date('2026-10-06T12:00:00.000Z'), + }); + await sut.execute({ + amount: 130, + currency: 'BRL', + source: 'Monthly support', + actorDiscordId: '226828637586849795', + occurredAt: new Date('2026-12-24T23:15:00.000Z'), + }); + + // Then + const [first, second] = ledgerRepository.insert.mock.calls.map(([call]) => call.entry); + expect(first.dedupeKey).toBe('donation:226828637586849795:130:BRL:Monthly support'); + expect(second.dedupeKey).toBe(first.dedupeKey); + expect(first.source).toBe('Monthly support'); + }); + + it.each([ + { currency: 'BRL' as LedgerCurrency, amount: 130, message: 'A donation of 130 BRL from "Monthly support" is already registered.' }, + { currency: 'USD' as LedgerCurrency, amount: 10, message: 'A donation of 10 USD from "Monthly support" is already registered.' }, + ])('should reject an already registered $currency donation without calling the FX provider', async ({ currency, amount, message }) => { + // Given + const { sut, ledgerRepository, exchangeRateProvider } = makeSut(); + when(ledgerRepository.existsByDedupeKey).calledWith(expect.anything()).thenResolve(true); + + // When + const attempt = sut.execute({ + amount, + currency, + source: 'Monthly support', + actorDiscordId: '226828637586849795', + occurredAt: new Date('2026-10-06T12:00:00.000Z'), + }); + + // Then + await expect(attempt).rejects.toThrow(message); + expect(ledgerRepository.existsByDedupeKey).toHaveBeenCalledWith({ + dedupeKey: `donation:226828637586849795:${amount}:${currency}:Monthly support`, + }); + expect(ledgerRepository.insert).not.toHaveBeenCalled(); + expect(exchangeRateProvider.getBrlPerUsd).not.toHaveBeenCalled(); + }); + + it.each([ + { amount: 0, currency: 'BRL' as LedgerCurrency, source: 'Monthly support', actorDiscordId: '226828637586849795' }, + { amount: -5, currency: 'BRL' as LedgerCurrency, source: 'Monthly support', actorDiscordId: '226828637586849795' }, + { amount: Number.NaN, currency: 'BRL' as LedgerCurrency, source: 'Monthly support', actorDiscordId: '226828637586849795' }, + { amount: 10, currency: 'BRL' as LedgerCurrency, source: 'ab', actorDiscordId: '226828637586849795' }, + { amount: 10, currency: 'BRL' as LedgerCurrency, source: ' ', actorDiscordId: '226828637586849795' }, + { amount: 10, currency: 'BRL' as LedgerCurrency, source: 'Monthly support', actorDiscordId: '' }, + { amount: 10, currency: 'EUR' as unknown as LedgerCurrency, source: 'Monthly support', actorDiscordId: '226828637586849795' }, + ])('should reject invalid donation $amount $currency $source', async ({ amount, currency, source, actorDiscordId }) => { + // Given + const { sut, ledgerRepository } = makeSut(); + + // When + const attempt = sut.execute({ amount, currency, source, actorDiscordId }); + + // Then + await expect(attempt).rejects.toThrow(); + expect(ledgerRepository.existsByDedupeKey).not.toHaveBeenCalled(); + expect(ledgerRepository.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/usecase/RegisterDonation.ts b/packages/core/src/usecase/RegisterDonation.ts new file mode 100644 index 0000000..9feb7c9 --- /dev/null +++ b/packages/core/src/usecase/RegisterDonation.ts @@ -0,0 +1,90 @@ +import { LedgerCurrency, LedgerTransaction, toBrlCents } from "../domain/LedgerTransaction"; +import { LedgerRepository } from "../repository/LedgerRepository"; +import { ExchangeRateProvider } from "../services/ExchangeRateProvider"; +import { UserError } from "../errors/UserError"; + +type RegisterDonationDependencies = { + ledgerRepository: LedgerRepository; + exchangeRateProvider: ExchangeRateProvider; +}; + +type RegisterDonationExecuteParams = { + amount: number; + currency: LedgerCurrency; + source: string; + actorDiscordId: string; + occurredAt?: Date; +}; + +export class RegisterDonation { + constructor(private readonly dependencies: RegisterDonationDependencies) {} + + async execute(params: RegisterDonationExecuteParams): Promise { + const { ledgerRepository, exchangeRateProvider } = this.dependencies; + const { amount, currency, source, actorDiscordId } = params; + const occurredAt = params.occurredAt ?? new Date(); + + validateDonation({ amount, currency, source, actorDiscordId }); + + const normalizedSource = source.trim(); + const dedupeKey = buildDonationDedupeKey({ actorDiscordId, amount, currency, source: normalizedSource }); + const alreadyRegistered = await ledgerRepository.existsByDedupeKey({ dedupeKey }); + if (alreadyRegistered) { + throw new UserError(`A donation of ${amount} ${currency} from "${normalizedSource}" is already registered.`); + } + + const fxRate = currency === 'BRL' + ? 1 + : (await exchangeRateProvider.getBrlPerUsd({ date: occurredAt })).rate; + + return ledgerRepository.insert({ + entry: { + occurredAt, + type: 'DONATION', + amountOriginal: amount, + currencyOriginal: currency, + amountBrlCents: toBrlCents({ amount, fxRate }), + fxRate, + fxSource: currency === 'BRL' ? 'NONE' : 'PTAX', + source: normalizedSource, + actorDiscordId, + dedupeKey, + }, + }); + } +} + +type ValidateDonationParams = { + amount: number; + currency: LedgerCurrency; + source: string; + actorDiscordId: string; +}; + +function validateDonation(params: ValidateDonationParams): void { + const { amount, currency, source, actorDiscordId } = params; + if (!Number.isFinite(amount) || amount <= 0) { + throw new UserError('Donation amount must be greater than zero.'); + } + if (currency !== 'BRL' && currency !== 'USD') { + throw new UserError('Donation currency must be BRL or USD.'); + } + if (source.trim().length < 3 || source.trim().length > 120) { + throw new UserError('Donation source must be between 3 and 120 characters.'); + } + if (actorDiscordId.trim().length === 0) { + throw new UserError('Donation actor is required.'); + } +} + +type BuildDonationDedupeKeyParams = { + actorDiscordId: string; + amount: number; + currency: LedgerCurrency; + source: string; +}; + +function buildDonationDedupeKey(params: BuildDonationDedupeKeyParams): string { + const { actorDiscordId, amount, currency, source } = params; + return `donation:${actorDiscordId}:${amount}:${currency}:${source}`; +} diff --git a/packages/core/src/utils/ConfigManager.ts b/packages/core/src/utils/ConfigManager.ts index 6bf0799..c220888 100644 --- a/packages/core/src/utils/ConfigManager.ts +++ b/packages/core/src/utils/ConfigManager.ts @@ -1,4 +1,4 @@ -import { AWSConfig, DiscordConfig, GithubConfig, OracleConfig, Region, RegionConfig, Variant, VariantConfig } from "../domain"; +import { AWSConfig, DiscordConfig, GithubConfig, MoneyConfig, OracleConfig, Region, RegionConfig, Variant, VariantConfig } from "../domain"; export interface ConfigManager { getVariantConfig(variant: Variant): VariantConfig; @@ -7,4 +7,5 @@ export interface ConfigManager { getAWSConfig(): AWSConfig; getDiscordConfig(): DiscordConfig; getGithubConfig(): GithubConfig; + getMoneyConfig(): MoneyConfig; } diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts new file mode 100644 index 0000000..9bc5ee6 --- /dev/null +++ b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts @@ -0,0 +1,222 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Knex, knex as createKnex } from "knex"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NewLedgerEntry, UserError } from "@tf2qs/core"; +import { down, up } from "../../../../migrations/20261011000000_ledger_transactions"; +import { SQLiteLedgerRepository } from "./SQLiteLedgerRepository"; + +function makeEntry(overrides: Partial = {}): NewLedgerEntry { + return { + occurredAt: new Date("2026-10-06T12:00:00.000Z"), + type: "DONATION", + amountOriginal: 130, + currencyOriginal: "BRL", + amountBrlCents: 13000, + fxRate: 1, + fxSource: "NONE", + source: "Monthly support", + actorDiscordId: "226828637586849795", + dedupeKey: "donation:2026-10-06:226828637586849795:130:BRL:Monthly support", + ...overrides, + }; +} + +describe("SQLiteLedgerRepository", () => { + let knex: Knex; + let sut: SQLiteLedgerRepository; + + async function makeSut() { + knex = createKnex({ + client: "sqlite3", + connection: { filename: ":memory:" }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + }); + await up(knex); + sut = new SQLiteLedgerRepository({ knex }); + return { sut, knex }; + } + + beforeEach(async () => { + await makeSut(); + }); + + afterEach(async () => { + await knex.destroy(); + }); + + it("should insert and read back an entry with deserialized dates", async () => { + // Given + const entry = makeEntry(); + + // When + const stored = await sut.insert({ entry }); + + // Then + expect(stored.id).toBeGreaterThan(0); + expect(stored.occurredAt).toEqual(entry.occurredAt); + expect(stored.createdAt).toBeInstanceOf(Date); + expect(await sut.existsByDedupeKey({ dedupeKey: entry.dedupeKey })).toBe(true); + expect(await sut.existsByDedupeKey({ dedupeKey: "donation:missing" })).toBe(false); + }); + + it("should report zero balance on an empty ledger", async () => { + // When + const balance = await sut.getBalance(); + + // Then + expect(balance).toBe(0); + }); + + it("should sum opening plus donation minus cost entries", async () => { + // Given + await sut.insert({ + entry: makeEntry({ + type: "OPENING", + amountOriginal: 130, + amountBrlCents: 13000, + source: "Opening balance from #money history", + actorDiscordId: "system", + dedupeKey: "opening:seed", + }), + }); + await sut.insert({ + entry: makeEntry({ + amountOriginal: 10, + currencyOriginal: "USD", + amountBrlCents: 5500, + fxRate: 5.5, + fxSource: "PTAX", + dedupeKey: "donation:usd-10", + }), + }); + await sut.insert({ + entry: makeEntry({ + type: "COST", + amountOriginal: 5.05, + currencyOriginal: "USD", + amountBrlCents: -2778, + fxRate: 5.5, + fxSource: "PTAX", + actorDiscordId: "system", + dedupeKey: "cost:2026-09:bot-infra", + }), + }); + + // When + const balance = await sut.getBalance(); + + // Then + expect(balance).toBe(13000 + 5500 - 2778); + }); + + it("should list recent entries newest first honoring the limit", async () => { + // Given + await sut.insert({ entry: makeEntry({ dedupeKey: "donation:first" }) }); + await sut.insert({ entry: makeEntry({ dedupeKey: "donation:second" }) }); + await sut.insert({ entry: makeEntry({ dedupeKey: "donation:third" }) }); + + // When + const recent = await sut.listRecent({ limit: 2 }); + + // Then + expect(recent.map((entry) => entry.dedupeKey)).toEqual(["donation:third", "donation:second"]); + }); + + it("should reject a duplicate dedupe key as a user error", async () => { + // Given + await sut.insert({ entry: makeEntry() }); + + // When + const duplicate = sut.insert({ entry: makeEntry() }); + + // Then + const error = await duplicate.then(() => undefined, (thrown: unknown) => thrown as UserError); + expect(error).toBeInstanceOf(UserError); + expect(error?.name).toBe("UserError"); + expect(error?.message).toBe("This ledger entry is already recorded."); + }); + + it("should store the balance in integer cents", async () => { + // Given + const amountBrlCents = 123456789; + await sut.insert({ entry: makeEntry({ amountBrlCents }) }); + + // When + const stored = await knex("ledger_transactions") + .select("amount_brl_cents", knex.raw("typeof(amount_brl_cents) as storage_class")) + .first(); + + // Then + expect(typeof stored?.amount_brl_cents).toBe("number"); + expect(stored?.storage_class).toBe("integer"); + expect(stored?.amount_brl_cents).toBe(amountBrlCents); + }); + + it.each([ + { description: "an unknown type", overrides: { type: "BOGUS" } }, + { description: "an unsupported currency", overrides: { currencyOriginal: "EUR" } }, + { description: "an unsupported fx source", overrides: { fxSource: "BCB" } }, + { description: "fractional cents", overrides: { amountBrlCents: 13000.5 } }, + ])("should reject a ledger entry with $description", async ({ description, overrides }) => { + // Given + const entry = { ...makeEntry(), ...overrides, dedupeKey: `invalid:${description}` } as NewLedgerEntry; + + // When + const attempt = sut.insert({ entry }); + + // Then + const error = await attempt.then(() => undefined, (thrown: unknown) => thrown as Error); + expect(error?.message).toMatch(/CHECK constraint failed/); + expect(error?.name).not.toBe("UserError"); + }); + + it("should read created_at back as the UTC instant the database stored", async () => { + // Given + await sut.insert({ entry: makeEntry() }); + await knex("ledger_transactions").update({ created_at: "2026-10-15 12:47:38" }); + + // When + const recent = await sut.listRecent({ limit: 1 }); + + // Then + expect(recent[0].createdAt.toISOString()).toBe("2026-10-15T12:47:38.000Z"); + expect(recent[0].createdAt.getTime()).toBe(Date.UTC(2026, 9, 15, 12, 47, 38)); + }); + + it("should round-trip the migration down and up", async () => { + // Given + const directory = await mkdtemp(join(tmpdir(), "ledger-test-")); + const fileKnex = createKnex({ + client: "sqlite3", + connection: { filename: join(directory, "ledger.sqlite3") }, + useNullAsDefault: true, + pool: { min: 1, max: 1 }, + }); + + try { + await up(fileKnex); + const fileSut = new SQLiteLedgerRepository({ knex: fileKnex }); + await fileSut.insert({ entry: makeEntry() }); + + // When + await down(fileKnex); + + // Then + await expect(fileKnex("ledger_transactions").select("*")).rejects.toThrow(); + + // When + await up(fileKnex); + const stored = await fileSut.insert({ entry: makeEntry() }); + + // Then + expect(stored.amountBrlCents).toBe(13000); + expect(await fileSut.getBalance()).toBe(13000); + } finally { + await fileKnex.destroy(); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.ts b/packages/providers/src/repository/SQLiteLedgerRepository.ts new file mode 100644 index 0000000..fcce33a --- /dev/null +++ b/packages/providers/src/repository/SQLiteLedgerRepository.ts @@ -0,0 +1,114 @@ +import { Knex } from "knex"; +import { LedgerTransaction, NewLedgerEntry } from "@tf2qs/core"; +import { LedgerRepository, UserError } from "@tf2qs/core"; + +type LedgerRow = { + id: number; + occurred_at: string; + type: LedgerTransaction['type']; + amount_original: number; + currency_original: LedgerTransaction['currencyOriginal']; + amount_brl_cents: number; + fx_rate: number; + fx_source: LedgerTransaction['fxSource']; + source: string; + actor_discord_id: string; + dedupe_key: string; + created_at: string; +}; + +export class SQLiteLedgerRepository implements LedgerRepository { + constructor(private readonly dependencies: { knex: Knex }) {} + + async insert(params: { entry: NewLedgerEntry }): Promise { + const { entry } = params; + try { + const [id] = await this.dependencies.knex('ledger_transactions').insert(toRow(entry)); + const row = await this.dependencies.knex('ledger_transactions').where({ id }).first(); + return toDomain(row as LedgerRow); + } catch (error) { + if (isDuplicateDedupeKeyError({ error })) { + throw new UserError('This ledger entry is already recorded.'); + } + throw error; + } + } + + async existsByDedupeKey(params: { dedupeKey: string }): Promise { + const { dedupeKey } = params; + const row = await this.dependencies.knex('ledger_transactions') + .where({ dedupe_key: dedupeKey }) + .first(); + return Boolean(row); + } + + async getBalance(): Promise { + const row = await this.dependencies.knex('ledger_transactions') + .sum('amount_brl_cents as total') + .first(); + return Number((row as { total: number | null } | undefined)?.total ?? 0); + } + + async listRecent(params: { limit: number }): Promise { + const { limit } = params; + const rows = await this.dependencies.knex('ledger_transactions') + .orderBy('id', 'desc') + .limit(limit); + return (rows as LedgerRow[]).map(toDomain); + } +} + +type IsDuplicateDedupeKeyErrorParams = { + error: unknown; +}; + +function isDuplicateDedupeKeyError(params: IsDuplicateDedupeKeyErrorParams): boolean { + const { error } = params; + if (!(error instanceof Error)) { + return false; + } + const code = (error as Error & { code?: string }).code; + return code === 'SQLITE_CONSTRAINT' + && error.message.includes('UNIQUE constraint failed: ledger_transactions.dedupe_key'); +} + +function toRow(entry: NewLedgerEntry): Omit { + return { + occurred_at: entry.occurredAt.toISOString(), + type: entry.type, + amount_original: entry.amountOriginal, + currency_original: entry.currencyOriginal, + amount_brl_cents: entry.amountBrlCents, + fx_rate: entry.fxRate, + fx_source: entry.fxSource, + source: entry.source, + actor_discord_id: entry.actorDiscordId, + dedupe_key: entry.dedupeKey, + }; +} + +function toDomain(row: LedgerRow): LedgerTransaction { + return { + id: row.id, + occurredAt: new Date(row.occurred_at), + type: row.type, + amountOriginal: row.amount_original, + currencyOriginal: row.currency_original, + amountBrlCents: row.amount_brl_cents, + fxRate: row.fx_rate, + fxSource: row.fx_source, + source: row.source, + actorDiscordId: row.actor_discord_id, + dedupeKey: row.dedupe_key, + createdAt: parseUtcTimestamp({ value: row.created_at }), + }; +} + +type ParseUtcTimestampParams = { + value: string; +}; + +function parseUtcTimestamp(params: ParseUtcTimestampParams): Date { + const { value } = params; + return new Date(`${value.replace(' ', 'T')}Z`); +} diff --git a/packages/providers/src/repository/index.ts b/packages/providers/src/repository/index.ts index 53a27c9..cf41770 100644 --- a/packages/providers/src/repository/index.ts +++ b/packages/providers/src/repository/index.ts @@ -8,3 +8,4 @@ export * from './SQLiteUserRepository'; export * from './SQLiteServerStatusMetricsRepository'; export * from './SQLitePlayerConnectionHistoryRepository'; export * from './SQLiteScheduledServerRepository'; +export * from './SQLiteLedgerRepository'; diff --git a/packages/providers/src/utils/DefaultConfigManager.ts b/packages/providers/src/utils/DefaultConfigManager.ts index da0c540..f246f80 100644 --- a/packages/providers/src/utils/DefaultConfigManager.ts +++ b/packages/providers/src/utils/DefaultConfigManager.ts @@ -1,4 +1,4 @@ -import { AWSConfig, DiscordConfig, getAWSConfig, getDiscordConfig, getGithubConfig, getOracleConfig, getRegionConfig, getVariantConfig, GithubConfig, OracleConfig, Region, RegionConfig, Variant, VariantConfig } from "@tf2qs/core"; +import { AWSConfig, DiscordConfig, getAWSConfig, getDiscordConfig, getGithubConfig, getMoneyConfig, getOracleConfig, getRegionConfig, getVariantConfig, GithubConfig, MoneyConfig, OracleConfig, Region, RegionConfig, Variant, VariantConfig } from "@tf2qs/core"; import { ConfigManager } from "@tf2qs/core"; export class DefaultConfigManager implements ConfigManager { @@ -20,5 +20,8 @@ export class DefaultConfigManager implements ConfigManager { getGithubConfig(): GithubConfig { return getGithubConfig(); } + getMoneyConfig(): MoneyConfig { + return getMoneyConfig(); + } } export const defaultConfigManager = new DefaultConfigManager(); diff --git a/vite.config.ts b/vite.config.ts index 4662147..6c6fc33 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,5 +12,6 @@ export default defineConfig({ test: { globals: false, setupFiles: ["tests/setup.ts"], + testTimeout: 15000, }, }); \ No newline at end of file