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: 4 additions & 0 deletions config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,10 @@
"481598720165675009",
"759144874095280168",
"135608307065946112"
],
"moneyChannelId": "1374803880298418186",
"moneyOwnerDiscordIds": [
"226828637586849795"
]
},
"money": {
Expand Down
1 change: 1 addition & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export * from './src/usecase/RegisterDonation';
export * from './src/usecase/RegisterCost';
export * from './src/usecase/GetBalance';
export * from './src/usecase/MonthlyCostSettlement';
export * from './src/usecase/SetLedgerOpening';

// Utils
export * from './src/utils/ConfigManager';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/domain/DiscordConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export type DiscordConfig = {
streamerChannelId: string;
supportChannelId: string;
ipHistoryAdminDiscordIds: string[];
moneyChannelId: string;
moneyOwnerDiscordIds: string[];
}

export const getDiscordConfig = () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/domain/LedgerTransaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildCostDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl, toMajorUnits } from './LedgerTransaction';
import { buildCostDedupeKey, buildOpeningDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl, toMajorUnits } from './LedgerTransaction';

describe('LedgerTransaction helpers', () => {
it.each([
Expand Down Expand Up @@ -65,6 +65,14 @@ describe('LedgerTransaction helpers', () => {
expect(key).toBe('cost:2026-09:sa-saopaulo-1');
});

it('should build the stable opening dedupe key', () => {
// When
const key = buildOpeningDedupeKey();

// Then
expect(key).toBe('opening:ledger');
});

it('should settle on the last day of the month at noon UTC', () => {
// When
const occurredAt = buildSettlementOccurredAt({ month: 9, year: 2026 });
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/domain/LedgerTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export function buildCostDedupeKey(params: { month: number; year: number; segmen
return `cost:${year}-${String(month).padStart(2, '0')}:${segment}`;
}

export function buildOpeningDedupeKey(): string {
return 'opening:ledger';
}

export function buildSettlementOccurredAt(params: { month: number; year: number }): Date {
const { month, year } = params;
return new Date(Date.UTC(year, month, 0, 12, 0, 0));
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/repository/LedgerRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { LedgerTransaction, NewLedgerEntry } from "../domain/LedgerTransaction";
export interface LedgerRepository {
insert(params: { entry: NewLedgerEntry }): Promise<LedgerTransaction>;
existsByDedupeKey(params: { dedupeKey: string }): Promise<boolean>;
hasTransactions(): Promise<boolean>;
getBalance(): Promise<number>;
listRecent(params: { limit: number }): Promise<LedgerTransaction[]>;
}
12 changes: 6 additions & 6 deletions packages/core/src/usecase/MonthlyCostSettlement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('MonthlyCostSettlement', () => {
const result = await sut.execute({ month: 9, year: 2026 });

// Then
expect(result).toEqual({ inserted: expectedSegments, skipped: 0, failed: 0 });
expect(result).toEqual({ inserted: expectedSegments, skipped: 0, noCost: 0, failed: 0 });
expect(registerCost.execute).toHaveBeenCalledTimes(expectedSegments);
expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({
dedupeKey: 'cost:2026-09:bot-infra',
Expand All @@ -83,10 +83,10 @@ describe('MonthlyCostSettlement', () => {
const result = await sut.execute({ month: 9, year: 2026 });

// Then
expect(result).toEqual({ inserted: 0, skipped: expectedSegments, failed: 0 });
expect(result).toEqual({ inserted: 0, skipped: expectedSegments, noCost: 0, failed: 0 });
});

it('should skip zero-value region costs without registering them', async () => {
it('should count zero-value region costs as no cost instead of already recorded', async () => {
// Given
const { sut, costProvider, exchangeRateProvider, registerCost } = makeSut();
givenExchangeRate(exchangeRateProvider);
Expand All @@ -97,7 +97,7 @@ describe('MonthlyCostSettlement', () => {
const result = await sut.execute({ month: 9, year: 2026 });

// Then
expect(result).toEqual({ inserted: 1, skipped: getRegions().length, failed: 0 });
expect(result).toEqual({ inserted: 1, skipped: 0, noCost: getRegions().length, failed: 0 });
expect(registerCost.execute).toHaveBeenCalledTimes(1);
expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({
dedupeKey: 'cost:2026-09:bot-infra',
Expand Down Expand Up @@ -161,7 +161,7 @@ describe('MonthlyCostSettlement', () => {
const result = await sut.execute({ month: 9, year: 2026 });

// Then
expect(result).toEqual({ inserted: regions.length, skipped: 0, failed: 1 });
expect(result).toEqual({ inserted: regions.length, skipped: 0, noCost: 0, failed: 1 });
expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({
dedupeKey: 'cost:2026-09:bot-infra',
}));
Expand Down Expand Up @@ -193,7 +193,7 @@ describe('MonthlyCostSettlement', () => {
const result = await sut.execute({ month: 9, year: 2026 });

// Then
expect(result).toEqual({ inserted: regions.length, skipped: 0, failed: 1 });
expect(result).toEqual({ inserted: regions.length, skipped: 0, noCost: 0, failed: 1 });
expect(registerCost.execute).toHaveBeenCalledWith(expect.objectContaining({
dedupeKey: 'cost:2026-09:bot-infra',
}));
Expand Down
20 changes: 13 additions & 7 deletions packages/core/src/usecase/MonthlyCostSettlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type MonthlyCostSettlementExecuteParams = {
type MonthlyCostSettlementResult = {
inserted: number;
skipped: number;
noCost: number;
failed: number;
};

Expand All @@ -49,15 +50,18 @@ export class MonthlyCostSettlement {

let inserted = 0;
let skipped = 0;
let noCost = 0;
let failed = 0;

for (const { segment, region } of segments) {
try {
const settled = await settleSegment({ segment, region, costProvider, registerCost, moneyConfig, fxRate: fx.rate, month, year, occurredAt, dateRange });
if (settled) {
const outcome = await settleSegment({ segment, region, costProvider, registerCost, moneyConfig, fxRate: fx.rate, month, year, occurredAt, dateRange });
if (outcome === 'inserted') {
inserted += 1;
} else {
} else if (outcome === 'already-recorded') {
skipped += 1;
} else {
noCost += 1;
}
} catch (error) {
failed += 1;
Expand All @@ -68,7 +72,7 @@ export class MonthlyCostSettlement {
}
}

return { inserted, skipped, failed };
return { inserted, skipped, noCost, failed };
}
}

Expand All @@ -85,7 +89,9 @@ type SettleSegmentParams = {
dateRange: { startDate: Date; endDate: Date };
};

async function settleSegment(params: SettleSegmentParams): Promise<boolean> {
type SettleSegmentOutcome = 'inserted' | 'already-recorded' | 'no-cost';

async function settleSegment(params: SettleSegmentParams): Promise<SettleSegmentOutcome> {
const { segment, region, costProvider, registerCost, moneyConfig, fxRate, month, year, occurredAt, dateRange } = params;
const { fixedMonthlyInfrastructureCost } = moneyConfig;
const cost = region
Expand All @@ -95,7 +101,7 @@ async function settleSegment(params: SettleSegmentParams): Promise<boolean> {
value: toMajorUnits({ amountInMinorUnits: fixedMonthlyInfrastructureCost.amountInMinorUnits }),
};
if (cost.value <= 0) {
return false;
return 'no-cost';
}
const currency = cost.currency as LedgerCurrency;
const settled = await registerCost.execute({
Expand All @@ -107,7 +113,7 @@ async function settleSegment(params: SettleSegmentParams): Promise<boolean> {
fxRate: currency === 'BRL' ? 1 : fxRate,
fxSource: currency === 'BRL' ? 'NONE' : 'PTAX',
});
return settled !== null;
return settled === null ? 'already-recorded' : 'inserted';
}

type ValidateSettlementParams = {
Expand Down
135 changes: 135 additions & 0 deletions packages/core/src/usecase/SetLedgerOpening.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { when } from 'vitest-when';
import { LedgerCurrency, LedgerTransaction } from '../domain/LedgerTransaction';
import { LedgerRepository } from '../repository/LedgerRepository';
import { ExchangeRateProvider } from '../services/ExchangeRateProvider';
import { SetLedgerOpening } from './SetLedgerOpening';

const ACTOR_DISCORD_ID = '226828637586849795';
const OCCURRED_AT = new Date('2026-10-06T12:00:00.000Z');

describe('SetLedgerOpening', () => {
function makeSut() {
const ledgerRepository = mock<LedgerRepository>();
const exchangeRateProvider = mock<ExchangeRateProvider>();
const sut = new SetLedgerOpening({ ledgerRepository, exchangeRateProvider });
return { sut, ledgerRepository, exchangeRateProvider };
}

function makeStoredEntry(overrides: Partial<LedgerTransaction> = {}): LedgerTransaction {
return {
id: 1,
occurredAt: OCCURRED_AT,
type: 'OPENING',
amountOriginal: 130,
currencyOriginal: 'BRL',
amountBrlCents: 13000,
fxRate: 1,
fxSource: 'NONE',
source: 'Opening balance from #money history',
actorDiscordId: ACTOR_DISCORD_ID,
dedupeKey: 'opening:ledger',
createdAt: new Date('2026-10-06T12:00:01.000Z'),
...overrides,
};
}

it('should record a BRL opening balance without calling the FX provider', async () => {
// Given
const { sut, ledgerRepository, exchangeRateProvider } = makeSut();
const stored = makeStoredEntry();
when(ledgerRepository.hasTransactions).calledWith().thenResolve(false);
when(ledgerRepository.insert).calledWith(expect.anything()).thenResolve(stored);

// When
const result = await sut.execute({ amount: 130, currency: 'BRL', actorDiscordId: ACTOR_DISCORD_ID, occurredAt: OCCURRED_AT });

// Then
expect(result).toEqual(stored);
expect(exchangeRateProvider.getBrlPerUsd).not.toHaveBeenCalled();
const inserted = ledgerRepository.insert.mock.calls[0][0].entry;
expect(inserted.type).toBe('OPENING');
expect(inserted.source).toBe('Opening balance from #money history');
expect(inserted.amountBrlCents).toBe(13000);
expect(inserted.fxRate).toBe(1);
expect(inserted.fxSource).toBe('NONE');
expect(inserted.actorDiscordId).toBe(ACTOR_DISCORD_ID);
expect(inserted.dedupeKey).toBe('opening:ledger');
});

it('should record a USD opening balance at the PTAX rate', async () => {
// Given
const { sut, ledgerRepository, exchangeRateProvider } = makeSut();
when(ledgerRepository.hasTransactions).calledWith().thenResolve(false);
when(exchangeRateProvider.getBrlPerUsd)
.calledWith({ date: OCCURRED_AT })
.thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: OCCURRED_AT });
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', actorDiscordId: ACTOR_DISCORD_ID, occurredAt: OCCURRED_AT });

// Then
expect(result.amountBrlCents).toBe(5500);
const inserted = ledgerRepository.insert.mock.calls[0][0].entry;
expect(inserted.currencyOriginal).toBe('USD');
expect(inserted.fxRate).toBe(5.5);
expect(inserted.fxSource).toBe('PTAX');
});

it('should use the same stable dedupe key for every invocation', async () => {
// Given
const { sut, ledgerRepository, exchangeRateProvider } = makeSut();
when(ledgerRepository.hasTransactions).calledWith().thenResolve(false);
when(ledgerRepository.insert).calledWith(expect.anything()).thenResolve(makeStoredEntry());
when(exchangeRateProvider.getBrlPerUsd)
.calledWith({ date: new Date('2026-12-24T23:15:00.000Z') })
.thenResolve({ rate: 5.5, source: 'PTAX', quotedAt: new Date('2026-12-24T23:15:00.000Z') });

// When
await sut.execute({ amount: 130, currency: 'BRL', actorDiscordId: ACTOR_DISCORD_ID, occurredAt: OCCURRED_AT });
await sut.execute({ amount: 250, currency: 'USD', actorDiscordId: '999999999999999999', 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('opening:ledger');
expect(second.dedupeKey).toBe(first.dedupeKey);
});

it('should refuse to set the opening balance when the ledger already holds transactions', async () => {
// Given
const { sut, ledgerRepository, exchangeRateProvider } = makeSut();
when(ledgerRepository.hasTransactions).calledWith().thenResolve(true);

// When
const attempt = sut.execute({ amount: 130, currency: 'USD', actorDiscordId: ACTOR_DISCORD_ID, occurredAt: OCCURRED_AT });

// Then
await expect(attempt).rejects.toThrow('The ledger already holds transactions; the opening balance can only be set once.');
expect(ledgerRepository.insert).not.toHaveBeenCalled();
expect(exchangeRateProvider.getBrlPerUsd).not.toHaveBeenCalled();
});

it.each([
{ amount: 0, currency: 'BRL' as LedgerCurrency, actorDiscordId: ACTOR_DISCORD_ID },
{ amount: -5, currency: 'BRL' as LedgerCurrency, actorDiscordId: ACTOR_DISCORD_ID },
{ amount: Number.NaN, currency: 'BRL' as LedgerCurrency, actorDiscordId: ACTOR_DISCORD_ID },
{ amount: 130, currency: 'EUR' as unknown as LedgerCurrency, actorDiscordId: ACTOR_DISCORD_ID },
{ amount: 130, currency: 'BRL' as LedgerCurrency, actorDiscordId: '' },
{ amount: 130, currency: 'BRL' as LedgerCurrency, actorDiscordId: ' ' },
])('should reject an invalid opening balance $amount $currency $actorDiscordId', async ({ amount, currency, actorDiscordId }) => {
// Given
const { sut, ledgerRepository, exchangeRateProvider } = makeSut();

// When
const attempt = sut.execute({ amount, currency, actorDiscordId, occurredAt: OCCURRED_AT });

// Then
await expect(attempt).rejects.toThrow();
expect(ledgerRepository.hasTransactions).not.toHaveBeenCalled();
expect(ledgerRepository.insert).not.toHaveBeenCalled();
expect(exchangeRateProvider.getBrlPerUsd).not.toHaveBeenCalled();
});
});
71 changes: 71 additions & 0 deletions packages/core/src/usecase/SetLedgerOpening.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { LedgerCurrency, LedgerTransaction, buildOpeningDedupeKey, toBrlCents } from "../domain/LedgerTransaction";
import { LedgerRepository } from "../repository/LedgerRepository";
import { ExchangeRateProvider } from "../services/ExchangeRateProvider";
import { UserError } from "../errors/UserError";

type SetLedgerOpeningDependencies = {
ledgerRepository: LedgerRepository;
exchangeRateProvider: ExchangeRateProvider;
};

type SetLedgerOpeningExecuteParams = {
amount: number;
currency: LedgerCurrency;
actorDiscordId: string;
occurredAt?: Date;
};

export class SetLedgerOpening {
constructor(private readonly dependencies: SetLedgerOpeningDependencies) {}

async execute(params: SetLedgerOpeningExecuteParams): Promise<LedgerTransaction> {
const { ledgerRepository, exchangeRateProvider } = this.dependencies;
const { amount, currency, actorDiscordId } = params;
const occurredAt = params.occurredAt ?? new Date();

validateOpening({ amount, currency, actorDiscordId });

const alreadyOpened = await ledgerRepository.hasTransactions();
if (alreadyOpened) {
throw new UserError('The ledger already holds transactions; the opening balance can only be set once.');
}

const fxRate = currency === 'BRL'
? 1
: (await exchangeRateProvider.getBrlPerUsd({ date: occurredAt })).rate;

return ledgerRepository.insert({
entry: {
occurredAt,
type: 'OPENING',
amountOriginal: amount,
currencyOriginal: currency,
amountBrlCents: toBrlCents({ amount, fxRate }),
fxRate,
fxSource: currency === 'BRL' ? 'NONE' : 'PTAX',
source: 'Opening balance from #money history',
actorDiscordId,
dedupeKey: buildOpeningDedupeKey(),
},
});
}
}

type ValidateOpeningParams = {
amount: number;
currency: LedgerCurrency;
actorDiscordId: string;
};

function validateOpening(params: ValidateOpeningParams): void {
const { amount, currency, actorDiscordId } = params;
if (!Number.isFinite(amount) || amount <= 0) {
throw new UserError('Opening balance amount must be greater than zero.');
}
if (currency !== 'BRL' && currency !== 'USD') {
throw new UserError('Opening balance currency must be BRL or USD.');
}
if (actorDiscordId.trim().length === 0) {
throw new UserError('Opening balance actor is required.');
}
}
Loading
Loading