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
} 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,

View File

@@ -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
}

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: {
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<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(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
): 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'

View File

@@ -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,

View File

@@ -76,6 +76,7 @@ async function readBody(request: Request): Promise<ReminderJobRequestBody> {
export function createReminderJobsHandler(options: {
listReminderTargets: () => Promise<readonly ReminderTarget[]>
ensureBillingCycle?: (input: { householdId: string; at: Temporal.Instant }) => Promise<void>
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
}

View File

@@ -104,6 +104,36 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response>
}
| 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?:
| {
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)
}