mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
feat(finance): add billing correction APIs and cycle rollover
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>
|
||||||
|
|||||||
Reference in New Issue
Block a user