From f40a7be41096df44bfcde9a16807890d787f4f88 Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Sat, 10 Oct 2026 23:48:29 -0300 Subject: [PATCH 01/11] feat(ledger): add immutable BRL ledger core with settlement skeleton --- .../20261011000000_ledger_transactions.ts | 25 +++ packages/core/index.ts | 6 + packages/core/src/domain/index.ts | 2 + .../src/domain/ledger_transaction.test.ts | 61 ++++++ .../core/src/domain/ledger_transaction.ts | 58 +++++ .../core/src/repository/ledger_repository.ts | 8 + .../src/services/exchange_rate_provider.ts | 3 + packages/core/src/usecase/get_balance.test.ts | 37 ++++ packages/core/src/usecase/get_balance.ts | 19 ++ .../usecase/monthly_cost_settlement.test.ts | 206 ++++++++++++++++++ .../src/usecase/monthly_cost_settlement.ts | 117 ++++++++++ .../core/src/usecase/register_cost.test.ts | 89 ++++++++ packages/core/src/usecase/register_cost.ts | 75 +++++++ .../src/usecase/register_donation.test.ts | 105 +++++++++ .../core/src/usecase/register_donation.ts | 85 ++++++++ packages/providers/src/repository/index.ts | 1 + .../sqlite_ledger_repository.test.ts | 160 ++++++++++++++ .../repository/sqlite_ledger_repository.ts | 84 +++++++ packages/scripts/package.json | 1 + packages/scripts/seed_ledger_opening.ts | 49 +++++ 20 files changed, 1191 insertions(+) create mode 100644 migrations/20261011000000_ledger_transactions.ts create mode 100644 packages/core/src/domain/ledger_transaction.test.ts create mode 100644 packages/core/src/domain/ledger_transaction.ts create mode 100644 packages/core/src/repository/ledger_repository.ts create mode 100644 packages/core/src/services/exchange_rate_provider.ts create mode 100644 packages/core/src/usecase/get_balance.test.ts create mode 100644 packages/core/src/usecase/get_balance.ts create mode 100644 packages/core/src/usecase/monthly_cost_settlement.test.ts create mode 100644 packages/core/src/usecase/monthly_cost_settlement.ts create mode 100644 packages/core/src/usecase/register_cost.test.ts create mode 100644 packages/core/src/usecase/register_cost.ts create mode 100644 packages/core/src/usecase/register_donation.test.ts create mode 100644 packages/core/src/usecase/register_donation.ts create mode 100644 packages/providers/src/repository/sqlite_ledger_repository.test.ts create mode 100644 packages/providers/src/repository/sqlite_ledger_repository.ts create mode 100644 packages/scripts/seed_ledger_opening.ts diff --git a/migrations/20261011000000_ledger_transactions.ts b/migrations/20261011000000_ledger_transactions.ts new file mode 100644 index 0000000..dddfbbd --- /dev/null +++ b/migrations/20261011000000_ledger_transactions.ts @@ -0,0 +1,25 @@ +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').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').defaultTo(knex.fn.now()); + table.unique('dedupe_key', { indexName: 'uq_ledger_dedupe' }); + }); +} + + +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..fbcaffc 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/ledger_repository'; // 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/exchange_rate_provider'; 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/register_donation'; +export * from './src/usecase/register_cost'; +export * from './src/usecase/get_balance'; +export * from './src/usecase/monthly_cost_settlement'; // Utils export * from './src/utils/ConfigManager'; diff --git a/packages/core/src/domain/index.ts b/packages/core/src/domain/index.ts index 4da1818..5f63d71 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 "./ledger_constants"; +export * from "./ledger_transaction"; export * from "./DateRange"; export * from "./ServerActivity"; export * from "./PlayerConnectionHistory"; diff --git a/packages/core/src/domain/ledger_transaction.test.ts b/packages/core/src/domain/ledger_transaction.test.ts new file mode 100644 index 0000000..2353b21 --- /dev/null +++ b/packages/core/src/domain/ledger_transaction.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { buildCostDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl } from './ledger_transaction'; + +describe('ledger_transaction 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([ + { 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/ledger_transaction.ts b/packages/core/src/domain/ledger_transaction.ts new file mode 100644 index 0000000..f4ec910 --- /dev/null +++ b/packages/core/src/domain/ledger_transaction.ts @@ -0,0 +1,58 @@ +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 toBalanceBrl(params: { balanceBrlCents: number }): number { + const { balanceBrlCents } = params; + return balanceBrlCents / 100; +} + +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/repository/ledger_repository.ts b/packages/core/src/repository/ledger_repository.ts new file mode 100644 index 0000000..f738ffc --- /dev/null +++ b/packages/core/src/repository/ledger_repository.ts @@ -0,0 +1,8 @@ +import { LedgerTransaction, NewLedgerEntry } from "../domain/ledger_transaction"; + +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/exchange_rate_provider.ts b/packages/core/src/services/exchange_rate_provider.ts new file mode 100644 index 0000000..a623fab --- /dev/null +++ b/packages/core/src/services/exchange_rate_provider.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/get_balance.test.ts b/packages/core/src/usecase/get_balance.test.ts new file mode 100644 index 0000000..ce77f5a --- /dev/null +++ b/packages/core/src/usecase/get_balance.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/ledger_repository'; +import { GetBalance } from './get_balance'; + +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/get_balance.ts b/packages/core/src/usecase/get_balance.ts new file mode 100644 index 0000000..32d3627 --- /dev/null +++ b/packages/core/src/usecase/get_balance.ts @@ -0,0 +1,19 @@ +import { LedgerRepository } from "../repository/ledger_repository"; + +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/monthly_cost_settlement.test.ts b/packages/core/src/usecase/monthly_cost_settlement.test.ts new file mode 100644 index 0000000..89a5ab9 --- /dev/null +++ b/packages/core/src/usecase/monthly_cost_settlement.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { when } from 'vitest-when'; +import { getRegions } from '../domain/Region'; +import { BOT_INFRASTRUCTURE_COST_USD } from '../domain/ledger_constants'; +import { LedgerTransaction } from '../domain/ledger_transaction'; +import { CostProvider } from '../services/CostProvider'; +import { EventLogger } from '../services/EventLogger'; +import { ExchangeRateProvider } from '../services/exchange_rate_provider'; +import { MonthlyCostSettlement } from './monthly_cost_settlement'; +import { RegisterCost } from './register_cost'; + +describe('MonthlyCostSettlement', () => { + function makeSut() { + const costProvider = mock(); + const exchangeRateProvider = mock(); + const registerCost = mock(); + const eventLogger = mock(); + const sut = new MonthlyCostSettlement({ costProvider, exchangeRateProvider, registerCost, eventLogger }); + return { sut, costProvider, exchangeRateProvider, registerCost, eventLogger }; + } + + function makeCostEntry(dedupeKey: string): LedgerTransaction { + return { + id: 1, + occurredAt: new Date('2026-09-30T12:00:00.000Z'), + 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(); + const settlementDate = new Date('2026-09-30T12:00:00.000Z'); + when(exchangeRateProvider.getBrlPerUsd) + .calledWith({ date: settlementDate }) + .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); + 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 }); + expect(registerCost.execute).toHaveBeenCalledTimes(expectedSegments); + expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ + dedupeKey: 'cost:2026-09:bot-infra', + fxRate: 5.5, + fxSource: 'PTAX', + occurredAt: settlementDate, + })); + }); + + it('should skip already-settled segments without double-inserting', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + const settlementDate = new Date('2026-09-30T12:00:00.000Z'); + when(exchangeRateProvider.getBrlPerUsd) + .calledWith({ date: settlementDate }) + .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); + 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 }); + }); + + it('should skip zero-value region costs without registering them', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + const settlementDate = new Date('2026-09-30T12:00:00.000Z'); + when(exchangeRateProvider.getBrlPerUsd) + .calledWith({ date: settlementDate }) + .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); + 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.inserted).toBe(1); + expect(result.skipped).toBe(getRegions().length); + expect(registerCost.execute).toHaveBeenCalledTimes(1); + expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ + dedupeKey: 'cost:2026-09:bot-infra', + })); + }); + + it('should settle bot infrastructure with the shared default cost', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + const settlementDate = new Date('2026-09-30T12:00:00.000Z'); + when(exchangeRateProvider.getBrlPerUsd) + .calledWith({ date: settlementDate }) + .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); + 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: BOT_INFRASTRUCTURE_COST_USD, + currency: 'USD', + dedupeKey: 'cost:2026-09:bot-infra', + })); + }); + + it('should settle remaining segments when one region fetch fails', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost, eventLogger } = makeSut(); + const settlementDate = new Date('2026-09-30T12:00:00.000Z'); + when(exchangeRateProvider.getBrlPerUsd) + .calledWith({ date: settlementDate }) + .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); + 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: 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 skip a segment with an unexpected currency without aborting the rest', async () => { + // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + const settlementDate = new Date('2026-09-30T12:00:00.000Z'); + when(exchangeRateProvider.getBrlPerUsd) + .calledWith({ date: settlementDate }) + .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); + 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: 1 }); + expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ + dedupeKey: 'cost:2026-09:bot-infra', + })); + }); + + it.each([{ month: 0 }, { month: 13 }])('should reject settlement month $month', async ({ month }) => { // Given + const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); + + // When + const attempt = sut.execute({ month, year: 2026 }); + + // Then + await expect(attempt).rejects.toThrow(); + expect(costProvider.fetchCost).not.toHaveBeenCalled(); + expect(registerCost.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/usecase/monthly_cost_settlement.ts b/packages/core/src/usecase/monthly_cost_settlement.ts new file mode 100644 index 0000000..ee63658 --- /dev/null +++ b/packages/core/src/usecase/monthly_cost_settlement.ts @@ -0,0 +1,117 @@ +import { Region, getRegions } from "../domain/Region"; +import { BOT_INFRASTRUCTURE_COST_USD } from "../domain/ledger_constants"; +import { LedgerCurrency, buildCostDedupeKey, buildSettlementOccurredAt } from "../domain/ledger_transaction"; +import { CostProvider } from "../services/CostProvider"; +import { EventLogger } from "../services/EventLogger"; +import { ExchangeRateProvider } from "../services/exchange_rate_provider"; +import { UserError } from "../errors/UserError"; +import { RegisterCost } from "./register_cost"; + +type MonthlyCostSettlementDependencies = { + costProvider: CostProvider; + exchangeRateProvider: ExchangeRateProvider; + registerCost: RegisterCost; + botInfrastructureCostUsd?: number; + eventLogger?: EventLogger; +}; + +type MonthlyCostSettlementExecuteParams = { + month: number; + year: number; +}; + +type MonthlyCostSettlementResult = { + inserted: number; + skipped: number; +}; + +export class MonthlyCostSettlement { + constructor(private readonly dependencies: MonthlyCostSettlementDependencies) {} + + async execute(params: MonthlyCostSettlementExecuteParams): Promise { + const { costProvider, exchangeRateProvider, registerCost } = this.dependencies; + const { month, year } = params; + const botInfrastructureCostUsd = this.dependencies.botInfrastructureCostUsd ?? BOT_INFRASTRUCTURE_COST_USD; + + validateSettlement({ month }); + + const occurredAt = buildSettlementOccurredAt({ month, year }); + const dateRange = { + startDate: new Date(year, month - 1, 1), + endDate: new Date(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; + + for (const { segment, region } of segments) { + try { + const settled = await settleSegment({ segment, region, costProvider, registerCost, fxRate: fx.rate, month, year, occurredAt, dateRange, botInfrastructureCostUsd }); + if (settled) { + inserted += 1; + } else { + skipped += 1; + } + } catch (error) { + skipped += 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 }; + } +} + +type SettleSegmentParams = { + segment: string; + region: Region | null; + costProvider: CostProvider; + registerCost: RegisterCost; + fxRate: number; + month: number; + year: number; + occurredAt: Date; + dateRange: { startDate: Date; endDate: Date }; + botInfrastructureCostUsd: number; +}; + +async function settleSegment(params: SettleSegmentParams): Promise { + const { segment, region, costProvider, registerCost, fxRate, month, year, occurredAt, dateRange, botInfrastructureCostUsd } = params; + const cost = region + ? await costProvider.fetchCost({ region, dateRange }) + : { currency: 'USD', value: botInfrastructureCostUsd }; + 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/register_cost.test.ts b/packages/core/src/usecase/register_cost.test.ts new file mode 100644 index 0000000..6594de6 --- /dev/null +++ b/packages/core/src/usecase/register_cost.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/ledger_transaction'; +import { LedgerRepository } from '../repository/ledger_repository'; +import { RegisterCost } from './register_cost'; + +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/register_cost.ts b/packages/core/src/usecase/register_cost.ts new file mode 100644 index 0000000..efbec66 --- /dev/null +++ b/packages/core/src/usecase/register_cost.ts @@ -0,0 +1,75 @@ +import { LedgerCurrency, LedgerFxSource, LedgerTransaction, toBrlCents } from "../domain/ledger_transaction"; +import { LedgerRepository } from "../repository/ledger_repository"; +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/register_donation.test.ts b/packages/core/src/usecase/register_donation.test.ts new file mode 100644 index 0000000..7ed426a --- /dev/null +++ b/packages/core/src/usecase/register_donation.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { when } from 'vitest-when'; +import { LedgerCurrency, LedgerTransaction } from '../domain/ledger_transaction'; +import { LedgerRepository } from '../repository/ledger_repository'; +import { ExchangeRateProvider } from '../services/exchange_rate_provider'; +import { RegisterDonation } from './register_donation'; + +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.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(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.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.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/usecase/register_donation.ts b/packages/core/src/usecase/register_donation.ts new file mode 100644 index 0000000..0398912 --- /dev/null +++ b/packages/core/src/usecase/register_donation.ts @@ -0,0 +1,85 @@ +import { LedgerCurrency, LedgerTransaction, toBrlCents } from "../domain/ledger_transaction"; +import { LedgerRepository } from "../repository/ledger_repository"; +import { ExchangeRateProvider } from "../services/exchange_rate_provider"; +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 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: buildDonationDedupeKey({ occurredAt, actorDiscordId, amount, currency, source: normalizedSource }), + }, + }); + } +} + +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 = { + occurredAt: Date; + actorDiscordId: string; + amount: number; + currency: LedgerCurrency; + source: string; +}; + +function buildDonationDedupeKey(params: BuildDonationDedupeKeyParams): string { + const { occurredAt, actorDiscordId, amount, currency, source } = params; + return `donation:${occurredAt.toISOString()}:${actorDiscordId}:${amount}:${currency}:${source}`; +} diff --git a/packages/providers/src/repository/index.ts b/packages/providers/src/repository/index.ts index 53a27c9..7ef59b5 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 './sqlite_ledger_repository'; diff --git a/packages/providers/src/repository/sqlite_ledger_repository.test.ts b/packages/providers/src/repository/sqlite_ledger_repository.test.ts new file mode 100644 index 0000000..5a174aa --- /dev/null +++ b/packages/providers/src/repository/sqlite_ledger_repository.test.ts @@ -0,0 +1,160 @@ +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 } from "@tf2qs/core"; +import { down, up } from "../../../../migrations/20261011000000_ledger_transactions"; +import { SQLiteLedgerRepository } from "./sqlite_ledger_repository"; + +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 directory: string; + let sut: SQLiteLedgerRepository; + + async function makeSut() { + directory = await mkdtemp(join(tmpdir(), "ledger-test-")); + knex = createKnex({ + client: "sqlite3", + connection: { filename: join(directory, "ledger.sqlite3") }, + useNullAsDefault: true, + pool: { min: 0, max: 1 }, + }); + await up(knex); + sut = new SQLiteLedgerRepository({ knex }); + return { sut, knex }; + } + + beforeEach(async () => { + await makeSut(); + }); + + afterEach(async () => { + await knex.destroy(); + await rm(directory, { recursive: true, force: true }); + }); + + 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", async () => { + // Given + await sut.insert({ entry: makeEntry() }); + + // When + const duplicate = sut.insert({ entry: makeEntry() }); + + // Then + await expect(duplicate).rejects.toThrow(); + }); + + it("should round-trip the migration down and up", async () => { + // Given + await sut.insert({ entry: makeEntry() }); + + // When + await down(knex); + + // Then + await expect(knex("ledger_transactions").select("*")).rejects.toThrow(); + + // When + await up(knex); + const stored = await sut.insert({ entry: makeEntry() }); + + // Then + expect(stored.amountBrlCents).toBe(13000); + expect(await sut.getBalance()).toBe(13000); + }); +}); diff --git a/packages/providers/src/repository/sqlite_ledger_repository.ts b/packages/providers/src/repository/sqlite_ledger_repository.ts new file mode 100644 index 0000000..60e86db --- /dev/null +++ b/packages/providers/src/repository/sqlite_ledger_repository.ts @@ -0,0 +1,84 @@ +import { Knex } from "knex"; +import { LedgerTransaction, NewLedgerEntry } from "@tf2qs/core"; +import { LedgerRepository } from "@tf2qs/core"; + +type LedgerRow = { + id: number; + occurred_at: string; + type: LedgerTransaction['type']; + amount_original: number; + currency_original: LedgerTransaction['currencyOriginal']; + amount_brl: 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; + 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); + } + + 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 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); + } +} + +function toRow(entry: NewLedgerEntry): Omit { + return { + occurred_at: entry.occurredAt.toISOString(), + type: entry.type, + amount_original: entry.amountOriginal, + currency_original: entry.currencyOriginal, + amount_brl: 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, + fxRate: row.fx_rate, + fxSource: row.fx_source, + source: row.source, + actorDiscordId: row.actor_discord_id, + dedupeKey: row.dedupe_key, + createdAt: new Date(row.created_at), + }; +} diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 12463bc..3840199 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -8,6 +8,7 @@ "scripts": { "download:maps": "tsx ./downloadMaps.ts", "sync:fastdl": "tsx ./syncFastDL.ts", + "seed:ledger": "tsx ./seed_ledger_opening.ts", "gen:openapi": "tsx ./generateOpenApiSpec.ts" }, "dependencies": { diff --git a/packages/scripts/seed_ledger_opening.ts b/packages/scripts/seed_ledger_opening.ts new file mode 100644 index 0000000..74965c2 --- /dev/null +++ b/packages/scripts/seed_ledger_opening.ts @@ -0,0 +1,49 @@ +import knex, { Knex } from "knex"; +import dbConfig from "../../knexfile"; + +const OPENING_DEDUPE_KEY = 'opening:seed'; +const OPENING_AMOUNT_BRL_CENTS = 13000; +const OPENING_OCCURRED_AT = '2026-10-06T12:00:00.000Z'; +const OPENING_ACTOR = 'system'; + +type SeedLedgerOpeningParams = { + client?: Knex; +}; + +export async function seedLedgerOpening(params: SeedLedgerOpeningParams): Promise { + const { client } = params; + const db = client ?? knex(dbConfig); + const ownsConnection = !client; + try { + const existing = await db('ledger_transactions').where({ dedupe_key: OPENING_DEDUPE_KEY }).first(); + if (existing) { + console.log('Ledger opening entry already exists. Skipping seed.'); + return; + } + await db('ledger_transactions').insert({ + occurred_at: OPENING_OCCURRED_AT, + type: 'OPENING', + amount_original: 130, + currency_original: 'BRL', + amount_brl: OPENING_AMOUNT_BRL_CENTS, + fx_rate: 1, + fx_source: 'NONE', + source: 'Opening balance from #money history', + actor_discord_id: OPENING_ACTOR, + dedupe_key: OPENING_DEDUPE_KEY, + }); + console.log('Ledger opening entry seeded: +130.00 BRL.'); + } finally { + if (ownsConnection) { + await db.destroy(); + } + } +} + +const invokedDirectly = process.argv[1]?.endsWith('seed_ledger_opening.ts'); +if (invokedDirectly) { + seedLedgerOpening({}).catch((error) => { + console.error('Failed to seed ledger opening entry:', (error as Error).message); + process.exit(1); + }); +} From a18e8a625ff7cc21664619000819271d25a8c58e Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Sat, 10 Oct 2026 23:55:28 -0300 Subject: [PATCH 02/11] feat(ledger): add bot infrastructure cost constant --- packages/core/src/domain/ledger_constants.test.ts | 12 ++++++++++++ packages/core/src/domain/ledger_constants.ts | 1 + 2 files changed, 13 insertions(+) create mode 100644 packages/core/src/domain/ledger_constants.test.ts create mode 100644 packages/core/src/domain/ledger_constants.ts diff --git a/packages/core/src/domain/ledger_constants.test.ts b/packages/core/src/domain/ledger_constants.test.ts new file mode 100644 index 0000000..1cd7d22 --- /dev/null +++ b/packages/core/src/domain/ledger_constants.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { BOT_INFRASTRUCTURE_COST_USD } from './ledger_constants'; + +describe('ledger_constants', () => { + it('should keep the fixed monthly bot infrastructure cost', () => { + // When + const cost = BOT_INFRASTRUCTURE_COST_USD; + + // Then + expect(cost).toBe(5.05); + }); +}); diff --git a/packages/core/src/domain/ledger_constants.ts b/packages/core/src/domain/ledger_constants.ts new file mode 100644 index 0000000..aa738b2 --- /dev/null +++ b/packages/core/src/domain/ledger_constants.ts @@ -0,0 +1 @@ +export const BOT_INFRASTRUCTURE_COST_USD = 5.05; From 1c5aff8e2c1633ae9fad7c471f9af178dc213757 Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 08:56:06 -0300 Subject: [PATCH 03/11] refactor(core): rename ledger modules to PascalCase The repository names modules after their primary export: PascalCase for classes and types (Region.ts, SQLiteServerRepository.ts), camelCase for function modules. The ledger stack shipped snake_case files, which is the convention written down in AGENTS.md and the code-style skill but not the one the codebase actually follows. Rename the ledger domain, repository, service and use-case modules, and update the core, domain and provider barrel exports plus every relative import that points at them. No behaviour change. The ledger_constants and seed_ledger_opening modules are left untouched here: they are removed by later work in this stack. --- packages/core/index.ts | 12 ++++++------ ...transaction.test.ts => LedgerTransaction.test.ts} | 4 ++-- .../{ledger_transaction.ts => LedgerTransaction.ts} | 0 packages/core/src/domain/index.ts | 2 +- .../{ledger_repository.ts => LedgerRepository.ts} | 2 +- ...ange_rate_provider.ts => ExchangeRateProvider.ts} | 0 .../{get_balance.test.ts => GetBalance.test.ts} | 4 ++-- .../src/usecase/{get_balance.ts => GetBalance.ts} | 2 +- ...tlement.test.ts => MonthlyCostSettlement.test.ts} | 8 ++++---- ...y_cost_settlement.ts => MonthlyCostSettlement.ts} | 6 +++--- .../{register_cost.test.ts => RegisterCost.test.ts} | 6 +++--- .../usecase/{register_cost.ts => RegisterCost.ts} | 4 ++-- ...ter_donation.test.ts => RegisterDonation.test.ts} | 8 ++++---- .../{register_donation.ts => RegisterDonation.ts} | 6 +++--- ...sitory.test.ts => SQLiteLedgerRepository.test.ts} | 2 +- ...edger_repository.ts => SQLiteLedgerRepository.ts} | 0 packages/providers/src/repository/index.ts | 2 +- 17 files changed, 34 insertions(+), 34 deletions(-) rename packages/core/src/domain/{ledger_transaction.test.ts => LedgerTransaction.test.ts} (94%) rename packages/core/src/domain/{ledger_transaction.ts => LedgerTransaction.ts} (100%) rename packages/core/src/repository/{ledger_repository.ts => LedgerRepository.ts} (77%) rename packages/core/src/services/{exchange_rate_provider.ts => ExchangeRateProvider.ts} (100%) rename packages/core/src/usecase/{get_balance.test.ts => GetBalance.test.ts} (89%) rename packages/core/src/usecase/{get_balance.ts => GetBalance.ts} (86%) rename packages/core/src/usecase/{monthly_cost_settlement.test.ts => MonthlyCostSettlement.test.ts} (96%) rename packages/core/src/usecase/{monthly_cost_settlement.ts => MonthlyCostSettlement.ts} (95%) rename packages/core/src/usecase/{register_cost.test.ts => RegisterCost.test.ts} (95%) rename packages/core/src/usecase/{register_cost.ts => RegisterCost.ts} (94%) rename packages/core/src/usecase/{register_donation.test.ts => RegisterDonation.test.ts} (93%) rename packages/core/src/usecase/{register_donation.ts => RegisterDonation.ts} (93%) rename packages/providers/src/repository/{sqlite_ledger_repository.test.ts => SQLiteLedgerRepository.test.ts} (98%) rename packages/providers/src/repository/{sqlite_ledger_repository.ts => SQLiteLedgerRepository.ts} (100%) diff --git a/packages/core/index.ts b/packages/core/index.ts index fbcaffc..5332d0b 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -18,7 +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/ledger_repository'; +export * from './src/repository/LedgerRepository'; // Services export * from './src/services/BackgroundTaskQueue'; @@ -37,7 +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/exchange_rate_provider'; +export * from './src/services/ExchangeRateProvider'; export * from './src/services/TF2ServerReadinessService'; // Use cases @@ -61,10 +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/register_donation'; -export * from './src/usecase/register_cost'; -export * from './src/usecase/get_balance'; -export * from './src/usecase/monthly_cost_settlement'; +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/ledger_transaction.test.ts b/packages/core/src/domain/LedgerTransaction.test.ts similarity index 94% rename from packages/core/src/domain/ledger_transaction.test.ts rename to packages/core/src/domain/LedgerTransaction.test.ts index 2353b21..e760a37 100644 --- a/packages/core/src/domain/ledger_transaction.test.ts +++ b/packages/core/src/domain/LedgerTransaction.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { buildCostDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl } from './ledger_transaction'; +import { buildCostDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl } from './LedgerTransaction'; -describe('ledger_transaction helpers', () => { +describe('LedgerTransaction helpers', () => { it.each([ { amount: 10, fxRate: 5.4321, expected: 5432 }, { amount: 10, fxRate: 5.5, expected: 5500 }, diff --git a/packages/core/src/domain/ledger_transaction.ts b/packages/core/src/domain/LedgerTransaction.ts similarity index 100% rename from packages/core/src/domain/ledger_transaction.ts rename to packages/core/src/domain/LedgerTransaction.ts diff --git a/packages/core/src/domain/index.ts b/packages/core/src/domain/index.ts index 5f63d71..ee20ab5 100644 --- a/packages/core/src/domain/index.ts +++ b/packages/core/src/domain/index.ts @@ -12,7 +12,7 @@ export * from "./User"; export * from "./GuildParameters"; export * from "./Cost"; export * from "./ledger_constants"; -export * from "./ledger_transaction"; +export * from "./LedgerTransaction"; export * from "./DateRange"; export * from "./ServerActivity"; export * from "./PlayerConnectionHistory"; diff --git a/packages/core/src/repository/ledger_repository.ts b/packages/core/src/repository/LedgerRepository.ts similarity index 77% rename from packages/core/src/repository/ledger_repository.ts rename to packages/core/src/repository/LedgerRepository.ts index f738ffc..4e66282 100644 --- a/packages/core/src/repository/ledger_repository.ts +++ b/packages/core/src/repository/LedgerRepository.ts @@ -1,4 +1,4 @@ -import { LedgerTransaction, NewLedgerEntry } from "../domain/ledger_transaction"; +import { LedgerTransaction, NewLedgerEntry } from "../domain/LedgerTransaction"; export interface LedgerRepository { insert(params: { entry: NewLedgerEntry }): Promise; diff --git a/packages/core/src/services/exchange_rate_provider.ts b/packages/core/src/services/ExchangeRateProvider.ts similarity index 100% rename from packages/core/src/services/exchange_rate_provider.ts rename to packages/core/src/services/ExchangeRateProvider.ts diff --git a/packages/core/src/usecase/get_balance.test.ts b/packages/core/src/usecase/GetBalance.test.ts similarity index 89% rename from packages/core/src/usecase/get_balance.test.ts rename to packages/core/src/usecase/GetBalance.test.ts index ce77f5a..991a05f 100644 --- a/packages/core/src/usecase/get_balance.test.ts +++ b/packages/core/src/usecase/GetBalance.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { when } from 'vitest-when'; -import { LedgerRepository } from '../repository/ledger_repository'; -import { GetBalance } from './get_balance'; +import { LedgerRepository } from '../repository/LedgerRepository'; +import { GetBalance } from './GetBalance'; describe('GetBalance', () => { function makeSut() { diff --git a/packages/core/src/usecase/get_balance.ts b/packages/core/src/usecase/GetBalance.ts similarity index 86% rename from packages/core/src/usecase/get_balance.ts rename to packages/core/src/usecase/GetBalance.ts index 32d3627..8da13af 100644 --- a/packages/core/src/usecase/get_balance.ts +++ b/packages/core/src/usecase/GetBalance.ts @@ -1,4 +1,4 @@ -import { LedgerRepository } from "../repository/ledger_repository"; +import { LedgerRepository } from "../repository/LedgerRepository"; type GetBalanceDependencies = { ledgerRepository: LedgerRepository; diff --git a/packages/core/src/usecase/monthly_cost_settlement.test.ts b/packages/core/src/usecase/MonthlyCostSettlement.test.ts similarity index 96% rename from packages/core/src/usecase/monthly_cost_settlement.test.ts rename to packages/core/src/usecase/MonthlyCostSettlement.test.ts index 89a5ab9..bab6206 100644 --- a/packages/core/src/usecase/monthly_cost_settlement.test.ts +++ b/packages/core/src/usecase/MonthlyCostSettlement.test.ts @@ -3,12 +3,12 @@ import { mock } from 'vitest-mock-extended'; import { when } from 'vitest-when'; import { getRegions } from '../domain/Region'; import { BOT_INFRASTRUCTURE_COST_USD } from '../domain/ledger_constants'; -import { LedgerTransaction } from '../domain/ledger_transaction'; +import { LedgerTransaction } from '../domain/LedgerTransaction'; import { CostProvider } from '../services/CostProvider'; import { EventLogger } from '../services/EventLogger'; -import { ExchangeRateProvider } from '../services/exchange_rate_provider'; -import { MonthlyCostSettlement } from './monthly_cost_settlement'; -import { RegisterCost } from './register_cost'; +import { ExchangeRateProvider } from '../services/ExchangeRateProvider'; +import { MonthlyCostSettlement } from './MonthlyCostSettlement'; +import { RegisterCost } from './RegisterCost'; describe('MonthlyCostSettlement', () => { function makeSut() { diff --git a/packages/core/src/usecase/monthly_cost_settlement.ts b/packages/core/src/usecase/MonthlyCostSettlement.ts similarity index 95% rename from packages/core/src/usecase/monthly_cost_settlement.ts rename to packages/core/src/usecase/MonthlyCostSettlement.ts index ee63658..d3b8a51 100644 --- a/packages/core/src/usecase/monthly_cost_settlement.ts +++ b/packages/core/src/usecase/MonthlyCostSettlement.ts @@ -1,11 +1,11 @@ import { Region, getRegions } from "../domain/Region"; import { BOT_INFRASTRUCTURE_COST_USD } from "../domain/ledger_constants"; -import { LedgerCurrency, buildCostDedupeKey, buildSettlementOccurredAt } from "../domain/ledger_transaction"; +import { LedgerCurrency, buildCostDedupeKey, buildSettlementOccurredAt } from "../domain/LedgerTransaction"; import { CostProvider } from "../services/CostProvider"; import { EventLogger } from "../services/EventLogger"; -import { ExchangeRateProvider } from "../services/exchange_rate_provider"; +import { ExchangeRateProvider } from "../services/ExchangeRateProvider"; import { UserError } from "../errors/UserError"; -import { RegisterCost } from "./register_cost"; +import { RegisterCost } from "./RegisterCost"; type MonthlyCostSettlementDependencies = { costProvider: CostProvider; diff --git a/packages/core/src/usecase/register_cost.test.ts b/packages/core/src/usecase/RegisterCost.test.ts similarity index 95% rename from packages/core/src/usecase/register_cost.test.ts rename to packages/core/src/usecase/RegisterCost.test.ts index 6594de6..8ccdee9 100644 --- a/packages/core/src/usecase/register_cost.test.ts +++ b/packages/core/src/usecase/RegisterCost.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { when } from 'vitest-when'; -import { LedgerCurrency, LedgerFxSource, LedgerTransaction } from '../domain/ledger_transaction'; -import { LedgerRepository } from '../repository/ledger_repository'; -import { RegisterCost } from './register_cost'; +import { LedgerCurrency, LedgerFxSource, LedgerTransaction } from '../domain/LedgerTransaction'; +import { LedgerRepository } from '../repository/LedgerRepository'; +import { RegisterCost } from './RegisterCost'; describe('RegisterCost', () => { function makeSut() { diff --git a/packages/core/src/usecase/register_cost.ts b/packages/core/src/usecase/RegisterCost.ts similarity index 94% rename from packages/core/src/usecase/register_cost.ts rename to packages/core/src/usecase/RegisterCost.ts index efbec66..e423309 100644 --- a/packages/core/src/usecase/register_cost.ts +++ b/packages/core/src/usecase/RegisterCost.ts @@ -1,5 +1,5 @@ -import { LedgerCurrency, LedgerFxSource, LedgerTransaction, toBrlCents } from "../domain/ledger_transaction"; -import { LedgerRepository } from "../repository/ledger_repository"; +import { LedgerCurrency, LedgerFxSource, LedgerTransaction, toBrlCents } from "../domain/LedgerTransaction"; +import { LedgerRepository } from "../repository/LedgerRepository"; import { UserError } from "../errors/UserError"; type RegisterCostDependencies = { diff --git a/packages/core/src/usecase/register_donation.test.ts b/packages/core/src/usecase/RegisterDonation.test.ts similarity index 93% rename from packages/core/src/usecase/register_donation.test.ts rename to packages/core/src/usecase/RegisterDonation.test.ts index 7ed426a..c36f723 100644 --- a/packages/core/src/usecase/register_donation.test.ts +++ b/packages/core/src/usecase/RegisterDonation.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { when } from 'vitest-when'; -import { LedgerCurrency, LedgerTransaction } from '../domain/ledger_transaction'; -import { LedgerRepository } from '../repository/ledger_repository'; -import { ExchangeRateProvider } from '../services/exchange_rate_provider'; -import { RegisterDonation } from './register_donation'; +import { LedgerCurrency, LedgerTransaction } from '../domain/LedgerTransaction'; +import { LedgerRepository } from '../repository/LedgerRepository'; +import { ExchangeRateProvider } from '../services/ExchangeRateProvider'; +import { RegisterDonation } from './RegisterDonation'; describe('RegisterDonation', () => { function makeSut() { diff --git a/packages/core/src/usecase/register_donation.ts b/packages/core/src/usecase/RegisterDonation.ts similarity index 93% rename from packages/core/src/usecase/register_donation.ts rename to packages/core/src/usecase/RegisterDonation.ts index 0398912..5327545 100644 --- a/packages/core/src/usecase/register_donation.ts +++ b/packages/core/src/usecase/RegisterDonation.ts @@ -1,6 +1,6 @@ -import { LedgerCurrency, LedgerTransaction, toBrlCents } from "../domain/ledger_transaction"; -import { LedgerRepository } from "../repository/ledger_repository"; -import { ExchangeRateProvider } from "../services/exchange_rate_provider"; +import { LedgerCurrency, LedgerTransaction, toBrlCents } from "../domain/LedgerTransaction"; +import { LedgerRepository } from "../repository/LedgerRepository"; +import { ExchangeRateProvider } from "../services/ExchangeRateProvider"; import { UserError } from "../errors/UserError"; type RegisterDonationDependencies = { diff --git a/packages/providers/src/repository/sqlite_ledger_repository.test.ts b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts similarity index 98% rename from packages/providers/src/repository/sqlite_ledger_repository.test.ts rename to packages/providers/src/repository/SQLiteLedgerRepository.test.ts index 5a174aa..eeb0e9f 100644 --- a/packages/providers/src/repository/sqlite_ledger_repository.test.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts @@ -5,7 +5,7 @@ import { Knex, knex as createKnex } from "knex"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { NewLedgerEntry } from "@tf2qs/core"; import { down, up } from "../../../../migrations/20261011000000_ledger_transactions"; -import { SQLiteLedgerRepository } from "./sqlite_ledger_repository"; +import { SQLiteLedgerRepository } from "./SQLiteLedgerRepository"; function makeEntry(overrides: Partial = {}): NewLedgerEntry { return { diff --git a/packages/providers/src/repository/sqlite_ledger_repository.ts b/packages/providers/src/repository/SQLiteLedgerRepository.ts similarity index 100% rename from packages/providers/src/repository/sqlite_ledger_repository.ts rename to packages/providers/src/repository/SQLiteLedgerRepository.ts diff --git a/packages/providers/src/repository/index.ts b/packages/providers/src/repository/index.ts index 7ef59b5..cf41770 100644 --- a/packages/providers/src/repository/index.ts +++ b/packages/providers/src/repository/index.ts @@ -8,4 +8,4 @@ export * from './SQLiteUserRepository'; export * from './SQLiteServerStatusMetricsRepository'; export * from './SQLitePlayerConnectionHistoryRepository'; export * from './SQLiteScheduledServerRepository'; -export * from './sqlite_ledger_repository'; +export * from './SQLiteLedgerRepository'; From f2fbe3e36cc35266f2ed8d3d7e76060355ed670f Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:09:23 -0300 Subject: [PATCH 04/11] feat(core): read the bot infrastructure cost from the money config The monthly Discord bot infrastructure cost was a constant in source (ledger_constants.ts), so changing it meant a code change and a release, and the report formatter carried its own copy of the same literal. Move the value into config/default.json under a top-level money block as a decimal string plus its currency, and parse it once in a new core MoneyConfig module that exposes the amount in minor units. The settlement use case now takes the parsed config as a required dependency instead of an optional cost override, and the monthly report formatter will read it from the same place. The parser rejects a missing block, a non-decimal amount and a currency that is not BRL or USD. A segment that fails to settle is now reported as failed rather than skipped, so a permanently failing month can no longer be announced as already recorded. The settlement date range moves to the UTC basis of the entry it writes, so the queried period matches the recorded one. --- config/default.json | 6 + .../core/src/domain/LedgerTransaction.test.ts | 16 +- packages/core/src/domain/LedgerTransaction.ts | 7 +- packages/core/src/domain/MoneyConfig.test.ts | 79 ++++++++++ packages/core/src/domain/MoneyConfig.ts | 53 +++++++ packages/core/src/domain/index.ts | 2 +- .../core/src/domain/ledger_constants.test.ts | 12 -- packages/core/src/domain/ledger_constants.ts | 1 - .../src/usecase/MonthlyCostSettlement.test.ts | 147 +++++++++++------- .../core/src/usecase/MonthlyCostSettlement.ts | 31 ++-- packages/core/src/utils/ConfigManager.ts | 3 +- .../src/utils/DefaultConfigManager.ts | 5 +- 12 files changed, 279 insertions(+), 83 deletions(-) create mode 100644 packages/core/src/domain/MoneyConfig.test.ts create mode 100644 packages/core/src/domain/MoneyConfig.ts delete mode 100644 packages/core/src/domain/ledger_constants.test.ts delete mode 100644 packages/core/src/domain/ledger_constants.ts 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/packages/core/src/domain/LedgerTransaction.test.ts b/packages/core/src/domain/LedgerTransaction.test.ts index e760a37..015ceac 100644 --- a/packages/core/src/domain/LedgerTransaction.test.ts +++ b/packages/core/src/domain/LedgerTransaction.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildCostDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl } from './LedgerTransaction'; +import { buildCostDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl, toMajorUnits } from './LedgerTransaction'; describe('LedgerTransaction helpers', () => { it.each([ @@ -30,6 +30,20 @@ describe('LedgerTransaction helpers', () => { 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' }, diff --git a/packages/core/src/domain/LedgerTransaction.ts b/packages/core/src/domain/LedgerTransaction.ts index f4ec910..9d0ee2c 100644 --- a/packages/core/src/domain/LedgerTransaction.ts +++ b/packages/core/src/domain/LedgerTransaction.ts @@ -37,9 +37,14 @@ export function toBrlCents(params: { amount: number; fxRate: number }): number { 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 balanceBrlCents / 100; + return toMajorUnits({ amountInMinorUnits: balanceBrlCents }); } export function toDisplayBrl(params: { balanceBrlCents: number }): string { diff --git a/packages/core/src/domain/MoneyConfig.test.ts b/packages/core/src/domain/MoneyConfig.test.ts new file mode 100644 index 0000000..a9dc4d2 --- /dev/null +++ b/packages/core/src/domain/MoneyConfig.test.ts @@ -0,0 +1,79 @@ +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 }, + ])('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"' }, + ])('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..3e15935 --- /dev/null +++ b/packages/core/src/domain/MoneyConfig.ts @@ -0,0 +1,53 @@ +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.`); + } + if (!isLedgerCurrency(currency)) { + throw new Error(`Invalid fixed monthly infrastructure cost currency ${JSON.stringify(currency)}: expected "BRL" or "USD".`); + } + + return { + fixedMonthlyInfrastructureCost: { + amountInMinorUnits: Math.round(Number(amount) * 100), + 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 ee20ab5..cb077e2 100644 --- a/packages/core/src/domain/index.ts +++ b/packages/core/src/domain/index.ts @@ -11,7 +11,7 @@ export * from "./MonthlyUsageReport"; export * from "./User"; export * from "./GuildParameters"; export * from "./Cost"; -export * from "./ledger_constants"; +export * from "./MoneyConfig"; export * from "./LedgerTransaction"; export * from "./DateRange"; export * from "./ServerActivity"; diff --git a/packages/core/src/domain/ledger_constants.test.ts b/packages/core/src/domain/ledger_constants.test.ts deleted file mode 100644 index 1cd7d22..0000000 --- a/packages/core/src/domain/ledger_constants.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { BOT_INFRASTRUCTURE_COST_USD } from './ledger_constants'; - -describe('ledger_constants', () => { - it('should keep the fixed monthly bot infrastructure cost', () => { - // When - const cost = BOT_INFRASTRUCTURE_COST_USD; - - // Then - expect(cost).toBe(5.05); - }); -}); diff --git a/packages/core/src/domain/ledger_constants.ts b/packages/core/src/domain/ledger_constants.ts deleted file mode 100644 index aa738b2..0000000 --- a/packages/core/src/domain/ledger_constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const BOT_INFRASTRUCTURE_COST_USD = 5.05; diff --git a/packages/core/src/usecase/MonthlyCostSettlement.test.ts b/packages/core/src/usecase/MonthlyCostSettlement.test.ts index bab6206..9856753 100644 --- a/packages/core/src/usecase/MonthlyCostSettlement.test.ts +++ b/packages/core/src/usecase/MonthlyCostSettlement.test.ts @@ -2,28 +2,40 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { when } from 'vitest-when'; import { getRegions } from '../domain/Region'; -import { BOT_INFRASTRUCTURE_COST_USD } from '../domain/ledger_constants'; 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() { + 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, eventLogger }); + 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: new Date('2026-09-30T12:00:00.000Z'), + occurredAt: SETTLEMENT_DATE, type: 'COST', amountOriginal: 10, currencyOriginal: 'USD', @@ -40,13 +52,8 @@ describe('MonthlyCostSettlement', () => { it('should insert one cost per region plus bot infrastructure', async () => { // Given const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); - const settlementDate = new Date('2026-09-30T12:00:00.000Z'); - when(exchangeRateProvider.getBrlPerUsd) - .calledWith({ date: settlementDate }) - .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); - when(costProvider.fetchCost) - .calledWith(expect.anything()) - .thenResolve({ currency: 'USD', value: 10 }); + 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; @@ -54,26 +61,21 @@ describe('MonthlyCostSettlement', () => { const result = await sut.execute({ month: 9, year: 2026 }); // Then - expect(result).toEqual({ inserted: expectedSegments, skipped: 0 }); + 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: settlementDate, + occurredAt: SETTLEMENT_DATE, })); }); it('should skip already-settled segments without double-inserting', async () => { // Given const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); - const settlementDate = new Date('2026-09-30T12:00:00.000Z'); - when(exchangeRateProvider.getBrlPerUsd) - .calledWith({ date: settlementDate }) - .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); - when(costProvider.fetchCost) - .calledWith(expect.anything()) - .thenResolve({ currency: 'USD', value: 10 }); + givenExchangeRate(exchangeRateProvider); + when(costProvider.fetchCost).calledWith(expect.anything()).thenResolve({ currency: 'USD', value: 10 }); registerCost.execute.mockResolvedValue(null); const expectedSegments = getRegions().length + 1; @@ -81,43 +83,32 @@ describe('MonthlyCostSettlement', () => { const result = await sut.execute({ month: 9, year: 2026 }); // Then - expect(result).toEqual({ inserted: 0, skipped: expectedSegments }); + 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(); - const settlementDate = new Date('2026-09-30T12:00:00.000Z'); - when(exchangeRateProvider.getBrlPerUsd) - .calledWith({ date: settlementDate }) - .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); - when(costProvider.fetchCost) - .calledWith(expect.anything()) - .thenResolve({ currency: 'USD', value: 0 }); + 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.inserted).toBe(1); - expect(result.skipped).toBe(getRegions().length); + 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 with the shared default cost', async () => { + it('should settle bot infrastructure from the injected money config', async () => { // Given const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); - const settlementDate = new Date('2026-09-30T12:00:00.000Z'); - when(exchangeRateProvider.getBrlPerUsd) - .calledWith({ date: settlementDate }) - .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); - when(costProvider.fetchCost) - .calledWith(expect.anything()) - .thenResolve({ currency: 'USD', value: 0 }); + givenExchangeRate(exchangeRateProvider); + when(costProvider.fetchCost).calledWith(expect.anything()).thenResolve({ currency: 'USD', value: 0 }); registerCost.execute.mockImplementation(async (params) => makeCostEntry(params.dedupeKey)); // When @@ -125,19 +116,38 @@ describe('MonthlyCostSettlement', () => { // Then expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ - amount: BOT_INFRASTRUCTURE_COST_USD, + amount: 5.05, currency: 'USD', + fxSource: 'PTAX', dedupeKey: 'cost:2026-09:bot-infra', })); }); - it('should settle remaining segments when one region fetch fails', async () => { + 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(); - const settlementDate = new Date('2026-09-30T12:00:00.000Z'); - when(exchangeRateProvider.getBrlPerUsd) - .calledWith({ date: settlementDate }) - .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); + givenExchangeRate(exchangeRateProvider); const regions = getRegions(); costProvider.fetchCost.mockImplementation(async ({ region }) => { if (region === regions[0]) { @@ -151,7 +161,7 @@ describe('MonthlyCostSettlement', () => { const result = await sut.execute({ month: 9, year: 2026 }); // Then - expect(result).toEqual({ inserted: regions.length, skipped: 1 }); + expect(result).toEqual({ inserted: regions.length, skipped: 0, failed: 1 }); expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ dedupeKey: 'cost:2026-09:bot-infra', })); @@ -161,13 +171,10 @@ describe('MonthlyCostSettlement', () => { })); }); - it('should skip a segment with an unexpected currency without aborting the rest', async () => { + it('should count a segment with an unexpected currency as failed without aborting the rest', async () => { // Given const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut(); - const settlementDate = new Date('2026-09-30T12:00:00.000Z'); - when(exchangeRateProvider.getBrlPerUsd) - .calledWith({ date: settlementDate }) - .thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: settlementDate }); + givenExchangeRate(exchangeRateProvider); const regions = getRegions(); costProvider.fetchCost.mockImplementation(async ({ region }) => { if (region === regions[0]) { @@ -186,20 +193,56 @@ describe('MonthlyCostSettlement', () => { const result = await sut.execute({ month: 9, year: 2026 }); // Then - expect(result).toEqual({ inserted: regions.length, skipped: 1 }); + expect(result).toEqual({ inserted: regions.length, skipped: 0, failed: 1 }); expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({ dedupeKey: 'cost:2026-09:bot-infra', })); }); - it.each([{ month: 0 }, { month: 13 }])('should reject settlement month $month', async ({ month }) => { // Given + 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 index d3b8a51..10a39ad 100644 --- a/packages/core/src/usecase/MonthlyCostSettlement.ts +++ b/packages/core/src/usecase/MonthlyCostSettlement.ts @@ -1,6 +1,6 @@ import { Region, getRegions } from "../domain/Region"; -import { BOT_INFRASTRUCTURE_COST_USD } from "../domain/ledger_constants"; -import { LedgerCurrency, buildCostDedupeKey, buildSettlementOccurredAt } from "../domain/LedgerTransaction"; +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"; @@ -11,7 +11,7 @@ type MonthlyCostSettlementDependencies = { costProvider: CostProvider; exchangeRateProvider: ExchangeRateProvider; registerCost: RegisterCost; - botInfrastructureCostUsd?: number; + moneyConfig: MoneyConfig; eventLogger?: EventLogger; }; @@ -23,22 +23,22 @@ type MonthlyCostSettlementExecuteParams = { 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 } = this.dependencies; + const { costProvider, exchangeRateProvider, registerCost, moneyConfig } = this.dependencies; const { month, year } = params; - const botInfrastructureCostUsd = this.dependencies.botInfrastructureCostUsd ?? BOT_INFRASTRUCTURE_COST_USD; validateSettlement({ month }); const occurredAt = buildSettlementOccurredAt({ month, year }); const dateRange = { - startDate: new Date(year, month - 1, 1), - endDate: new Date(year, month, 0), + startDate: new Date(Date.UTC(year, month - 1, 1)), + endDate: new Date(Date.UTC(year, month, 0)), }; const fx = await exchangeRateProvider.getBrlPerUsd({ date: occurredAt }); @@ -49,17 +49,18 @@ export class MonthlyCostSettlement { let inserted = 0; let skipped = 0; + let failed = 0; for (const { segment, region } of segments) { try { - const settled = await settleSegment({ segment, region, costProvider, registerCost, fxRate: fx.rate, month, year, occurredAt, dateRange, botInfrastructureCostUsd }); + 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) { - skipped += 1; + 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', @@ -67,7 +68,7 @@ export class MonthlyCostSettlement { } } - return { inserted, skipped }; + return { inserted, skipped, failed }; } } @@ -76,19 +77,23 @@ type SettleSegmentParams = { region: Region | null; costProvider: CostProvider; registerCost: RegisterCost; + moneyConfig: MoneyConfig; fxRate: number; month: number; year: number; occurredAt: Date; dateRange: { startDate: Date; endDate: Date }; - botInfrastructureCostUsd: number; }; async function settleSegment(params: SettleSegmentParams): Promise { - const { segment, region, costProvider, registerCost, fxRate, month, year, occurredAt, dateRange, botInfrastructureCostUsd } = params; + const { segment, region, costProvider, registerCost, moneyConfig, fxRate, month, year, occurredAt, dateRange } = params; + const { fixedMonthlyInfrastructureCost } = moneyConfig; const cost = region ? await costProvider.fetchCost({ region, dateRange }) - : { currency: 'USD', value: botInfrastructureCostUsd }; + : { + currency: fixedMonthlyInfrastructureCost.currency, + value: toMajorUnits({ amountInMinorUnits: fixedMonthlyInfrastructureCost.amountInMinorUnits }), + }; if (cost.value <= 0) { return false; } 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/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(); From 0e678ce2230d784d1cbac5827579e5351236e19a Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:12:57 -0300 Subject: [PATCH 05/11] fix(database): constrain the ledger table and name its cents column The ledger migration is unreleased: it exists only on this unmerged stack and has never run in any environment, so it can still be corrected in place. The balance column held centavos while being named amount_brl, which reads as a BRL amount and invites a unit mistake in every later query. Rename it to amount_brl_cents, require created_at, and constrain the enumerated columns (type, currency_original, fx_source) so a typo cannot reach the ledger. An explicit integer check keeps a fractional value from being stored as a real in the cents column. Cover the down/up round trip, the integer storage and each rejected value. --- .../20261011000000_ledger_transactions.ts | 8 +++-- .../repository/SQLiteLedgerRepository.test.ts | 32 +++++++++++++++++++ .../src/repository/SQLiteLedgerRepository.ts | 8 ++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/migrations/20261011000000_ledger_transactions.ts b/migrations/20261011000000_ledger_transactions.ts index dddfbbd..25735ec 100644 --- a/migrations/20261011000000_ledger_transactions.ts +++ b/migrations/20261011000000_ledger_transactions.ts @@ -8,14 +8,18 @@ export async function up(knex: Knex): Promise { table.text('type').notNullable(); table.float('amount_original').notNullable(); table.text('currency_original').notNullable(); - table.integer('amount_brl').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').defaultTo(knex.fn.now()); + 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'"); }); } diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts index eeb0e9f..02559e1 100644 --- a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts @@ -139,6 +139,38 @@ describe("SQLiteLedgerRepository", () => { await expect(duplicate).rejects.toThrow(); }); + 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 + await expect(attempt).rejects.toThrow(/CHECK constraint failed/); + }); + it("should round-trip the migration down and up", async () => { // Given await sut.insert({ entry: makeEntry() }); diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.ts b/packages/providers/src/repository/SQLiteLedgerRepository.ts index 60e86db..4e3c589 100644 --- a/packages/providers/src/repository/SQLiteLedgerRepository.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.ts @@ -8,7 +8,7 @@ type LedgerRow = { type: LedgerTransaction['type']; amount_original: number; currency_original: LedgerTransaction['currencyOriginal']; - amount_brl: number; + amount_brl_cents: number; fx_rate: number; fx_source: LedgerTransaction['fxSource']; source: string; @@ -37,7 +37,7 @@ export class SQLiteLedgerRepository implements LedgerRepository { async getBalance(): Promise { const row = await this.dependencies.knex('ledger_transactions') - .sum('amount_brl as total') + .sum('amount_brl_cents as total') .first(); return Number((row as { total: number | null } | undefined)?.total ?? 0); } @@ -57,7 +57,7 @@ function toRow(entry: NewLedgerEntry): Omit { type: entry.type, amount_original: entry.amountOriginal, currency_original: entry.currencyOriginal, - amount_brl: entry.amountBrlCents, + amount_brl_cents: entry.amountBrlCents, fx_rate: entry.fxRate, fx_source: entry.fxSource, source: entry.source, @@ -73,7 +73,7 @@ function toDomain(row: LedgerRow): LedgerTransaction { type: row.type, amountOriginal: row.amount_original, currencyOriginal: row.currency_original, - amountBrlCents: row.amount_brl, + amountBrlCents: row.amount_brl_cents, fxRate: row.fx_rate, fxSource: row.fx_source, source: row.source, From f90d63b21d97f51a28e426fd5c4c467fd769ae84 Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:16:58 -0300 Subject: [PATCH 06/11] fix(core): make donation dedupe keys stable and reject duplicates The donation dedupe key embedded the occurrence timestamp, which is generated at call time, so every call produced a distinct key and the unique index could never catch a repeated registration. The use case also never consulted the repository before inserting, so a duplicate surfaced as a raw SQLite error. Drop the timestamp from the key, check it before doing any I/O, and reject an identical donation with a message naming the amount, currency and source so the owner knows to vary the source when a second donation really is intended. The exchange rate is only fetched once the donation is known to be new. The repository now translates the dedupe-key unique violation into a UserError as well, so a double submission racing past the pre-check still produces a clear message instead of a database error. Other constraint violations are rethrown untouched. --- .../core/src/usecase/RegisterDonation.test.ts | 58 +++++++++++++++++++ packages/core/src/usecase/RegisterDonation.ts | 13 +++-- .../repository/SQLiteLedgerRepository.test.ts | 13 +++-- .../src/repository/SQLiteLedgerRepository.ts | 29 ++++++++-- 4 files changed, 101 insertions(+), 12 deletions(-) diff --git a/packages/core/src/usecase/RegisterDonation.test.ts b/packages/core/src/usecase/RegisterDonation.test.ts index c36f723..8234092 100644 --- a/packages/core/src/usecase/RegisterDonation.test.ts +++ b/packages/core/src/usecase/RegisterDonation.test.ts @@ -36,6 +36,7 @@ describe('RegisterDonation', () => { // 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 @@ -61,6 +62,7 @@ describe('RegisterDonation', () => { // 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 }); @@ -83,6 +85,61 @@ describe('RegisterDonation', () => { 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' }, @@ -100,6 +157,7 @@ describe('RegisterDonation', () => { // 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 index 5327545..9feb7c9 100644 --- a/packages/core/src/usecase/RegisterDonation.ts +++ b/packages/core/src/usecase/RegisterDonation.ts @@ -27,6 +27,12 @@ export class RegisterDonation { 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; @@ -42,7 +48,7 @@ export class RegisterDonation { fxSource: currency === 'BRL' ? 'NONE' : 'PTAX', source: normalizedSource, actorDiscordId, - dedupeKey: buildDonationDedupeKey({ occurredAt, actorDiscordId, amount, currency, source: normalizedSource }), + dedupeKey, }, }); } @@ -72,7 +78,6 @@ function validateDonation(params: ValidateDonationParams): void { } type BuildDonationDedupeKeyParams = { - occurredAt: Date; actorDiscordId: string; amount: number; currency: LedgerCurrency; @@ -80,6 +85,6 @@ type BuildDonationDedupeKeyParams = { }; function buildDonationDedupeKey(params: BuildDonationDedupeKeyParams): string { - const { occurredAt, actorDiscordId, amount, currency, source } = params; - return `donation:${occurredAt.toISOString()}:${actorDiscordId}:${amount}:${currency}:${source}`; + const { actorDiscordId, amount, currency, source } = params; + return `donation:${actorDiscordId}:${amount}:${currency}:${source}`; } diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts index 02559e1..d6db28b 100644 --- a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts @@ -3,7 +3,7 @@ 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 } from "@tf2qs/core"; +import { NewLedgerEntry, UserError } from "@tf2qs/core"; import { down, up } from "../../../../migrations/20261011000000_ledger_transactions"; import { SQLiteLedgerRepository } from "./SQLiteLedgerRepository"; @@ -128,7 +128,7 @@ describe("SQLiteLedgerRepository", () => { expect(recent.map((entry) => entry.dedupeKey)).toEqual(["donation:third", "donation:second"]); }); - it("should reject a duplicate dedupe key", async () => { + it("should reject a duplicate dedupe key as a user error", async () => { // Given await sut.insert({ entry: makeEntry() }); @@ -136,7 +136,10 @@ describe("SQLiteLedgerRepository", () => { const duplicate = sut.insert({ entry: makeEntry() }); // Then - await expect(duplicate).rejects.toThrow(); + 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 () => { @@ -168,7 +171,9 @@ describe("SQLiteLedgerRepository", () => { const attempt = sut.insert({ entry }); // Then - await expect(attempt).rejects.toThrow(/CHECK constraint failed/); + 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 round-trip the migration down and up", async () => { diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.ts b/packages/providers/src/repository/SQLiteLedgerRepository.ts index 4e3c589..076364e 100644 --- a/packages/providers/src/repository/SQLiteLedgerRepository.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.ts @@ -1,6 +1,6 @@ import { Knex } from "knex"; import { LedgerTransaction, NewLedgerEntry } from "@tf2qs/core"; -import { LedgerRepository } from "@tf2qs/core"; +import { LedgerRepository, UserError } from "@tf2qs/core"; type LedgerRow = { id: number; @@ -22,9 +22,16 @@ export class SQLiteLedgerRepository implements LedgerRepository { async insert(params: { entry: NewLedgerEntry }): Promise { const { entry } = params; - 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); + 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 { @@ -51,6 +58,20 @@ export class SQLiteLedgerRepository implements LedgerRepository { } } +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(), From f327bc0f01fbb05f4908ae37da33a2bc30954c0a Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:18:50 -0300 Subject: [PATCH 07/11] chore(scripts): remove the ledger opening seed script The opening balance is a one-time decision an owner makes, not a deploy-time step, and exposing it as an npm script meant anyone with shell access and a copy of the database could run it repeatedly. The owner-only opening command added later in this stack replaces it, with the ledger's own emptiness as the one-shot guard. --- packages/scripts/package.json | 1 - packages/scripts/seed_ledger_opening.ts | 49 ------------------------- 2 files changed, 50 deletions(-) delete mode 100644 packages/scripts/seed_ledger_opening.ts diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 3840199..12463bc 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -8,7 +8,6 @@ "scripts": { "download:maps": "tsx ./downloadMaps.ts", "sync:fastdl": "tsx ./syncFastDL.ts", - "seed:ledger": "tsx ./seed_ledger_opening.ts", "gen:openapi": "tsx ./generateOpenApiSpec.ts" }, "dependencies": { diff --git a/packages/scripts/seed_ledger_opening.ts b/packages/scripts/seed_ledger_opening.ts deleted file mode 100644 index 74965c2..0000000 --- a/packages/scripts/seed_ledger_opening.ts +++ /dev/null @@ -1,49 +0,0 @@ -import knex, { Knex } from "knex"; -import dbConfig from "../../knexfile"; - -const OPENING_DEDUPE_KEY = 'opening:seed'; -const OPENING_AMOUNT_BRL_CENTS = 13000; -const OPENING_OCCURRED_AT = '2026-10-06T12:00:00.000Z'; -const OPENING_ACTOR = 'system'; - -type SeedLedgerOpeningParams = { - client?: Knex; -}; - -export async function seedLedgerOpening(params: SeedLedgerOpeningParams): Promise { - const { client } = params; - const db = client ?? knex(dbConfig); - const ownsConnection = !client; - try { - const existing = await db('ledger_transactions').where({ dedupe_key: OPENING_DEDUPE_KEY }).first(); - if (existing) { - console.log('Ledger opening entry already exists. Skipping seed.'); - return; - } - await db('ledger_transactions').insert({ - occurred_at: OPENING_OCCURRED_AT, - type: 'OPENING', - amount_original: 130, - currency_original: 'BRL', - amount_brl: OPENING_AMOUNT_BRL_CENTS, - fx_rate: 1, - fx_source: 'NONE', - source: 'Opening balance from #money history', - actor_discord_id: OPENING_ACTOR, - dedupe_key: OPENING_DEDUPE_KEY, - }); - console.log('Ledger opening entry seeded: +130.00 BRL.'); - } finally { - if (ownsConnection) { - await db.destroy(); - } - } -} - -const invokedDirectly = process.argv[1]?.endsWith('seed_ledger_opening.ts'); -if (invokedDirectly) { - seedLedgerOpening({}).catch((error) => { - console.error('Failed to seed ledger opening entry:', (error as Error).message); - process.exit(1); - }); -} From 9fc2502e877415ec3dbbf0598532deb6034446eb Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:18:50 -0300 Subject: [PATCH 08/11] docs: document the real file naming convention AGENTS.md and the code-style skill both told agents to use snake_case file names, but the codebase names class and type modules in PascalCase (Region.ts, SQLiteLedgerRepository.ts) and function modules in camelCase (maskIp.ts, commandErrorHandler.ts); snake_case appears only in migrations. That written rule is why the ledger stack shipped snake_case files, so it is the root cause rather than a detail. State the convention the repository actually follows. --- .agents/skills/code-style/SKILL.md | 4 ++-- AGENTS.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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`**. From 6aacd8f6d1d69034a56be26db198785f765e5af6 Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:53:16 -0300 Subject: [PATCH 09/11] fix(ledger): parse created_at as UTC and bound the money config amount SQLite writes CURRENT_TIMESTAMP as a UTC string without a zone, and parsing it directly treated it as host-local time, so a created_at timestamp read back three hours off on a non-UTC host. Nothing consumes it yet, which is exactly why it would have gone unnoticed until something did. The money config parser also accepted an amount whose minor-unit result is not a safe integer. Reject it, so a nonsense config value fails loudly at startup instead of producing a corrupted cost. --- packages/core/src/domain/MoneyConfig.test.ts | 4 ++++ packages/core/src/domain/MoneyConfig.ts | 8 +++++++- .../src/repository/SQLiteLedgerRepository.test.ts | 13 +++++++++++++ .../src/repository/SQLiteLedgerRepository.ts | 11 ++++++++++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/core/src/domain/MoneyConfig.test.ts b/packages/core/src/domain/MoneyConfig.test.ts index a9dc4d2..2f900c0 100644 --- a/packages/core/src/domain/MoneyConfig.test.ts +++ b/packages/core/src/domain/MoneyConfig.test.ts @@ -24,6 +24,7 @@ describe('MoneyConfig', () => { { 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 } }; @@ -54,6 +55,9 @@ describe('MoneyConfig', () => { { 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 } }; diff --git a/packages/core/src/domain/MoneyConfig.ts b/packages/core/src/domain/MoneyConfig.ts index 3e15935..85e939a 100644 --- a/packages/core/src/domain/MoneyConfig.ts +++ b/packages/core/src/domain/MoneyConfig.ts @@ -32,13 +32,19 @@ export function parseMoneyConfig(params: ParseMoneyConfigParams): MoneyConfig { 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: Math.round(Number(amount) * 100), + amountInMinorUnits, currency, }, }; diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts index d6db28b..f625468 100644 --- a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts @@ -176,6 +176,19 @@ describe("SQLiteLedgerRepository", () => { 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 await sut.insert({ entry: makeEntry() }); diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.ts b/packages/providers/src/repository/SQLiteLedgerRepository.ts index 076364e..fcce33a 100644 --- a/packages/providers/src/repository/SQLiteLedgerRepository.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.ts @@ -100,6 +100,15 @@ function toDomain(row: LedgerRow): LedgerTransaction { source: row.source, actorDiscordId: row.actor_discord_id, dedupeKey: row.dedupe_key, - createdAt: new Date(row.created_at), + 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`); +} From ff6d384e31e7a81d2537323e7138e1194e12dfae Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 10:35:24 -0300 Subject: [PATCH 10/11] test: raise the suite timeout above what real I/O needs The default five second test timeout is not enough for the suite under parallel load: the HTTP tests, the timer test and the file-backed migration round trip each exceed it intermittently, so CI goes red on tests that pass in isolation. The failures are genuine timeouts caused by machine load rather than hanging logic, and a longer ceiling changes no assertion: a hanging test still fails, just later. --- vite.config.ts | 1 + 1 file changed, 1 insertion(+) 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 From 33c1f1184a5b171c30d3d79d0bb013e896d54762 Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 10:35:24 -0300 Subject: [PATCH 11/11] test(providers): run the ledger repository cases in memory Every case but the migration round trip was creating a temporary database on disk and removing it afterwards, which made this the heaviest source of real filesystem I/O in the suite for no benefit: the cases assert repository behaviour, not persistence. Keep the file-backed database only for the down/up round trip, which is the one case actually about persistence. Same cases, same assertions. --- .../repository/SQLiteLedgerRepository.test.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts index f625468..9bc5ee6 100644 --- a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts @@ -25,16 +25,14 @@ function makeEntry(overrides: Partial = {}): NewLedgerEntry { describe("SQLiteLedgerRepository", () => { let knex: Knex; - let directory: string; let sut: SQLiteLedgerRepository; async function makeSut() { - directory = await mkdtemp(join(tmpdir(), "ledger-test-")); knex = createKnex({ client: "sqlite3", - connection: { filename: join(directory, "ledger.sqlite3") }, + connection: { filename: ":memory:" }, useNullAsDefault: true, - pool: { min: 0, max: 1 }, + pool: { min: 1, max: 1 }, }); await up(knex); sut = new SQLiteLedgerRepository({ knex }); @@ -47,7 +45,6 @@ describe("SQLiteLedgerRepository", () => { afterEach(async () => { await knex.destroy(); - await rm(directory, { recursive: true, force: true }); }); it("should insert and read back an entry with deserialized dates", async () => { @@ -191,20 +188,35 @@ describe("SQLiteLedgerRepository", () => { it("should round-trip the migration down and up", async () => { // Given - await sut.insert({ entry: makeEntry() }); + 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 }, + }); - // When - await down(knex); + try { + await up(fileKnex); + const fileSut = new SQLiteLedgerRepository({ knex: fileKnex }); + await fileSut.insert({ entry: makeEntry() }); - // Then - await expect(knex("ledger_transactions").select("*")).rejects.toThrow(); + // When + await down(fileKnex); - // When - await up(knex); - const stored = await sut.insert({ entry: makeEntry() }); + // Then + await expect(fileKnex("ledger_transactions").select("*")).rejects.toThrow(); - // Then - expect(stored.amountBrlCents).toBe(13000); - expect(await sut.getBalance()).toBe(13000); + // 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 }); + } }); });