mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:54:02 +00:00
feat(finance): add billing correction APIs and cycle rollover
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -68,7 +68,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
}
|
||||
|
||||
async getCycleByPeriod(): Promise<FinanceCycleRecord | null> {
|
||||
return this.cycleByPeriodRecord
|
||||
return this.cycleByPeriodRecord ?? this.openCycleRecord ?? this.latestCycleRecord
|
||||
}
|
||||
|
||||
async getLatestCycle(): Promise<FinanceCycleRecord | null> {
|
||||
@@ -76,11 +76,14 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
}
|
||||
|
||||
async openCycle(period: string, currency: 'USD' | 'GEL'): Promise<void> {
|
||||
this.openCycleRecord = {
|
||||
const cycle = {
|
||||
id: 'opened-cycle',
|
||||
period,
|
||||
currency
|
||||
}
|
||||
this.openCycleRecord = cycle
|
||||
this.cycleByPeriodRecord = cycle
|
||||
this.latestCycleRecord = cycle
|
||||
}
|
||||
|
||||
async closeCycle(): Promise<void> {}
|
||||
@@ -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<FinanceRentRuleRecord | null> {
|
||||
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 () => {
|
||||
|
||||
@@ -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<FinanceMemberRecord | null>
|
||||
getOpenCycle(): Promise<FinanceCycleRecord | null>
|
||||
ensureExpectedCycle(referenceInstant?: Temporal.Instant): Promise<FinanceCycleRecord>
|
||||
getAdminCycleState(periodArg?: string): Promise<FinanceAdminCycleState>
|
||||
openCycle(periodArg: string, currencyArg?: string): Promise<FinanceCycleRecord>
|
||||
closeCycle(periodArg?: string): Promise<FinanceCycleRecord | null>
|
||||
@@ -477,6 +499,40 @@ export interface FinanceCommandService {
|
||||
currency: CurrencyCode
|
||||
} | null>
|
||||
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>
|
||||
generateStatement(periodArg?: string): Promise<string | null>
|
||||
}
|
||||
@@ -486,6 +542,34 @@ export function createFinanceCommandService(
|
||||
): FinanceCommandService {
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,13 @@ export interface FinanceRepository {
|
||||
currency: CurrencyCode
|
||||
createdByMemberId: string
|
||||
}): Promise<void>
|
||||
updateParsedPurchase(input: {
|
||||
purchaseId: string
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
description: string | null
|
||||
}): Promise<FinanceParsedPurchaseRecord | null>
|
||||
deleteParsedPurchase(purchaseId: string): Promise<boolean>
|
||||
updateUtilityBill(input: {
|
||||
billId: string
|
||||
billName: string
|
||||
@@ -172,6 +179,22 @@ export interface FinanceRepository {
|
||||
currency: CurrencyCode
|
||||
}): Promise<FinanceUtilityBillRecord | null>
|
||||
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>
|
||||
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
||||
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
||||
|
||||
Reference in New Issue
Block a user