diff --git a/config/default.json b/config/default.json index 2029715..03e711b 100644 --- a/config/default.json +++ b/config/default.json @@ -498,6 +498,10 @@ "481598720165675009", "759144874095280168", "135608307065946112" + ], + "moneyChannelId": "1374803880298418186", + "moneyOwnerDiscordIds": [ + "226828637586849795" ] }, "money": { diff --git a/packages/core/index.ts b/packages/core/index.ts index 5332d0b..6dc7646 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -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'; diff --git a/packages/core/src/domain/DiscordConfig.ts b/packages/core/src/domain/DiscordConfig.ts index 05bdf51..e1aa6f8 100644 --- a/packages/core/src/domain/DiscordConfig.ts +++ b/packages/core/src/domain/DiscordConfig.ts @@ -6,6 +6,8 @@ export type DiscordConfig = { streamerChannelId: string; supportChannelId: string; ipHistoryAdminDiscordIds: string[]; + moneyChannelId: string; + moneyOwnerDiscordIds: string[]; } export const getDiscordConfig = () => { diff --git a/packages/core/src/domain/LedgerTransaction.test.ts b/packages/core/src/domain/LedgerTransaction.test.ts index 015ceac..3dceea4 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, toMajorUnits } from './LedgerTransaction'; +import { buildCostDedupeKey, buildOpeningDedupeKey, buildSettlementOccurredAt, toBalanceBrl, toBrlCents, toDisplayBrl, toMajorUnits } from './LedgerTransaction'; describe('LedgerTransaction helpers', () => { it.each([ @@ -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 }); diff --git a/packages/core/src/domain/LedgerTransaction.ts b/packages/core/src/domain/LedgerTransaction.ts index 9d0ee2c..b49f0c7 100644 --- a/packages/core/src/domain/LedgerTransaction.ts +++ b/packages/core/src/domain/LedgerTransaction.ts @@ -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)); diff --git a/packages/core/src/repository/LedgerRepository.ts b/packages/core/src/repository/LedgerRepository.ts index 4e66282..dd58792 100644 --- a/packages/core/src/repository/LedgerRepository.ts +++ b/packages/core/src/repository/LedgerRepository.ts @@ -3,6 +3,7 @@ import { LedgerTransaction, NewLedgerEntry } from "../domain/LedgerTransaction"; export interface LedgerRepository { insert(params: { entry: NewLedgerEntry }): Promise; existsByDedupeKey(params: { dedupeKey: string }): Promise; + hasTransactions(): Promise; getBalance(): Promise; listRecent(params: { limit: number }): Promise; } diff --git a/packages/core/src/usecase/MonthlyCostSettlement.test.ts b/packages/core/src/usecase/MonthlyCostSettlement.test.ts index 9856753..b114750 100644 --- a/packages/core/src/usecase/MonthlyCostSettlement.test.ts +++ b/packages/core/src/usecase/MonthlyCostSettlement.test.ts @@ -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', @@ -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); @@ -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', @@ -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', })); @@ -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', })); diff --git a/packages/core/src/usecase/MonthlyCostSettlement.ts b/packages/core/src/usecase/MonthlyCostSettlement.ts index 10a39ad..839cba5 100644 --- a/packages/core/src/usecase/MonthlyCostSettlement.ts +++ b/packages/core/src/usecase/MonthlyCostSettlement.ts @@ -23,6 +23,7 @@ type MonthlyCostSettlementExecuteParams = { type MonthlyCostSettlementResult = { inserted: number; skipped: number; + noCost: number; failed: number; }; @@ -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; @@ -68,7 +72,7 @@ export class MonthlyCostSettlement { } } - return { inserted, skipped, failed }; + return { inserted, skipped, noCost, failed }; } } @@ -85,7 +89,9 @@ type SettleSegmentParams = { dateRange: { startDate: Date; endDate: Date }; }; -async function settleSegment(params: SettleSegmentParams): Promise { +type SettleSegmentOutcome = 'inserted' | 'already-recorded' | 'no-cost'; + +async function settleSegment(params: SettleSegmentParams): Promise { const { segment, region, costProvider, registerCost, moneyConfig, fxRate, month, year, occurredAt, dateRange } = params; const { fixedMonthlyInfrastructureCost } = moneyConfig; const cost = region @@ -95,7 +101,7 @@ async function settleSegment(params: SettleSegmentParams): Promise { 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({ @@ -107,7 +113,7 @@ async function settleSegment(params: SettleSegmentParams): Promise { fxRate: currency === 'BRL' ? 1 : fxRate, fxSource: currency === 'BRL' ? 'NONE' : 'PTAX', }); - return settled !== null; + return settled === null ? 'already-recorded' : 'inserted'; } type ValidateSettlementParams = { diff --git a/packages/core/src/usecase/SetLedgerOpening.test.ts b/packages/core/src/usecase/SetLedgerOpening.test.ts new file mode 100644 index 0000000..b0ef1af --- /dev/null +++ b/packages/core/src/usecase/SetLedgerOpening.test.ts @@ -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(); + const exchangeRateProvider = mock(); + const sut = new SetLedgerOpening({ ledgerRepository, exchangeRateProvider }); + return { sut, ledgerRepository, exchangeRateProvider }; + } + + function makeStoredEntry(overrides: Partial = {}): 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(); + }); +}); diff --git a/packages/core/src/usecase/SetLedgerOpening.ts b/packages/core/src/usecase/SetLedgerOpening.ts new file mode 100644 index 0000000..07f9906 --- /dev/null +++ b/packages/core/src/usecase/SetLedgerOpening.ts @@ -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 { + 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.'); + } +} diff --git a/packages/entrypoints/src/commands/Balance/definition.ts b/packages/entrypoints/src/commands/Balance/definition.ts new file mode 100644 index 0000000..3ba68c0 --- /dev/null +++ b/packages/entrypoints/src/commands/Balance/definition.ts @@ -0,0 +1,6 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; + +export const balanceCommandDefinition = new SlashCommandBuilder() + .setName('balance') + .setDescription('Show the current money ledger balance (owner only)') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator); diff --git a/packages/entrypoints/src/commands/Balance/handler.test.ts b/packages/entrypoints/src/commands/Balance/handler.test.ts new file mode 100644 index 0000000..c0d2776 --- /dev/null +++ b/packages/entrypoints/src/commands/Balance/handler.test.ts @@ -0,0 +1,117 @@ +import { ChatInputCommandInteraction, MessageFlags, PermissionsBitField } from "discord.js"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; +import { GetBalance } from "@tf2qs/core"; +import { createBalanceCommandHandlerFactory } from "./handler"; +import Chance from "chance"; + +const chance = new Chance(); + +describe("Balance Command Handler", () => { + const makeSut = (allowedUserIds: string[] = ['owner-id']) => { + const getBalance = mock(); + const handler = createBalanceCommandHandlerFactory({ getBalance, allowedUserIds }); + return { handler, getBalance }; + }; + + const createAuthorizedInteraction = () => { + const interaction = mock(); + interaction.guildId = chance.guid(); + interaction.user = { id: 'owner-id' } as any; + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(true); + interaction.memberPermissions = memberPermissions as any; + return { interaction, memberPermissions }; + }; + + it("should deny the command when invoked in DMs (no guildId)", async () => { + // Given + const { handler, getBalance } = makeSut(); + const interaction = mock(); + interaction.guildId = null as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('only available within a Discord server'), + flags: MessageFlags.Ephemeral + }); + expect(getBalance.execute).not.toHaveBeenCalled(); + }); + + it("should deny the command when user lacks Administrator permission", async () => { + // Given + const { handler, getBalance } = makeSut(); + const interaction = mock(); + interaction.guildId = chance.guid(); + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(false); + interaction.memberPermissions = memberPermissions as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('Administrator'), + flags: MessageFlags.Ephemeral + }); + expect(getBalance.execute).not.toHaveBeenCalled(); + }); + + it("should deny the command when user is not in the allowlist", async () => { + // Given + const { handler, getBalance } = makeSut(['owner-id']); + const interaction = mock(); + interaction.guildId = chance.guid(); + interaction.user = { id: 'other-user' } as any; + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(true); + interaction.memberPermissions = memberPermissions as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('not authorized'), + flags: MessageFlags.Ephemeral + }); + expect(getBalance.execute).not.toHaveBeenCalled(); + }); + + it("should reply with the current balance", async () => { + // Given + const { handler, getBalance } = makeSut(); + const { interaction } = createAuthorizedInteraction(); + getBalance.execute.mockResolvedValue({ balanceBrlCents: 18500 }); + + // When + await handler(interaction); + + // Then + expect(interaction.deferReply).toHaveBeenCalledWith({ flags: MessageFlags.Ephemeral }); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('R$ 185.00'), + flags: MessageFlags.Ephemeral + }); + }); + + it("should reply with the generic error message on unexpected error", async () => { + // Given + const { handler, getBalance } = makeSut(); + const { interaction } = createAuthorizedInteraction(); + getBalance.execute.mockRejectedValue(new Error('boom')); + + // When + await handler(interaction); + + // Then + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('unexpected error running the command'), + flags: MessageFlags.Ephemeral + }); + }); +}); diff --git a/packages/entrypoints/src/commands/Balance/handler.ts b/packages/entrypoints/src/commands/Balance/handler.ts new file mode 100644 index 0000000..807ce9a --- /dev/null +++ b/packages/entrypoints/src/commands/Balance/handler.ts @@ -0,0 +1,32 @@ +import { ChatInputCommandInteraction, MessageFlags } from "discord.js"; +import { GetBalance, toDisplayBrl } from "@tf2qs/core"; +import { commandErrorHandler } from "../commandErrorHandler"; +import { requireMoneyOwner } from "../requireMoneyOwner"; + +type BalanceCommandHandlerFactoryDependencies = { + getBalance: GetBalance; + allowedUserIds: string[]; +}; + +export function createBalanceCommandHandlerFactory(dependencies: BalanceCommandHandlerFactoryDependencies) { + return async function balanceCommandHandler(interaction: ChatInputCommandInteraction) { + const { getBalance, allowedUserIds } = dependencies; + + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action: 'check the balance' }); + if (!authorized) { + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + const { balanceBrlCents } = await getBalance.execute(); + await interaction.followUp({ + content: `💰 Current balance: R$ ${toDisplayBrl({ balanceBrlCents })}.`, + flags: MessageFlags.Ephemeral + }); + } catch (error: Error | any) { + await commandErrorHandler(interaction, error); + } + }; +} diff --git a/packages/entrypoints/src/commands/Balance/index.ts b/packages/entrypoints/src/commands/Balance/index.ts new file mode 100644 index 0000000..56b8ab0 --- /dev/null +++ b/packages/entrypoints/src/commands/Balance/index.ts @@ -0,0 +1,2 @@ +export * from "./definition"; +export * from "./handler"; diff --git a/packages/entrypoints/src/commands/RegisterDonation/definition.test.ts b/packages/entrypoints/src/commands/RegisterDonation/definition.test.ts new file mode 100644 index 0000000..270d074 --- /dev/null +++ b/packages/entrypoints/src/commands/RegisterDonation/definition.test.ts @@ -0,0 +1,35 @@ +import { ApplicationCommandOptionType } from "discord.js"; +import { describe, expect, it } from "vitest"; +import { registerDonationCommandDefinition } from "./definition"; + +describe("registerDonationCommandDefinition", () => { + const amountOption = () => registerDonationCommandDefinition.toJSON().options?.find((option) => option.name === "amount"); + + it("should require a positive amount above the zero the use case rejects", () => { + // When + const option = amountOption(); + + // Then + expect(option).toMatchObject({ + type: ApplicationCommandOptionType.Number, + name: "amount", + required: true, + min_value: 0.01, + }); + }); + + it("should offer only the supported currencies", () => { + // When + const option = registerDonationCommandDefinition.toJSON().options?.find((option) => option.name === "currency"); + + // Then + expect(option).toMatchObject({ + type: ApplicationCommandOptionType.String, + required: true, + choices: [ + { name: "BRL", value: "BRL" }, + { name: "USD", value: "USD" }, + ], + }); + }); +}); diff --git a/packages/entrypoints/src/commands/RegisterDonation/definition.ts b/packages/entrypoints/src/commands/RegisterDonation/definition.ts new file mode 100644 index 0000000..8ba843f --- /dev/null +++ b/packages/entrypoints/src/commands/RegisterDonation/definition.ts @@ -0,0 +1,25 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; + +export const registerDonationCommandDefinition = new SlashCommandBuilder() + .setName('register-donation') + .setDescription('Register a manual donation in the money ledger (owner only)') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addNumberOption(option => + option.setName('amount') + .setDescription('Donation amount, must be greater than zero') + .setRequired(true) + .setMinValue(0.01)) + .addStringOption(option => + option.setName('currency') + .setDescription('Donation currency') + .setRequired(true) + .addChoices( + { name: 'BRL', value: 'BRL' }, + { name: 'USD', value: 'USD' }, + )) + .addStringOption(option => + option.setName('source') + .setDescription('Donation source, 3-120 characters') + .setRequired(true) + .setMinLength(3) + .setMaxLength(120)); diff --git a/packages/entrypoints/src/commands/RegisterDonation/handler.test.ts b/packages/entrypoints/src/commands/RegisterDonation/handler.test.ts new file mode 100644 index 0000000..bf04f19 --- /dev/null +++ b/packages/entrypoints/src/commands/RegisterDonation/handler.test.ts @@ -0,0 +1,252 @@ +import { ChannelType, ChatInputCommandInteraction, Client, MessageFlags, PermissionsBitField, TextChannel } from "discord.js";import { describe, expect, it } from "vitest"; +import { mock, mockDeep } from "vitest-mock-extended"; +import { when } from "vitest-when"; +import { GetBalance, LedgerTransaction, RegisterDonation } from "@tf2qs/core"; +import { createRegisterDonationCommandHandlerFactory } from "./handler"; +import Chance from "chance"; + +const chance = new Chance(); + +describe("RegisterDonation Command Handler", () => { + const makeSut = (allowedUserIds: string[] = ['owner-id']) => { + const registerDonation = mock(); + const getBalance = mock(); + const discordClient = mockDeep(); + const moneyChannel = mockDeep(); + moneyChannel.type = ChannelType.GuildText; + const handler = createRegisterDonationCommandHandlerFactory({ + registerDonation, + getBalance, + allowedUserIds, + moneyChannelId: 'money-channel-id', + discordClient, + }); + return { handler, registerDonation, getBalance, discordClient, moneyChannel }; + }; + + const createAuthorizedInteraction = (overrides: { amount?: number; currency?: string; source?: string } = {}) => { + const interaction = mock(); + interaction.guildId = chance.guid(); + interaction.user = { id: 'owner-id' } as any; + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(true); + interaction.memberPermissions = memberPermissions as any; + interaction.options = mock(); + when(interaction.options.getNumber).calledWith('amount', true).thenReturn(overrides.amount ?? 130); + when(interaction.options.getString).calledWith('currency', true).thenReturn(overrides.currency ?? 'BRL'); + when(interaction.options.getString).calledWith('source', true).thenReturn(overrides.source ?? 'Monthly support'); + return { interaction, memberPermissions }; + }; + + const makeEntry = (overrides: Partial = {}): LedgerTransaction => ({ + 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: 'owner-id', + dedupeKey: 'donation:seed', + createdAt: new Date('2026-10-06T12:00:01.000Z'), + ...overrides, + }); + + it("should deny the command when invoked in DMs (no guildId)", async () => { + // Given + const { handler, registerDonation } = makeSut(); + const interaction = mock(); + interaction.guildId = null as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('only available within a Discord server'), + flags: MessageFlags.Ephemeral + }); + expect(interaction.deferReply).not.toHaveBeenCalled(); + expect(registerDonation.execute).not.toHaveBeenCalled(); + }); + + it("should deny the command when user lacks Administrator permission", async () => { + // Given + const { handler, registerDonation } = makeSut(); + const interaction = mock(); + interaction.guildId = chance.guid(); + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(false); + interaction.memberPermissions = memberPermissions as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('Administrator'), + flags: MessageFlags.Ephemeral + }); + expect(registerDonation.execute).not.toHaveBeenCalled(); + }); + + it("should deny the command when memberPermissions is null", async () => { + // Given + const { handler, registerDonation } = makeSut(); + const interaction = mock(); + interaction.guildId = chance.guid(); + interaction.memberPermissions = null as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('Administrator'), + flags: MessageFlags.Ephemeral + }); + expect(registerDonation.execute).not.toHaveBeenCalled(); + }); + + it("should deny the command when user is not in the allowlist", async () => { + // Given + const { handler, registerDonation } = makeSut(['owner-id']); + const interaction = mock(); + interaction.guildId = chance.guid(); + interaction.user = { id: 'other-user' } as any; + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(true); + interaction.memberPermissions = memberPermissions as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('not authorized'), + flags: MessageFlags.Ephemeral + }); + expect(registerDonation.execute).not.toHaveBeenCalled(); + }); + + it("should register a BRL donation and post the new balance to #money", async () => { + // Given + const { handler, registerDonation, getBalance, discordClient, moneyChannel } = makeSut(); + const { interaction } = createAuthorizedInteraction(); + when(registerDonation.execute) + .calledWith({ amount: 130, currency: 'BRL', source: 'Monthly support', actorDiscordId: 'owner-id' }) + .thenResolve(makeEntry()); + getBalance.execute.mockResolvedValue({ balanceBrlCents: 18500 }); + when(discordClient.channels.fetch).calledWith('money-channel-id').thenResolve(moneyChannel); + + // When + await handler(interaction); + + // Then + expect(interaction.deferReply).toHaveBeenCalledWith({ flags: MessageFlags.Ephemeral }); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('R$ 130.00'), + flags: MessageFlags.Ephemeral + }); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('R$ 185.00'), + flags: MessageFlags.Ephemeral + }); + expect(moneyChannel.send).toHaveBeenCalledWith(expect.stringContaining('R$ 185.00')); + expect(moneyChannel.send).toHaveBeenCalledWith(expect.stringContaining('Monthly support')); + }); + + it("should register a USD donation showing the converted amount", async () => { + // Given + const { handler, registerDonation, getBalance, discordClient, moneyChannel } = makeSut(); + const { interaction } = createAuthorizedInteraction({ amount: 10, currency: 'USD', source: 'Patreon payout' }); + when(registerDonation.execute) + .calledWith({ amount: 10, currency: 'USD', source: 'Patreon payout', actorDiscordId: 'owner-id' }) + .thenResolve(makeEntry({ + amountOriginal: 10, + currencyOriginal: 'USD', + amountBrlCents: 5500, + fxRate: 5.5, + fxSource: 'PTAX', + source: 'Patreon payout', + })); + getBalance.execute.mockResolvedValue({ balanceBrlCents: 18500 }); + when(discordClient.channels.fetch).calledWith('money-channel-id').thenResolve(moneyChannel); + + // When + await handler(interaction); + + // Then + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('10.00 USD (~R$ 55.00)'), + flags: MessageFlags.Ephemeral + }); + expect(moneyChannel.send).toHaveBeenCalledWith(expect.stringContaining('10.00 USD (~R$ 55.00)')); + }); + + it("should reply with the user error message when the donation is invalid", async () => { + // Given + const { handler, registerDonation, discordClient, moneyChannel } = makeSut(); + const { interaction } = createAuthorizedInteraction({ amount: 0 }); + when(registerDonation.execute) + .calledWith({ amount: 0, currency: 'BRL', source: 'Monthly support', actorDiscordId: 'owner-id' }) + .thenReject(Object.assign(new Error('Donation amount must be greater than zero.'), { name: 'UserError' })); + when(discordClient.channels.fetch).calledWith('money-channel-id').thenResolve(moneyChannel); + + // When + await handler(interaction); + + // Then + expect(interaction.followUp).toHaveBeenCalledWith({ + content: 'Donation amount must be greater than zero.', + flags: MessageFlags.Ephemeral + }); + expect(moneyChannel.send).not.toHaveBeenCalled(); + }); + + it("should confirm without posting when the money channel is missing", async () => { + // Given + const { handler, registerDonation, getBalance, discordClient, moneyChannel } = makeSut(); + const { interaction } = createAuthorizedInteraction(); + when(registerDonation.execute).calledWith(expect.anything()).thenResolve(makeEntry()); + getBalance.execute.mockResolvedValue({ balanceBrlCents: 18500 }); + when(discordClient.channels.fetch).calledWith('money-channel-id').thenResolve(null); + + // When + await handler(interaction); + + // Then + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('Donation registered'), + flags: MessageFlags.Ephemeral + }); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: '⚠️ Donation recorded, but it could not be posted to #money.', + flags: MessageFlags.Ephemeral + }); + expect(moneyChannel.send).not.toHaveBeenCalled(); + }); + + it("should confirm without posting when the money channel is not a text channel", async () => { + // Given + const { handler, registerDonation, getBalance, discordClient, moneyChannel } = makeSut(); + const { interaction } = createAuthorizedInteraction(); + when(registerDonation.execute).calledWith(expect.anything()).thenResolve(makeEntry()); + getBalance.execute.mockResolvedValue({ balanceBrlCents: 18500 }); + when(discordClient.channels.fetch) + .calledWith('money-channel-id') + .thenResolve({ type: ChannelType.DM } as any); + + // When + await handler(interaction); + + // Then + expect(interaction.followUp).toHaveBeenCalledWith({ + content: '⚠️ Donation recorded, but it could not be posted to #money.', + flags: MessageFlags.Ephemeral + }); + expect(moneyChannel.send).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/entrypoints/src/commands/RegisterDonation/handler.ts b/packages/entrypoints/src/commands/RegisterDonation/handler.ts new file mode 100644 index 0000000..966743b --- /dev/null +++ b/packages/entrypoints/src/commands/RegisterDonation/handler.ts @@ -0,0 +1,73 @@ +import { ChatInputCommandInteraction, Client, MessageFlags } from "discord.js"; +import { GetBalance, LedgerCurrency, LedgerTransaction, RegisterDonation, toDisplayBrl } from "@tf2qs/core"; +import { commandErrorHandler } from "../commandErrorHandler"; +import { requireMoneyOwner } from "../requireMoneyOwner"; +import { sendMoneyChannelMessage } from "../../discord/sendMoneyChannelMessage"; +import { formatLedgerEntryAmount } from "../formatLedgerEntryAmount"; + +type RegisterDonationCommandHandlerFactoryDependencies = { + registerDonation: RegisterDonation; + getBalance: GetBalance; + allowedUserIds: string[]; + moneyChannelId: string; + discordClient: Client; +}; + +export function createRegisterDonationCommandHandlerFactory(dependencies: RegisterDonationCommandHandlerFactoryDependencies) { + return async function registerDonationCommandHandler(interaction: ChatInputCommandInteraction) { + const { registerDonation, getBalance, allowedUserIds, moneyChannelId, discordClient } = dependencies; + + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action: 'register donations' }); + if (!authorized) { + return; + } + + const amount = interaction.options.getNumber('amount', true); + const currency = interaction.options.getString('currency', true) as LedgerCurrency; + const source = interaction.options.getString('source', true); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + const entry = await registerDonation.execute({ + amount, + currency, + source, + actorDiscordId: interaction.user.id, + }); + const { balanceBrlCents } = await getBalance.execute(); + + await interaction.followUp({ + content: `✅ Donation registered: ${formatLedgerEntryAmount({ entry })} from "${entry.source}". New balance: R$ ${toDisplayBrl({ balanceBrlCents })}.`, + flags: MessageFlags.Ephemeral + }); + + await postDonationToMoneyChannel({ discordClient, moneyChannelId, entry, balanceBrlCents, interaction }); + } catch (error: Error | any) { + await commandErrorHandler(interaction, error); + } + }; +} + +type PostDonationParams = { + discordClient: Client; + moneyChannelId: string; + entry: LedgerTransaction; + balanceBrlCents: number; + interaction: ChatInputCommandInteraction; +}; + +async function postDonationToMoneyChannel(params: PostDonationParams): Promise { + const { discordClient, moneyChannelId, entry, balanceBrlCents, interaction } = params; + const delivered = await sendMoneyChannelMessage({ + discordClient, + moneyChannelId, + content: `💰 New donation: ${formatLedgerEntryAmount({ entry })} from "${entry.source}". Current balance: R$ ${toDisplayBrl({ balanceBrlCents })}.`, + }); + if (!delivered) { + await interaction.followUp({ + content: '⚠️ Donation recorded, but it could not be posted to #money.', + flags: MessageFlags.Ephemeral + }); + } +} diff --git a/packages/entrypoints/src/commands/RegisterDonation/index.ts b/packages/entrypoints/src/commands/RegisterDonation/index.ts new file mode 100644 index 0000000..56b8ab0 --- /dev/null +++ b/packages/entrypoints/src/commands/RegisterDonation/index.ts @@ -0,0 +1,2 @@ +export * from "./definition"; +export * from "./handler"; diff --git a/packages/entrypoints/src/commands/SetLedgerOpening/definition.test.ts b/packages/entrypoints/src/commands/SetLedgerOpening/definition.test.ts new file mode 100644 index 0000000..e3c8407 --- /dev/null +++ b/packages/entrypoints/src/commands/SetLedgerOpening/definition.test.ts @@ -0,0 +1,45 @@ +import { ApplicationCommandOptionType, PermissionFlagsBits } from "discord.js"; +import { describe, expect, it } from "vitest"; +import { setLedgerOpeningCommandDefinition } from "./definition"; + +describe("setLedgerOpeningCommandDefinition", () => { + const amountOption = () => setLedgerOpeningCommandDefinition.toJSON().options?.find((option) => option.name === "amount"); + + it("should require a positive amount above the zero the use case rejects", () => { + // When + const option = amountOption(); + + // Then + expect(option).toMatchObject({ + type: ApplicationCommandOptionType.Number, + name: "amount", + required: true, + min_value: 0.01, + }); + }); + + it("should be restricted to administrators and marked owner only", () => { + // When + const definition = setLedgerOpeningCommandDefinition.toJSON(); + + // Then + expect(definition.name).toBe("set-ledger-opening"); + expect(definition.description).toContain("(owner only)"); + expect(definition.default_member_permissions).toBe(String(PermissionFlagsBits.Administrator)); + }); + + it("should offer only the supported currencies", () => { + // When + const option = setLedgerOpeningCommandDefinition.toJSON().options?.find((option) => option.name === "currency"); + + // Then + expect(option).toMatchObject({ + type: ApplicationCommandOptionType.String, + required: true, + choices: [ + { name: "BRL", value: "BRL" }, + { name: "USD", value: "USD" }, + ], + }); + }); +}); diff --git a/packages/entrypoints/src/commands/SetLedgerOpening/definition.ts b/packages/entrypoints/src/commands/SetLedgerOpening/definition.ts new file mode 100644 index 0000000..4491c9c --- /dev/null +++ b/packages/entrypoints/src/commands/SetLedgerOpening/definition.ts @@ -0,0 +1,19 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; + +export const setLedgerOpeningCommandDefinition = new SlashCommandBuilder() + .setName('set-ledger-opening') + .setDescription('Record the ledger opening balance (owner only)') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addNumberOption(option => + option.setName('amount') + .setDescription('Opening balance amount, must be greater than zero') + .setRequired(true) + .setMinValue(0.01)) + .addStringOption(option => + option.setName('currency') + .setDescription('Opening balance currency') + .setRequired(true) + .addChoices( + { name: 'BRL', value: 'BRL' }, + { name: 'USD', value: 'USD' }, + )); diff --git a/packages/entrypoints/src/commands/SetLedgerOpening/handler.test.ts b/packages/entrypoints/src/commands/SetLedgerOpening/handler.test.ts new file mode 100644 index 0000000..2cef85c --- /dev/null +++ b/packages/entrypoints/src/commands/SetLedgerOpening/handler.test.ts @@ -0,0 +1,223 @@ +import { ChannelType, ChatInputCommandInteraction, Client, MessageFlags, PermissionsBitField, TextChannel } from "discord.js"; +import { describe, expect, it } from "vitest"; +import { mock, mockDeep } from "vitest-mock-extended"; +import { when } from "vitest-when"; +import { GetBalance, LedgerTransaction, SetLedgerOpening } from "@tf2qs/core"; +import { createSetLedgerOpeningCommandHandlerFactory } from "./handler"; +import Chance from "chance"; + +const chance = new Chance(); + +describe("SetLedgerOpening Command Handler", () => { + const makeSut = (allowedUserIds: string[] = ['owner-id']) => { + const setLedgerOpening = mock(); + const getBalance = mock(); + const discordClient = mockDeep(); + const moneyChannel = mockDeep(); + moneyChannel.type = ChannelType.GuildText; + const handler = createSetLedgerOpeningCommandHandlerFactory({ + setLedgerOpening, + getBalance, + allowedUserIds, + moneyChannelId: 'money-channel-id', + discordClient, + }); + return { handler, setLedgerOpening, getBalance, discordClient, moneyChannel }; + }; + + const createAuthorizedInteraction = (overrides: { amount?: number; currency?: string } = {}) => { + const interaction = mock(); + interaction.guildId = chance.guid(); + interaction.user = { id: 'owner-id' } as any; + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(true); + interaction.memberPermissions = memberPermissions as any; + interaction.options = mock(); + when(interaction.options.getNumber).calledWith('amount', true).thenReturn(overrides.amount ?? 130); + when(interaction.options.getString).calledWith('currency', true).thenReturn(overrides.currency ?? 'BRL'); + return { interaction, memberPermissions }; + }; + + const makeEntry = (overrides: Partial = {}): LedgerTransaction => ({ + id: 1, + occurredAt: new Date('2026-10-06T12:00:00.000Z'), + type: 'OPENING', + amountOriginal: 130, + currencyOriginal: 'BRL', + amountBrlCents: 13000, + fxRate: 1, + fxSource: 'NONE', + source: 'Opening balance from #money history', + actorDiscordId: 'owner-id', + dedupeKey: 'opening:ledger', + createdAt: new Date('2026-10-06T12:00:01.000Z'), + ...overrides, + }); + + it("should deny the command when invoked in DMs (no guildId)", async () => { + // Given + const { handler, setLedgerOpening } = makeSut(); + const interaction = mock(); + interaction.guildId = null as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('only available within a Discord server'), + flags: MessageFlags.Ephemeral + }); + expect(interaction.deferReply).not.toHaveBeenCalled(); + expect(setLedgerOpening.execute).not.toHaveBeenCalled(); + }); + + it("should deny the command when user lacks Administrator permission", async () => { + // Given + const { handler, setLedgerOpening } = makeSut(); + const interaction = mock(); + interaction.guildId = chance.guid(); + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(false); + interaction.memberPermissions = memberPermissions as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('Administrator'), + flags: MessageFlags.Ephemeral + }); + expect(setLedgerOpening.execute).not.toHaveBeenCalled(); + }); + + it("should deny the command when memberPermissions is null", async () => { + // Given + const { handler, setLedgerOpening } = makeSut(); + const interaction = mock(); + interaction.guildId = chance.guid(); + interaction.memberPermissions = null as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('Administrator'), + flags: MessageFlags.Ephemeral + }); + expect(setLedgerOpening.execute).not.toHaveBeenCalled(); + }); + + it("should deny the command when user is not in the allowlist", async () => { + // Given + const { handler, setLedgerOpening } = makeSut(['owner-id']); + const interaction = mock(); + interaction.guildId = chance.guid(); + interaction.user = { id: 'other-user' } as any; + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(true); + interaction.memberPermissions = memberPermissions as any; + + // When + await handler(interaction); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: expect.stringContaining('not authorized'), + flags: MessageFlags.Ephemeral + }); + expect(setLedgerOpening.execute).not.toHaveBeenCalled(); + }); + + it("should record the opening balance and post it to #money", async () => { + // Given + const { handler, setLedgerOpening, getBalance, discordClient, moneyChannel } = makeSut(); + const { interaction } = createAuthorizedInteraction(); + when(setLedgerOpening.execute) + .calledWith({ amount: 130, currency: 'BRL', actorDiscordId: 'owner-id' }) + .thenResolve(makeEntry()); + getBalance.execute.mockResolvedValue({ balanceBrlCents: 18500 }); + when(discordClient.channels.fetch).calledWith('money-channel-id').thenResolve(moneyChannel); + + // When + await handler(interaction); + + // Then + expect(interaction.deferReply).toHaveBeenCalledWith({ flags: MessageFlags.Ephemeral }); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('Opening balance recorded: R$ 130.00'), + flags: MessageFlags.Ephemeral + }); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('New balance: R$ 185.00'), + flags: MessageFlags.Ephemeral + }); + expect(moneyChannel.send).toHaveBeenCalledWith("💰 Opening balance recorded: R$ 130.00. Current balance: R$ 185.00."); + expect(moneyChannel.send).toHaveBeenCalledWith(expect.not.stringContaining("from \"")); + }); + + it("should surface the ledger-not-empty rejection and post nothing to #money", async () => { + // Given + const { handler, setLedgerOpening, discordClient, moneyChannel } = makeSut(); + const { interaction } = createAuthorizedInteraction(); + when(setLedgerOpening.execute) + .calledWith({ amount: 130, currency: 'BRL', actorDiscordId: 'owner-id' }) + .thenReject(Object.assign( + new Error('The ledger already holds transactions; the opening balance can only be set once.'), + { name: 'UserError' } + )); + when(discordClient.channels.fetch).calledWith('money-channel-id').thenResolve(moneyChannel); + + // When + await handler(interaction); + + // Then + expect(interaction.followUp).toHaveBeenCalledWith({ + content: 'The ledger already holds transactions; the opening balance can only be set once.', + flags: MessageFlags.Ephemeral + }); + expect(discordClient.channels.fetch).not.toHaveBeenCalled(); + expect(moneyChannel.send).not.toHaveBeenCalled(); + }); + + it("should reply with the generic error message on unexpected error", async () => { + // Given + const { handler, setLedgerOpening } = makeSut(); + const { interaction } = createAuthorizedInteraction(); + when(setLedgerOpening.execute).calledWith(expect.anything()).thenReject(new Error('boom')); + + // When + await handler(interaction); + + // Then + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('unexpected error running the command'), + flags: MessageFlags.Ephemeral + }); + }); + + it("should confirm without posting when the money channel is missing", async () => { + // Given + const { handler, setLedgerOpening, getBalance, discordClient, moneyChannel } = makeSut(); + const { interaction } = createAuthorizedInteraction(); + when(setLedgerOpening.execute).calledWith(expect.anything()).thenResolve(makeEntry()); + getBalance.execute.mockResolvedValue({ balanceBrlCents: 18500 }); + when(discordClient.channels.fetch).calledWith('money-channel-id').thenResolve(null); + + // When + await handler(interaction); + + // Then + expect(interaction.followUp).toHaveBeenCalledWith({ + content: expect.stringContaining('Opening balance recorded'), + flags: MessageFlags.Ephemeral + }); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: '⚠️ Opening balance recorded, but it could not be posted to #money.', + flags: MessageFlags.Ephemeral + }); + expect(moneyChannel.send).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/entrypoints/src/commands/SetLedgerOpening/handler.ts b/packages/entrypoints/src/commands/SetLedgerOpening/handler.ts new file mode 100644 index 0000000..1abc51b --- /dev/null +++ b/packages/entrypoints/src/commands/SetLedgerOpening/handler.ts @@ -0,0 +1,71 @@ +import { ChatInputCommandInteraction, Client, MessageFlags } from "discord.js"; +import { GetBalance, LedgerCurrency, LedgerTransaction, SetLedgerOpening, toDisplayBrl } from "@tf2qs/core"; +import { commandErrorHandler } from "../commandErrorHandler"; +import { requireMoneyOwner } from "../requireMoneyOwner"; +import { sendMoneyChannelMessage } from "../../discord/sendMoneyChannelMessage"; +import { formatLedgerEntryAmount } from "../formatLedgerEntryAmount"; + +type SetLedgerOpeningCommandHandlerFactoryDependencies = { + setLedgerOpening: SetLedgerOpening; + getBalance: GetBalance; + allowedUserIds: string[]; + moneyChannelId: string; + discordClient: Client; +}; + +export function createSetLedgerOpeningCommandHandlerFactory(dependencies: SetLedgerOpeningCommandHandlerFactoryDependencies) { + return async function setLedgerOpeningCommandHandler(interaction: ChatInputCommandInteraction) { + const { setLedgerOpening, getBalance, allowedUserIds, moneyChannelId, discordClient } = dependencies; + + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action: 'change the opening balance' }); + if (!authorized) { + return; + } + + const amount = interaction.options.getNumber('amount', true); + const currency = interaction.options.getString('currency', true) as LedgerCurrency; + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + const entry = await setLedgerOpening.execute({ + amount, + currency, + actorDiscordId: interaction.user.id, + }); + const { balanceBrlCents } = await getBalance.execute(); + + await interaction.followUp({ + content: `✅ Opening balance recorded: ${formatLedgerEntryAmount({ entry })}. New balance: R$ ${toDisplayBrl({ balanceBrlCents })}.`, + flags: MessageFlags.Ephemeral + }); + + await postOpeningToMoneyChannel({ discordClient, moneyChannelId, entry, balanceBrlCents, interaction }); + } catch (error: Error | any) { + await commandErrorHandler(interaction, error); + } + }; +} + +type PostOpeningParams = { + discordClient: Client; + moneyChannelId: string; + entry: LedgerTransaction; + balanceBrlCents: number; + interaction: ChatInputCommandInteraction; +}; + +async function postOpeningToMoneyChannel(params: PostOpeningParams): Promise { + const { discordClient, moneyChannelId, entry, balanceBrlCents, interaction } = params; + const delivered = await sendMoneyChannelMessage({ + discordClient, + moneyChannelId, + content: `💰 Opening balance recorded: ${formatLedgerEntryAmount({ entry })}. Current balance: R$ ${toDisplayBrl({ balanceBrlCents })}.`, + }); + if (!delivered) { + await interaction.followUp({ + content: '⚠️ Opening balance recorded, but it could not be posted to #money.', + flags: MessageFlags.Ephemeral + }); + } +} diff --git a/packages/entrypoints/src/commands/SetLedgerOpening/index.ts b/packages/entrypoints/src/commands/SetLedgerOpening/index.ts new file mode 100644 index 0000000..56b8ab0 --- /dev/null +++ b/packages/entrypoints/src/commands/SetLedgerOpening/index.ts @@ -0,0 +1,2 @@ +export * from "./definition"; +export * from "./handler"; diff --git a/packages/entrypoints/src/commands/formatLedgerEntryAmount.test.ts b/packages/entrypoints/src/commands/formatLedgerEntryAmount.test.ts new file mode 100644 index 0000000..1ad2337 --- /dev/null +++ b/packages/entrypoints/src/commands/formatLedgerEntryAmount.test.ts @@ -0,0 +1,60 @@ +import { LedgerTransaction } from "@tf2qs/core"; +import { describe, expect, it } from "vitest"; +import { formatLedgerEntryAmount } from "./formatLedgerEntryAmount"; + +describe("formatLedgerEntryAmount", () => { + const makeEntry = (overrides: Partial = {}): LedgerTransaction => ({ + 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: 'owner-id', + dedupeKey: 'donation:seed', + createdAt: new Date('2026-10-06T12:00:01.000Z'), + ...overrides, + }); + + it("should render a BRL entry with the real symbol", () => { + // Given + const entry = makeEntry(); + + // When + const formatted = formatLedgerEntryAmount({ entry }); + + // Then + expect(formatted).toBe("R$ 130.00"); + }); + + it("should render a USD entry with the converted real amount", () => { + // Given + const entry = makeEntry({ + amountOriginal: 10, + currencyOriginal: 'USD', + amountBrlCents: 5500, + fxRate: 5.5, + fxSource: 'PTAX', + }); + + // When + const formatted = formatLedgerEntryAmount({ entry }); + + // Then + expect(formatted).toBe("10.00 USD (~R$ 55.00)"); + }); + + it("should render a negative cost entry in reais", () => { + // Given + const entry = makeEntry({ type: 'COST', amountOriginal: 5.05, currencyOriginal: 'BRL', amountBrlCents: -505 }); + + // When + const formatted = formatLedgerEntryAmount({ entry }); + + // Then + expect(formatted).toBe("R$ -5.05"); + }); +}); diff --git a/packages/entrypoints/src/commands/formatLedgerEntryAmount.ts b/packages/entrypoints/src/commands/formatLedgerEntryAmount.ts new file mode 100644 index 0000000..1b1613f --- /dev/null +++ b/packages/entrypoints/src/commands/formatLedgerEntryAmount.ts @@ -0,0 +1,13 @@ +import { LedgerTransaction, toDisplayBrl } from "@tf2qs/core"; + +type FormatLedgerEntryAmountParams = { + entry: LedgerTransaction; +}; + +export function formatLedgerEntryAmount(params: FormatLedgerEntryAmountParams): string { + const { entry } = params; + if (entry.currencyOriginal === 'BRL') { + return `R$ ${toDisplayBrl({ balanceBrlCents: entry.amountBrlCents })}`; + } + return `${entry.amountOriginal.toFixed(2)} USD (~R$ ${toDisplayBrl({ balanceBrlCents: entry.amountBrlCents })})`; +} diff --git a/packages/entrypoints/src/commands/index.ts b/packages/entrypoints/src/commands/index.ts index b2c3d7d..7693623 100644 --- a/packages/entrypoints/src/commands/index.ts +++ b/packages/entrypoints/src/commands/index.ts @@ -1,5 +1,8 @@ -import { ChatInputCommandInteraction, SlashCommandOptionsOnlyBuilder } from "discord.js"; +import { ChatInputCommandInteraction, Client, SlashCommandOptionsOnlyBuilder } from "discord.js"; import { CreateServerForUser } from "@tf2qs/core"; +import { GetBalance } from "@tf2qs/core"; +import { RegisterDonation } from "@tf2qs/core"; +import { SetLedgerOpening } from "@tf2qs/core"; import { GetServerStatus } from "@tf2qs/core"; import { GetGuildServers } from "@tf2qs/core"; import { GetUserServers } from "@tf2qs/core"; @@ -19,6 +22,9 @@ import { scheduleCommandDefinition, createScheduleCommandHandlerFactory } from " import { showSchedulesCommandDefinition, showSchedulesCommandHandlerFactory } from "./ShowSchedules"; import { cancelScheduleCommandDefinition, cancelScheduleCommandHandlerFactory } from "./CancelSchedule"; import { checkSharedIpsCommandDefinition, checkSharedIpsCommandHandlerFactory } from "./CheckSharedIps"; +import { registerDonationCommandDefinition, createRegisterDonationCommandHandlerFactory } from "./RegisterDonation"; +import { balanceCommandDefinition, createBalanceCommandHandlerFactory } from "./Balance"; +import { setLedgerOpeningCommandDefinition, createSetLedgerOpeningCommandHandlerFactory } from "./SetLedgerOpening"; export type CommandDependencies = { createServerForUser: CreateServerForUser; @@ -32,6 +38,12 @@ export type CommandDependencies = { cancelScheduledServer: CancelScheduledServer; comparePlayerSharedIps: ComparePlayerSharedIps; ipHistoryAdminDiscordIds: string[]; + registerDonation: RegisterDonation; + setLedgerOpening: SetLedgerOpening; + getBalance: GetBalance; + moneyOwnerDiscordIds: string[]; + moneyChannelId: string; + discordClient: Client; } export function createCommands(dependencies: CommandDependencies) { @@ -107,6 +119,36 @@ export function createCommands(dependencies: CommandDependencies) { comparePlayerSharedIps: dependencies.comparePlayerSharedIps, allowedUserIds: dependencies.ipHistoryAdminDiscordIds, }) + }, + registerDonation: { + name: "register-donation", + definition: registerDonationCommandDefinition, + handler: createRegisterDonationCommandHandlerFactory({ + registerDonation: dependencies.registerDonation, + getBalance: dependencies.getBalance, + allowedUserIds: dependencies.moneyOwnerDiscordIds, + moneyChannelId: dependencies.moneyChannelId, + discordClient: dependencies.discordClient, + }) + }, + balance: { + name: "balance", + definition: balanceCommandDefinition, + handler: createBalanceCommandHandlerFactory({ + getBalance: dependencies.getBalance, + allowedUserIds: dependencies.moneyOwnerDiscordIds, + }) + }, + setLedgerOpening: { + name: "set-ledger-opening", + definition: setLedgerOpeningCommandDefinition, + handler: createSetLedgerOpeningCommandHandlerFactory({ + setLedgerOpening: dependencies.setLedgerOpening, + getBalance: dependencies.getBalance, + allowedUserIds: dependencies.moneyOwnerDiscordIds, + moneyChannelId: dependencies.moneyChannelId, + discordClient: dependencies.discordClient, + }) } } satisfies Record { + const makeSut = (overrides: { allowedUserIds?: string[]; action?: string } = {}) => { + const allowedUserIds = overrides.allowedUserIds ?? ['owner-id']; + const action = overrides.action ?? 'register donations'; + const interaction = mock(); + interaction.guildId = chance.guid(); + interaction.user = { id: 'owner-id' } as any; + const memberPermissions = mock(); + memberPermissions.has.mockReturnValue(true); + interaction.memberPermissions = memberPermissions as any; + return { interaction, allowedUserIds, action, memberPermissions }; + }; + + it("should authorize an allowlisted administrator in a guild", async () => { + // Given + const { interaction, allowedUserIds, action } = makeSut(); + + // When + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action }); + + // Then + expect(authorized).toBe(true); + expect(interaction.reply).not.toHaveBeenCalled(); + }); + + it("should reject the command when invoked in DMs (no guildId)", async () => { + // Given + const { interaction, allowedUserIds, action } = makeSut(); + interaction.guildId = null as any; + + // When + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action }); + + // Then + expect(authorized).toBe(false); + expect(interaction.reply).toHaveBeenCalledWith({ + content: '❌ This command is only available within a Discord server, not in direct messages.', + flags: MessageFlags.Ephemeral + }); + }); + + it("should reject the command when user lacks Administrator permission", async () => { + // Given + const { interaction, allowedUserIds, action, memberPermissions } = makeSut(); + memberPermissions.has.mockReturnValue(false); + + // When + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action }); + + // Then + expect(authorized).toBe(false); + expect(interaction.reply).toHaveBeenCalledWith({ + content: `❌ Only users with the **Administrator** permission can ${action}.`, + flags: MessageFlags.Ephemeral + }); + }); + + it("should reject the command when memberPermissions is null", async () => { + // Given + const { interaction, allowedUserIds, action } = makeSut(); + interaction.memberPermissions = null as any; + + // When + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action }); + + // Then + expect(authorized).toBe(false); + expect(interaction.reply).toHaveBeenCalledWith({ + content: `❌ Only users with the **Administrator** permission can ${action}.`, + flags: MessageFlags.Ephemeral + }); + }); + + it("should reject the command when user is not in the allowlist", async () => { + // Given + const { interaction, allowedUserIds, action } = makeSut({ allowedUserIds: ['other-user'] }); + + // When + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action }); + + // Then + expect(authorized).toBe(false); + expect(interaction.reply).toHaveBeenCalledWith({ + content: '❌ You are not authorized to use this command.', + flags: MessageFlags.Ephemeral + }); + }); + + it("should word the permission denial with the requested action", async () => { + // Given + const { interaction, allowedUserIds } = makeSut(); + interaction.memberPermissions = null as any; + + // When + await requireMoneyOwner({ interaction, allowedUserIds, action: 'check the balance' }); + + // Then + expect(interaction.reply).toHaveBeenCalledWith({ + content: '❌ Only users with the **Administrator** permission can check the balance.', + flags: MessageFlags.Ephemeral + }); + }); +}); diff --git a/packages/entrypoints/src/commands/requireMoneyOwner.ts b/packages/entrypoints/src/commands/requireMoneyOwner.ts new file mode 100644 index 0000000..d368aee --- /dev/null +++ b/packages/entrypoints/src/commands/requireMoneyOwner.ts @@ -0,0 +1,37 @@ +import { ChatInputCommandInteraction, MessageFlags, PermissionFlagsBits } from "discord.js"; + +type RequireMoneyOwnerParams = { + interaction: ChatInputCommandInteraction; + allowedUserIds: string[]; + action: string; +}; + +export async function requireMoneyOwner(params: RequireMoneyOwnerParams): Promise { + const { interaction, allowedUserIds, action } = params; + + if (!interaction.guildId) { + await interaction.reply({ + content: '❌ This command is only available within a Discord server, not in direct messages.', + flags: MessageFlags.Ephemeral + }); + return false; + } + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { + await interaction.reply({ + content: `❌ Only users with the **Administrator** permission can ${action}.`, + flags: MessageFlags.Ephemeral + }); + return false; + } + + if (!allowedUserIds.includes(interaction.user.id)) { + await interaction.reply({ + content: '❌ You are not authorized to use this command.', + flags: MessageFlags.Ephemeral + }); + return false; + } + + return true; +} diff --git a/packages/entrypoints/src/discord/sendMoneyChannelMessage.test.ts b/packages/entrypoints/src/discord/sendMoneyChannelMessage.test.ts new file mode 100644 index 0000000..73f4e47 --- /dev/null +++ b/packages/entrypoints/src/discord/sendMoneyChannelMessage.test.ts @@ -0,0 +1,151 @@ +import { ChannelType, Client, TextChannel } from "discord.js"; +import { describe, expect, it, vi } from "vitest"; +import { mockDeep } from "vitest-mock-extended"; +import { when } from "vitest-when"; +import { logger } from "@tf2qs/telemetry"; +import { sendMoneyChannelMessage } from "./sendMoneyChannelMessage"; + +vi.mock("@tf2qs/telemetry", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + logger: { + ...actual.logger, + emit: vi.fn(), + }, + }; +}); + +describe("sendMoneyChannelMessage", () => { + const makeSut = () => { + const discordClient = mockDeep(); + const moneyChannel = mockDeep(); + moneyChannel.type = ChannelType.GuildText; + return { discordClient, moneyChannel }; + }; + + it("should deliver the message and report success", async () => { + // Given + const { discordClient, moneyChannel } = makeSut(); + when(discordClient.channels.fetch).calledWith("money-channel-id").thenResolve(moneyChannel); + + // When + const delivered = await sendMoneyChannelMessage({ + discordClient, + moneyChannelId: "money-channel-id", + content: "💰 New donation", + }); + + // Then + expect(delivered).toBe(true); + expect(moneyChannel.send).toHaveBeenCalledWith("💰 New donation"); + }); + + it("should refuse an unconfigured money channel id and log a warning", async () => { + // Given + const { discordClient, moneyChannel } = makeSut(); + + // When + const delivered = await sendMoneyChannelMessage({ + discordClient, + moneyChannelId: "", + content: "💰 New donation", + }); + + // Then + expect(delivered).toBe(false); + expect(discordClient.channels.fetch).not.toHaveBeenCalled(); + expect(moneyChannel.send).not.toHaveBeenCalled(); + expect(vi.mocked(logger.emit)).toHaveBeenCalledWith({ + severityText: "WARN", + body: "Money Channel ID is not configured. Skipping money channel post.", + }); + }); + + it("should report a missing money channel and log an error", async () => { + // Given + const { discordClient, moneyChannel } = makeSut(); + when(discordClient.channels.fetch).calledWith("money-channel-id").thenResolve(null); + + // When + const delivered = await sendMoneyChannelMessage({ + discordClient, + moneyChannelId: "money-channel-id", + content: "💰 New donation", + }); + + // Then + expect(delivered).toBe(false); + expect(moneyChannel.send).not.toHaveBeenCalled(); + expect(vi.mocked(logger.emit)).toHaveBeenCalledWith({ + severityText: "ERROR", + body: "Money Channel not found: money-channel-id", + }); + }); + + it("should report a non-text money channel and log an error", async () => { + // Given + const { discordClient, moneyChannel } = makeSut(); + when(discordClient.channels.fetch) + .calledWith("money-channel-id") + .thenResolve({ type: ChannelType.DM } as any); + + // When + const delivered = await sendMoneyChannelMessage({ + discordClient, + moneyChannelId: "money-channel-id", + content: "💰 New donation", + }); + + // Then + expect(delivered).toBe(false); + expect(moneyChannel.send).not.toHaveBeenCalled(); + expect(vi.mocked(logger.emit)).toHaveBeenCalledWith({ + severityText: "ERROR", + body: "Money Channel is not a text channel: money-channel-id", + }); + }); + + it("should log the serialized error when fetching the money channel rejects", async () => { + // Given + const { discordClient } = makeSut(); + when(discordClient.channels.fetch).calledWith("money-channel-id").thenReject(new Error("fetch boom")); + + // When + const delivered = await sendMoneyChannelMessage({ + discordClient, + moneyChannelId: "money-channel-id", + content: "💰 New donation", + }); + + // Then + expect(delivered).toBe(false); + expect(vi.mocked(logger.emit)).toHaveBeenCalledWith(expect.objectContaining({ + severityText: "ERROR", + body: "Money Channel post failed: money-channel-id", + attributes: { error: expect.stringContaining("fetch boom") }, + })); + }); + + it("should log the serialized error when sending the message fails", async () => { + // Given + const { discordClient, moneyChannel } = makeSut(); + when(discordClient.channels.fetch).calledWith("money-channel-id").thenResolve(moneyChannel); + moneyChannel.send.mockRejectedValue(new Error("send boom")); + + // When + const delivered = await sendMoneyChannelMessage({ + discordClient, + moneyChannelId: "money-channel-id", + content: "💰 New donation", + }); + + // Then + expect(delivered).toBe(false); + expect(vi.mocked(logger.emit)).toHaveBeenCalledWith(expect.objectContaining({ + severityText: "ERROR", + body: "Money Channel post failed: money-channel-id", + attributes: { error: expect.stringContaining("send boom") }, + })); + }); +}); diff --git a/packages/entrypoints/src/discord/sendMoneyChannelMessage.ts b/packages/entrypoints/src/discord/sendMoneyChannelMessage.ts new file mode 100644 index 0000000..5b3eb99 --- /dev/null +++ b/packages/entrypoints/src/discord/sendMoneyChannelMessage.ts @@ -0,0 +1,52 @@ +import { ChannelType, Client } from "discord.js"; +import { logger } from "@tf2qs/telemetry"; + +type SendMoneyChannelMessageParams = { + discordClient: Client; + moneyChannelId: string; + content: string; +}; + +export async function sendMoneyChannelMessage(params: SendMoneyChannelMessageParams): Promise { + const { discordClient, moneyChannelId, content } = params; + + if (!moneyChannelId) { + logger.emit({ + severityText: "WARN", + body: "Money Channel ID is not configured. Skipping money channel post.", + }); + return false; + } + + try { + const channel = await discordClient.channels.fetch(moneyChannelId); + + if (!channel) { + logger.emit({ + severityText: "ERROR", + body: `Money Channel not found: ${moneyChannelId}`, + }); + return false; + } + + if (channel.type !== ChannelType.GuildText) { + logger.emit({ + severityText: "ERROR", + body: `Money Channel is not a text channel: ${moneyChannelId}`, + }); + return false; + } + + await channel.send(content); + return true; + } catch (error) { + logger.emit({ + severityText: "ERROR", + body: `Money Channel post failed: ${moneyChannelId}`, + attributes: { + error: JSON.stringify(error, Object.getOwnPropertyNames(error)), + }, + }); + return false; + } +} diff --git a/packages/entrypoints/src/discordBot.ts b/packages/entrypoints/src/discordBot.ts index 4717ed8..5243a5c 100644 --- a/packages/entrypoints/src/discordBot.ts +++ b/packages/entrypoints/src/discordBot.ts @@ -15,6 +15,11 @@ import { GetUserServers } from "@tf2qs/core"; import { GetSourceTvInfo } from "@tf2qs/core"; import { SetUserData } from "@tf2qs/core"; import { ComparePlayerSharedIps } from "@tf2qs/core"; +import { GetBalance } from "@tf2qs/core"; +import { MonthlyCostSettlement } from "@tf2qs/core"; +import { RegisterCost } from "@tf2qs/core"; +import { RegisterDonation } from "@tf2qs/core"; +import { SetLedgerOpening } from "@tf2qs/core"; import { CheckForTf2Update } from "@tf2qs/core"; import { TerminateEmptyServers } from "@tf2qs/core"; import { TerminateLongRunningServers } from "@tf2qs/core"; @@ -37,6 +42,8 @@ import { SQLiteScheduledServerRepository } from "@tf2qs/providers"; import { SQLiteUserRepository } from "@tf2qs/providers"; import { SQLiteServerStatusMetricsRepository } from "@tf2qs/providers"; import { SQLitePlayerConnectionHistoryRepository } from "@tf2qs/providers"; +import { BcbPtaxProvider } from "@tf2qs/providers"; +import { SQLiteLedgerRepository } from "@tf2qs/providers"; import { ChancePasswordGeneratorService } from "@tf2qs/providers"; import { defaultAWSServiceFactory } from "@tf2qs/providers"; import { defaultGracefulShutdownManager, ShutdownInProgressError } from "@tf2qs/providers"; @@ -211,6 +218,26 @@ export async function startDiscordBot() { eventLogger }); + const ledgerRepository = new SQLiteLedgerRepository({ + knex: KnexConnectionManager.client, + }); + + const exchangeRateProvider = new BcbPtaxProvider({}); + + const registerDonation = new RegisterDonation({ + ledgerRepository, + exchangeRateProvider, + }); + + const getBalance = new GetBalance({ + ledgerRepository, + }); + + const setLedgerOpening = new SetLedgerOpening({ + ledgerRepository, + exchangeRateProvider, + }); + const discordCommands = createCommands({ createServerForUser, setUserData: new SetUserData({ @@ -231,6 +258,12 @@ export async function startDiscordBot() { backgroundTaskQueue, comparePlayerSharedIps: new ComparePlayerSharedIps({ playerConnectionHistoryRepository }), ipHistoryAdminDiscordIds: defaultConfigManager.getDiscordConfig().ipHistoryAdminDiscordIds, + registerDonation, + setLedgerOpening, + getBalance, + moneyOwnerDiscordIds: defaultConfigManager.getDiscordConfig().moneyOwnerDiscordIds, + moneyChannelId: defaultConfigManager.getDiscordConfig().moneyChannelId, + discordClient: client, }) // Schedule jobs @@ -304,6 +337,14 @@ export async function startDiscordBot() { configManager: defaultConfigManager, eventLogger, discordClient: client, + monthlyCostSettlement: new MonthlyCostSettlement({ + costProvider: defaultCostProvider, + exchangeRateProvider, + registerCost: new RegisterCost({ ledgerRepository }), + moneyConfig: defaultConfigManager.getMoneyConfig(), + eventLogger, + }), + getBalance, }) const ghToken = process.env.GH_TOKEN; diff --git a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts index 66a4353..d06dce4 100644 --- a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts +++ b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts @@ -1,31 +1,49 @@ -import { Client } from "discord.js"; +import { ChannelType, Client } from "discord.js"; import schedule from "node-schedule"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import { EventLogger } from "@tf2qs/core"; import { GenerateMonthlyUsageReport } from "@tf2qs/core"; import { ConfigManager } from "@tf2qs/core"; +import { GetBalance } from "@tf2qs/core"; +import { MonthlyCostSettlement } from "@tf2qs/core"; +import { logger } from "@tf2qs/telemetry"; import { scheduleMonthlyUsageReportRoutine } from "./MonthlyUsageReportRoutine"; vi.mock("node-schedule") +vi.mock("@tf2qs/telemetry", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + logger: { + ...actual.logger, + emit: vi.fn(), + }, + }; +}) + describe("MonthlyUsageReportRoutine", () => { const makeSut = () => { const scheduleMock = vi.mocked(schedule) const channelGetFn = vi.fn(); + const channelFetchFn = vi.fn(); const dependencies = { configManager: mock(), discordClient: mock({ channels: mock({ cache: { get: channelGetFn - } + }, + fetch: channelFetchFn }) as any }), eventLogger: mock(), generateMonthlyUsageReport: mock(), + monthlyCostSettlement: mock(), + getBalance: mock(), } return { @@ -33,9 +51,31 @@ describe("MonthlyUsageReportRoutine", () => { scheduleMock, dependencies, channelGetFn, + channelFetchFn, } } + const makeReport = () => ({ + averageServerDurationMinutes: 120, + month: 10, + peakConcurrentServers: { eventTime: 1, maxServersRunning: 10 }, + regionCosts: [{ region: "sa-santiago-1", cost: 100, currency: "USD" }], + longestServerRun: { createdAt: 0, createdBy: "user", serverId: "server1", terminatedAt: 1000, durationMinutes: 160 }, + regionMetrics: [{ region: "sa-santiago-1", timePlayedMinutes: 500 }], + totalServersCreated: 50, + totalTimePlayedMinutes: 10000, + topUsers: [{ userId: "user1", totalTimePlayedMinutes: 3000 }], + year: 2025, + uniqueUsersCount: 25, + }); + + const runJob = async (sut: ReturnType["sut"], dependencies: any, scheduleMock: any, now: Date) => { + vi.setSystemTime(now); + sut(dependencies); + const scheduledJobCallback = scheduleMock.scheduleJob.mock.calls[scheduleMock.scheduleJob.mock.calls.length - 1][1]; + await scheduledJobCallback(now); + } + describe("Scheduling", () => { it("should schedule the job to run monthly at the first day of the month at 15:00", () => { @@ -53,23 +93,36 @@ describe("MonthlyUsageReportRoutine", () => { describe("Job Execution", () => { // Given - const { sut, dependencies, scheduleMock, channelGetFn } = makeSut(); + const { sut, dependencies, scheduleMock, channelGetFn, channelFetchFn } = makeSut(); const now = new Date("2025-11-15T10:00:00Z"); // Fixed current date to Nov 15, 2025 vi.setSystemTime(now); const reportChannelId = "test-channel-id"; + const moneyChannelId = "money-channel-id"; dependencies.configManager.getDiscordConfig.mockReturnValue({ reportDiscordChannelId: reportChannelId, logChannelId: "log-channel-id", streamerChannelId: "streamer-channel-id", supportChannelId: "1365408843676520508", - }); + moneyChannelId, + moneyOwnerDiscordIds: ["owner-id"], + } as any); const sendFn = vi.fn(); + const moneySendFn = vi.fn(); channelGetFn.mockReturnValue({ type: 0, // GuildText send: sendFn, }); + channelFetchFn.mockImplementation((channelId: string) => { + if (channelId === moneyChannelId) { + return { type: 0, send: moneySendFn }; + } + return undefined; + }); + + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 9, skipped: 0, noCost: 0, failed: 0 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); dependencies.generateMonthlyUsageReport.execute.mockResolvedValue({ averageServerDurationMinutes: 120, @@ -84,22 +137,22 @@ describe("MonthlyUsageReportRoutine", () => { year: 2025, uniqueUsersCount: 25, }) - // When - - sut(dependencies); - - const scheduledJobCallback = scheduleMock.scheduleJob.mock.calls[0][1]; - scheduledJobCallback(now); - // Then + it("should call generateMonthlyUsageReport.execute with the previous month's date", async () => { + // When + await runJob(sut, dependencies, scheduleMock, now); - it("should call generateMonthlyUsageReport.execute with the previous month's date", () => { + // Then expect(dependencies.generateMonthlyUsageReport.execute).toHaveBeenCalledWith({ date: new Date(2025, 9, 1) // October 1, 2025 }) }) it("should send the report to the configured Discord channel", async () => { + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then expect(channelGetFn).toHaveBeenCalledWith(reportChannelId); expect(sendFn).toHaveBeenCalledWith(`@everyone 📊 **TF2-QuickServer | October 2025 Metrics & Costs** @@ -129,5 +182,304 @@ describe("MonthlyUsageReportRoutine", () => { * 👥 **Unique users:** **25** See <#1365408843676520508> to help!`); }) + + it("should settle costs for the reported month and year", async () => { + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(dependencies.monthlyCostSettlement.execute).toHaveBeenCalledWith({ month: 10, year: 2025 }); + }) + + it("should post the cost and balance summary to the money channel", async () => { + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(channelFetchFn).toHaveBeenCalledWith(moneyChannelId); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("costs for 10/2025 settled")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("New cost entries: 9")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("Already recorded: 0")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("No cost: 0")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("Failed: 0")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("R$ 123.45")); + }) + }) + + describe("Money Settlement Failures", () => { + const makeFailureSut = () => { + const { sut, scheduleMock, dependencies, channelGetFn, channelFetchFn } = makeSut(); + const now = new Date("2025-11-15T10:00:00Z"); + vi.setSystemTime(now); + dependencies.configManager.getDiscordConfig.mockReturnValue({ + reportDiscordChannelId: "test-channel-id", + logChannelId: "log-channel-id", + streamerChannelId: "streamer-channel-id", + supportChannelId: "1365408843676520508", + moneyChannelId: "money-channel-id", + moneyOwnerDiscordIds: ["owner-id"], + } as any); + const sendFn = vi.fn(); + const moneySendFn = vi.fn(); + channelGetFn.mockReturnValue({ type: 0, send: sendFn }); + channelFetchFn.mockImplementation((channelId: string) => + channelId === "money-channel-id" ? { type: 0, send: moneySendFn } : undefined + ); + dependencies.generateMonthlyUsageReport.execute.mockResolvedValue(makeReport() as any); + return { sut, scheduleMock, dependencies, channelGetFn, channelFetchFn, sendFn, moneySendFn, now }; + } + + it("should still deliver the report when settlement fails", async () => { + // Given + const { sut, scheduleMock, dependencies, sendFn, now } = makeFailureSut(); + dependencies.monthlyCostSettlement.execute.mockRejectedValue(new Error("PTAX down")); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 0 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(sendFn).toHaveBeenCalled(); + expect(dependencies.eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: expect.stringContaining("Error during monthly cost settlement"), + })); + }) + + it("should still deliver the report when the settlement error path cannot log", async () => { + // Given + const { sut, scheduleMock, dependencies, sendFn, now } = makeFailureSut(); + dependencies.monthlyCostSettlement.execute.mockRejectedValue(new Error("PTAX down")); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 0 }); + dependencies.eventLogger.log.mockImplementation(async ({ eventMessage }: { eventMessage: string }) => { + if (eventMessage.includes("Error during monthly cost settlement")) { + throw new Error("Discord log channel gone"); + } + }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(sendFn).toHaveBeenCalled(); + expect(vi.mocked(logger.emit)).toHaveBeenCalledWith(expect.objectContaining({ + severityText: "ERROR", + body: "Failed to record a monthly cost settlement event", + attributes: expect.objectContaining({ + eventMessage: expect.stringContaining("Error during monthly cost settlement"), + }), + })); + }) + + it("should report the failed segments in the money summary", async () => { + // Given + const { sut, scheduleMock, dependencies, moneySendFn, now } = makeFailureSut(); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 8, skipped: 0, noCost: 0, failed: 1 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("Money ledger — costs for 10/2025: 1 failed segment(s)")); + expect(moneySendFn).toHaveBeenCalledWith(expect.not.stringContaining("costs for 10/2025 settled")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("New cost entries: 8")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("Already recorded: 0")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("No cost: 0")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("Failed: 1")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("R$ 123.45")); + }) + + it("should record a fully settled month as settled", async () => { + // Given + const { sut, scheduleMock, dependencies, now } = makeFailureSut(); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 9, skipped: 0, noCost: 0, failed: 0 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(dependencies.eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: "Monthly costs for 10/2025 settled and posted to money channel.", + })); + }) + + it("should record a month with failed segments as posted with failures instead of settled", async () => { + // Given + const { sut, scheduleMock, dependencies, now } = makeFailureSut(); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 0, skipped: 0, noCost: 0, failed: 9 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 0 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(dependencies.eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: "Monthly costs for 10/2025 posted with 9 failed segment(s).", + })); + expect(dependencies.eventLogger.log).not.toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: expect.stringContaining("settled"), + })); + }) + + it("should report no-cost segments separately from the already recorded ones", async () => { + // Given + const { sut, scheduleMock, dependencies, moneySendFn, now } = makeFailureSut(); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 2, skipped: 1, noCost: 5, failed: 0 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("New cost entries: 2")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("Already recorded: 1")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("No cost: 5")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("Failed: 0")); + }) + + it("should settle the costs when no money channel is configured", async () => { + // Given + const { sut, scheduleMock, dependencies, channelFetchFn, sendFn, moneySendFn, now } = makeFailureSut(); + dependencies.configManager.getDiscordConfig.mockReturnValue({ + reportDiscordChannelId: "test-channel-id", + logChannelId: "log-channel-id", + streamerChannelId: "streamer-channel-id", + supportChannelId: "1365408843676520508", + moneyChannelId: "", + moneyOwnerDiscordIds: ["owner-id"], + } as any); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 9, skipped: 0, noCost: 0, failed: 0 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(dependencies.monthlyCostSettlement.execute).toHaveBeenCalledWith({ month: 10, year: 2025 }); + expect(channelFetchFn).not.toHaveBeenCalled(); + expect(moneySendFn).not.toHaveBeenCalled(); + expect(sendFn).toHaveBeenCalled(); + expect(dependencies.eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: "Money Channel is not configured, so the monthly cost summary was not posted.", + })); + expect(dependencies.eventLogger.log).not.toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: expect.stringContaining("skipped"), + })); + }) + + it("should still deliver the report when the money channel is missing", async () => { + // Given + const { sut, scheduleMock, dependencies, channelFetchFn, sendFn, moneySendFn, now } = makeFailureSut(); + channelFetchFn.mockImplementation((channelId: string) => + channelId === "money-channel-id" ? undefined : { type: 0, send: sendFn } + ); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 0, skipped: 9, noCost: 0, failed: 0 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 100 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(sendFn).toHaveBeenCalled(); + expect(moneySendFn).not.toHaveBeenCalled(); + expect(dependencies.eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: "Money Channel not delivered: money-channel-id", + })); + }) + + it("should still deliver the report when the money channel fetch rejects", async () => { + // Given + const { sut, scheduleMock, dependencies, channelFetchFn, sendFn, moneySendFn, now } = makeFailureSut(); + channelFetchFn.mockRejectedValue(new Error("fetch boom")); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 0, skipped: 9, noCost: 0, failed: 0 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 100 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(sendFn).toHaveBeenCalled(); + expect(moneySendFn).not.toHaveBeenCalled(); + expect(dependencies.eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: "Money Channel not delivered: money-channel-id", + })); + }) + }) + + describe("Settlement Independence From Report Delivery", () => { + const makeIndependenceSut = () => { + const { sut, scheduleMock, dependencies, channelGetFn, channelFetchFn } = makeSut(); + const now = new Date("2025-11-15T10:00:00Z"); + vi.setSystemTime(now); + dependencies.configManager.getDiscordConfig.mockReturnValue({ + reportDiscordChannelId: "test-channel-id", + logChannelId: "log-channel-id", + streamerChannelId: "streamer-channel-id", + supportChannelId: "1365408843676520508", + moneyChannelId: "money-channel-id", + moneyOwnerDiscordIds: ["owner-id"], + } as any); + const sendFn = vi.fn(); + const moneySendFn = vi.fn(); + channelGetFn.mockReturnValue({ type: 0, send: sendFn }); + channelFetchFn.mockReturnValue({ type: 0, send: moneySendFn }); + dependencies.generateMonthlyUsageReport.execute.mockResolvedValue(makeReport() as any); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 9, skipped: 0, noCost: 0, failed: 0 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); + return { sut, scheduleMock, dependencies, channelGetFn, channelFetchFn, sendFn, moneySendFn, now }; + } + + it("should settle the costs when the report channel is not configured", async () => { + // Given + const { sut, scheduleMock, dependencies, sendFn, moneySendFn, now } = makeIndependenceSut(); + dependencies.configManager.getDiscordConfig.mockReturnValue({ + reportDiscordChannelId: "", + logChannelId: "log-channel-id", + streamerChannelId: "streamer-channel-id", + supportChannelId: "1365408843676520508", + moneyChannelId: "money-channel-id", + moneyOwnerDiscordIds: ["owner-id"], + } as any); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(dependencies.monthlyCostSettlement.execute).toHaveBeenCalledWith({ month: 10, year: 2025 }); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("costs for 10/2025 settled")); + expect(sendFn).not.toHaveBeenCalled(); + }) + + it("should settle the costs when the report channel is missing", async () => { + // Given + const { sut, scheduleMock, dependencies, channelGetFn, sendFn, moneySendFn, now } = makeIndependenceSut(); + channelGetFn.mockReturnValue(undefined); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(dependencies.monthlyCostSettlement.execute).toHaveBeenCalledWith({ month: 10, year: 2025 }); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("costs for 10/2025 settled")); + expect(sendFn).not.toHaveBeenCalled(); + expect(dependencies.eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: "Monthly Usage Report Channel not found: test-channel-id", + })); + }) + + it("should settle the costs when the report channel is not a text channel", async () => { + // Given + const { sut, scheduleMock, dependencies, channelGetFn, sendFn, moneySendFn, now } = makeIndependenceSut(); + channelGetFn.mockReturnValue({ type: ChannelType.DM, send: sendFn }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(dependencies.monthlyCostSettlement.execute).toHaveBeenCalledWith({ month: 10, year: 2025 }); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("costs for 10/2025 settled")); + expect(sendFn).not.toHaveBeenCalled(); + }) }) }) \ No newline at end of file diff --git a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts index b091d74..605287f 100644 --- a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts +++ b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts @@ -1,16 +1,23 @@ import { Client, ChannelType } from "discord.js"; import schedule from "node-schedule"; +import { DiscordConfig } from "@tf2qs/core"; import { GenerateMonthlyUsageReport } from "@tf2qs/core"; import { ConfigManager } from "@tf2qs/core"; import { EventLogger } from "@tf2qs/core"; +import { GetBalance } from "@tf2qs/core"; +import { MonthlyCostSettlement } from "@tf2qs/core"; +import { toDisplayBrl } from "@tf2qs/core"; import { logger } from "@tf2qs/telemetry"; import { MonthlyReportFormatter } from "@tf2qs/providers"; +import { sendMoneyChannelMessage } from "../discord/sendMoneyChannelMessage"; export type ScheduleMonthlyUsageReportRoutineDependencies = { generateMonthlyUsageReport: GenerateMonthlyUsageReport; configManager: ConfigManager; eventLogger: EventLogger; discordClient: Client; + monthlyCostSettlement: MonthlyCostSettlement; + getBalance: GetBalance; }; export const scheduleMonthlyUsageReportRoutine = ( @@ -31,6 +38,17 @@ export const scheduleMonthlyUsageReportRoutine = ( const report = await generateMonthlyUsageReport.execute({ date: previousMonthDate }); const discordConfig = configManager.getDiscordConfig(); + + await settleMonthlyCosts({ + monthlyCostSettlement: dependencies.monthlyCostSettlement, + getBalance: dependencies.getBalance, + discordConfig, + discordClient, + eventLogger, + month: report.month, + year: report.year, + }); + const reportChannelId = discordConfig.reportDiscordChannelId; if (!reportChannelId) { @@ -95,3 +113,86 @@ export const scheduleMonthlyUsageReportRoutine = ( }); }; + +type SettleMonthlyCostsParams = { + monthlyCostSettlement: MonthlyCostSettlement; + getBalance: GetBalance; + discordConfig: DiscordConfig; + discordClient: Client; + eventLogger: EventLogger; + month: number; + year: number; +}; + +async function settleMonthlyCosts(params: SettleMonthlyCostsParams): Promise { + const { monthlyCostSettlement, getBalance, discordConfig, discordClient, eventLogger, month, year } = params; + + try { + const moneyChannelId = discordConfig.moneyChannelId; + + const settlement = await monthlyCostSettlement.execute({ month, year }); + const { balanceBrlCents } = await getBalance.execute(); + + const moneySummaryHeader = settlement.failed > 0 + ? `⚠️ **Money ledger — costs for ${month}/${year}: ${settlement.failed} failed segment(s)**` + : `💰 **Money ledger — costs for ${month}/${year} settled**`; + + const delivered = await sendMoneyChannelMessage({ + discordClient, + moneyChannelId, + content: `${moneySummaryHeader}\n* New cost entries: ${settlement.inserted}\n* Already recorded: ${settlement.skipped}\n* No cost: ${settlement.noCost}\n* Failed: ${settlement.failed}\n* Current balance: R$ ${toDisplayBrl({ balanceBrlCents })}`, + }); + + if (!delivered) { + await logSettlementEvent({ + eventLogger, + eventMessage: moneyChannelId + ? `Money Channel not delivered: ${moneyChannelId}` + : "Money Channel is not configured, so the monthly cost summary was not posted.", + }); + return; + } + + await logSettlementEvent({ + eventLogger, + eventMessage: settlement.failed > 0 + ? `Monthly costs for ${month}/${year} posted with ${settlement.failed} failed segment(s).` + : `Monthly costs for ${month}/${year} settled and posted to money channel.`, + }); + } catch (error) { + logger.emit({ + severityText: "ERROR", + body: "Error during monthly cost settlement", + attributes: { + error: JSON.stringify(error, Object.getOwnPropertyNames(error)), + }, + }); + await logSettlementEvent({ + eventLogger, + eventMessage: `Error during monthly cost settlement: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } +} + +type LogSettlementEventParams = { + eventLogger: EventLogger; + eventMessage: string; +}; + +async function logSettlementEvent(params: LogSettlementEventParams): Promise { + const { eventLogger, eventMessage } = params; + try { + await eventLogger.log({ eventMessage, actorId: "system" }); + } catch (error) { + logger.emit({ + severityText: "ERROR", + body: "Failed to record a monthly cost settlement event", + attributes: { + eventMessage, + error: JSON.stringify(error, Object.getOwnPropertyNames(error)), + }, + }); + } +} diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts index 9bc5ee6..a681ec3 100644 --- a/packages/providers/src/repository/SQLiteLedgerRepository.test.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.test.ts @@ -70,6 +70,25 @@ describe("SQLiteLedgerRepository", () => { expect(balance).toBe(0); }); + it("should report an empty ledger", async () => { + // When + const hasTransactions = await sut.hasTransactions(); + + // Then + expect(hasTransactions).toBe(false); + }); + + it("should report a ledger with one entry", async () => { + // Given + await sut.insert({ entry: makeEntry() }); + + // When + const hasTransactions = await sut.hasTransactions(); + + // Then + expect(hasTransactions).toBe(true); + }); + it("should sum opening plus donation minus cost entries", async () => { // Given await sut.insert({ diff --git a/packages/providers/src/repository/SQLiteLedgerRepository.ts b/packages/providers/src/repository/SQLiteLedgerRepository.ts index fcce33a..d2cabe7 100644 --- a/packages/providers/src/repository/SQLiteLedgerRepository.ts +++ b/packages/providers/src/repository/SQLiteLedgerRepository.ts @@ -42,6 +42,13 @@ export class SQLiteLedgerRepository implements LedgerRepository { return Boolean(row); } + async hasTransactions(): Promise { + const row = await this.dependencies.knex('ledger_transactions') + .select('id') + .first(); + return Boolean(row); + } + async getBalance(): Promise { const row = await this.dependencies.knex('ledger_transactions') .sum('amount_brl_cents as total')