From 753286a1f6ef0aeec310c5d8a4898c1fb3f09294 Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 10 Mar 2026 22:03:30 +0400 Subject: [PATCH] feat(finance): add billing correction APIs and cycle rollover --- apps/bot/src/index.ts | 53 +++ apps/bot/src/miniapp-billing.test.ts | 23 ++ apps/bot/src/miniapp-billing.ts | 377 ++++++++++++++++++ apps/bot/src/miniapp-dashboard.test.ts | 32 +- apps/bot/src/miniapp-dashboard.ts | 1 + apps/bot/src/reminder-jobs.ts | 6 + apps/bot/src/server.ts | 59 +++ .../adapters-db/src/finance-repository.ts | 148 +++++++ .../src/finance-command-service.test.ts | 54 ++- .../src/finance-command-service.ts | 193 ++++++++- packages/ports/src/finance.ts | 23 ++ 11 files changed, 943 insertions(+), 26 deletions(-) diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 59c890b..75ff45e 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -46,12 +46,17 @@ import { createMiniAppUpsertUtilityCategoryHandler } from './miniapp-admin' import { + createMiniAppAddPaymentHandler, createMiniAppAddUtilityBillHandler, createMiniAppBillingCycleHandler, createMiniAppCloseCycleHandler, + createMiniAppDeletePaymentHandler, + createMiniAppDeletePurchaseHandler, createMiniAppDeleteUtilityBillHandler, createMiniAppOpenCycleHandler, createMiniAppRentUpdateHandler, + createMiniAppUpdatePaymentHandler, + createMiniAppUpdatePurchaseHandler, createMiniAppUpdateUtilityBillHandler } from './miniapp-billing' import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' @@ -268,6 +273,9 @@ const reminderJobs = runtime.reminderJobsEnabled return createReminderJobsHandler({ listReminderTargets: () => householdConfigurationRepositoryClient!.repository.listReminderTargets(), + ensureBillingCycle: async ({ householdId, at }) => { + await financeServiceForHousehold(householdId).ensureExpectedCycle(at) + }, releaseReminderDispatch: (input) => reminderRepositoryClient.repository.releaseReminderDispatch(input), sendReminderMessage: async (target, text) => { @@ -483,6 +491,51 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-billing') }) : undefined, + miniAppUpdatePurchase: householdOnboardingService + ? createMiniAppUpdatePurchaseHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, + miniAppDeletePurchase: householdOnboardingService + ? createMiniAppDeletePurchaseHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, + miniAppAddPayment: householdOnboardingService + ? createMiniAppAddPaymentHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, + miniAppUpdatePayment: householdOnboardingService + ? createMiniAppUpdatePaymentHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, + miniAppDeletePayment: householdOnboardingService + ? createMiniAppDeletePaymentHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + financeServiceForHousehold, + logger: getLogger('miniapp-billing') + }) + : undefined, miniAppLocalePreference: householdOnboardingService ? createMiniAppLocalePreferenceHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index 3e1f476..259511d 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -135,6 +135,11 @@ function onboardingRepository(): HouseholdConfigurationRepository { function createFinanceServiceStub(): FinanceCommandService { return { getMemberByTelegramUserId: async () => null, + ensureExpectedCycle: async () => ({ + id: 'cycle-2026-03', + period: '2026-03', + currency: 'USD' + }), getOpenCycle: async () => ({ id: 'cycle-2026-03', period: '2026-03', @@ -187,6 +192,24 @@ function createFinanceServiceStub(): FinanceCommandService { currency: 'USD' }), deleteUtilityBill: async () => true, + updatePurchase: async () => ({ + purchaseId: 'purchase-1', + amount: Money.fromMinor(3000n, 'USD'), + currency: 'USD' + }), + deletePurchase: async () => true, + addPayment: async () => ({ + paymentId: 'payment-1', + amount: Money.fromMinor(10000n, 'USD'), + currency: 'USD', + period: '2026-03' + }), + updatePayment: async () => ({ + paymentId: 'payment-1', + amount: Money.fromMinor(10000n, 'USD'), + currency: 'USD' + }), + deletePayment: async () => true, generateDashboard: async () => null, generateStatement: async () => null } diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts index b7012fa..3cd25d7 100644 --- a/apps/bot/src/miniapp-billing.ts +++ b/apps/bot/src/miniapp-billing.ts @@ -283,6 +283,101 @@ async function readUtilityBillDeletePayload(request: Request): Promise<{ } } +async function readPurchaseMutationPayload(request: Request): Promise<{ + initData: string + purchaseId: string + description?: string + amountMajor?: string + currency?: string +}> { + const parsed = await parseJsonBody<{ + initData?: string + purchaseId?: string + description?: string + amountMajor?: string + currency?: string + }>(request) + const initData = parsed.initData?.trim() + if (!initData) { + throw new Error('Missing initData') + } + const purchaseId = parsed.purchaseId?.trim() + if (!purchaseId) { + throw new Error('Missing purchase id') + } + + return { + initData, + purchaseId, + ...(parsed.description !== undefined + ? { + description: parsed.description.trim() + } + : {}), + ...(parsed.amountMajor !== undefined + ? { + amountMajor: parsed.amountMajor.trim() + } + : {}), + ...(parsed.currency?.trim() + ? { + currency: parsed.currency.trim() + } + : {}) + } +} + +async function readPaymentMutationPayload(request: Request): Promise<{ + initData: string + paymentId?: string + memberId?: string + kind?: 'rent' | 'utilities' + amountMajor?: string + currency?: string +}> { + const parsed = await parseJsonBody<{ + initData?: string + paymentId?: string + memberId?: string + kind?: 'rent' | 'utilities' + amountMajor?: string + currency?: string + }>(request) + const initData = parsed.initData?.trim() + if (!initData) { + throw new Error('Missing initData') + } + + return { + initData, + ...(parsed.paymentId?.trim() + ? { + paymentId: parsed.paymentId.trim() + } + : {}), + ...(parsed.memberId?.trim() + ? { + memberId: parsed.memberId.trim() + } + : {}), + ...(parsed.kind + ? { + kind: parsed.kind + } + : {}), + ...(parsed.amountMajor?.trim() + ? { + amountMajor: parsed.amountMajor.trim() + } + : {}), + ...(parsed.currency?.trim() + ? { + currency: parsed.currency.trim() + } + : {}) + } +} + export function createMiniAppBillingCycleHandler(options: { allowedOrigins: readonly string[] botToken: string @@ -718,3 +813,285 @@ export function createMiniAppDeleteUtilityBillHandler(options: { } } } + +export function createMiniAppUpdatePurchaseHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const auth = await authenticateAdminSession( + request.clone() as Request, + sessionService, + origin + ) + if (auth instanceof Response) { + return auth + } + + const payload = await readPurchaseMutationPayload(request) + if (!payload.description || !payload.amountMajor) { + return miniAppJsonResponse({ ok: false, error: 'Missing purchase fields' }, 400, origin) + } + + const service = options.financeServiceForHousehold(auth.member.householdId) + const updated = await service.updatePurchase( + payload.purchaseId, + payload.description, + payload.amountMajor, + payload.currency + ) + + if (!updated) { + return miniAppJsonResponse({ ok: false, error: 'Purchase not found' }, 404, origin) + } + + return miniAppJsonResponse({ ok: true, authorized: true }, 200, origin) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppDeletePurchaseHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const auth = await authenticateAdminSession( + request.clone() as Request, + sessionService, + origin + ) + if (auth instanceof Response) { + return auth + } + + const payload = await readPurchaseMutationPayload(request) + const service = options.financeServiceForHousehold(auth.member.householdId) + const deleted = await service.deletePurchase(payload.purchaseId) + + if (!deleted) { + return miniAppJsonResponse({ ok: false, error: 'Purchase not found' }, 404, origin) + } + + return miniAppJsonResponse({ ok: true, authorized: true }, 200, origin) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppAddPaymentHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const auth = await authenticateAdminSession( + request.clone() as Request, + sessionService, + origin + ) + if (auth instanceof Response) { + return auth + } + + const payload = await readPaymentMutationPayload(request) + if (!payload.memberId || !payload.kind || !payload.amountMajor) { + return miniAppJsonResponse({ ok: false, error: 'Missing payment fields' }, 400, origin) + } + + const service = options.financeServiceForHousehold(auth.member.householdId) + const payment = await service.addPayment( + payload.memberId, + payload.kind, + payload.amountMajor, + payload.currency + ) + + if (!payment) { + return miniAppJsonResponse({ ok: false, error: 'No open billing cycle' }, 409, origin) + } + + return miniAppJsonResponse({ ok: true, authorized: true }, 200, origin) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppUpdatePaymentHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const auth = await authenticateAdminSession( + request.clone() as Request, + sessionService, + origin + ) + if (auth instanceof Response) { + return auth + } + + const payload = await readPaymentMutationPayload(request) + if (!payload.paymentId || !payload.memberId || !payload.kind || !payload.amountMajor) { + return miniAppJsonResponse({ ok: false, error: 'Missing payment fields' }, 400, origin) + } + + const service = options.financeServiceForHousehold(auth.member.householdId) + const payment = await service.updatePayment( + payload.paymentId, + payload.memberId, + payload.kind, + payload.amountMajor, + payload.currency + ) + + if (!payment) { + return miniAppJsonResponse({ ok: false, error: 'Payment not found' }, 404, origin) + } + + return miniAppJsonResponse({ ok: true, authorized: true }, 200, origin) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppDeletePaymentHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeServiceForHousehold: (householdId: string) => FinanceCommandService + onboardingService: HouseholdOnboardingService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const auth = await authenticateAdminSession( + request.clone() as Request, + sessionService, + origin + ) + if (auth instanceof Response) { + return auth + } + + const payload = await readPaymentMutationPayload(request) + if (!payload.paymentId) { + return miniAppJsonResponse({ ok: false, error: 'Missing payment id' }, 400, origin) + } + + const service = options.financeServiceForHousehold(auth.member.householdId) + const deleted = await service.deletePayment(payload.paymentId) + + if (!deleted) { + return miniAppJsonResponse({ ok: false, error: 'Payment not found' }, 404, origin) + } + + return miniAppJsonResponse({ ok: true, authorized: true }, 200, origin) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index ba5d8f0..9dabc6f 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -18,6 +18,12 @@ import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' function repository( member: Awaited> ): FinanceRepository { + const cycle = { + id: 'cycle-1', + period: '2026-03', + currency: 'GEL' as const + } + return { getMemberByTelegramUserId: async () => member, listMembers: async () => [ @@ -29,25 +35,29 @@ function repository( isAdmin: true } ], - getOpenCycle: async () => ({ - id: 'cycle-1', - period: '2026-03', - currency: 'GEL' - }), - getCycleByPeriod: async () => null, - getLatestCycle: async () => ({ - id: 'cycle-1', - period: '2026-03', - currency: 'GEL' - }), + getOpenCycle: async () => cycle, + getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null), + getLatestCycle: async () => cycle, openCycle: async () => {}, closeCycle: async () => {}, saveRentRule: async () => {}, getCycleExchangeRate: async () => null, saveCycleExchangeRate: async (input) => input, addUtilityBill: async () => {}, + updateParsedPurchase: async () => null, + deleteParsedPurchase: async () => false, updateUtilityBill: async () => null, deleteUtilityBill: async () => false, + addPaymentRecord: async (input) => ({ + id: 'payment-new', + memberId: input.memberId, + kind: input.kind, + amountMinor: input.amountMinor, + currency: input.currency, + recordedAt: input.recordedAt + }), + updatePaymentRecord: async () => null, + deletePaymentRecord: async () => false, getRentRuleForPeriod: async () => ({ amountMinor: 70000n, currency: 'USD' diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 6d810cb..3a2d970 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -111,6 +111,7 @@ export function createMiniAppDashboardHandler(options: { id: entry.id, kind: entry.kind, title: entry.title, + memberId: entry.memberId, paymentKind: entry.paymentKind, amountMajor: entry.amount.toMajorString(), currency: entry.currency, diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index 14d2ddf..6726293 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -76,6 +76,7 @@ async function readBody(request: Request): Promise { export function createReminderJobsHandler(options: { listReminderTargets: () => Promise + ensureBillingCycle?: (input: { householdId: string; at: Temporal.Instant }) => Promise releaseReminderDispatch: (input: { householdId: string period: string @@ -132,6 +133,11 @@ export function createReminderJobsHandler(options: { }> = [] for (const target of targets) { + await options.ensureBillingCycle?.({ + householdId: target.householdId, + at: currentInstant + }) + if (!requestedPeriod && !isReminderDueToday(target, reminderType, currentInstant)) { continue } diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 19fadb2..2c50056 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -104,6 +104,36 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppUpdatePurchase?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppDeletePurchase?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppAddPayment?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppUpdatePayment?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppDeletePayment?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppLocalePreference?: | { path?: string @@ -169,6 +199,15 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update' const miniAppDeleteUtilityBillPath = options.miniAppDeleteUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/delete' + const miniAppUpdatePurchasePath = + options.miniAppUpdatePurchase?.path ?? '/api/miniapp/admin/purchases/update' + const miniAppDeletePurchasePath = + options.miniAppDeletePurchase?.path ?? '/api/miniapp/admin/purchases/delete' + const miniAppAddPaymentPath = options.miniAppAddPayment?.path ?? '/api/miniapp/admin/payments/add' + const miniAppUpdatePaymentPath = + options.miniAppUpdatePayment?.path ?? '/api/miniapp/admin/payments/update' + const miniAppDeletePaymentPath = + options.miniAppDeletePayment?.path ?? '/api/miniapp/admin/payments/delete' const miniAppLocalePreferencePath = options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale' const schedulerPathPrefix = options.scheduler @@ -257,6 +296,26 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppDeleteUtilityBill.handler(request) } + if (options.miniAppUpdatePurchase && url.pathname === miniAppUpdatePurchasePath) { + return await options.miniAppUpdatePurchase.handler(request) + } + + if (options.miniAppDeletePurchase && url.pathname === miniAppDeletePurchasePath) { + return await options.miniAppDeletePurchase.handler(request) + } + + if (options.miniAppAddPayment && url.pathname === miniAppAddPaymentPath) { + return await options.miniAppAddPayment.handler(request) + } + + if (options.miniAppUpdatePayment && url.pathname === miniAppUpdatePaymentPath) { + return await options.miniAppUpdatePayment.handler(request) + } + + if (options.miniAppDeletePayment && url.pathname === miniAppDeletePaymentPath) { + return await options.miniAppDeletePayment.handler(request) + } + if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) { return await options.miniAppLocalePreference.handler(request) } diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index fe1cde1..fa287d0 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -296,6 +296,63 @@ export function createDbFinanceRepository( }) }, + async updateParsedPurchase(input) { + const rows = await db + .update(schema.purchaseMessages) + .set({ + parsedAmountMinor: input.amountMinor, + parsedCurrency: input.currency, + parsedItemDescription: input.description, + needsReview: 0, + processingStatus: 'parsed', + parserError: null + }) + .where( + and( + eq(schema.purchaseMessages.householdId, householdId), + eq(schema.purchaseMessages.id, input.purchaseId) + ) + ) + .returning({ + id: schema.purchaseMessages.id, + payerMemberId: schema.purchaseMessages.senderMemberId, + amountMinor: schema.purchaseMessages.parsedAmountMinor, + currency: schema.purchaseMessages.parsedCurrency, + description: schema.purchaseMessages.parsedItemDescription, + occurredAt: schema.purchaseMessages.messageSentAt + }) + + const row = rows[0] + if (!row || !row.payerMemberId || row.amountMinor == null || row.currency == null) { + return null + } + + return { + id: row.id, + payerMemberId: row.payerMemberId, + amountMinor: row.amountMinor, + currency: toCurrencyCode(row.currency), + description: row.description, + occurredAt: instantFromDatabaseValue(row.occurredAt) + } + }, + + async deleteParsedPurchase(purchaseId) { + const rows = await db + .delete(schema.purchaseMessages) + .where( + and( + eq(schema.purchaseMessages.householdId, householdId), + eq(schema.purchaseMessages.id, purchaseId) + ) + ) + .returning({ + id: schema.purchaseMessages.id + }) + + return rows.length > 0 + }, + async updateUtilityBill(input) { const rows = await db .update(schema.utilityBills) @@ -344,6 +401,97 @@ export function createDbFinanceRepository( return rows.length > 0 }, + async addPaymentRecord(input) { + const rows = await db + .insert(schema.paymentRecords) + .values({ + householdId, + cycleId: input.cycleId, + memberId: input.memberId, + kind: input.kind, + amountMinor: input.amountMinor, + currency: input.currency, + recordedAt: instantToDate(input.recordedAt) + }) + .returning({ + id: schema.paymentRecords.id, + memberId: schema.paymentRecords.memberId, + kind: schema.paymentRecords.kind, + amountMinor: schema.paymentRecords.amountMinor, + currency: schema.paymentRecords.currency, + recordedAt: schema.paymentRecords.recordedAt + }) + + const row = rows[0] + if (!row) { + throw new Error('Failed to add payment record') + } + + return { + id: row.id, + memberId: row.memberId, + kind: row.kind === 'utilities' ? 'utilities' : 'rent', + amountMinor: row.amountMinor, + currency: toCurrencyCode(row.currency), + recordedAt: instantFromDatabaseValue(row.recordedAt)! + } + }, + + async updatePaymentRecord(input) { + const rows = await db + .update(schema.paymentRecords) + .set({ + memberId: input.memberId, + kind: input.kind, + amountMinor: input.amountMinor, + currency: input.currency + }) + .where( + and( + eq(schema.paymentRecords.householdId, householdId), + eq(schema.paymentRecords.id, input.paymentId) + ) + ) + .returning({ + id: schema.paymentRecords.id, + memberId: schema.paymentRecords.memberId, + kind: schema.paymentRecords.kind, + amountMinor: schema.paymentRecords.amountMinor, + currency: schema.paymentRecords.currency, + recordedAt: schema.paymentRecords.recordedAt + }) + + const row = rows[0] + if (!row) { + return null + } + + return { + id: row.id, + memberId: row.memberId, + kind: row.kind === 'utilities' ? 'utilities' : 'rent', + amountMinor: row.amountMinor, + currency: toCurrencyCode(row.currency), + recordedAt: instantFromDatabaseValue(row.recordedAt)! + } + }, + + async deletePaymentRecord(paymentId) { + const rows = await db + .delete(schema.paymentRecords) + .where( + and( + eq(schema.paymentRecords.householdId, householdId), + eq(schema.paymentRecords.id, paymentId) + ) + ) + .returning({ + id: schema.paymentRecords.id + }) + + return rows.length > 0 + }, + async getRentRuleForPeriod(period) { const rows = await db .select({ diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index 8e15457..1f3e452 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -68,7 +68,7 @@ class FinanceRepositoryStub implements FinanceRepository { } async getCycleByPeriod(): Promise { - return this.cycleByPeriodRecord + return this.cycleByPeriodRecord ?? this.openCycleRecord ?? this.latestCycleRecord } async getLatestCycle(): Promise { @@ -76,11 +76,14 @@ class FinanceRepositoryStub implements FinanceRepository { } async openCycle(period: string, currency: 'USD' | 'GEL'): Promise { - this.openCycleRecord = { + const cycle = { id: 'opened-cycle', period, currency } + this.openCycleRecord = cycle + this.cycleByPeriodRecord = cycle + this.latestCycleRecord = cycle } async closeCycle(): Promise {} @@ -129,6 +132,40 @@ class FinanceRepositoryStub implements FinanceRepository { return false } + async updateParsedPurchase() { + return null + } + + async deleteParsedPurchase() { + return false + } + + async addPaymentRecord(input: { + cycleId: string + memberId: string + kind: 'rent' | 'utilities' + amountMinor: bigint + currency: 'USD' | 'GEL' + recordedAt: Instant + }) { + return { + id: 'payment-record-1', + memberId: input.memberId, + kind: input.kind, + amountMinor: input.amountMinor, + currency: input.currency, + recordedAt: input.recordedAt + } + } + + async updatePaymentRecord() { + return null + } + + async deletePaymentRecord() { + return false + } + async getRentRuleForPeriod(): Promise { return this.rentRule } @@ -304,14 +341,21 @@ describe('createFinanceCommandService', () => { }) }) - test('addUtilityBill returns null when no open cycle exists', async () => { + test('addUtilityBill auto-opens the expected cycle when none is active', async () => { const repository = new FinanceRepositoryStub() const service = createService(repository) const result = await service.addUtilityBill('Electricity', '55.20', 'member-1') - expect(result).toBeNull() - expect(repository.lastUtilityBill).toBeNull() + expect(result).not.toBeNull() + expect(result?.period).toBe('2026-03') + expect(repository.lastUtilityBill).toEqual({ + cycleId: 'opened-cycle', + billName: 'Electricity', + amountMinor: 5520n, + currency: 'GEL', + createdByMemberId: 'member-1' + }) }) test('generateStatement settles into cycle currency and persists snapshot', async () => { diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index 315bb2d..8afbeb5 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -80,6 +80,23 @@ function localDateInTimezone(timezone: string): Temporal.PlainDate { return nowInstant().toZonedDateTimeISO(timezone).toPlainDate() } +function periodFromLocalDate(localDate: Temporal.PlainDate): BillingPeriod { + return BillingPeriod.fromString(`${localDate.year}-${String(localDate.month).padStart(2, '0')}`) +} + +function expectedOpenCyclePeriod( + settings: { + rentDueDay: number + timezone: string + }, + instant: Temporal.Instant +): BillingPeriod { + const localDate = instant.toZonedDateTimeISO(settings.timezone).toPlainDate() + const currentPeriod = periodFromLocalDate(localDate) + + return localDate.day > settings.rentDueDay ? currentPeriod.next() : currentPeriod +} + export interface FinanceDashboardMemberLine { memberId: string displayName: string @@ -96,6 +113,7 @@ export interface FinanceDashboardLedgerEntry { id: string kind: 'purchase' | 'utility' | 'payment' title: string + memberId: string | null amount: Money currency: CurrencyCode displayAmount: Money @@ -372,6 +390,7 @@ async function buildFinanceDashboard( id: bill.id, kind: 'utility' as const, title: bill.billName, + memberId: bill.createdByMemberId, amount: converted.originalAmount, currency: bill.currency, displayAmount: converted.settlementAmount, @@ -388,6 +407,7 @@ async function buildFinanceDashboard( id: purchase.id, kind: 'purchase' as const, title: purchase.description ?? 'Shared purchase', + memberId: purchase.payerMemberId, amount: converted.originalAmount, currency: purchase.currency, displayAmount: converted.settlementAmount, @@ -402,6 +422,7 @@ async function buildFinanceDashboard( id: payment.id, kind: 'payment' as const, title: payment.kind, + memberId: payment.memberId, amount: Money.fromMinor(payment.amountMinor, payment.currency), currency: payment.currency, displayAmount: Money.fromMinor(payment.amountMinor, payment.currency), @@ -444,6 +465,7 @@ async function buildFinanceDashboard( export interface FinanceCommandService { getMemberByTelegramUserId(telegramUserId: string): Promise getOpenCycle(): Promise + ensureExpectedCycle(referenceInstant?: Temporal.Instant): Promise getAdminCycleState(periodArg?: string): Promise openCycle(periodArg: string, currencyArg?: string): Promise closeCycle(periodArg?: string): Promise @@ -477,6 +499,40 @@ export interface FinanceCommandService { currency: CurrencyCode } | null> deleteUtilityBill(billId: string): Promise + updatePurchase( + purchaseId: string, + description: string, + amountArg: string, + currencyArg?: string + ): Promise<{ + purchaseId: string + amount: Money + currency: CurrencyCode + } | null> + deletePurchase(purchaseId: string): Promise + addPayment( + memberId: string, + kind: FinancePaymentKind, + amountArg: string, + currencyArg?: string + ): Promise<{ + paymentId: string + amount: Money + currency: CurrencyCode + period: string + } | null> + updatePayment( + paymentId: string, + memberId: string, + kind: FinancePaymentKind, + amountArg: string, + currencyArg?: string + ): Promise<{ + paymentId: string + amount: Money + currency: CurrencyCode + } | null> + deletePayment(paymentId: string): Promise generateDashboard(periodArg?: string): Promise generateStatement(periodArg?: string): Promise } @@ -486,6 +542,34 @@ export function createFinanceCommandService( ): FinanceCommandService { const { repository, householdConfigurationRepository } = dependencies + async function ensureExpectedCycle(referenceInstant = nowInstant()): Promise { + const settings = await householdConfigurationRepository.getHouseholdBillingSettings( + dependencies.householdId + ) + const period = expectedOpenCyclePeriod(settings, referenceInstant).toString() + let cycle = await repository.getCycleByPeriod(period) + + if (!cycle) { + await repository.openCycle(period, settings.settlementCurrency) + cycle = await repository.getCycleByPeriod(period) + } + + if (!cycle) { + throw new Error(`Failed to ensure billing cycle for period ${period}`) + } + + const openCycle = await repository.getOpenCycle() + if (openCycle && openCycle.id !== cycle.id) { + await repository.closeCycle(openCycle.id, referenceInstant) + } + + if (settings.rentAmountMinor !== null) { + await repository.saveRentRule(period, settings.rentAmountMinor, settings.rentCurrency) + } + + return cycle + } + return { getMemberByTelegramUserId(telegramUserId) { return repository.getMemberByTelegramUserId(telegramUserId) @@ -495,10 +579,14 @@ export function createFinanceCommandService( return repository.getOpenCycle() }, + ensureExpectedCycle(referenceInstant) { + return ensureExpectedCycle(referenceInstant) + }, + async getAdminCycleState(periodArg) { const cycle = periodArg ? await repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString()) - : ((await repository.getOpenCycle()) ?? (await repository.getLatestCycle())) + : await ensureExpectedCycle() if (!cycle) { return { @@ -555,11 +643,11 @@ export function createFinanceCommandService( }, async setRent(amountArg, currencyArg, periodArg) { - const [openCycle, settings] = await Promise.all([ - repository.getOpenCycle(), - householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId) + const [settings, cycle] = await Promise.all([ + householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId), + periodArg ? Promise.resolve(null) : ensureExpectedCycle() ]) - const period = periodArg ?? openCycle?.period + const period = periodArg ?? cycle?.period if (!period) { return null } @@ -582,12 +670,9 @@ export function createFinanceCommandService( async addUtilityBill(billName, amountArg, createdByMemberId, currencyArg) { const [openCycle, settings] = await Promise.all([ - repository.getOpenCycle(), + ensureExpectedCycle(), householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId) ]) - if (!openCycle) { - return null - } const currency = parseCurrency(currencyArg, settings.settlementCurrency) const amount = Money.fromMajor(amountArg, currency) @@ -635,7 +720,93 @@ export function createFinanceCommandService( return repository.deleteUtilityBill(billId) }, + async updatePurchase(purchaseId, description, amountArg, currencyArg) { + const settings = await householdConfigurationRepository.getHouseholdBillingSettings( + dependencies.householdId + ) + const currency = parseCurrency(currencyArg, settings.settlementCurrency) + const amount = Money.fromMajor(amountArg, currency) + const updated = await repository.updateParsedPurchase({ + purchaseId, + amountMinor: amount.amountMinor, + currency, + description: description.trim().length > 0 ? description.trim() : null + }) + + if (!updated) { + return null + } + + return { + purchaseId: updated.id, + amount, + currency + } + }, + + deletePurchase(purchaseId) { + return repository.deleteParsedPurchase(purchaseId) + }, + + async addPayment(memberId, kind, amountArg, currencyArg) { + const [openCycle, settings] = await Promise.all([ + ensureExpectedCycle(), + householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId) + ]) + + const currency = parseCurrency(currencyArg, settings.settlementCurrency) + const amount = Money.fromMajor(amountArg, currency) + const payment = await repository.addPaymentRecord({ + cycleId: openCycle.id, + memberId, + kind, + amountMinor: amount.amountMinor, + currency, + recordedAt: nowInstant() + }) + + return { + paymentId: payment.id, + amount, + currency, + period: openCycle.period + } + }, + + async updatePayment(paymentId, memberId, kind, amountArg, currencyArg) { + const settings = await householdConfigurationRepository.getHouseholdBillingSettings( + dependencies.householdId + ) + const currency = parseCurrency(currencyArg, settings.settlementCurrency) + const amount = Money.fromMajor(amountArg, currency) + const payment = await repository.updatePaymentRecord({ + paymentId, + memberId, + kind, + amountMinor: amount.amountMinor, + currency + }) + + if (!payment) { + return null + } + + return { + paymentId: payment.id, + amount, + currency + } + }, + + deletePayment(paymentId) { + return repository.deletePaymentRecord(paymentId) + }, + async generateStatement(periodArg) { + if (!periodArg) { + await ensureExpectedCycle() + } + const dashboard = await buildFinanceDashboard(dependencies, periodArg) if (!dashboard) { return null @@ -661,7 +832,9 @@ export function createFinanceCommandService( }, generateDashboard(periodArg) { - return buildFinanceDashboard(dependencies, periodArg) + return periodArg + ? buildFinanceDashboard(dependencies, periodArg) + : ensureExpectedCycle().then(() => buildFinanceDashboard(dependencies)) } } } diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts index 2951d79..a820dab 100644 --- a/packages/ports/src/finance.ts +++ b/packages/ports/src/finance.ts @@ -165,6 +165,13 @@ export interface FinanceRepository { currency: CurrencyCode createdByMemberId: string }): Promise + updateParsedPurchase(input: { + purchaseId: string + amountMinor: bigint + currency: CurrencyCode + description: string | null + }): Promise + deleteParsedPurchase(purchaseId: string): Promise updateUtilityBill(input: { billId: string billName: string @@ -172,6 +179,22 @@ export interface FinanceRepository { currency: CurrencyCode }): Promise deleteUtilityBill(billId: string): Promise + addPaymentRecord(input: { + cycleId: string + memberId: string + kind: FinancePaymentKind + amountMinor: bigint + currency: CurrencyCode + recordedAt: Instant + }): Promise + updatePaymentRecord(input: { + paymentId: string + memberId: string + kind: FinancePaymentKind + amountMinor: bigint + currency: CurrencyCode + }): Promise + deletePaymentRecord(paymentId: string): Promise getRentRuleForPeriod(period: string): Promise getUtilityTotalForCycle(cycleId: string): Promise listUtilityBillsForCycle(cycleId: string): Promise