Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/code-style/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`**.
Expand Down
6 changes: 6 additions & 0 deletions config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,12 @@
"135608307065946112"
]
},
"money": {
"fixedMonthlyInfrastructureCost": {
"amount": "5.05",
"currency": "USD"
}
},
"github": {
"repository": "sonikro/TF2-QuickServer",
"workflowFileName": "check-tf2-update.yaml",
Expand Down
29 changes: 29 additions & 0 deletions migrations/20261011000000_ledger_transactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Knex } from "knex";


export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('ledger_transactions', (table) => {
table.increments('id').primary();
table.text('occurred_at').notNullable();
table.text('type').notNullable();
table.float('amount_original').notNullable();
table.text('currency_original').notNullable();
table.integer('amount_brl_cents').notNullable();
table.float('fx_rate').notNullable();
table.text('fx_source').notNullable();
table.text('source').notNullable();
table.text('actor_discord_id').notNullable();
table.text('dedupe_key').notNullable();
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.unique('dedupe_key', { indexName: 'uq_ledger_dedupe' });
table.check("type IN ('OPENING', 'COST', 'DONATION', 'ADJUSTMENT')");
table.check("currency_original IN ('BRL', 'USD')");
table.check("fx_source IN ('PTAX', 'NONE')");
table.check("typeof(amount_brl_cents) = 'integer'");
});
}


export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('ledger_transactions');
}
6 changes: 6 additions & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './src/repository/UserBanRepository';
export * from './src/repository/ServerStatusMetricsRepository';
export * from './src/repository/PlayerConnectionHistoryRepository';
export * from './src/repository/ScheduledServerRepository';
export * from './src/repository/LedgerRepository';

// Services
export * from './src/services/BackgroundTaskQueue';
Expand All @@ -36,6 +37,7 @@ export * from './src/services/SteamUpdateChecker';
export * from './src/services/Tf2VersionReader';
export * from './src/services/WorkflowDispatcher';
export * from './src/services/StatusUpdater';
export * from './src/services/ExchangeRateProvider';
export * from './src/services/TF2ServerReadinessService';

// Use cases
Expand All @@ -59,6 +61,10 @@ export * from './src/usecase/SetUserData';
export * from './src/usecase/TerminateEmptyServers';
export * from './src/usecase/TerminateLongRunningServers';
export * from './src/usecase/TerminatePendingServers';
export * from './src/usecase/RegisterDonation';
export * from './src/usecase/RegisterCost';
export * from './src/usecase/GetBalance';
export * from './src/usecase/MonthlyCostSettlement';

// Utils
export * from './src/utils/ConfigManager';
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/domain/LedgerTransaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { buildCostDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl, toMajorUnits } from './LedgerTransaction';

describe('LedgerTransaction helpers', () => {
it.each([
{ amount: 10, fxRate: 5.4321, expected: 5432 },
{ amount: 10, fxRate: 5.5, expected: 5500 },
{ amount: 130, fxRate: 1, expected: 13000 },
{ amount: 0.335, fxRate: 1, expected: 34 },
{ amount: 3, fxRate: 1.234, expected: 370 },
])('should convert $amount at rate $fxRate to $expected cents', ({ amount, fxRate, expected }) => {
// Given
const params = { amount, fxRate };

// When
const cents = toBrlCents(params);

// Then
expect(cents).toBe(expected);
});

it('should derive balance reais from cents', () => {
// Given
const params = { balanceBrlCents: 18500 };

// When
const balance = toBalanceBrl(params);

// Then
expect(balance).toBe(185);
});

it.each([
{ amountInMinorUnits: 505, expected: 5.05 },
{ amountInMinorUnits: 999, expected: 9.99 },
{ amountInMinorUnits: 1, expected: 0.01 },
{ amountInMinorUnits: 0, expected: 0 },
{ amountInMinorUnits: -250, expected: -2.5 },
])('should convert $amountInMinorUnits minor units to $expected major units', ({ amountInMinorUnits, expected }) => {
// When
const majorUnits = toMajorUnits({ amountInMinorUnits });

// Then
expect(majorUnits).toBe(expected);
});

it.each([
{ balanceBrlCents: 13000, expected: '130.00' },
{ balanceBrlCents: 18500, expected: '185.00' },
{ balanceBrlCents: -250, expected: '-2.50' },
{ balanceBrlCents: 0, expected: '0.00' },
])('should display $balanceBrlCents cents as $expected BRL', ({ balanceBrlCents, expected }) => {
// When
const display = toDisplayBrl({ balanceBrlCents });

// Then
expect(display).toBe(expected);
});

it('should build zero-padded monthly cost dedupe keys', () => {
// When
const key = buildCostDedupeKey({ month: 9, year: 2026, segment: 'sa-saopaulo-1' });

// Then
expect(key).toBe('cost:2026-09:sa-saopaulo-1');
});

it('should settle on the last day of the month at noon UTC', () => {
// When
const occurredAt = buildSettlementOccurredAt({ month: 9, year: 2026 });

// Then
expect(occurredAt.toISOString()).toBe('2026-09-30T12:00:00.000Z');
});
});
63 changes: 63 additions & 0 deletions packages/core/src/domain/LedgerTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export type LedgerType = 'OPENING' | 'COST' | 'DONATION' | 'ADJUSTMENT';

export type LedgerCurrency = 'BRL' | 'USD';

export type LedgerFxSource = 'PTAX' | 'NONE';

export type LedgerTransaction = {
id: number;
occurredAt: Date;
type: LedgerType;
amountOriginal: number;
currencyOriginal: LedgerCurrency;
amountBrlCents: number;
fxRate: number;
fxSource: LedgerFxSource;
source: string;
actorDiscordId: string;
dedupeKey: string;
createdAt: Date;
};

export type NewLedgerEntry = {
occurredAt: Date;
type: LedgerType;
amountOriginal: number;
currencyOriginal: LedgerCurrency;
amountBrlCents: number;
fxRate: number;
fxSource: LedgerFxSource;
source: string;
actorDiscordId: string;
dedupeKey: string;
};

export function toBrlCents(params: { amount: number; fxRate: number }): number {
const { amount, fxRate } = params;
return Math.round(amount * fxRate * 100);
}

export function toMajorUnits(params: { amountInMinorUnits: number }): number {
const { amountInMinorUnits } = params;
return amountInMinorUnits / 100;
}

export function toBalanceBrl(params: { balanceBrlCents: number }): number {
const { balanceBrlCents } = params;
return toMajorUnits({ amountInMinorUnits: balanceBrlCents });
}

export function toDisplayBrl(params: { balanceBrlCents: number }): string {
const { balanceBrlCents } = params;
return (balanceBrlCents / 100).toFixed(2);
}

export function buildCostDedupeKey(params: { month: number; year: number; segment: string }): string {
const { month, year, segment } = params;
return `cost:${year}-${String(month).padStart(2, '0')}:${segment}`;
}

export function buildSettlementOccurredAt(params: { month: number; year: number }): Date {
const { month, year } = params;
return new Date(Date.UTC(year, month, 0, 12, 0, 0));
}
83 changes: 83 additions & 0 deletions packages/core/src/domain/MoneyConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { getMoneyConfig, parseMoneyConfig } from './MoneyConfig';

describe('MoneyConfig', () => {
describe('getMoneyConfig', () => {
it('should read the fixed monthly infrastructure cost from the application config', () => {
// When
const moneyConfig = getMoneyConfig();

// Then
expect(moneyConfig).toEqual({
fixedMonthlyInfrastructureCost: { amountInMinorUnits: 505, currency: 'USD' },
});
expect(Number.isInteger(moneyConfig.fixedMonthlyInfrastructureCost.amountInMinorUnits)).toBe(true);
});
});

describe('parseMoneyConfig', () => {
it.each([
{ amount: '5.05', currency: 'USD', expected: 505 },
{ amount: '5', currency: 'USD', expected: 500 },
{ amount: '9.99', currency: 'USD', expected: 999 },
{ amount: '1234.5', currency: 'USD', expected: 123450 },
{ amount: '0.01', currency: 'USD', expected: 1 },
{ amount: '0', currency: 'USD', expected: 0 },
{ amount: '5.05', currency: 'BRL', expected: 505 },
{ amount: '90071992547409.91', currency: 'USD', expected: 9007199254740991 },
])('should parse amount $amount in $currency into $expected minor units', ({ amount, currency, expected }) => {
// Given
const source = { fixedMonthlyInfrastructureCost: { amount, currency } };

// When
const moneyConfig = parseMoneyConfig({ source });

// Then
expect(moneyConfig).toEqual({
fixedMonthlyInfrastructureCost: { amountInMinorUnits: expected, currency },
});
});

it.each([
{ amount: '5.055', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: 'abc', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: '-1', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: '', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: '5.', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: '.5', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: '1,50', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: ' ', currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: 505, currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: undefined, currency: 'USD', expectedMessage: 'expected a non-negative decimal string with at most two fraction digits' },
{ amount: '5.05', currency: 'EUR', expectedMessage: 'expected "BRL" or "USD"' },
{ amount: '5.05', currency: 'brl', expectedMessage: 'expected "BRL" or "USD"' },
{ amount: '5.05', currency: '', expectedMessage: 'expected "BRL" or "USD"' },
{ amount: '5.05', currency: undefined, expectedMessage: 'expected "BRL" or "USD"' },
{ amount: '5.05', currency: null, expectedMessage: 'expected "BRL" or "USD"' },
{ amount: '5.05', currency: 42, expectedMessage: 'expected "BRL" or "USD"' },
{ amount: '9007199254740992', currency: 'USD', expectedMessage: 'expected minor units within the safe integer range' },
{ amount: '99999999999999999999.99', currency: 'USD', expectedMessage: 'expected minor units within the safe integer range' },
{ amount: '99999999999999999999999999999999999999999', currency: 'USD', expectedMessage: 'expected minor units within the safe integer range' },
])('should reject the invalid money config amount $amount and currency $currency', ({ amount, currency, expectedMessage }) => {
// Given
const source = { fixedMonthlyInfrastructureCost: { amount, currency } };

// When
const attempt = () => parseMoneyConfig({ source });

// Then
expect(attempt).toThrow(expectedMessage);
});

it.each([
{ source: undefined },
{ source: {} },
])('should reject a missing money block', ({ source }) => {
// When
const attempt = () => parseMoneyConfig({ source });

// Then
expect(attempt).toThrow('The "money.fixedMonthlyInfrastructureCost" configuration block is required.');
});
});
});
59 changes: 59 additions & 0 deletions packages/core/src/domain/MoneyConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import config from "config";
import { LedgerCurrency } from "./LedgerTransaction";

export type MoneyConfig = {
fixedMonthlyInfrastructureCost: {
amountInMinorUnits: number;
currency: LedgerCurrency;
};
};

type MoneyConfigSource = {
fixedMonthlyInfrastructureCost?: {
amount?: unknown;
currency?: unknown;
};
};

const AMOUNT_PATTERN = /^\d+(\.\d{1,2})?$/;

type ParseMoneyConfigParams = {
source: MoneyConfigSource | undefined;
};

export function parseMoneyConfig(params: ParseMoneyConfigParams): MoneyConfig {
const { source } = params;
const fixedMonthlyInfrastructureCost = source?.fixedMonthlyInfrastructureCost;
if (!fixedMonthlyInfrastructureCost) {
throw new Error('The "money.fixedMonthlyInfrastructureCost" configuration block is required.');
}

const { amount, currency } = fixedMonthlyInfrastructureCost;
if (typeof amount !== "string" || !AMOUNT_PATTERN.test(amount)) {
throw new Error(`Invalid fixed monthly infrastructure cost amount ${JSON.stringify(amount)}: expected a non-negative decimal string with at most two fraction digits.`);
}

const amountInMinorUnits = Math.round(Number(amount) * 100);
if (!Number.isSafeInteger(amountInMinorUnits)) {
throw new Error(`Invalid fixed monthly infrastructure cost amount ${JSON.stringify(amount)}: expected minor units within the safe integer range.`);
}

if (!isLedgerCurrency(currency)) {
throw new Error(`Invalid fixed monthly infrastructure cost currency ${JSON.stringify(currency)}: expected "BRL" or "USD".`);
}

return {
fixedMonthlyInfrastructureCost: {
amountInMinorUnits,
currency,
},
};
}

function isLedgerCurrency(value: unknown): value is LedgerCurrency {
return value === "BRL" || value === "USD";
}

export function getMoneyConfig(): MoneyConfig {
return parseMoneyConfig({ source: config.has("money") ? config.get<MoneyConfigSource>("money") : undefined });
}
2 changes: 2 additions & 0 deletions packages/core/src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export * from "./MonthlyUsageReport";
export * from "./User";
export * from "./GuildParameters";
export * from "./Cost";
export * from "./MoneyConfig";
export * from "./LedgerTransaction";
export * from "./DateRange";
export * from "./ServerActivity";
export * from "./PlayerConnectionHistory";
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/repository/LedgerRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { LedgerTransaction, NewLedgerEntry } from "../domain/LedgerTransaction";

export interface LedgerRepository {
insert(params: { entry: NewLedgerEntry }): Promise<LedgerTransaction>;
existsByDedupeKey(params: { dedupeKey: string }): Promise<boolean>;
getBalance(): Promise<number>;
listRecent(params: { limit: number }): Promise<LedgerTransaction[]>;
}
Loading
Loading