diff --git a/bun.lock b/bun.lock index fe31d6d..12b03ee 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,13 @@ "packages/ports": { "name": "@household/ports", }, + "scripts": { + "name": "@household/scripts", + "devDependencies": { + "@household/db": "workspace:*", + "drizzle-orm": "*", + }, + }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -187,6 +194,8 @@ "@household/ports": ["@household/ports@workspace:packages/ports"], + "@household/scripts": ["@household/scripts@workspace:scripts"], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], diff --git a/package.json b/package.json index d27e02d..74545fd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "type": "module", "workspaces": [ "apps/*", - "packages/*" + "packages/*", + "scripts" ], "scripts": { "build": "bun run --filter '*' build", diff --git a/scripts/e2e/billing-flow.ts b/scripts/e2e/billing-flow.ts new file mode 100644 index 0000000..1eafd58 --- /dev/null +++ b/scripts/e2e/billing-flow.ts @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' + +import { eq } from 'drizzle-orm' + +import { createDbClient, schema } from '@household/db' + +import { createTelegramBot } from '../../apps/bot/src/bot' +import { createFinanceCommandsService } from '../../apps/bot/src/finance-commands' +import { + createPurchaseMessageRepository, + registerPurchaseTopicIngestion +} from '../../apps/bot/src/purchase-topic-ingestion' + +const databaseUrl = process.env.DATABASE_URL +if (!databaseUrl) { + throw new Error('DATABASE_URL is required for e2e smoke test') +} + +const chatId = '-100123456' +const purchaseTopicId = 77 +const commandChatIdNumber = -100123456 + +function unixSeconds(year: number, month: number, day: number): number { + return Math.floor(Date.UTC(year, month - 1, day, 12, 0, 0) / 1000) +} + +function commandUpdate(params: { + updateId: number + fromUserId: string + fromName: string + text: string + unixTime: number +}) { + const commandToken = params.text.split(' ')[0] ?? params.text + + return { + update_id: params.updateId, + message: { + message_id: params.updateId, + date: params.unixTime, + chat: { + id: commandChatIdNumber, + type: 'supergroup' + }, + from: { + id: Number(params.fromUserId), + is_bot: false, + first_name: params.fromName + }, + text: params.text, + entities: [ + { + offset: 0, + length: commandToken.length, + type: 'bot_command' + } + ] + } + } +} + +function topicPurchaseUpdate(params: { + updateId: number + fromUserId: string + fromName: string + text: string + unixTime: number +}) { + return { + update_id: params.updateId, + message: { + message_id: params.updateId, + date: params.unixTime, + chat: { + id: commandChatIdNumber, + type: 'supergroup' + }, + from: { + id: Number(params.fromUserId), + is_bot: false, + first_name: params.fromName + }, + is_topic_message: true, + message_thread_id: purchaseTopicId, + text: params.text + } + } +} + +function parseStatement(text: string): Map { + const lines = text.split('\n').slice(1) + const amounts = new Map() + + for (const line of lines) { + const match = /^-\s(.+?):\s([+-]?\d+\.\d{2})\s(?:USD|GEL)$/.exec(line.trim()) + if (!match) { + continue + } + + amounts.set(match[1]!, match[2]!) + } + + return amounts +} + +async function run(): Promise { + const ids = { + household: randomUUID(), + admin: randomUUID(), + bob: randomUUID(), + carol: randomUUID() + } + + const telegram = { + admin: '900001', + bob: '900002', + carol: '900003' + } + + const coreClient = createDbClient(databaseUrl as string, { + max: 2, + prepare: false + }) + + const ingestionClient = createPurchaseMessageRepository(databaseUrl as string) + const financeService = createFinanceCommandsService(databaseUrl as string, { + householdId: ids.household + }) + + const bot = createTelegramBot('000000:test-token') + const replies: string[] = [] + + bot.api.config.use(async (_prev, method, payload) => { + if (method === 'sendMessage') { + const p = payload as any + const messageText = typeof p?.text === 'string' ? p.text : '' + replies.push(messageText) + + return { + ok: true, + result: { + message_id: replies.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: commandChatIdNumber, + type: 'supergroup' + }, + text: messageText + } + } as any + } + + return { ok: true, result: true } as any + }) + + registerPurchaseTopicIngestion( + bot, + { + householdId: ids.household, + householdChatId: chatId, + purchaseTopicId + }, + ingestionClient.repository + ) + + financeService.register(bot) + + try { + await coreClient.db.insert(schema.households).values({ + id: ids.household, + name: 'E2E Smoke Household' + }) + + await coreClient.db.insert(schema.members).values([ + { + id: ids.admin, + householdId: ids.household, + telegramUserId: telegram.admin, + displayName: 'Alice', + isAdmin: 1 + }, + { + id: ids.bob, + householdId: ids.household, + telegramUserId: telegram.bob, + displayName: 'Bob', + isAdmin: 0 + }, + { + id: ids.carol, + householdId: ids.household, + telegramUserId: telegram.carol, + displayName: 'Carol', + isAdmin: 0 + } + ]) + + let updateId = 1000 + const march12 = unixSeconds(2026, 3, 12) + + await bot.handleUpdate( + commandUpdate({ + updateId: ++updateId, + fromUserId: telegram.admin, + fromName: 'Alice', + text: '/cycle_open 2026-03 USD', + unixTime: march12 + }) as never + ) + + await bot.handleUpdate( + commandUpdate({ + updateId: ++updateId, + fromUserId: telegram.admin, + fromName: 'Alice', + text: '/rent_set 700 USD 2026-03', + unixTime: march12 + }) as never + ) + + await bot.handleUpdate( + topicPurchaseUpdate({ + updateId: ++updateId, + fromUserId: telegram.bob, + fromName: 'Bob', + text: 'Bought soap 30 USD', + unixTime: march12 + }) as never + ) + + await bot.handleUpdate( + commandUpdate({ + updateId: ++updateId, + fromUserId: telegram.admin, + fromName: 'Alice', + text: '/utility_add electricity 120 USD', + unixTime: march12 + }) as never + ) + + await bot.handleUpdate( + commandUpdate({ + updateId: ++updateId, + fromUserId: telegram.admin, + fromName: 'Alice', + text: '/statement 2026-03', + unixTime: march12 + }) as never + ) + + const firstStatement = replies.find((entry) => entry.startsWith('Statement for 2026-03')) + assert.ok(firstStatement, 'First statement message was not emitted') + + const firstTotals = parseStatement(firstStatement) + assert.equal(firstTotals.get('Alice'), '283.34') + assert.equal(firstTotals.get('Bob'), '253.33') + assert.equal(firstTotals.get('Carol'), '283.33') + + await bot.handleUpdate( + commandUpdate({ + updateId: ++updateId, + fromUserId: telegram.admin, + fromName: 'Alice', + text: '/utility_add water 30 USD', + unixTime: march12 + }) as never + ) + + await bot.handleUpdate( + commandUpdate({ + updateId: ++updateId, + fromUserId: telegram.admin, + fromName: 'Alice', + text: '/statement 2026-03', + unixTime: march12 + }) as never + ) + + const secondStatement = replies.at(-1) + assert.ok(secondStatement?.startsWith('Statement for 2026-03'), 'Second statement missing') + + const secondTotals = parseStatement(secondStatement ?? '') + assert.equal(secondTotals.get('Alice'), '293.34') + assert.equal(secondTotals.get('Bob'), '263.33') + assert.equal(secondTotals.get('Carol'), '293.33') + + const purchaseRows = await coreClient.db + .select({ + status: schema.purchaseMessages.processingStatus, + amountMinor: schema.purchaseMessages.parsedAmountMinor, + senderMemberId: schema.purchaseMessages.senderMemberId + }) + .from(schema.purchaseMessages) + .where(eq(schema.purchaseMessages.householdId, ids.household)) + + assert.equal(purchaseRows.length, 1, 'Expected one ingested purchase message') + assert.equal(purchaseRows[0]?.status, 'parsed') + assert.equal(purchaseRows[0]?.amountMinor, 3000n) + assert.equal(purchaseRows[0]?.senderMemberId, ids.bob) + + console.log( + 'E2E smoke passed: purchase ingestion, utility updates, and statements are deterministic' + ) + } finally { + await coreClient.db.delete(schema.households).where(eq(schema.households.id, ids.household)) + + await Promise.all([ + coreClient.queryClient.end({ timeout: 5 }), + ingestionClient.close(), + financeService.close() + ]) + } +} + +try { + await run() +} catch (error) { + console.error('E2E smoke failed', error) + process.exitCode = 1 +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..af74a54 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,12 @@ +{ + "name": "@household/scripts", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsgo --project tsconfig.json --noEmit" + }, + "devDependencies": { + "drizzle-orm": "*", + "@household/db": "workspace:*" + } +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..6f29a6c --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "paths": { + "@household/*": ["../packages/*/src", "../apps/*/src"] + } + }, + "include": ["**/*.ts", "../apps/bot/src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index d88127c..b1f49b0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,35 @@ { "files": [], "references": [ - { "path": "./apps/bot" }, - { "path": "./apps/miniapp" }, - { "path": "./packages/domain" }, - { "path": "./packages/application" }, - { "path": "./packages/ports" }, - { "path": "./packages/contracts" }, - { "path": "./packages/observability" }, - { "path": "./packages/config" }, - { "path": "./packages/db" } + { + "path": "./apps/bot" + }, + { + "path": "./apps/miniapp" + }, + { + "path": "./packages/domain" + }, + { + "path": "./packages/application" + }, + { + "path": "./packages/ports" + }, + { + "path": "./packages/contracts" + }, + { + "path": "./packages/observability" + }, + { + "path": "./packages/config" + }, + { + "path": "./packages/db" + }, + { + "path": "./scripts" + } ] }