feat(finance): add billing correction APIs and cycle rollover

This commit is contained in:
2026-03-10 22:03:30 +04:00
parent 05561a397d
commit 753286a1f6
11 changed files with 943 additions and 26 deletions

View File

@@ -46,12 +46,17 @@ import {
createMiniAppUpsertUtilityCategoryHandler createMiniAppUpsertUtilityCategoryHandler
} from './miniapp-admin' } from './miniapp-admin'
import { import {
createMiniAppAddPaymentHandler,
createMiniAppAddUtilityBillHandler, createMiniAppAddUtilityBillHandler,
createMiniAppBillingCycleHandler, createMiniAppBillingCycleHandler,
createMiniAppCloseCycleHandler, createMiniAppCloseCycleHandler,
createMiniAppDeletePaymentHandler,
createMiniAppDeletePurchaseHandler,
createMiniAppDeleteUtilityBillHandler, createMiniAppDeleteUtilityBillHandler,
createMiniAppOpenCycleHandler, createMiniAppOpenCycleHandler,
createMiniAppRentUpdateHandler, createMiniAppRentUpdateHandler,
createMiniAppUpdatePaymentHandler,
createMiniAppUpdatePurchaseHandler,
createMiniAppUpdateUtilityBillHandler createMiniAppUpdateUtilityBillHandler
} from './miniapp-billing' } from './miniapp-billing'
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
@@ -268,6 +273,9 @@ const reminderJobs = runtime.reminderJobsEnabled
return createReminderJobsHandler({ return createReminderJobsHandler({
listReminderTargets: () => listReminderTargets: () =>
householdConfigurationRepositoryClient!.repository.listReminderTargets(), householdConfigurationRepositoryClient!.repository.listReminderTargets(),
ensureBillingCycle: async ({ householdId, at }) => {
await financeServiceForHousehold(householdId).ensureExpectedCycle(at)
},
releaseReminderDispatch: (input) => releaseReminderDispatch: (input) =>
reminderRepositoryClient.repository.releaseReminderDispatch(input), reminderRepositoryClient.repository.releaseReminderDispatch(input),
sendReminderMessage: async (target, text) => { sendReminderMessage: async (target, text) => {
@@ -483,6 +491,51 @@ const server = createBotWebhookServer({
logger: getLogger('miniapp-billing') logger: getLogger('miniapp-billing')
}) })
: undefined, : 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 miniAppLocalePreference: householdOnboardingService
? createMiniAppLocalePreferenceHandler({ ? createMiniAppLocalePreferenceHandler({
allowedOrigins: runtime.miniAppAllowedOrigins, allowedOrigins: runtime.miniAppAllowedOrigins,

View File

@@ -135,6 +135,11 @@ function onboardingRepository(): HouseholdConfigurationRepository {
function createFinanceServiceStub(): FinanceCommandService { function createFinanceServiceStub(): FinanceCommandService {
return { return {
getMemberByTelegramUserId: async () => null, getMemberByTelegramUserId: async () => null,
ensureExpectedCycle: async () => ({
id: 'cycle-2026-03',
period: '2026-03',
currency: 'USD'
}),
getOpenCycle: async () => ({ getOpenCycle: async () => ({
id: 'cycle-2026-03', id: 'cycle-2026-03',
period: '2026-03', period: '2026-03',
@@ -187,6 +192,24 @@ function createFinanceServiceStub(): FinanceCommandService {
currency: 'USD' currency: 'USD'
}), }),
deleteUtilityBill: async () => true, 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, generateDashboard: async () => null,
generateStatement: async () => null generateStatement: async () => null
} }

View File

@@ -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: { export function createMiniAppBillingCycleHandler(options: {
allowedOrigins: readonly string[] allowedOrigins: readonly string[]
botToken: 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<Response>
} {
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<Response>
} {
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<Response>
} {
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<Response>
} {
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<Response>
} {
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)
}
}
}
}

View File

@@ -18,6 +18,12 @@ import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
function repository( function repository(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>> member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
): FinanceRepository { ): FinanceRepository {
const cycle = {
id: 'cycle-1',
period: '2026-03',
currency: 'GEL' as const
}
return { return {
getMemberByTelegramUserId: async () => member, getMemberByTelegramUserId: async () => member,
listMembers: async () => [ listMembers: async () => [
@@ -29,25 +35,29 @@ function repository(
isAdmin: true isAdmin: true
} }
], ],
getOpenCycle: async () => ({ getOpenCycle: async () => cycle,
id: 'cycle-1', getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null),
period: '2026-03', getLatestCycle: async () => cycle,
currency: 'GEL'
}),
getCycleByPeriod: async () => null,
getLatestCycle: async () => ({
id: 'cycle-1',
period: '2026-03',
currency: 'GEL'
}),
openCycle: async () => {}, openCycle: async () => {},
closeCycle: async () => {}, closeCycle: async () => {},
saveRentRule: async () => {}, saveRentRule: async () => {},
getCycleExchangeRate: async () => null, getCycleExchangeRate: async () => null,
saveCycleExchangeRate: async (input) => input, saveCycleExchangeRate: async (input) => input,
addUtilityBill: async () => {}, addUtilityBill: async () => {},
updateParsedPurchase: async () => null,
deleteParsedPurchase: async () => false,
updateUtilityBill: async () => null, updateUtilityBill: async () => null,
deleteUtilityBill: async () => false, 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 () => ({ getRentRuleForPeriod: async () => ({
amountMinor: 70000n, amountMinor: 70000n,
currency: 'USD' currency: 'USD'

View File

@@ -111,6 +111,7 @@ export function createMiniAppDashboardHandler(options: {
id: entry.id, id: entry.id,
kind: entry.kind, kind: entry.kind,
title: entry.title, title: entry.title,
memberId: entry.memberId,
paymentKind: entry.paymentKind, paymentKind: entry.paymentKind,
amountMajor: entry.amount.toMajorString(), amountMajor: entry.amount.toMajorString(),
currency: entry.currency, currency: entry.currency,

View File

@@ -76,6 +76,7 @@ async function readBody(request: Request): Promise<ReminderJobRequestBody> {
export function createReminderJobsHandler(options: { export function createReminderJobsHandler(options: {
listReminderTargets: () => Promise<readonly ReminderTarget[]> listReminderTargets: () => Promise<readonly ReminderTarget[]>
ensureBillingCycle?: (input: { householdId: string; at: Temporal.Instant }) => Promise<void>
releaseReminderDispatch: (input: { releaseReminderDispatch: (input: {
householdId: string householdId: string
period: string period: string
@@ -132,6 +133,11 @@ export function createReminderJobsHandler(options: {
}> = [] }> = []
for (const target of targets) { for (const target of targets) {
await options.ensureBillingCycle?.({
householdId: target.householdId,
at: currentInstant
})
if (!requestedPeriod && !isReminderDueToday(target, reminderType, currentInstant)) { if (!requestedPeriod && !isReminderDueToday(target, reminderType, currentInstant)) {
continue continue
} }

View File

@@ -104,6 +104,36 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response> handler: (request: Request) => Promise<Response>
} }
| undefined | undefined
miniAppUpdatePurchase?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppDeletePurchase?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppAddPayment?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppUpdatePayment?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppDeletePayment?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppLocalePreference?: miniAppLocalePreference?:
| { | {
path?: string path?: string
@@ -169,6 +199,15 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update' options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update'
const miniAppDeleteUtilityBillPath = const miniAppDeleteUtilityBillPath =
options.miniAppDeleteUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/delete' 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 = const miniAppLocalePreferencePath =
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale' options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
const schedulerPathPrefix = options.scheduler const schedulerPathPrefix = options.scheduler
@@ -257,6 +296,26 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppDeleteUtilityBill.handler(request) 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) { if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) {
return await options.miniAppLocalePreference.handler(request) return await options.miniAppLocalePreference.handler(request)
} }

View File

@@ -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) { async updateUtilityBill(input) {
const rows = await db const rows = await db
.update(schema.utilityBills) .update(schema.utilityBills)
@@ -344,6 +401,97 @@ export function createDbFinanceRepository(
return rows.length > 0 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) { async getRentRuleForPeriod(period) {
const rows = await db const rows = await db
.select({ .select({

View File

@@ -68,7 +68,7 @@ class FinanceRepositoryStub implements FinanceRepository {
} }
async getCycleByPeriod(): Promise<FinanceCycleRecord | null> { async getCycleByPeriod(): Promise<FinanceCycleRecord | null> {
return this.cycleByPeriodRecord return this.cycleByPeriodRecord ?? this.openCycleRecord ?? this.latestCycleRecord
} }
async getLatestCycle(): Promise<FinanceCycleRecord | null> { async getLatestCycle(): Promise<FinanceCycleRecord | null> {
@@ -76,11 +76,14 @@ class FinanceRepositoryStub implements FinanceRepository {
} }
async openCycle(period: string, currency: 'USD' | 'GEL'): Promise<void> { async openCycle(period: string, currency: 'USD' | 'GEL'): Promise<void> {
this.openCycleRecord = { const cycle = {
id: 'opened-cycle', id: 'opened-cycle',
period, period,
currency currency
} }
this.openCycleRecord = cycle
this.cycleByPeriodRecord = cycle
this.latestCycleRecord = cycle
} }
async closeCycle(): Promise<void> {} async closeCycle(): Promise<void> {}
@@ -129,6 +132,40 @@ class FinanceRepositoryStub implements FinanceRepository {
return false 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<FinanceRentRuleRecord | null> { async getRentRuleForPeriod(): Promise<FinanceRentRuleRecord | null> {
return this.rentRule 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 repository = new FinanceRepositoryStub()
const service = createService(repository) const service = createService(repository)
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1') const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
expect(result).toBeNull() expect(result).not.toBeNull()
expect(repository.lastUtilityBill).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 () => { test('generateStatement settles into cycle currency and persists snapshot', async () => {

View File

@@ -80,6 +80,23 @@ function localDateInTimezone(timezone: string): Temporal.PlainDate {
return nowInstant().toZonedDateTimeISO(timezone).toPlainDate() 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 { export interface FinanceDashboardMemberLine {
memberId: string memberId: string
displayName: string displayName: string
@@ -96,6 +113,7 @@ export interface FinanceDashboardLedgerEntry {
id: string id: string
kind: 'purchase' | 'utility' | 'payment' kind: 'purchase' | 'utility' | 'payment'
title: string title: string
memberId: string | null
amount: Money amount: Money
currency: CurrencyCode currency: CurrencyCode
displayAmount: Money displayAmount: Money
@@ -372,6 +390,7 @@ async function buildFinanceDashboard(
id: bill.id, id: bill.id,
kind: 'utility' as const, kind: 'utility' as const,
title: bill.billName, title: bill.billName,
memberId: bill.createdByMemberId,
amount: converted.originalAmount, amount: converted.originalAmount,
currency: bill.currency, currency: bill.currency,
displayAmount: converted.settlementAmount, displayAmount: converted.settlementAmount,
@@ -388,6 +407,7 @@ async function buildFinanceDashboard(
id: purchase.id, id: purchase.id,
kind: 'purchase' as const, kind: 'purchase' as const,
title: purchase.description ?? 'Shared purchase', title: purchase.description ?? 'Shared purchase',
memberId: purchase.payerMemberId,
amount: converted.originalAmount, amount: converted.originalAmount,
currency: purchase.currency, currency: purchase.currency,
displayAmount: converted.settlementAmount, displayAmount: converted.settlementAmount,
@@ -402,6 +422,7 @@ async function buildFinanceDashboard(
id: payment.id, id: payment.id,
kind: 'payment' as const, kind: 'payment' as const,
title: payment.kind, title: payment.kind,
memberId: payment.memberId,
amount: Money.fromMinor(payment.amountMinor, payment.currency), amount: Money.fromMinor(payment.amountMinor, payment.currency),
currency: payment.currency, currency: payment.currency,
displayAmount: Money.fromMinor(payment.amountMinor, payment.currency), displayAmount: Money.fromMinor(payment.amountMinor, payment.currency),
@@ -444,6 +465,7 @@ async function buildFinanceDashboard(
export interface FinanceCommandService { export interface FinanceCommandService {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null> getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
getOpenCycle(): Promise<FinanceCycleRecord | null> getOpenCycle(): Promise<FinanceCycleRecord | null>
ensureExpectedCycle(referenceInstant?: Temporal.Instant): Promise<FinanceCycleRecord>
getAdminCycleState(periodArg?: string): Promise<FinanceAdminCycleState> getAdminCycleState(periodArg?: string): Promise<FinanceAdminCycleState>
openCycle(periodArg: string, currencyArg?: string): Promise<FinanceCycleRecord> openCycle(periodArg: string, currencyArg?: string): Promise<FinanceCycleRecord>
closeCycle(periodArg?: string): Promise<FinanceCycleRecord | null> closeCycle(periodArg?: string): Promise<FinanceCycleRecord | null>
@@ -477,6 +499,40 @@ export interface FinanceCommandService {
currency: CurrencyCode currency: CurrencyCode
} | null> } | null>
deleteUtilityBill(billId: string): Promise<boolean> deleteUtilityBill(billId: string): Promise<boolean>
updatePurchase(
purchaseId: string,
description: string,
amountArg: string,
currencyArg?: string
): Promise<{
purchaseId: string
amount: Money
currency: CurrencyCode
} | null>
deletePurchase(purchaseId: string): Promise<boolean>
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<boolean>
generateDashboard(periodArg?: string): Promise<FinanceDashboard | null> generateDashboard(periodArg?: string): Promise<FinanceDashboard | null>
generateStatement(periodArg?: string): Promise<string | null> generateStatement(periodArg?: string): Promise<string | null>
} }
@@ -486,6 +542,34 @@ export function createFinanceCommandService(
): FinanceCommandService { ): FinanceCommandService {
const { repository, householdConfigurationRepository } = dependencies const { repository, householdConfigurationRepository } = dependencies
async function ensureExpectedCycle(referenceInstant = nowInstant()): Promise<FinanceCycleRecord> {
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 { return {
getMemberByTelegramUserId(telegramUserId) { getMemberByTelegramUserId(telegramUserId) {
return repository.getMemberByTelegramUserId(telegramUserId) return repository.getMemberByTelegramUserId(telegramUserId)
@@ -495,10 +579,14 @@ export function createFinanceCommandService(
return repository.getOpenCycle() return repository.getOpenCycle()
}, },
ensureExpectedCycle(referenceInstant) {
return ensureExpectedCycle(referenceInstant)
},
async getAdminCycleState(periodArg) { async getAdminCycleState(periodArg) {
const cycle = periodArg const cycle = periodArg
? await repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString()) ? await repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString())
: ((await repository.getOpenCycle()) ?? (await repository.getLatestCycle())) : await ensureExpectedCycle()
if (!cycle) { if (!cycle) {
return { return {
@@ -555,11 +643,11 @@ export function createFinanceCommandService(
}, },
async setRent(amountArg, currencyArg, periodArg) { async setRent(amountArg, currencyArg, periodArg) {
const [openCycle, settings] = await Promise.all([ const [settings, cycle] = await Promise.all([
repository.getOpenCycle(), householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId),
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId) periodArg ? Promise.resolve(null) : ensureExpectedCycle()
]) ])
const period = periodArg ?? openCycle?.period const period = periodArg ?? cycle?.period
if (!period) { if (!period) {
return null return null
} }
@@ -582,12 +670,9 @@ export function createFinanceCommandService(
async addUtilityBill(billName, amountArg, createdByMemberId, currencyArg) { async addUtilityBill(billName, amountArg, createdByMemberId, currencyArg) {
const [openCycle, settings] = await Promise.all([ const [openCycle, settings] = await Promise.all([
repository.getOpenCycle(), ensureExpectedCycle(),
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId) householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId)
]) ])
if (!openCycle) {
return null
}
const currency = parseCurrency(currencyArg, settings.settlementCurrency) const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency) const amount = Money.fromMajor(amountArg, currency)
@@ -635,7 +720,93 @@ export function createFinanceCommandService(
return repository.deleteUtilityBill(billId) 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) { async generateStatement(periodArg) {
if (!periodArg) {
await ensureExpectedCycle()
}
const dashboard = await buildFinanceDashboard(dependencies, periodArg) const dashboard = await buildFinanceDashboard(dependencies, periodArg)
if (!dashboard) { if (!dashboard) {
return null return null
@@ -661,7 +832,9 @@ export function createFinanceCommandService(
}, },
generateDashboard(periodArg) { generateDashboard(periodArg) {
return buildFinanceDashboard(dependencies, periodArg) return periodArg
? buildFinanceDashboard(dependencies, periodArg)
: ensureExpectedCycle().then(() => buildFinanceDashboard(dependencies))
} }
} }
} }

View File

@@ -165,6 +165,13 @@ export interface FinanceRepository {
currency: CurrencyCode currency: CurrencyCode
createdByMemberId: string createdByMemberId: string
}): Promise<void> }): Promise<void>
updateParsedPurchase(input: {
purchaseId: string
amountMinor: bigint
currency: CurrencyCode
description: string | null
}): Promise<FinanceParsedPurchaseRecord | null>
deleteParsedPurchase(purchaseId: string): Promise<boolean>
updateUtilityBill(input: { updateUtilityBill(input: {
billId: string billId: string
billName: string billName: string
@@ -172,6 +179,22 @@ export interface FinanceRepository {
currency: CurrencyCode currency: CurrencyCode
}): Promise<FinanceUtilityBillRecord | null> }): Promise<FinanceUtilityBillRecord | null>
deleteUtilityBill(billId: string): Promise<boolean> deleteUtilityBill(billId: string): Promise<boolean>
addPaymentRecord(input: {
cycleId: string
memberId: string
kind: FinancePaymentKind
amountMinor: bigint
currency: CurrencyCode
recordedAt: Instant
}): Promise<FinancePaymentRecord>
updatePaymentRecord(input: {
paymentId: string
memberId: string
kind: FinancePaymentKind
amountMinor: bigint
currency: CurrencyCode
}): Promise<FinancePaymentRecord | null>
deletePaymentRecord(paymentId: string): Promise<boolean>
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null> getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
getUtilityTotalForCycle(cycleId: string): Promise<bigint> getUtilityTotalForCycle(cycleId: string): Promise<bigint>
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]> listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>