From 9f56fea35946f3f794b967b92a86283131a2dcad Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Sat, 10 Oct 2026 23:48:43 -0300 Subject: [PATCH 01/10] feat(ledger): add owner-only donation and balance commands --- config/default.json | 4 + packages/core/src/domain/DiscordConfig.ts | 2 + .../src/commands/Balance/definition.ts | 6 + .../src/commands/Balance/handler.test.ts | 117 ++++++++ .../src/commands/Balance/handler.ts | 50 ++++ .../entrypoints/src/commands/Balance/index.ts | 2 + .../commands/RegisterDonation/definition.ts | 25 ++ .../commands/RegisterDonation/handler.test.ts | 252 ++++++++++++++++++ .../src/commands/RegisterDonation/handler.ts | 108 ++++++++ .../src/commands/RegisterDonation/index.ts | 2 + packages/entrypoints/src/commands/index.ts | 30 ++- 11 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 packages/entrypoints/src/commands/Balance/definition.ts create mode 100644 packages/entrypoints/src/commands/Balance/handler.test.ts create mode 100644 packages/entrypoints/src/commands/Balance/handler.ts create mode 100644 packages/entrypoints/src/commands/Balance/index.ts create mode 100644 packages/entrypoints/src/commands/RegisterDonation/definition.ts create mode 100644 packages/entrypoints/src/commands/RegisterDonation/handler.test.ts create mode 100644 packages/entrypoints/src/commands/RegisterDonation/handler.ts create mode 100644 packages/entrypoints/src/commands/RegisterDonation/index.ts 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/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/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..a275c24 --- /dev/null +++ b/packages/entrypoints/src/commands/Balance/handler.ts @@ -0,0 +1,50 @@ +import { ChatInputCommandInteraction, MessageFlags, PermissionFlagsBits } from "discord.js"; +import { GetBalance, toDisplayBrl } from "@tf2qs/core"; +import { commandErrorHandler } from "../commandErrorHandler"; + +type BalanceCommandHandlerFactoryDependencies = { + getBalance: GetBalance; + allowedUserIds: string[]; +}; + +export function createBalanceCommandHandlerFactory(dependencies: BalanceCommandHandlerFactoryDependencies) { + return async function balanceCommandHandler(interaction: ChatInputCommandInteraction) { + const { getBalance, allowedUserIds } = dependencies; + + if (!interaction.guildId) { + await interaction.reply({ + content: '❌ This command is only available within a Discord server, not in direct messages.', + flags: MessageFlags.Ephemeral + }); + return; + } + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { + await interaction.reply({ + content: '❌ Only users with the **Administrator** permission can check the balance.', + flags: MessageFlags.Ephemeral + }); + return; + } + + if (!allowedUserIds.includes(interaction.user.id)) { + await interaction.reply({ + content: '❌ You are not authorized to use this command.', + flags: MessageFlags.Ephemeral + }); + 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.ts b/packages/entrypoints/src/commands/RegisterDonation/definition.ts new file mode 100644 index 0000000..c329920 --- /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)) + .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..da300be --- /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: expect.stringContaining('could not be found'), + 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: expect.stringContaining('could not be found'), + 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..9b41189 --- /dev/null +++ b/packages/entrypoints/src/commands/RegisterDonation/handler.ts @@ -0,0 +1,108 @@ +import { ChannelType, ChatInputCommandInteraction, Client, MessageFlags, PermissionFlagsBits } from "discord.js"; +import { GetBalance, LedgerCurrency, LedgerTransaction, RegisterDonation, toDisplayBrl } from "@tf2qs/core"; +import { commandErrorHandler } from "../commandErrorHandler"; + +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; + + if (!interaction.guildId) { + await interaction.reply({ + content: '❌ This command is only available within a Discord server, not in direct messages.', + flags: MessageFlags.Ephemeral + }); + return; + } + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { + await interaction.reply({ + content: '❌ Only users with the **Administrator** permission can register donations.', + flags: MessageFlags.Ephemeral + }); + return; + } + + if (!allowedUserIds.includes(interaction.user.id)) { + await interaction.reply({ + content: '❌ You are not authorized to use this command.', + flags: MessageFlags.Ephemeral + }); + 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: ${formatDonationAmount({ 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 FormatDonationAmountParams = { + entry: LedgerTransaction; +}; + +function formatDonationAmount(params: FormatDonationAmountParams): 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 })})`; +} + +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; + try { + const channel = await discordClient.channels.fetch(moneyChannelId); + if (!channel || channel.type !== ChannelType.GuildText) { + await interaction.followUp({ + content: '⚠️ Donation recorded, but the #money channel could not be found.', + flags: MessageFlags.Ephemeral + }); + return; + } + await channel.send( + `💰 New donation: ${formatDonationAmount({ entry })} from "${entry.source}". Current balance: R$ ${toDisplayBrl({ balanceBrlCents })}.` + ); + } catch { + await interaction.followUp({ + content: '⚠️ Donation recorded, but posting to #money failed.', + 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/index.ts b/packages/entrypoints/src/commands/index.ts index b2c3d7d..4812578 100644 --- a/packages/entrypoints/src/commands/index.ts +++ b/packages/entrypoints/src/commands/index.ts @@ -1,5 +1,7 @@ -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 { GetServerStatus } from "@tf2qs/core"; import { GetGuildServers } from "@tf2qs/core"; import { GetUserServers } from "@tf2qs/core"; @@ -19,6 +21,8 @@ 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"; export type CommandDependencies = { createServerForUser: CreateServerForUser; @@ -32,6 +36,11 @@ export type CommandDependencies = { cancelScheduledServer: CancelScheduledServer; comparePlayerSharedIps: ComparePlayerSharedIps; ipHistoryAdminDiscordIds: string[]; + registerDonation: RegisterDonation; + getBalance: GetBalance; + moneyOwnerDiscordIds: string[]; + moneyChannelId: string; + discordClient: Client; } export function createCommands(dependencies: CommandDependencies) { @@ -107,6 +116,25 @@ 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, + }) } } satisfies Record Date: Sat, 10 Oct 2026 23:48:55 -0300 Subject: [PATCH 02/10] feat(ledger): wire donation commands and monthly settlement routine --- packages/entrypoints/src/discordBot.ts | 33 +++++ .../jobs/MonthlyUsageReportRoutine.test.ts | 113 +++++++++++++++++- .../src/jobs/MonthlyUsageReportRoutine.ts | 90 ++++++++++++++ 3 files changed, 231 insertions(+), 5 deletions(-) diff --git a/packages/entrypoints/src/discordBot.ts b/packages/entrypoints/src/discordBot.ts index 4717ed8..da7b594 100644 --- a/packages/entrypoints/src/discordBot.ts +++ b/packages/entrypoints/src/discordBot.ts @@ -15,6 +15,10 @@ 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 { CheckForTf2Update } from "@tf2qs/core"; import { TerminateEmptyServers } from "@tf2qs/core"; import { TerminateLongRunningServers } from "@tf2qs/core"; @@ -37,6 +41,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 +217,21 @@ 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 discordCommands = createCommands({ createServerForUser, setUserData: new SetUserData({ @@ -231,6 +252,11 @@ export async function startDiscordBot() { backgroundTaskQueue, comparePlayerSharedIps: new ComparePlayerSharedIps({ playerConnectionHistoryRepository }), ipHistoryAdminDiscordIds: defaultConfigManager.getDiscordConfig().ipHistoryAdminDiscordIds, + registerDonation, + getBalance, + moneyOwnerDiscordIds: defaultConfigManager.getDiscordConfig().moneyOwnerDiscordIds, + moneyChannelId: defaultConfigManager.getDiscordConfig().moneyChannelId, + discordClient: client, }) // Schedule jobs @@ -304,6 +330,13 @@ export async function startDiscordBot() { configManager: defaultConfigManager, eventLogger, discordClient: client, + monthlyCostSettlement: new MonthlyCostSettlement({ + costProvider: defaultCostProvider, + exchangeRateProvider, + registerCost: new RegisterCost({ ledgerRepository }), + 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..47d66c5 100644 --- a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts +++ b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts @@ -5,6 +5,8 @@ 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 { scheduleMonthlyUsageReportRoutine } from "./MonthlyUsageReportRoutine"; vi.mock("node-schedule") @@ -26,6 +28,8 @@ describe("MonthlyUsageReportRoutine", () => { }), eventLogger: mock(), generateMonthlyUsageReport: mock(), + monthlyCostSettlement: mock(), + getBalance: mock(), } return { @@ -58,19 +62,31 @@ describe("MonthlyUsageReportRoutine", () => { 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(); - channelGetFn.mockReturnValue({ - type: 0, // GuildText - send: sendFn, + const moneySendFn = vi.fn(); + channelGetFn.mockImplementation((channelId: string) => { + if (channelId === moneyChannelId) { + return { type: 0, send: moneySendFn }; + } + return { + type: 0, // GuildText + send: sendFn, + }; }); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 9, skipped: 0 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); + dependencies.generateMonthlyUsageReport.execute.mockResolvedValue({ averageServerDurationMinutes: 120, month: 10, @@ -89,7 +105,7 @@ describe("MonthlyUsageReportRoutine", () => { sut(dependencies); const scheduledJobCallback = scheduleMock.scheduleJob.mock.calls[0][1]; - scheduledJobCallback(now); + const jobPromise = scheduledJobCallback(now); // Then @@ -129,5 +145,92 @@ describe("MonthlyUsageReportRoutine", () => { * 👥 **Unique users:** **25** See <#1365408843676520508> to help!`); }) + + it("should settle costs for the reported month and year", async () => { + await jobPromise; + expect(dependencies.monthlyCostSettlement.execute).toHaveBeenCalledWith({ month: 10, year: 2025 }); + }) + + it("should post the cost and balance summary to the money channel", async () => { + await jobPromise; + expect(channelGetFn).toHaveBeenCalledWith(moneyChannelId); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("costs for 10/2025 settled")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("R$ 123.45")); + }) + }) + + describe("Money Settlement Failures", () => { + 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 makeFailureSut = () => { + const { sut, scheduleMock, dependencies, channelGetFn } = 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(); + channelGetFn.mockReturnValue({ type: 0, send: sendFn }); + dependencies.generateMonthlyUsageReport.execute.mockResolvedValue(makeReport() as any); + return { sut, scheduleMock, dependencies, channelGetFn, sendFn, now }; + } + + const runJob = async (sut: ReturnType["sut"], dependencies: any, scheduleMock: any, now: Date) => { + sut(dependencies); + const scheduledJobCallback = scheduleMock.scheduleJob.mock.calls[scheduleMock.scheduleJob.mock.calls.length - 1][1]; + await scheduledJobCallback(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 money channel is missing", async () => { + // Given + const { sut, scheduleMock, dependencies, channelGetFn, sendFn, now } = makeFailureSut(); + channelGetFn.mockImplementation((channelId: string) => + channelId === "money-channel-id" ? undefined : { type: 0, send: sendFn } + ); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 0, skipped: 9 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 100 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(sendFn).toHaveBeenCalled(); + expect(dependencies.eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ + eventMessage: expect.stringContaining("Money Channel not found"), + })); + }) }) }) \ No newline at end of file diff --git a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts index b091d74..16d15c5 100644 --- a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts +++ b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts @@ -1,8 +1,12 @@ 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"; @@ -11,6 +15,8 @@ export type ScheduleMonthlyUsageReportRoutineDependencies = { configManager: ConfigManager; eventLogger: EventLogger; discordClient: Client; + monthlyCostSettlement: MonthlyCostSettlement; + getBalance: GetBalance; }; export const scheduleMonthlyUsageReportRoutine = ( @@ -77,6 +83,16 @@ export const scheduleMonthlyUsageReportRoutine = ( eventMessage: `Monthly Usage Report for ${report.month}/${report.year} posted successfully.`, actorId: "system", }); + + await settleMonthlyCosts({ + monthlyCostSettlement: dependencies.monthlyCostSettlement, + getBalance: dependencies.getBalance, + discordConfig, + discordClient, + eventLogger, + month: report.month, + year: report.year, + }); } catch (error) { logger.emit({ severityText: "ERROR", @@ -95,3 +111,77 @@ 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; + + if (!moneyChannelId) { + logger.emit({ + severityText: "WARN", + body: "Money Channel ID is not configured. Skipping money settlement post.", + }); + return; + } + + const settlement = await monthlyCostSettlement.execute({ month, year }); + const { balanceBrlCents } = await getBalance.execute(); + + const channel = discordClient.channels.cache.get(moneyChannelId); + + if (!channel) { + logger.emit({ + severityText: "ERROR", + body: `Money Channel not found: ${moneyChannelId}`, + }); + await eventLogger.log({ + eventMessage: `Money Channel not found: ${moneyChannelId}`, + actorId: "system", + }); + return; + } + + if (channel.type !== ChannelType.GuildText) { + logger.emit({ + severityText: "ERROR", + body: `Money Channel is not a text channel: ${moneyChannelId}`, + }); + return; + } + + await channel.send( + `💰 **Money ledger — costs for ${month}/${year} settled**\n* New cost entries: ${settlement.inserted}\n* Already recorded: ${settlement.skipped}\n* Current balance: R$ ${toDisplayBrl({ balanceBrlCents })}` + ); + + await eventLogger.log({ + eventMessage: `Monthly costs for ${month}/${year} settled and posted to money channel.`, + actorId: "system", + }); + } catch (error) { + logger.emit({ + severityText: "ERROR", + body: "Error during monthly cost settlement", + attributes: { + error: JSON.stringify(error, Object.getOwnPropertyNames(error)), + }, + }); + await eventLogger.log({ + eventMessage: `Error during monthly cost settlement: ${ + error instanceof Error ? error.message : String(error) + }`, + actorId: "system", + }); + } +} From e903021fdc6e28a27fcba69bb7fb4bdb86e6e82e Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:25:10 -0300 Subject: [PATCH 03/10] fix(entrypoints): pass the money config to the monthly cost settlement The settlement now takes the parsed money config as a required dependency, so the composition root has to provide it or the branch does not compile. --- packages/entrypoints/src/discordBot.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/entrypoints/src/discordBot.ts b/packages/entrypoints/src/discordBot.ts index da7b594..9fa59d9 100644 --- a/packages/entrypoints/src/discordBot.ts +++ b/packages/entrypoints/src/discordBot.ts @@ -334,6 +334,7 @@ export async function startDiscordBot() { costProvider: defaultCostProvider, exchangeRateProvider, registerCost: new RegisterCost({ ledgerRepository }), + moneyConfig: defaultConfigManager.getMoneyConfig(), eventLogger, }), getBalance, From 65c21880109e0e1fc5eed9037f4355102221e9bb Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:25:10 -0300 Subject: [PATCH 04/10] feat(core): let the ledger repository report whether it holds entries The opening balance may only be set once, so the use case that sets it needs to ask the ledger whether anything is already recorded. Expose that as an existence probe rather than a count, since nothing needs the number. --- .../core/src/repository/LedgerRepository.ts | 1 + .../repository/SQLiteLedgerRepository.test.ts | 19 +++++++++++++++++++ .../src/repository/SQLiteLedgerRepository.ts | 7 +++++++ 3 files changed, 27 insertions(+) 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/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') From b263e0bd11eed29db0298833061e40250de76a00 Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:29:22 -0300 Subject: [PATCH 05/10] feat(core): add the opening balance use case The ledger needs an opening balance, and it may only ever be set once: an opening recorded twice would double the money the ledger believes it has. Add a use case that refuses to run unless the ledger is empty, converts a USD opening at the PTAX rate while a BRL opening needs no rate at all, and records the entry as OPENING with the admin who set it as the actor. The dedupe key is a ledger-wide constant, so the unique index is the real guarantee: two concurrent invocations that both see an empty ledger cannot both write an opening. --- packages/core/index.ts | 1 + .../core/src/domain/LedgerTransaction.test.ts | 10 +- packages/core/src/domain/LedgerTransaction.ts | 4 + .../core/src/usecase/SetLedgerOpening.test.ts | 135 ++++++++++++++++++ packages/core/src/usecase/SetLedgerOpening.ts | 71 +++++++++ 5 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/usecase/SetLedgerOpening.test.ts create mode 100644 packages/core/src/usecase/SetLedgerOpening.ts 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/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/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.'); + } +} From faa241ca036a4d8d8a8efd16893dc519592ce72b Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:31:57 -0300 Subject: [PATCH 06/10] refactor(entrypoints): extract the shared money owner guard The balance and donation commands each repeated the same three checks: guild only, Administrator permission, and the owner allowlist. Three copies of an authorization check is how one of them ends up weaker than the others when someone edits just one. Move the checks into one guard that takes the action it is protecting, and have both handlers call it. The replies are unchanged, so nothing the owner sees changes. --- .../src/commands/Balance/handler.ts | 26 +--- .../src/commands/RegisterDonation/handler.ts | 26 +--- .../src/commands/requireMoneyOwner.test.ts | 111 ++++++++++++++++++ .../src/commands/requireMoneyOwner.ts | 37 ++++++ 4 files changed, 156 insertions(+), 44 deletions(-) create mode 100644 packages/entrypoints/src/commands/requireMoneyOwner.test.ts create mode 100644 packages/entrypoints/src/commands/requireMoneyOwner.ts diff --git a/packages/entrypoints/src/commands/Balance/handler.ts b/packages/entrypoints/src/commands/Balance/handler.ts index a275c24..807ce9a 100644 --- a/packages/entrypoints/src/commands/Balance/handler.ts +++ b/packages/entrypoints/src/commands/Balance/handler.ts @@ -1,6 +1,7 @@ -import { ChatInputCommandInteraction, MessageFlags, PermissionFlagsBits } from "discord.js"; +import { ChatInputCommandInteraction, MessageFlags } from "discord.js"; import { GetBalance, toDisplayBrl } from "@tf2qs/core"; import { commandErrorHandler } from "../commandErrorHandler"; +import { requireMoneyOwner } from "../requireMoneyOwner"; type BalanceCommandHandlerFactoryDependencies = { getBalance: GetBalance; @@ -11,27 +12,8 @@ export function createBalanceCommandHandlerFactory(dependencies: BalanceCommandH return async function balanceCommandHandler(interaction: ChatInputCommandInteraction) { const { getBalance, allowedUserIds } = dependencies; - if (!interaction.guildId) { - await interaction.reply({ - content: '❌ This command is only available within a Discord server, not in direct messages.', - flags: MessageFlags.Ephemeral - }); - return; - } - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { - await interaction.reply({ - content: '❌ Only users with the **Administrator** permission can check the balance.', - flags: MessageFlags.Ephemeral - }); - return; - } - - if (!allowedUserIds.includes(interaction.user.id)) { - await interaction.reply({ - content: '❌ You are not authorized to use this command.', - flags: MessageFlags.Ephemeral - }); + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action: 'check the balance' }); + if (!authorized) { return; } diff --git a/packages/entrypoints/src/commands/RegisterDonation/handler.ts b/packages/entrypoints/src/commands/RegisterDonation/handler.ts index 9b41189..b89c69a 100644 --- a/packages/entrypoints/src/commands/RegisterDonation/handler.ts +++ b/packages/entrypoints/src/commands/RegisterDonation/handler.ts @@ -1,6 +1,7 @@ -import { ChannelType, ChatInputCommandInteraction, Client, MessageFlags, PermissionFlagsBits } from "discord.js"; +import { ChannelType, ChatInputCommandInteraction, Client, MessageFlags } from "discord.js"; import { GetBalance, LedgerCurrency, LedgerTransaction, RegisterDonation, toDisplayBrl } from "@tf2qs/core"; import { commandErrorHandler } from "../commandErrorHandler"; +import { requireMoneyOwner } from "../requireMoneyOwner"; type RegisterDonationCommandHandlerFactoryDependencies = { registerDonation: RegisterDonation; @@ -14,27 +15,8 @@ export function createRegisterDonationCommandHandlerFactory(dependencies: Regist return async function registerDonationCommandHandler(interaction: ChatInputCommandInteraction) { const { registerDonation, getBalance, allowedUserIds, moneyChannelId, discordClient } = dependencies; - if (!interaction.guildId) { - await interaction.reply({ - content: '❌ This command is only available within a Discord server, not in direct messages.', - flags: MessageFlags.Ephemeral - }); - return; - } - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { - await interaction.reply({ - content: '❌ Only users with the **Administrator** permission can register donations.', - flags: MessageFlags.Ephemeral - }); - return; - } - - if (!allowedUserIds.includes(interaction.user.id)) { - await interaction.reply({ - content: '❌ You are not authorized to use this command.', - flags: MessageFlags.Ephemeral - }); + const authorized = await requireMoneyOwner({ interaction, allowedUserIds, action: 'register donations' }); + if (!authorized) { return; } diff --git a/packages/entrypoints/src/commands/requireMoneyOwner.test.ts b/packages/entrypoints/src/commands/requireMoneyOwner.test.ts new file mode 100644 index 0000000..702151b --- /dev/null +++ b/packages/entrypoints/src/commands/requireMoneyOwner.test.ts @@ -0,0 +1,111 @@ +import { ChatInputCommandInteraction, MessageFlags, PermissionsBitField } from "discord.js"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; +import { requireMoneyOwner } from "./requireMoneyOwner"; +import Chance from "chance"; + +const chance = new Chance(); + +describe("requireMoneyOwner", () => { + 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; +} From 45abbe0f48a10d8f70e86993039642cea5179f6c Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 09:36:50 -0300 Subject: [PATCH 07/10] feat(entrypoints): share the #money poster and report settlement failures Posting to #money was written twice: the donation command fetched the channel and swallowed any error without a trace, while the monthly routine read it from the channel cache, which is empty right after a restart and silently dropped the monthly money post. Add one poster that fetches the channel, logs every failure through the telemetry logger and reports whether the message was delivered, and route both paths through it. The entry amount formatter moves next to it so both commands render a donation the same way. The monthly money summary now distinguishes costs that failed to settle from costs that were already recorded. Reporting a failed segment as already recorded told the owner a month was settled when its costs had been lost. --- .../commands/RegisterDonation/handler.test.ts | 4 +- .../src/commands/RegisterDonation/handler.ts | 39 ++--- .../commands/formatLedgerEntryAmount.test.ts | 60 +++++++ .../src/commands/formatLedgerEntryAmount.ts | 13 ++ .../discord/sendMoneyChannelMessage.test.ts | 151 ++++++++++++++++++ .../src/discord/sendMoneyChannelMessage.ts | 52 ++++++ .../jobs/MonthlyUsageReportRoutine.test.ts | 77 +++++++-- .../src/jobs/MonthlyUsageReportRoutine.ts | 27 +--- 8 files changed, 359 insertions(+), 64 deletions(-) create mode 100644 packages/entrypoints/src/commands/formatLedgerEntryAmount.test.ts create mode 100644 packages/entrypoints/src/commands/formatLedgerEntryAmount.ts create mode 100644 packages/entrypoints/src/discord/sendMoneyChannelMessage.test.ts create mode 100644 packages/entrypoints/src/discord/sendMoneyChannelMessage.ts diff --git a/packages/entrypoints/src/commands/RegisterDonation/handler.test.ts b/packages/entrypoints/src/commands/RegisterDonation/handler.test.ts index da300be..bf04f19 100644 --- a/packages/entrypoints/src/commands/RegisterDonation/handler.test.ts +++ b/packages/entrypoints/src/commands/RegisterDonation/handler.test.ts @@ -223,7 +223,7 @@ describe("RegisterDonation Command Handler", () => { flags: MessageFlags.Ephemeral }); expect(interaction.followUp).toHaveBeenCalledWith({ - content: expect.stringContaining('could not be found'), + content: '⚠️ Donation recorded, but it could not be posted to #money.', flags: MessageFlags.Ephemeral }); expect(moneyChannel.send).not.toHaveBeenCalled(); @@ -244,7 +244,7 @@ describe("RegisterDonation Command Handler", () => { // Then expect(interaction.followUp).toHaveBeenCalledWith({ - content: expect.stringContaining('could not be found'), + 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 index b89c69a..966743b 100644 --- a/packages/entrypoints/src/commands/RegisterDonation/handler.ts +++ b/packages/entrypoints/src/commands/RegisterDonation/handler.ts @@ -1,7 +1,9 @@ -import { ChannelType, ChatInputCommandInteraction, Client, MessageFlags } from "discord.js"; +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; @@ -36,7 +38,7 @@ export function createRegisterDonationCommandHandlerFactory(dependencies: Regist const { balanceBrlCents } = await getBalance.execute(); await interaction.followUp({ - content: `✅ Donation registered: ${formatDonationAmount({ entry })} from "${entry.source}". New balance: R$ ${toDisplayBrl({ balanceBrlCents })}.`, + content: `✅ Donation registered: ${formatLedgerEntryAmount({ entry })} from "${entry.source}". New balance: R$ ${toDisplayBrl({ balanceBrlCents })}.`, flags: MessageFlags.Ephemeral }); @@ -47,18 +49,6 @@ export function createRegisterDonationCommandHandlerFactory(dependencies: Regist }; } -type FormatDonationAmountParams = { - entry: LedgerTransaction; -}; - -function formatDonationAmount(params: FormatDonationAmountParams): 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 })})`; -} - type PostDonationParams = { discordClient: Client; moneyChannelId: string; @@ -69,21 +59,14 @@ type PostDonationParams = { async function postDonationToMoneyChannel(params: PostDonationParams): Promise { const { discordClient, moneyChannelId, entry, balanceBrlCents, interaction } = params; - try { - const channel = await discordClient.channels.fetch(moneyChannelId); - if (!channel || channel.type !== ChannelType.GuildText) { - await interaction.followUp({ - content: '⚠️ Donation recorded, but the #money channel could not be found.', - flags: MessageFlags.Ephemeral - }); - return; - } - await channel.send( - `💰 New donation: ${formatDonationAmount({ entry })} from "${entry.source}". Current balance: R$ ${toDisplayBrl({ balanceBrlCents })}.` - ); - } catch { + 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 posting to #money failed.', + content: '⚠️ Donation recorded, but it could not be posted to #money.', flags: MessageFlags.Ephemeral }); } 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/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/jobs/MonthlyUsageReportRoutine.test.ts b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts index 47d66c5..5d649a8 100644 --- a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts +++ b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts @@ -17,13 +17,15 @@ describe("MonthlyUsageReportRoutine", () => { 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(), @@ -37,6 +39,7 @@ describe("MonthlyUsageReportRoutine", () => { scheduleMock, dependencies, channelGetFn, + channelFetchFn, } } @@ -57,7 +60,7 @@ 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); @@ -74,17 +77,18 @@ describe("MonthlyUsageReportRoutine", () => { const sendFn = vi.fn(); const moneySendFn = vi.fn(); - channelGetFn.mockImplementation((channelId: string) => { + channelGetFn.mockReturnValue({ + type: 0, // GuildText + send: sendFn, + }); + channelFetchFn.mockImplementation((channelId: string) => { if (channelId === moneyChannelId) { return { type: 0, send: moneySendFn }; } - return { - type: 0, // GuildText - send: sendFn, - }; + return undefined; }); - dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 9, skipped: 0 }); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 9, skipped: 0, failed: 0 }); dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); dependencies.generateMonthlyUsageReport.execute.mockResolvedValue({ @@ -153,8 +157,11 @@ See <#1365408843676520508> to help!`); it("should post the cost and balance summary to the money channel", async () => { await jobPromise; - expect(channelGetFn).toHaveBeenCalledWith(moneyChannelId); + 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("Failed: 0")); expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("R$ 123.45")); }) }) @@ -175,7 +182,7 @@ See <#1365408843676520508> to help!`); }); const makeFailureSut = () => { - const { sut, scheduleMock, dependencies, channelGetFn } = makeSut(); + const { sut, scheduleMock, dependencies, channelGetFn, channelFetchFn } = makeSut(); const now = new Date("2025-11-15T10:00:00Z"); vi.setSystemTime(now); dependencies.configManager.getDiscordConfig.mockReturnValue({ @@ -187,9 +194,13 @@ See <#1365408843676520508> to help!`); 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, sendFn, now }; + return { sut, scheduleMock, dependencies, channelGetFn, channelFetchFn, sendFn, moneySendFn, now }; } const runJob = async (sut: ReturnType["sut"], dependencies: any, scheduleMock: any, now: Date) => { @@ -214,13 +225,48 @@ See <#1365408843676520508> to help!`); })); }) + 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, failed: 1 }); + dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); + + // When + await runJob(sut, dependencies, scheduleMock, now); + + // Then + expect(moneySendFn).toHaveBeenCalledWith(expect.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("Failed: 1")); + expect(moneySendFn).toHaveBeenCalledWith(expect.stringContaining("R$ 123.45")); + }) + it("should still deliver the report when the money channel is missing", async () => { // Given - const { sut, scheduleMock, dependencies, channelGetFn, sendFn, now } = makeFailureSut(); - channelGetFn.mockImplementation((channelId: string) => + 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 }); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 0, skipped: 9, 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, failed: 0 }); dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 100 }); // When @@ -228,8 +274,9 @@ See <#1365408843676520508> to help!`); // Then expect(sendFn).toHaveBeenCalled(); + expect(moneySendFn).not.toHaveBeenCalled(); expect(dependencies.eventLogger.log).toHaveBeenCalledWith(expect.objectContaining({ - eventMessage: expect.stringContaining("Money Channel not found"), + eventMessage: "Money Channel not delivered: money-channel-id", })); }) }) diff --git a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts index 16d15c5..32c6eef 100644 --- a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts +++ b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts @@ -9,6 +9,7 @@ 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; @@ -139,32 +140,20 @@ async function settleMonthlyCosts(params: SettleMonthlyCostsParams): Promise Date: Thu, 15 Oct 2026 09:39:40 -0300 Subject: [PATCH 08/10] feat(entrypoints): add the owner-only /set-ledger-opening command Opening the ledger is a one-time decision the owner makes, and it used to be an npm script that anyone with shell access could run again at will. Add an owner-gated slash command with an amount and a currency that records the opening balance, refuses once the ledger holds any transaction, and announces the result in #money. It shares the money owner guard, the amount formatter and the channel poster with the other money commands. --- .../commands/SetLedgerOpening/definition.ts | 19 ++ .../commands/SetLedgerOpening/handler.test.ts | 224 ++++++++++++++++++ .../src/commands/SetLedgerOpening/handler.ts | 71 ++++++ .../src/commands/SetLedgerOpening/index.ts | 2 + packages/entrypoints/src/commands/index.ts | 14 ++ packages/entrypoints/src/discordBot.ts | 7 + 6 files changed, 337 insertions(+) create mode 100644 packages/entrypoints/src/commands/SetLedgerOpening/definition.ts create mode 100644 packages/entrypoints/src/commands/SetLedgerOpening/handler.test.ts create mode 100644 packages/entrypoints/src/commands/SetLedgerOpening/handler.ts create mode 100644 packages/entrypoints/src/commands/SetLedgerOpening/index.ts diff --git a/packages/entrypoints/src/commands/SetLedgerOpening/definition.ts b/packages/entrypoints/src/commands/SetLedgerOpening/definition.ts new file mode 100644 index 0000000..0ec9064 --- /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)) + .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..04cdf8a --- /dev/null +++ b/packages/entrypoints/src/commands/SetLedgerOpening/handler.test.ts @@ -0,0 +1,224 @@ +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(expect.stringContaining('Opening balance recorded: R$ 130.00')); + expect(moneyChannel.send).toHaveBeenCalledWith(expect.stringContaining('from "Opening balance from #money history"')); + expect(moneyChannel.send).toHaveBeenCalledWith(expect.stringContaining('Current balance: R$ 185.00')); + }); + + 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..4c553a0 --- /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 })} from "${entry.source}". 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/index.ts b/packages/entrypoints/src/commands/index.ts index 4812578..7693623 100644 --- a/packages/entrypoints/src/commands/index.ts +++ b/packages/entrypoints/src/commands/index.ts @@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, Client, SlashCommandOptionsOnlyBuilder } f 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"; @@ -23,6 +24,7 @@ import { cancelScheduleCommandDefinition, cancelScheduleCommandHandlerFactory } 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; @@ -37,6 +39,7 @@ export type CommandDependencies = { comparePlayerSharedIps: ComparePlayerSharedIps; ipHistoryAdminDiscordIds: string[]; registerDonation: RegisterDonation; + setLedgerOpening: SetLedgerOpening; getBalance: GetBalance; moneyOwnerDiscordIds: string[]; moneyChannelId: string; @@ -135,6 +138,17 @@ export function createCommands(dependencies: CommandDependencies) { 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 Date: Thu, 15 Oct 2026 10:02:24 -0300 Subject: [PATCH 09/10] fix(entrypoints): settle costs independently of report delivery The monthly settlement ran after the usage report was posted, so a deleted report channel, a lost permission or a stale cache entry meant the month's cost entries were never written at all - and with no re-run path those costs were lost for good. Run the settlement before report delivery, in its own error boundary, so nothing about the report can prevent it. An unconfigured money channel also skipped the settlement while logging that only the post was skipped. The settlement now always runs and the shared poster owns the missing-channel case. A month with failed segments was still summarised as settled, which is the same false report this stack set out to remove; the summary now names the failures and avoids the word settled entirely. Segments with no cost to record are counted separately from segments already recorded, so the money channel no longer reports idle regions as already billed. Also stop the amount options from offering zero, which the use cases always reject, and drop the opening post's self-referential source clause. --- .../src/usecase/MonthlyCostSettlement.test.ts | 12 +- .../core/src/usecase/MonthlyCostSettlement.ts | 20 +- .../RegisterDonation/definition.test.ts | 35 +++ .../commands/RegisterDonation/definition.ts | 2 +- .../SetLedgerOpening/definition.test.ts | 45 ++++ .../commands/SetLedgerOpening/definition.ts | 2 +- .../commands/SetLedgerOpening/handler.test.ts | 5 +- .../src/commands/SetLedgerOpening/handler.ts | 2 +- .../jobs/MonthlyUsageReportRoutine.test.ts | 210 +++++++++++++++--- .../src/jobs/MonthlyUsageReportRoutine.ts | 43 ++-- 10 files changed, 310 insertions(+), 66 deletions(-) create mode 100644 packages/entrypoints/src/commands/RegisterDonation/definition.test.ts create mode 100644 packages/entrypoints/src/commands/SetLedgerOpening/definition.test.ts 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/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 index c329920..8ba843f 100644 --- a/packages/entrypoints/src/commands/RegisterDonation/definition.ts +++ b/packages/entrypoints/src/commands/RegisterDonation/definition.ts @@ -8,7 +8,7 @@ export const registerDonationCommandDefinition = new SlashCommandBuilder() option.setName('amount') .setDescription('Donation amount, must be greater than zero') .setRequired(true) - .setMinValue(0)) + .setMinValue(0.01)) .addStringOption(option => option.setName('currency') .setDescription('Donation currency') 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 index 0ec9064..4491c9c 100644 --- a/packages/entrypoints/src/commands/SetLedgerOpening/definition.ts +++ b/packages/entrypoints/src/commands/SetLedgerOpening/definition.ts @@ -8,7 +8,7 @@ export const setLedgerOpeningCommandDefinition = new SlashCommandBuilder() option.setName('amount') .setDescription('Opening balance amount, must be greater than zero') .setRequired(true) - .setMinValue(0)) + .setMinValue(0.01)) .addStringOption(option => option.setName('currency') .setDescription('Opening balance currency') diff --git a/packages/entrypoints/src/commands/SetLedgerOpening/handler.test.ts b/packages/entrypoints/src/commands/SetLedgerOpening/handler.test.ts index 04cdf8a..2cef85c 100644 --- a/packages/entrypoints/src/commands/SetLedgerOpening/handler.test.ts +++ b/packages/entrypoints/src/commands/SetLedgerOpening/handler.test.ts @@ -154,9 +154,8 @@ describe("SetLedgerOpening Command Handler", () => { content: expect.stringContaining('New balance: R$ 185.00'), flags: MessageFlags.Ephemeral }); - expect(moneyChannel.send).toHaveBeenCalledWith(expect.stringContaining('Opening balance recorded: R$ 130.00')); - expect(moneyChannel.send).toHaveBeenCalledWith(expect.stringContaining('from "Opening balance from #money history"')); - expect(moneyChannel.send).toHaveBeenCalledWith(expect.stringContaining('Current balance: R$ 185.00')); + 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 () => { diff --git a/packages/entrypoints/src/commands/SetLedgerOpening/handler.ts b/packages/entrypoints/src/commands/SetLedgerOpening/handler.ts index 4c553a0..1abc51b 100644 --- a/packages/entrypoints/src/commands/SetLedgerOpening/handler.ts +++ b/packages/entrypoints/src/commands/SetLedgerOpening/handler.ts @@ -60,7 +60,7 @@ async function postOpeningToMoneyChannel(params: PostOpeningParams): Promise { } } + 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) => { + 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", () => { @@ -88,7 +108,7 @@ describe("MonthlyUsageReportRoutine", () => { return undefined; }); - dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 9, skipped: 0, failed: 0 }); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 9, skipped: 0, noCost: 0, failed: 0 }); dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 12345 }); dependencies.generateMonthlyUsageReport.execute.mockResolvedValue({ @@ -161,26 +181,13 @@ See <#1365408843676520508> to help!`); 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 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 makeFailureSut = () => { const { sut, scheduleMock, dependencies, channelGetFn, channelFetchFn } = makeSut(); const now = new Date("2025-11-15T10:00:00Z"); @@ -203,12 +210,6 @@ See <#1365408843676520508> to help!`); return { sut, scheduleMock, dependencies, channelGetFn, channelFetchFn, sendFn, moneySendFn, now }; } - const runJob = async (sut: ReturnType["sut"], dependencies: any, scheduleMock: any, now: Date) => { - sut(dependencies); - const scheduledJobCallback = scheduleMock.scheduleJob.mock.calls[scheduleMock.scheduleJob.mock.calls.length - 1][1]; - await scheduledJobCallback(now); - } - it("should still deliver the report when settlement fails", async () => { // Given const { sut, scheduleMock, dependencies, sendFn, now } = makeFailureSut(); @@ -228,27 +229,108 @@ See <#1365408843676520508> to help!`); 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, failed: 1 }); + 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("costs for 10/2025 settled")); + 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, failed: 0 }); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 0, skipped: 9, noCost: 0, failed: 0 }); dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 100 }); // When @@ -266,7 +348,7 @@ See <#1365408843676520508> to help!`); // Given const { sut, scheduleMock, dependencies, channelFetchFn, sendFn, moneySendFn, now } = makeFailureSut(); channelFetchFn.mockRejectedValue(new Error("fetch boom")); - dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 0, skipped: 9, failed: 0 }); + dependencies.monthlyCostSettlement.execute.mockResolvedValue({ inserted: 0, skipped: 9, noCost: 0, failed: 0 }); dependencies.getBalance.execute.mockResolvedValue({ balanceBrlCents: 100 }); // When @@ -280,4 +362,80 @@ See <#1365408843676520508> to help!`); })); }) }) + + 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 32c6eef..1baf4fc 100644 --- a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts +++ b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts @@ -38,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) { @@ -84,16 +95,6 @@ export const scheduleMonthlyUsageReportRoutine = ( eventMessage: `Monthly Usage Report for ${report.month}/${report.year} posted successfully.`, actorId: "system", }); - - await settleMonthlyCosts({ - monthlyCostSettlement: dependencies.monthlyCostSettlement, - getBalance: dependencies.getBalance, - discordConfig, - discordClient, - eventLogger, - month: report.month, - year: report.year, - }); } catch (error) { logger.emit({ severityText: "ERROR", @@ -129,33 +130,33 @@ async function settleMonthlyCosts(params: SettleMonthlyCostsParams): Promise 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: `💰 **Money ledger — costs for ${month}/${year} settled**\n* New cost entries: ${settlement.inserted}\n* Already recorded: ${settlement.skipped}\n* Failed: ${settlement.failed}\n* Current balance: R$ ${toDisplayBrl({ balanceBrlCents })}`, + 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 eventLogger.log({ - eventMessage: `Money Channel not delivered: ${moneyChannelId}`, + eventMessage: moneyChannelId + ? `Money Channel not delivered: ${moneyChannelId}` + : "Money Channel is not configured, so the monthly cost summary was not posted.", actorId: "system", }); return; } await eventLogger.log({ - eventMessage: `Monthly costs for ${month}/${year} settled and posted to money channel.`, + 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.`, actorId: "system", }); } catch (error) { From 81f312cec5b502e4eb689160f6de7f0b3b5bb238 Mon Sep 17 00:00:00 2001 From: Jonathan Nagayoshi Date: Thu, 15 Oct 2026 10:19:59 -0300 Subject: [PATCH 10/10] fix(entrypoints): keep a failed settlement from suppressing the report Settling the costs ahead of report delivery made the report depend on the settlement's error path: its event logging performs a Discord call, and a rejection there escaped the settlement and aborted the routine before the usage report was posted. Route every settlement event through one helper that cannot reject, and record a failed log through the telemetry logger so the loss stays visible. Also make the routine's test invoke the scheduled job through a single awaited helper. It asserted on an unawaited job started while the file was collected, which held only because the report happened to be posted before the assertion ran; settling earlier added enough awaits to make that race reachable. --- .../jobs/MonthlyUsageReportRoutine.test.ts | 64 ++++++++++++++++--- .../src/jobs/MonthlyUsageReportRoutine.ts | 33 ++++++++-- 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts index bf89458..d06dce4 100644 --- a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts +++ b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.test.ts @@ -7,10 +7,22 @@ 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 = () => { @@ -58,6 +70,7 @@ describe("MonthlyUsageReportRoutine", () => { }); 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); @@ -124,22 +137,22 @@ describe("MonthlyUsageReportRoutine", () => { year: 2025, uniqueUsersCount: 25, }) - // When - - sut(dependencies); - - const scheduledJobCallback = scheduleMock.scheduleJob.mock.calls[0][1]; - const jobPromise = 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** @@ -171,12 +184,18 @@ See <#1365408843676520508> to help!`); }) it("should settle costs for the reported month and year", async () => { - await jobPromise; + // 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 () => { - await jobPromise; + // 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")); @@ -226,6 +245,31 @@ See <#1365408843676520508> to help!`); })); }) + 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(); diff --git a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts index 1baf4fc..605287f 100644 --- a/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts +++ b/packages/entrypoints/src/jobs/MonthlyUsageReportRoutine.ts @@ -144,20 +144,20 @@ async function settleMonthlyCosts(params: SettleMonthlyCostsParams): Promise 0 ? `Monthly costs for ${month}/${year} posted with ${settlement.failed} failed segment(s).` : `Monthly costs for ${month}/${year} settled and posted to money channel.`, - actorId: "system", }); } catch (error) { logger.emit({ @@ -167,11 +167,32 @@ async function settleMonthlyCosts(params: SettleMonthlyCostsParams): 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)), + }, }); } }