mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
feat(payments): add transparent balance guidance
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import type { FinanceCommandService } from '@household/application'
|
import type { FinanceCommandService } from '@household/application'
|
||||||
|
import { Money } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
ProcessedBotMessageRepository,
|
ProcessedBotMessageRepository,
|
||||||
@@ -247,57 +248,23 @@ function createFinanceService(): FinanceCommandService {
|
|||||||
generateDashboard: async () => ({
|
generateDashboard: async () => ({
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'GEL',
|
currency: 'GEL',
|
||||||
totalDue: {
|
totalDue: Money.fromMajor('1000.00', 'GEL'),
|
||||||
toMajorString: () => '1000.00'
|
totalPaid: Money.fromMajor('500.00', 'GEL'),
|
||||||
} as never,
|
totalRemaining: Money.fromMajor('500.00', 'GEL'),
|
||||||
totalPaid: {
|
rentSourceAmount: Money.fromMajor('700.00', 'USD'),
|
||||||
toMajorString: () => '500.00'
|
rentDisplayAmount: Money.fromMajor('1890.00', 'GEL'),
|
||||||
} as never,
|
|
||||||
totalRemaining: {
|
|
||||||
toMajorString: () => '500.00'
|
|
||||||
} as never,
|
|
||||||
rentSourceAmount: {
|
|
||||||
currency: 'USD',
|
|
||||||
toMajorString: () => '700.00'
|
|
||||||
} as never,
|
|
||||||
rentDisplayAmount: {
|
|
||||||
toMajorString: () => '1890.00'
|
|
||||||
} as never,
|
|
||||||
rentFxRateMicros: null,
|
rentFxRateMicros: null,
|
||||||
rentFxEffectiveDate: null,
|
rentFxEffectiveDate: null,
|
||||||
members: [
|
members: [
|
||||||
{
|
{
|
||||||
memberId: 'member-1',
|
memberId: 'member-1',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
rentShare: {
|
rentShare: Money.fromMajor('700.00', 'GEL'),
|
||||||
amountMinor: 70000n,
|
utilityShare: Money.fromMajor('100.00', 'GEL'),
|
||||||
currency: 'GEL',
|
purchaseOffset: Money.fromMajor('50.00', 'GEL'),
|
||||||
toMajorString: () => '700.00'
|
netDue: Money.fromMajor('850.00', 'GEL'),
|
||||||
} as never,
|
paid: Money.fromMajor('500.00', 'GEL'),
|
||||||
utilityShare: {
|
remaining: Money.fromMajor('350.00', 'GEL'),
|
||||||
amountMinor: 10000n,
|
|
||||||
currency: 'GEL',
|
|
||||||
toMajorString: () => '100.00'
|
|
||||||
} as never,
|
|
||||||
purchaseOffset: {
|
|
||||||
amountMinor: 5000n,
|
|
||||||
currency: 'GEL',
|
|
||||||
toMajorString: () => '50.00',
|
|
||||||
add: () => ({
|
|
||||||
amountMinor: 15000n,
|
|
||||||
currency: 'GEL',
|
|
||||||
toMajorString: () => '150.00'
|
|
||||||
})
|
|
||||||
} as never,
|
|
||||||
netDue: {
|
|
||||||
toMajorString: () => '850.00'
|
|
||||||
} as never,
|
|
||||||
paid: {
|
|
||||||
toMajorString: () => '500.00'
|
|
||||||
} as never,
|
|
||||||
remaining: {
|
|
||||||
toMajorString: () => '350.00'
|
|
||||||
} as never,
|
|
||||||
explanations: []
|
explanations: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -307,13 +274,9 @@ function createFinanceService(): FinanceCommandService {
|
|||||||
kind: 'purchase' as const,
|
kind: 'purchase' as const,
|
||||||
title: 'Soap',
|
title: 'Soap',
|
||||||
memberId: 'member-1',
|
memberId: 'member-1',
|
||||||
amount: {
|
amount: Money.fromMajor('30.00', 'GEL'),
|
||||||
toMajorString: () => '30.00'
|
|
||||||
} as never,
|
|
||||||
currency: 'GEL' as const,
|
currency: 'GEL' as const,
|
||||||
displayAmount: {
|
displayAmount: Money.fromMajor('30.00', 'GEL'),
|
||||||
toMajorString: () => '30.00'
|
|
||||||
} as never,
|
|
||||||
displayCurrency: 'GEL' as const,
|
displayCurrency: 'GEL' as const,
|
||||||
fxRateMicros: null,
|
fxRateMicros: null,
|
||||||
fxEffectiveDate: null,
|
fxEffectiveDate: null,
|
||||||
@@ -702,7 +665,7 @@ describe('registerDmAssistant', () => {
|
|||||||
|
|
||||||
expect(calls).toHaveLength(1)
|
expect(calls).toHaveLength(1)
|
||||||
expect(calls[0]?.payload).toMatchObject({
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
text: 'I can record this rent payment: 700.00 GEL. Confirm or cancel below.',
|
text: expect.stringContaining('I can record this rent payment: 700.00 GEL.'),
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[
|
[
|
||||||
@@ -730,6 +693,44 @@ describe('registerDmAssistant', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('answers utilities balance questions deterministically in DM', async () => {
|
||||||
|
const bot = createTestBot()
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: true
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
registerDmAssistant({
|
||||||
|
bot,
|
||||||
|
householdConfigurationRepository: createHouseholdRepository(),
|
||||||
|
promptRepository: createPromptRepository(),
|
||||||
|
financeServiceForHousehold: () => createFinanceService(),
|
||||||
|
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||||||
|
rateLimiter: createInMemoryAssistantRateLimiter({
|
||||||
|
burstLimit: 5,
|
||||||
|
burstWindowMs: 60_000,
|
||||||
|
rollingLimit: 50,
|
||||||
|
rollingWindowMs: 86_400_000
|
||||||
|
}),
|
||||||
|
usageTracker: createInMemoryAssistantUsageTracker()
|
||||||
|
})
|
||||||
|
|
||||||
|
await bot.handleUpdate(privateMessageUpdate('How much do I owe for utilities?') as never)
|
||||||
|
|
||||||
|
const replyCall = calls.find((call) => call.method === 'sendMessage')
|
||||||
|
expect(replyCall).toBeDefined()
|
||||||
|
const replyText = String((replyCall?.payload as { text?: unknown } | undefined)?.text ?? '')
|
||||||
|
expect(replyText).toContain('Current utilities payment guidance:')
|
||||||
|
expect(replyText).toContain('Utilities due: 100.00 GEL')
|
||||||
|
expect(replyText).toContain('Purchase balance: 50.00 GEL')
|
||||||
|
expect(replyText).toContain('Suggested payment under utilities adjustment: 150.00 GEL')
|
||||||
|
})
|
||||||
|
|
||||||
test('routes obvious purchase-like DMs into purchase confirmation flow', async () => {
|
test('routes obvious purchase-like DMs into purchase confirmation flow', async () => {
|
||||||
const bot = createTestBot()
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FinanceCommandService } from '@household/application'
|
import { buildMemberPaymentGuidance, type FinanceCommandService } from '@household/application'
|
||||||
import { instantFromEpochSeconds, Money } from '@household/domain'
|
import { instantFromEpochSeconds, Money } from '@household/domain'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type {
|
import type {
|
||||||
@@ -12,7 +12,13 @@ import { resolveReplyLocale } from './bot-locale'
|
|||||||
import { getBotTranslations, type BotLocale } from './i18n'
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant'
|
import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant'
|
||||||
import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter'
|
import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter'
|
||||||
import { maybeCreatePaymentProposal, parsePaymentProposalPayload } from './payment-proposals'
|
import {
|
||||||
|
formatPaymentBalanceReplyText,
|
||||||
|
formatPaymentProposalText,
|
||||||
|
maybeCreatePaymentBalanceReply,
|
||||||
|
maybeCreatePaymentProposal,
|
||||||
|
parsePaymentProposalPayload
|
||||||
|
} from './payment-proposals'
|
||||||
import type {
|
import type {
|
||||||
PurchaseMessageIngestionRepository,
|
PurchaseMessageIngestionRepository,
|
||||||
PurchaseProposalActionResult,
|
PurchaseProposalActionResult,
|
||||||
@@ -455,12 +461,34 @@ async function buildHouseholdContext(input: {
|
|||||||
|
|
||||||
const memberLine = dashboard.members.find((line) => line.memberId === input.memberId)
|
const memberLine = dashboard.members.find((line) => line.memberId === input.memberId)
|
||||||
if (memberLine) {
|
if (memberLine) {
|
||||||
|
const rentGuidance = buildMemberPaymentGuidance({
|
||||||
|
kind: 'rent',
|
||||||
|
period: dashboard.period,
|
||||||
|
memberLine,
|
||||||
|
settings
|
||||||
|
})
|
||||||
|
const utilitiesGuidance = buildMemberPaymentGuidance({
|
||||||
|
kind: 'utilities',
|
||||||
|
period: dashboard.period,
|
||||||
|
memberLine,
|
||||||
|
settings
|
||||||
|
})
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
`Member balance: due ${memberLine.netDue.toMajorString()} ${dashboard.currency}, paid ${memberLine.paid.toMajorString()} ${dashboard.currency}, remaining ${memberLine.remaining.toMajorString()} ${dashboard.currency}`
|
`Member balance: due ${memberLine.netDue.toMajorString()} ${dashboard.currency}, paid ${memberLine.paid.toMajorString()} ${dashboard.currency}, remaining ${memberLine.remaining.toMajorString()} ${dashboard.currency}`
|
||||||
)
|
)
|
||||||
lines.push(
|
lines.push(
|
||||||
`Rent share: ${memberLine.rentShare.toMajorString()} ${dashboard.currency}; utility share: ${memberLine.utilityShare.toMajorString()} ${dashboard.currency}; purchase offset: ${memberLine.purchaseOffset.toMajorString()} ${dashboard.currency}`
|
`Rent share: ${memberLine.rentShare.toMajorString()} ${dashboard.currency}; utility share: ${memberLine.utilityShare.toMajorString()} ${dashboard.currency}; purchase offset: ${memberLine.purchaseOffset.toMajorString()} ${dashboard.currency}`
|
||||||
)
|
)
|
||||||
|
lines.push(
|
||||||
|
`Payment adjustment policy: ${settings.paymentBalanceAdjustmentPolicy ?? 'utilities'}`
|
||||||
|
)
|
||||||
|
lines.push(
|
||||||
|
`Rent payment guidance: base ${rentGuidance.baseAmount.toMajorString()} ${dashboard.currency}; purchase offset ${rentGuidance.purchaseOffset.toMajorString()} ${dashboard.currency}; suggested payment ${rentGuidance.proposalAmount.toMajorString()} ${dashboard.currency}; reminder ${rentGuidance.reminderDate}; due ${rentGuidance.dueDate}`
|
||||||
|
)
|
||||||
|
lines.push(
|
||||||
|
`Utilities payment guidance: base ${utilitiesGuidance.baseAmount.toMajorString()} ${dashboard.currency}; purchase offset ${utilitiesGuidance.purchaseOffset.toMajorString()} ${dashboard.currency}; suggested payment ${utilitiesGuidance.proposalAmount.toMajorString()} ${dashboard.currency}; reminder ${utilitiesGuidance.reminderDate}; due ${utilitiesGuidance.dueDate}; payment_window_open=${utilitiesGuidance.paymentWindowOpen}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -993,6 +1021,29 @@ export function registerDmAssistant(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const financeService = options.financeServiceForHousehold(member.householdId)
|
const financeService = options.financeServiceForHousehold(member.householdId)
|
||||||
|
const paymentBalanceReply = await maybeCreatePaymentBalanceReply({
|
||||||
|
rawText: ctx.msg.text,
|
||||||
|
householdId: member.householdId,
|
||||||
|
memberId: member.id,
|
||||||
|
financeService,
|
||||||
|
householdConfigurationRepository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
|
||||||
|
if (paymentBalanceReply) {
|
||||||
|
const replyText = formatPaymentBalanceReplyText(locale, paymentBalanceReply)
|
||||||
|
options.memoryStore.appendTurn(memoryKey, {
|
||||||
|
role: 'user',
|
||||||
|
text: ctx.msg.text
|
||||||
|
})
|
||||||
|
options.memoryStore.appendTurn(memoryKey, {
|
||||||
|
role: 'assistant',
|
||||||
|
text: replyText
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.reply(replyText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const paymentProposal = await maybeCreatePaymentProposal({
|
const paymentProposal = await maybeCreatePaymentProposal({
|
||||||
rawText: ctx.msg.text,
|
rawText: ctx.msg.text,
|
||||||
householdId: member.householdId,
|
householdId: member.householdId,
|
||||||
@@ -1027,15 +1078,11 @@ export function registerDmAssistant(options: {
|
|||||||
expiresAt: null
|
expiresAt: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const amount = Money.fromMinor(
|
const proposalText = formatPaymentProposalText({
|
||||||
BigInt(paymentProposal.payload.amountMinor),
|
locale,
|
||||||
paymentProposal.payload.currency
|
surface: 'assistant',
|
||||||
)
|
proposal: paymentProposal
|
||||||
const proposalText = t.paymentProposal(
|
})
|
||||||
paymentProposal.payload.kind,
|
|
||||||
amount.toMajorString(),
|
|
||||||
amount.currency
|
|
||||||
)
|
|
||||||
options.memoryStore.appendTurn(memoryKey, {
|
options.memoryStore.appendTurn(memoryKey, {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
text: ctx.msg.text
|
text: ctx.msg.text
|
||||||
@@ -1155,6 +1202,20 @@ export function registerDmAssistant(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const financeService = options.financeServiceForHousehold(household.householdId)
|
||||||
|
const paymentBalanceReply = await maybeCreatePaymentBalanceReply({
|
||||||
|
rawText: mention.strippedText,
|
||||||
|
householdId: household.householdId,
|
||||||
|
memberId: member.id,
|
||||||
|
financeService,
|
||||||
|
householdConfigurationRepository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
|
||||||
|
if (paymentBalanceReply) {
|
||||||
|
await ctx.reply(formatPaymentBalanceReplyText(locale, paymentBalanceReply))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await replyWithAssistant({
|
await replyWithAssistant({
|
||||||
ctx,
|
ctx,
|
||||||
assistant: options.assistant,
|
assistant: options.assistant,
|
||||||
@@ -1166,7 +1227,7 @@ export function registerDmAssistant(options: {
|
|||||||
locale,
|
locale,
|
||||||
userMessage: mention.strippedText,
|
userMessage: mention.strippedText,
|
||||||
householdConfigurationRepository: options.householdConfigurationRepository,
|
householdConfigurationRepository: options.householdConfigurationRepository,
|
||||||
financeService: options.financeServiceForHousehold(household.householdId),
|
financeService,
|
||||||
memoryStore: options.memoryStore,
|
memoryStore: options.memoryStore,
|
||||||
usageTracker: options.usageTracker,
|
usageTracker: options.usageTracker,
|
||||||
logger: options.logger
|
logger: options.logger
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
payments: {
|
payments: {
|
||||||
topicMissing:
|
topicMissing:
|
||||||
'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.',
|
'Payments topic is not configured for this household yet. Ask an admin to run /bind_payments_topic.',
|
||||||
|
balanceReply: (kind) =>
|
||||||
|
kind === 'rent' ? 'Current rent payment guidance:' : 'Current utilities payment guidance:',
|
||||||
proposal: (kind, amount, currency) =>
|
proposal: (kind, amount, currency) =>
|
||||||
`I can record this ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}. Confirm or cancel below.`,
|
`I can record this ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}. Confirm or cancel below.`,
|
||||||
clarification:
|
clarification:
|
||||||
@@ -274,6 +276,24 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
unsupportedCurrency:
|
unsupportedCurrency:
|
||||||
'I can only record payments in the household settlement currency for this topic right now.',
|
'I can only record payments in the household settlement currency for this topic right now.',
|
||||||
noBalance: 'There is no payable balance for that payment type right now.',
|
noBalance: 'There is no payable balance for that payment type right now.',
|
||||||
|
breakdownBase: (kind, amount, currency) =>
|
||||||
|
`${kind === 'rent' ? 'Rent due' : 'Utilities due'}: ${amount} ${currency}`,
|
||||||
|
breakdownPurchaseBalance: (amount, currency) => `Purchase balance: ${amount} ${currency}`,
|
||||||
|
breakdownSuggestedTotal: (amount, currency, policy) =>
|
||||||
|
`Suggested payment under ${policy}: ${amount} ${currency}`,
|
||||||
|
breakdownRecordingAmount: (amount, currency) =>
|
||||||
|
`Amount from your message: ${amount} ${currency}`,
|
||||||
|
breakdownRemaining: (amount, currency) => `Total remaining balance: ${amount} ${currency}`,
|
||||||
|
adjustmentPolicy: (policy) =>
|
||||||
|
policy === 'utilities'
|
||||||
|
? 'utilities adjustment'
|
||||||
|
: policy === 'rent'
|
||||||
|
? 'rent adjustment'
|
||||||
|
: 'separate purchase settlement',
|
||||||
|
timingBeforeWindow: (kind, reminderDate, dueDate) =>
|
||||||
|
`${kind === 'rent' ? 'Rent' : 'Utilities'} are not due yet. Next reminder: ${reminderDate}. Due date: ${dueDate}.`,
|
||||||
|
timingDueNow: (kind, dueDate) =>
|
||||||
|
`${kind === 'rent' ? 'Rent' : 'Utilities'} are due now. Due date: ${dueDate}.`,
|
||||||
confirmButton: 'Confirm payment',
|
confirmButton: 'Confirm payment',
|
||||||
cancelButton: 'Cancel',
|
cancelButton: 'Cancel',
|
||||||
recorded: (kind, amount, currency) =>
|
recorded: (kind, amount, currency) =>
|
||||||
|
|||||||
@@ -270,6 +270,8 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
payments: {
|
payments: {
|
||||||
topicMissing:
|
topicMissing:
|
||||||
'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.',
|
'Для этого дома ещё не настроен топик оплат. Попросите админа выполнить /bind_payments_topic.',
|
||||||
|
balanceReply: (kind) =>
|
||||||
|
kind === 'rent' ? 'Текущая сводка по аренде:' : 'Текущая сводка по коммуналке:',
|
||||||
proposal: (kind, amount, currency) =>
|
proposal: (kind, amount, currency) =>
|
||||||
`Я могу записать эту оплату ${kind === 'rent' ? 'аренды' : 'коммуналки'}: ${amount} ${currency}. Подтвердите или отмените ниже.`,
|
`Я могу записать эту оплату ${kind === 'rent' ? 'аренды' : 'коммуналки'}: ${amount} ${currency}. Подтвердите или отмените ниже.`,
|
||||||
clarification:
|
clarification:
|
||||||
@@ -277,6 +279,25 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
unsupportedCurrency:
|
unsupportedCurrency:
|
||||||
'Сейчас я могу записывать оплаты в этом топике только в валюте расчётов по дому.',
|
'Сейчас я могу записывать оплаты в этом топике только в валюте расчётов по дому.',
|
||||||
noBalance: 'Сейчас для этого типа оплаты нет суммы к подтверждению.',
|
noBalance: 'Сейчас для этого типа оплаты нет суммы к подтверждению.',
|
||||||
|
breakdownBase: (kind, amount, currency) =>
|
||||||
|
`${kind === 'rent' ? 'Аренда к оплате' : 'Коммуналка к оплате'}: ${amount} ${currency}`,
|
||||||
|
breakdownPurchaseBalance: (amount, currency) =>
|
||||||
|
`Баланс по общим покупкам: ${amount} ${currency}`,
|
||||||
|
breakdownSuggestedTotal: (amount, currency, policy) =>
|
||||||
|
`Рекомендуемая сумма по политике «${policy}»: ${amount} ${currency}`,
|
||||||
|
breakdownRecordingAmount: (amount, currency) =>
|
||||||
|
`Сумма из вашего сообщения: ${amount} ${currency}`,
|
||||||
|
breakdownRemaining: (amount, currency) => `Общий остаток: ${amount} ${currency}`,
|
||||||
|
adjustmentPolicy: (policy) =>
|
||||||
|
policy === 'utilities'
|
||||||
|
? 'зачёт через коммуналку'
|
||||||
|
: policy === 'rent'
|
||||||
|
? 'зачёт через аренду'
|
||||||
|
: 'отдельный расчёт по покупкам',
|
||||||
|
timingBeforeWindow: (kind, reminderDate, dueDate) =>
|
||||||
|
`${kind === 'rent' ? 'Аренду' : 'Коммуналку'} пока рано оплачивать. Следующее напоминание: ${reminderDate}. Срок оплаты: ${dueDate}.`,
|
||||||
|
timingDueNow: (kind, dueDate) =>
|
||||||
|
`${kind === 'rent' ? 'Аренду' : 'Коммуналку'} уже пора оплачивать. Срок оплаты: ${dueDate}.`,
|
||||||
confirmButton: 'Подтвердить оплату',
|
confirmButton: 'Подтвердить оплату',
|
||||||
cancelButton: 'Отменить',
|
cancelButton: 'Отменить',
|
||||||
recorded: (kind, amount, currency) =>
|
recorded: (kind, amount, currency) =>
|
||||||
|
|||||||
@@ -254,11 +254,24 @@ export interface BotTranslationCatalog {
|
|||||||
}
|
}
|
||||||
payments: {
|
payments: {
|
||||||
topicMissing: string
|
topicMissing: string
|
||||||
|
balanceReply: (kind: 'rent' | 'utilities') => string
|
||||||
recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
|
recorded: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
|
||||||
proposal: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
|
proposal: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
|
||||||
clarification: string
|
clarification: string
|
||||||
unsupportedCurrency: string
|
unsupportedCurrency: string
|
||||||
noBalance: string
|
noBalance: string
|
||||||
|
breakdownBase: (kind: 'rent' | 'utilities', amount: string, currency: string) => string
|
||||||
|
breakdownPurchaseBalance: (amount: string, currency: string) => string
|
||||||
|
breakdownSuggestedTotal: (amount: string, currency: string, policy: string) => string
|
||||||
|
breakdownRecordingAmount: (amount: string, currency: string) => string
|
||||||
|
breakdownRemaining: (amount: string, currency: string) => string
|
||||||
|
adjustmentPolicy: (policy: 'utilities' | 'rent' | 'separate') => string
|
||||||
|
timingBeforeWindow: (
|
||||||
|
kind: 'rent' | 'utilities',
|
||||||
|
reminderDate: string,
|
||||||
|
dueDate: string
|
||||||
|
) => string
|
||||||
|
timingDueNow: (kind: 'rent' | 'utilities', dueDate: string) => string
|
||||||
confirmButton: string
|
confirmButton: string
|
||||||
cancelButton: string
|
cancelButton: string
|
||||||
cancelled: string
|
cancelled: string
|
||||||
|
|||||||
@@ -452,7 +452,8 @@ describe('createMiniAppSettingsHandler', () => {
|
|||||||
rentWarningDay: 17,
|
rentWarningDay: 17,
|
||||||
utilitiesDueDay: 4,
|
utilitiesDueDay: 4,
|
||||||
utilitiesReminderDay: 3,
|
utilitiesReminderDay: 3,
|
||||||
timezone: 'Asia/Tbilisi'
|
timezone: 'Asia/Tbilisi',
|
||||||
|
paymentBalanceAdjustmentPolicy: 'utilities'
|
||||||
},
|
},
|
||||||
topics: [
|
topics: [
|
||||||
{
|
{
|
||||||
@@ -547,7 +548,8 @@ describe('createMiniAppUpdateSettingsHandler', () => {
|
|||||||
rentWarningDay: 19,
|
rentWarningDay: 19,
|
||||||
utilitiesDueDay: 6,
|
utilitiesDueDay: 6,
|
||||||
utilitiesReminderDay: 5,
|
utilitiesReminderDay: 5,
|
||||||
timezone: 'Asia/Tbilisi'
|
timezone: 'Asia/Tbilisi',
|
||||||
|
paymentBalanceAdjustmentPolicy: 'utilities'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ async function readApprovalPayload(request: Request): Promise<{
|
|||||||
async function readSettingsUpdatePayload(request: Request): Promise<{
|
async function readSettingsUpdatePayload(request: Request): Promise<{
|
||||||
initData: string
|
initData: string
|
||||||
settlementCurrency?: string
|
settlementCurrency?: string
|
||||||
|
paymentBalanceAdjustmentPolicy?: string
|
||||||
rentAmountMajor?: string
|
rentAmountMajor?: string
|
||||||
rentCurrency?: string
|
rentCurrency?: string
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -67,6 +68,7 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
|||||||
const text = await clonedRequest.text()
|
const text = await clonedRequest.text()
|
||||||
let parsed: {
|
let parsed: {
|
||||||
settlementCurrency?: string
|
settlementCurrency?: string
|
||||||
|
paymentBalanceAdjustmentPolicy?: string
|
||||||
rentAmountMajor?: string
|
rentAmountMajor?: string
|
||||||
rentCurrency?: string
|
rentCurrency?: string
|
||||||
rentDueDay?: number
|
rentDueDay?: number
|
||||||
@@ -103,6 +105,11 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
|||||||
settlementCurrency: parsed.settlementCurrency
|
settlementCurrency: parsed.settlementCurrency
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof parsed.paymentBalanceAdjustmentPolicy === 'string'
|
||||||
|
? {
|
||||||
|
paymentBalanceAdjustmentPolicy: parsed.paymentBalanceAdjustmentPolicy
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(typeof parsed.rentCurrency === 'string'
|
...(typeof parsed.rentCurrency === 'string'
|
||||||
? {
|
? {
|
||||||
rentCurrency: parsed.rentCurrency
|
rentCurrency: parsed.rentCurrency
|
||||||
@@ -299,6 +306,7 @@ function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) {
|
|||||||
return {
|
return {
|
||||||
householdId: settings.householdId,
|
householdId: settings.householdId,
|
||||||
settlementCurrency: settings.settlementCurrency,
|
settlementCurrency: settings.settlementCurrency,
|
||||||
|
paymentBalanceAdjustmentPolicy: settings.paymentBalanceAdjustmentPolicy ?? 'utilities',
|
||||||
rentAmountMinor: settings.rentAmountMinor?.toString() ?? null,
|
rentAmountMinor: settings.rentAmountMinor?.toString() ?? null,
|
||||||
rentCurrency: settings.rentCurrency,
|
rentCurrency: settings.rentCurrency,
|
||||||
rentDueDay: settings.rentDueDay,
|
rentDueDay: settings.rentDueDay,
|
||||||
@@ -555,6 +563,11 @@ export function createMiniAppUpdateSettingsHandler(options: {
|
|||||||
settlementCurrency: payload.settlementCurrency
|
settlementCurrency: payload.settlementCurrency
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(payload.paymentBalanceAdjustmentPolicy
|
||||||
|
? {
|
||||||
|
paymentBalanceAdjustmentPolicy: payload.paymentBalanceAdjustmentPolicy
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(payload.rentAmountMajor !== undefined
|
...(payload.rentAmountMajor !== undefined
|
||||||
? {
|
? {
|
||||||
rentAmountMajor: payload.rentAmountMajor
|
rentAmountMajor: payload.rentAmountMajor
|
||||||
|
|||||||
@@ -1,7 +1,34 @@
|
|||||||
import { parsePaymentConfirmationMessage, type FinanceCommandService } from '@household/application'
|
import {
|
||||||
|
buildMemberPaymentGuidance,
|
||||||
|
parsePaymentConfirmationMessage,
|
||||||
|
type FinanceCommandService,
|
||||||
|
type MemberPaymentGuidance
|
||||||
|
} from '@household/application'
|
||||||
import { Money } from '@household/domain'
|
import { Money } from '@household/domain'
|
||||||
import type { HouseholdConfigurationRepository } from '@household/ports'
|
import type { HouseholdConfigurationRepository } from '@household/ports'
|
||||||
|
|
||||||
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
|
|
||||||
|
const RENT_BALANCE_KEYWORDS = [/\b(rent|housing|apartment|landlord)\b/i, /аренд/i, /жиль[её]/i]
|
||||||
|
const UTILITIES_BALANCE_KEYWORDS = [
|
||||||
|
/\b(utilities|utility|gas|water|electricity|internet|cleaning)\b/i,
|
||||||
|
/коммун/i,
|
||||||
|
/газ/i,
|
||||||
|
/вод/i,
|
||||||
|
/элект/i,
|
||||||
|
/свет/i,
|
||||||
|
/интернет/i,
|
||||||
|
/уборк/i
|
||||||
|
]
|
||||||
|
const BALANCE_QUESTION_KEYWORDS = [
|
||||||
|
/\?/,
|
||||||
|
/\b(how much|owe|due|balance|remaining)\b/i,
|
||||||
|
/сколько/i,
|
||||||
|
/долж/i,
|
||||||
|
/баланс/i,
|
||||||
|
/остат/i
|
||||||
|
]
|
||||||
|
|
||||||
export interface PaymentProposalPayload {
|
export interface PaymentProposalPayload {
|
||||||
proposalId: string
|
proposalId: string
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -11,6 +38,16 @@ export interface PaymentProposalPayload {
|
|||||||
currency: 'GEL' | 'USD'
|
currency: 'GEL' | 'USD'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaymentProposalBreakdown {
|
||||||
|
guidance: MemberPaymentGuidance
|
||||||
|
explicitAmount: Money | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentBalanceReply {
|
||||||
|
kind: 'rent' | 'utilities'
|
||||||
|
guidance: MemberPaymentGuidance
|
||||||
|
}
|
||||||
|
|
||||||
export function parsePaymentProposalPayload(
|
export function parsePaymentProposalPayload(
|
||||||
payload: Record<string, unknown>
|
payload: Record<string, unknown>
|
||||||
): PaymentProposalPayload | null {
|
): PaymentProposalPayload | null {
|
||||||
@@ -39,6 +76,147 @@ export function parsePaymentProposalPayload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasMatch(patterns: readonly RegExp[], value: string): boolean {
|
||||||
|
return patterns.some((pattern) => pattern.test(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBalanceQuestionKind(rawText: string): 'rent' | 'utilities' | null {
|
||||||
|
const normalized = rawText.trim()
|
||||||
|
if (normalized.length === 0 || !hasMatch(BALANCE_QUESTION_KEYWORDS, normalized)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionsRent = hasMatch(RENT_BALANCE_KEYWORDS, normalized)
|
||||||
|
const mentionsUtilities = hasMatch(UTILITIES_BALANCE_KEYWORDS, normalized)
|
||||||
|
|
||||||
|
if (mentionsRent === mentionsUtilities) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return mentionsRent ? 'rent' : 'utilities'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(locale: BotLocale, rawDate: string): string {
|
||||||
|
const [yearRaw, monthRaw, dayRaw] = rawDate.split('-')
|
||||||
|
const year = Number(yearRaw)
|
||||||
|
const month = Number(monthRaw)
|
||||||
|
const day = Number(dayRaw)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isInteger(year) ||
|
||||||
|
!Number.isInteger(month) ||
|
||||||
|
!Number.isInteger(day) ||
|
||||||
|
month < 1 ||
|
||||||
|
month > 12 ||
|
||||||
|
day < 1 ||
|
||||||
|
day > 31
|
||||||
|
) {
|
||||||
|
return rawDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(locale === 'ru' ? 'ru-RU' : 'en-US', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
timeZone: 'UTC'
|
||||||
|
}).format(new Date(Date.UTC(year, month - 1, day)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPaymentBreakdown(locale: BotLocale, breakdown: PaymentProposalBreakdown): string {
|
||||||
|
const t = getBotTranslations(locale).payments
|
||||||
|
const policyLabel = t.adjustmentPolicy(breakdown.guidance.adjustmentPolicy)
|
||||||
|
const lines = [
|
||||||
|
t.breakdownBase(
|
||||||
|
breakdown.guidance.kind,
|
||||||
|
breakdown.guidance.baseAmount.toMajorString(),
|
||||||
|
breakdown.guidance.baseAmount.currency
|
||||||
|
),
|
||||||
|
t.breakdownPurchaseBalance(
|
||||||
|
breakdown.guidance.purchaseOffset.toMajorString(),
|
||||||
|
breakdown.guidance.purchaseOffset.currency
|
||||||
|
),
|
||||||
|
t.breakdownSuggestedTotal(
|
||||||
|
breakdown.guidance.proposalAmount.toMajorString(),
|
||||||
|
breakdown.guidance.proposalAmount.currency,
|
||||||
|
policyLabel
|
||||||
|
),
|
||||||
|
t.breakdownRemaining(
|
||||||
|
breakdown.guidance.totalRemaining.toMajorString(),
|
||||||
|
breakdown.guidance.totalRemaining.currency
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if (
|
||||||
|
breakdown.explicitAmount &&
|
||||||
|
!breakdown.explicitAmount.equals(breakdown.guidance.proposalAmount)
|
||||||
|
) {
|
||||||
|
lines.push(
|
||||||
|
t.breakdownRecordingAmount(
|
||||||
|
breakdown.explicitAmount.toMajorString(),
|
||||||
|
breakdown.explicitAmount.currency
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!breakdown.guidance.paymentWindowOpen) {
|
||||||
|
lines.push(
|
||||||
|
t.timingBeforeWindow(
|
||||||
|
breakdown.guidance.kind,
|
||||||
|
formatDateLabel(locale, breakdown.guidance.reminderDate),
|
||||||
|
formatDateLabel(locale, breakdown.guidance.dueDate)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (breakdown.guidance.paymentDue) {
|
||||||
|
lines.push(
|
||||||
|
t.timingDueNow(breakdown.guidance.kind, formatDateLabel(locale, breakdown.guidance.dueDate))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPaymentProposalText(input: {
|
||||||
|
locale: BotLocale
|
||||||
|
surface: 'assistant' | 'topic'
|
||||||
|
proposal: {
|
||||||
|
payload: PaymentProposalPayload
|
||||||
|
breakdown: PaymentProposalBreakdown
|
||||||
|
}
|
||||||
|
}): string {
|
||||||
|
const amount = Money.fromMinor(
|
||||||
|
BigInt(input.proposal.payload.amountMinor),
|
||||||
|
input.proposal.payload.currency
|
||||||
|
)
|
||||||
|
const intro =
|
||||||
|
input.surface === 'assistant'
|
||||||
|
? getBotTranslations(input.locale).assistant.paymentProposal(
|
||||||
|
input.proposal.payload.kind,
|
||||||
|
amount.toMajorString(),
|
||||||
|
amount.currency
|
||||||
|
)
|
||||||
|
: getBotTranslations(input.locale).payments.proposal(
|
||||||
|
input.proposal.payload.kind,
|
||||||
|
amount.toMajorString(),
|
||||||
|
amount.currency
|
||||||
|
)
|
||||||
|
|
||||||
|
return `${intro}\n\n${formatPaymentBreakdown(input.locale, input.proposal.breakdown)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPaymentBalanceReplyText(
|
||||||
|
locale: BotLocale,
|
||||||
|
reply: PaymentBalanceReply
|
||||||
|
): string {
|
||||||
|
const t = getBotTranslations(locale).payments
|
||||||
|
|
||||||
|
return [
|
||||||
|
t.balanceReply(reply.kind),
|
||||||
|
formatPaymentBreakdown(locale, {
|
||||||
|
guidance: reply.guidance,
|
||||||
|
explicitAmount: null
|
||||||
|
})
|
||||||
|
].join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
export async function maybeCreatePaymentProposal(input: {
|
export async function maybeCreatePaymentProposal(input: {
|
||||||
rawText: string
|
rawText: string
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -61,6 +239,7 @@ export async function maybeCreatePaymentProposal(input: {
|
|||||||
| {
|
| {
|
||||||
status: 'proposal'
|
status: 'proposal'
|
||||||
payload: PaymentProposalPayload
|
payload: PaymentProposalPayload
|
||||||
|
breakdown: PaymentProposalBreakdown
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
const settings = await input.householdConfigurationRepository.getHouseholdBillingSettings(
|
const settings = await input.householdConfigurationRepository.getHouseholdBillingSettings(
|
||||||
@@ -100,11 +279,13 @@ export async function maybeCreatePaymentProposal(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount =
|
const guidance = buildMemberPaymentGuidance({
|
||||||
parsed.explicitAmount ??
|
kind: parsed.kind,
|
||||||
(parsed.kind === 'rent'
|
period: dashboard.period,
|
||||||
? memberLine.rentShare
|
memberLine,
|
||||||
: memberLine.utilityShare.add(memberLine.purchaseOffset))
|
settings
|
||||||
|
})
|
||||||
|
const amount = parsed.explicitAmount ?? guidance.proposalAmount
|
||||||
|
|
||||||
if (amount.amountMinor <= 0n) {
|
if (amount.amountMinor <= 0n) {
|
||||||
return {
|
return {
|
||||||
@@ -121,10 +302,50 @@ export async function maybeCreatePaymentProposal(input: {
|
|||||||
kind: parsed.kind,
|
kind: parsed.kind,
|
||||||
amountMinor: amount.amountMinor.toString(),
|
amountMinor: amount.amountMinor.toString(),
|
||||||
currency: amount.currency
|
currency: amount.currency
|
||||||
|
},
|
||||||
|
breakdown: {
|
||||||
|
guidance,
|
||||||
|
explicitAmount: parsed.explicitAmount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function maybeCreatePaymentBalanceReply(input: {
|
||||||
|
rawText: string
|
||||||
|
householdId: string
|
||||||
|
memberId: string
|
||||||
|
financeService: FinanceCommandService
|
||||||
|
householdConfigurationRepository: HouseholdConfigurationRepository
|
||||||
|
}): Promise<PaymentBalanceReply | null> {
|
||||||
|
const kind = detectBalanceQuestionKind(input.rawText)
|
||||||
|
if (!kind) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [settings, dashboard] = await Promise.all([
|
||||||
|
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId),
|
||||||
|
input.financeService.generateDashboard()
|
||||||
|
])
|
||||||
|
if (!dashboard) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberLine = dashboard.members.find((line) => line.memberId === input.memberId)
|
||||||
|
if (!memberLine) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
guidance: buildMemberPaymentGuidance({
|
||||||
|
kind,
|
||||||
|
period: dashboard.period,
|
||||||
|
memberLine,
|
||||||
|
settings
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function synthesizePaymentConfirmationText(payload: PaymentProposalPayload): string {
|
export function synthesizePaymentConfirmationText(payload: PaymentProposalPayload): string {
|
||||||
const amount = Money.fromMinor(BigInt(payload.amountMinor), payload.currency)
|
const amount = Money.fromMinor(BigInt(payload.amountMinor), payload.currency)
|
||||||
const kindText = payload.kind === 'rent' ? 'rent' : 'utilities'
|
const kindText = payload.kind === 'rent' ? 'rent' : 'utilities'
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
reply_parameters: {
|
reply_parameters: {
|
||||||
message_id: 55
|
message_id: 55
|
||||||
},
|
},
|
||||||
text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.',
|
text: expect.stringContaining('Я могу записать эту оплату аренды: 472.50 GEL.'),
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[
|
[
|
||||||
@@ -407,7 +407,7 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
text: 'Пока не могу подтвердить эту оплату. Уточните, это аренда или коммуналка, и при необходимости напишите сумму и валюту.'
|
text: 'Пока не могу подтвердить эту оплату. Уточните, это аренда или коммуналка, и при необходимости напишите сумму и валюту.'
|
||||||
})
|
})
|
||||||
expect(calls[1]?.payload).toMatchObject({
|
expect(calls[1]?.payload).toMatchObject({
|
||||||
text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.'
|
text: expect.stringContaining('Я могу записать эту оплату аренды: 472.50 GEL.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -639,7 +639,7 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
|
|
||||||
expect(calls).toHaveLength(1)
|
expect(calls).toHaveLength(1)
|
||||||
expect(calls[0]?.payload).toMatchObject({
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.'
|
text: expect.stringContaining('Я могу записать эту оплату аренды: 472.50 GEL.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { FinanceCommandService, PaymentConfirmationService } from '@household/application'
|
import type { FinanceCommandService, PaymentConfirmationService } from '@household/application'
|
||||||
import { Money } from '@household/domain'
|
|
||||||
import { instantFromEpochSeconds, nowInstant, type Instant } from '@household/domain'
|
import { instantFromEpochSeconds, nowInstant, type Instant } from '@household/domain'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
@@ -11,6 +10,7 @@ import type {
|
|||||||
|
|
||||||
import { getBotTranslations, type BotLocale } from './i18n'
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
import {
|
import {
|
||||||
|
formatPaymentProposalText,
|
||||||
maybeCreatePaymentProposal,
|
maybeCreatePaymentProposal,
|
||||||
parsePaymentProposalPayload,
|
parsePaymentProposalPayload,
|
||||||
synthesizePaymentConfirmationText
|
synthesizePaymentConfirmationText
|
||||||
@@ -485,10 +485,6 @@ export function registerConfiguredPaymentTopicIngestion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (proposal.status === 'proposal') {
|
if (proposal.status === 'proposal') {
|
||||||
const amount = Money.fromMinor(
|
|
||||||
BigInt(proposal.payload.amountMinor),
|
|
||||||
proposal.payload.currency
|
|
||||||
)
|
|
||||||
await promptRepository.upsertPendingAction({
|
await promptRepository.upsertPendingAction({
|
||||||
telegramUserId: record.senderTelegramUserId,
|
telegramUserId: record.senderTelegramUserId,
|
||||||
telegramChatId: record.chatId,
|
telegramChatId: record.chatId,
|
||||||
@@ -508,7 +504,11 @@ export function registerConfiguredPaymentTopicIngestion(
|
|||||||
|
|
||||||
await replyToPaymentMessage(
|
await replyToPaymentMessage(
|
||||||
ctx,
|
ctx,
|
||||||
t.proposal(proposal.payload.kind, amount.toMajorString(), amount.currency),
|
formatPaymentProposalText({
|
||||||
|
locale,
|
||||||
|
surface: 'topic',
|
||||||
|
proposal
|
||||||
|
}),
|
||||||
paymentProposalReplyMarkup(locale, proposal.payload.proposalId)
|
paymentProposalReplyMarkup(locale, proposal.payload.proposalId)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ function App() {
|
|||||||
const [addingPayment, setAddingPayment] = createSignal(false)
|
const [addingPayment, setAddingPayment] = createSignal(false)
|
||||||
const [billingForm, setBillingForm] = createSignal({
|
const [billingForm, setBillingForm] = createSignal({
|
||||||
settlementCurrency: 'GEL' as 'USD' | 'GEL',
|
settlementCurrency: 'GEL' as 'USD' | 'GEL',
|
||||||
|
paymentBalanceAdjustmentPolicy: 'utilities' as 'utilities' | 'rent' | 'separate',
|
||||||
rentAmountMajor: '',
|
rentAmountMajor: '',
|
||||||
rentCurrency: 'USD' as 'USD' | 'GEL',
|
rentCurrency: 'USD' as 'USD' | 'GEL',
|
||||||
rentDueDay: 20,
|
rentDueDay: 20,
|
||||||
@@ -530,6 +531,7 @@ function App() {
|
|||||||
}))
|
}))
|
||||||
setBillingForm({
|
setBillingForm({
|
||||||
settlementCurrency: payload.settings.settlementCurrency,
|
settlementCurrency: payload.settings.settlementCurrency,
|
||||||
|
paymentBalanceAdjustmentPolicy: payload.settings.paymentBalanceAdjustmentPolicy,
|
||||||
rentAmountMajor: payload.settings.rentAmountMinor
|
rentAmountMajor: payload.settings.rentAmountMinor
|
||||||
? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2)
|
? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2)
|
||||||
: '',
|
: '',
|
||||||
@@ -2273,6 +2275,29 @@ function App() {
|
|||||||
<option value="USD">USD</option>
|
<option value="USD">USD</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="settings-field settings-field--wide">
|
||||||
|
<span>{copy().paymentBalanceAdjustmentPolicy}</span>
|
||||||
|
<select
|
||||||
|
value={billingForm().paymentBalanceAdjustmentPolicy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBillingForm((current) => ({
|
||||||
|
...current,
|
||||||
|
paymentBalanceAdjustmentPolicy: event.currentTarget.value as
|
||||||
|
| 'utilities'
|
||||||
|
| 'rent'
|
||||||
|
| 'separate'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="utilities">
|
||||||
|
{copy().paymentBalanceAdjustmentUtilities}
|
||||||
|
</option>
|
||||||
|
<option value="rent">{copy().paymentBalanceAdjustmentRent}</option>
|
||||||
|
<option value="separate">
|
||||||
|
{copy().paymentBalanceAdjustmentSeparate}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
<span>{copy().rentAmount}</span>
|
<span>{copy().rentAmount}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ export const dictionary = {
|
|||||||
topicUnbound: 'Unbound',
|
topicUnbound: 'Unbound',
|
||||||
billingSettingsTitle: 'Billing settings',
|
billingSettingsTitle: 'Billing settings',
|
||||||
settlementCurrency: 'Settlement currency',
|
settlementCurrency: 'Settlement currency',
|
||||||
|
paymentBalanceAdjustmentPolicy: 'Purchase balance adjustment',
|
||||||
|
paymentBalanceAdjustmentUtilities: 'Adjust through utilities',
|
||||||
|
paymentBalanceAdjustmentRent: 'Adjust through rent',
|
||||||
|
paymentBalanceAdjustmentSeparate: 'Keep purchases separate',
|
||||||
billingCycleTitle: 'Current billing cycle',
|
billingCycleTitle: 'Current billing cycle',
|
||||||
billingCycleEmpty: 'No open cycle',
|
billingCycleEmpty: 'No open cycle',
|
||||||
billingCycleStatus: 'Current cycle currency: {currency}',
|
billingCycleStatus: 'Current cycle currency: {currency}',
|
||||||
@@ -286,6 +290,10 @@ export const dictionary = {
|
|||||||
topicUnbound: 'Не привязан',
|
topicUnbound: 'Не привязан',
|
||||||
billingSettingsTitle: 'Настройки биллинга',
|
billingSettingsTitle: 'Настройки биллинга',
|
||||||
settlementCurrency: 'Валюта расчёта',
|
settlementCurrency: 'Валюта расчёта',
|
||||||
|
paymentBalanceAdjustmentPolicy: 'Зачёт баланса по покупкам',
|
||||||
|
paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку',
|
||||||
|
paymentBalanceAdjustmentRent: 'Зачитывать через аренду',
|
||||||
|
paymentBalanceAdjustmentSeparate: 'Держать покупки отдельно',
|
||||||
billingCycleTitle: 'Текущий billing cycle',
|
billingCycleTitle: 'Текущий billing cycle',
|
||||||
billingCycleEmpty: 'Нет открытого цикла',
|
billingCycleEmpty: 'Нет открытого цикла',
|
||||||
billingCycleStatus: 'Валюта текущего цикла: {currency}',
|
billingCycleStatus: 'Валюта текущего цикла: {currency}',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface MiniAppMember {
|
|||||||
export interface MiniAppBillingSettings {
|
export interface MiniAppBillingSettings {
|
||||||
householdId: string
|
householdId: string
|
||||||
settlementCurrency: 'USD' | 'GEL'
|
settlementCurrency: 'USD' | 'GEL'
|
||||||
|
paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate'
|
||||||
rentAmountMinor: string | null
|
rentAmountMinor: string | null
|
||||||
rentCurrency: 'USD' | 'GEL'
|
rentCurrency: 'USD' | 'GEL'
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -410,6 +411,7 @@ export async function updateMiniAppBillingSettings(
|
|||||||
initData: string,
|
initData: string,
|
||||||
input: {
|
input: {
|
||||||
settlementCurrency?: 'USD' | 'GEL'
|
settlementCurrency?: 'USD' | 'GEL'
|
||||||
|
paymentBalanceAdjustmentPolicy?: 'utilities' | 'rent' | 'separate'
|
||||||
rentAmountMajor?: string
|
rentAmountMajor?: string
|
||||||
rentCurrency: 'USD' | 'GEL'
|
rentCurrency: 'USD' | 'GEL'
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
|
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
|
||||||
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
|
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
|
||||||
|
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
|
||||||
HOUSEHOLD_TOPIC_ROLES,
|
HOUSEHOLD_TOPIC_ROLES,
|
||||||
type HouseholdMemberAbsencePolicy,
|
type HouseholdMemberAbsencePolicy,
|
||||||
type HouseholdMemberAbsencePolicyRecord,
|
type HouseholdMemberAbsencePolicyRecord,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
type HouseholdJoinTokenRecord,
|
type HouseholdJoinTokenRecord,
|
||||||
type HouseholdMemberLifecycleStatus,
|
type HouseholdMemberLifecycleStatus,
|
||||||
type HouseholdMemberRecord,
|
type HouseholdMemberRecord,
|
||||||
|
type HouseholdPaymentBalanceAdjustmentPolicy,
|
||||||
type HouseholdPendingMemberRecord,
|
type HouseholdPendingMemberRecord,
|
||||||
type HouseholdTelegramChatRecord,
|
type HouseholdTelegramChatRecord,
|
||||||
type HouseholdTopicBindingRecord,
|
type HouseholdTopicBindingRecord,
|
||||||
@@ -47,6 +49,18 @@ function normalizeMemberLifecycleStatus(raw: string): HouseholdMemberLifecycleSt
|
|||||||
throw new Error(`Unsupported household member lifecycle status: ${raw}`)
|
throw new Error(`Unsupported household member lifecycle status: ${raw}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePaymentBalanceAdjustmentPolicy(
|
||||||
|
raw: string
|
||||||
|
): HouseholdPaymentBalanceAdjustmentPolicy {
|
||||||
|
const normalized = raw.trim().toLowerCase()
|
||||||
|
|
||||||
|
if ((HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES as readonly string[]).includes(normalized)) {
|
||||||
|
return normalized as HouseholdPaymentBalanceAdjustmentPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'utilities'
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMemberAbsencePolicy(raw: string): HouseholdMemberAbsencePolicy {
|
function normalizeMemberAbsencePolicy(raw: string): HouseholdMemberAbsencePolicy {
|
||||||
const normalized = raw.trim().toLowerCase()
|
const normalized = raw.trim().toLowerCase()
|
||||||
|
|
||||||
@@ -206,6 +220,7 @@ function toCurrencyCode(raw: string): CurrencyCode {
|
|||||||
function toHouseholdBillingSettingsRecord(row: {
|
function toHouseholdBillingSettingsRecord(row: {
|
||||||
householdId: string
|
householdId: string
|
||||||
settlementCurrency: string
|
settlementCurrency: string
|
||||||
|
paymentBalanceAdjustmentPolicy: string
|
||||||
rentAmountMinor: bigint | null
|
rentAmountMinor: bigint | null
|
||||||
rentCurrency: string
|
rentCurrency: string
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -217,6 +232,9 @@ function toHouseholdBillingSettingsRecord(row: {
|
|||||||
return {
|
return {
|
||||||
householdId: row.householdId,
|
householdId: row.householdId,
|
||||||
settlementCurrency: toCurrencyCode(row.settlementCurrency),
|
settlementCurrency: toCurrencyCode(row.settlementCurrency),
|
||||||
|
paymentBalanceAdjustmentPolicy: normalizePaymentBalanceAdjustmentPolicy(
|
||||||
|
row.paymentBalanceAdjustmentPolicy
|
||||||
|
),
|
||||||
rentAmountMinor: row.rentAmountMinor,
|
rentAmountMinor: row.rentAmountMinor,
|
||||||
rentCurrency: toCurrencyCode(row.rentCurrency),
|
rentCurrency: toCurrencyCode(row.rentCurrency),
|
||||||
rentDueDay: row.rentDueDay,
|
rentDueDay: row.rentDueDay,
|
||||||
@@ -917,6 +935,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
.select({
|
.select({
|
||||||
householdId: schema.householdBillingSettings.householdId,
|
householdId: schema.householdBillingSettings.householdId,
|
||||||
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
||||||
|
paymentBalanceAdjustmentPolicy:
|
||||||
|
schema.householdBillingSettings.paymentBalanceAdjustmentPolicy,
|
||||||
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
||||||
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
||||||
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
||||||
@@ -948,6 +968,11 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
settlementCurrency: input.settlementCurrency
|
settlementCurrency: input.settlementCurrency
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(input.paymentBalanceAdjustmentPolicy
|
||||||
|
? {
|
||||||
|
paymentBalanceAdjustmentPolicy: input.paymentBalanceAdjustmentPolicy
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(input.rentAmountMinor !== undefined
|
...(input.rentAmountMinor !== undefined
|
||||||
? {
|
? {
|
||||||
rentAmountMinor: input.rentAmountMinor
|
rentAmountMinor: input.rentAmountMinor
|
||||||
@@ -989,6 +1014,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
.returning({
|
.returning({
|
||||||
householdId: schema.householdBillingSettings.householdId,
|
householdId: schema.householdBillingSettings.householdId,
|
||||||
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
||||||
|
paymentBalanceAdjustmentPolicy:
|
||||||
|
schema.householdBillingSettings.paymentBalanceAdjustmentPolicy,
|
||||||
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
|
||||||
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
rentCurrency: schema.householdBillingSettings.rentCurrency,
|
||||||
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
rentDueDay: schema.householdBillingSettings.rentDueDay,
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateParsedPurchase(input) {
|
async updateParsedPurchase(input: Parameters<FinanceRepository['updateParsedPurchase']>[0]) {
|
||||||
this.lastUpdatedPurchaseInput = input
|
this.lastUpdatedPurchaseInput = input
|
||||||
return {
|
return {
|
||||||
id: input.purchaseId,
|
id: input.purchaseId,
|
||||||
@@ -149,12 +149,16 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
|
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
|
||||||
splitMode: input.splitMode ?? 'equal',
|
splitMode: input.splitMode ?? 'equal',
|
||||||
participants: input.participants?.map((participant, index) => ({
|
...(input.participants
|
||||||
id: `participant-${index + 1}`,
|
? {
|
||||||
memberId: participant.memberId,
|
participants: input.participants.map((participant, index) => ({
|
||||||
included: participant.included !== false,
|
id: `participant-${index + 1}`,
|
||||||
shareAmountMinor: participant.shareAmountMinor
|
memberId: participant.memberId,
|
||||||
}))
|
included: participant.included !== false,
|
||||||
|
shareAmountMinor: participant.shareAmountMinor
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
: {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,4 @@ export {
|
|||||||
parsePaymentConfirmationMessage,
|
parsePaymentConfirmationMessage,
|
||||||
type ParsedPaymentConfirmation
|
type ParsedPaymentConfirmation
|
||||||
} from './payment-confirmation-parser'
|
} from './payment-confirmation-parser'
|
||||||
|
export { buildMemberPaymentGuidance, type MemberPaymentGuidance } from './payment-guidance'
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface MiniAppAdminService {
|
|||||||
householdId: string
|
householdId: string
|
||||||
actorIsAdmin: boolean
|
actorIsAdmin: boolean
|
||||||
settlementCurrency?: string
|
settlementCurrency?: string
|
||||||
|
paymentBalanceAdjustmentPolicy?: string
|
||||||
rentAmountMajor?: string
|
rentAmountMajor?: string
|
||||||
rentCurrency?: string
|
rentCurrency?: string
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -228,6 +229,20 @@ export function createMiniAppAdminService(
|
|||||||
const settlementCurrency = input.settlementCurrency
|
const settlementCurrency = input.settlementCurrency
|
||||||
? parseCurrency(input.settlementCurrency)
|
? parseCurrency(input.settlementCurrency)
|
||||||
: undefined
|
: undefined
|
||||||
|
const paymentBalanceAdjustmentPolicy = input.paymentBalanceAdjustmentPolicy
|
||||||
|
? input.paymentBalanceAdjustmentPolicy === 'utilities' ||
|
||||||
|
input.paymentBalanceAdjustmentPolicy === 'rent' ||
|
||||||
|
input.paymentBalanceAdjustmentPolicy === 'separate'
|
||||||
|
? input.paymentBalanceAdjustmentPolicy
|
||||||
|
: null
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (paymentBalanceAdjustmentPolicy === null) {
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'invalid_settings'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) {
|
if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) {
|
||||||
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
|
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
|
||||||
@@ -244,6 +259,11 @@ export function createMiniAppAdminService(
|
|||||||
settlementCurrency
|
settlementCurrency
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(paymentBalanceAdjustmentPolicy
|
||||||
|
? {
|
||||||
|
paymentBalanceAdjustmentPolicy
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(rentAmountMinor !== undefined
|
...(rentAmountMinor !== undefined
|
||||||
? {
|
? {
|
||||||
rentAmountMinor
|
rentAmountMinor
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
|
|
||||||
import type { FinanceCommandService } from './finance-command-service'
|
import type { FinanceCommandService } from './finance-command-service'
|
||||||
import { parsePaymentConfirmationMessage } from './payment-confirmation-parser'
|
import { parsePaymentConfirmationMessage } from './payment-confirmation-parser'
|
||||||
|
import { buildMemberPaymentGuidance } from './payment-guidance'
|
||||||
|
|
||||||
function billingPeriodLockDate(period: BillingPeriod, day: number): Temporal.PlainDate {
|
function billingPeriodLockDate(period: BillingPeriod, day: number): Temporal.PlainDate {
|
||||||
const firstDay = Temporal.PlainDate.from({
|
const firstDay = Temporal.PlainDate.from({
|
||||||
@@ -284,10 +285,12 @@ export function createPaymentConfirmationService(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inferredAmount =
|
const guidance = buildMemberPaymentGuidance({
|
||||||
parsed.kind === 'rent'
|
kind: parsed.kind,
|
||||||
? memberLine.rentShare
|
period: cycle.period,
|
||||||
: memberLine.utilityShare.add(memberLine.purchaseOffset)
|
memberLine,
|
||||||
|
settings
|
||||||
|
})
|
||||||
|
|
||||||
const resolvedAmount = parsed.explicitAmount
|
const resolvedAmount = parsed.explicitAmount
|
||||||
? (
|
? (
|
||||||
@@ -305,7 +308,7 @@ export function createPaymentConfirmationService(input: {
|
|||||||
parsed.explicitAmount
|
parsed.explicitAmount
|
||||||
)
|
)
|
||||||
).amount
|
).amount
|
||||||
: inferredAmount
|
: guidance.proposalAmount
|
||||||
|
|
||||||
if (resolvedAmount.amountMinor <= 0n) {
|
if (resolvedAmount.amountMinor <= 0n) {
|
||||||
const saveResult = await input.repository.savePaymentConfirmation({
|
const saveResult = await input.repository.savePaymentConfirmation({
|
||||||
|
|||||||
76
packages/application/src/payment-guidance.ts
Normal file
76
packages/application/src/payment-guidance.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { BillingPeriod, Money, Temporal } from '@household/domain'
|
||||||
|
import type {
|
||||||
|
HouseholdBillingSettingsRecord,
|
||||||
|
HouseholdPaymentBalanceAdjustmentPolicy
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
import type { FinanceDashboardMemberLine } from './finance-command-service'
|
||||||
|
|
||||||
|
export interface MemberPaymentGuidance {
|
||||||
|
kind: 'rent' | 'utilities'
|
||||||
|
adjustmentPolicy: HouseholdPaymentBalanceAdjustmentPolicy
|
||||||
|
baseAmount: Money
|
||||||
|
purchaseOffset: Money
|
||||||
|
proposalAmount: Money
|
||||||
|
totalRemaining: Money
|
||||||
|
reminderDate: string
|
||||||
|
dueDate: string
|
||||||
|
paymentWindowOpen: boolean
|
||||||
|
paymentDue: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleDate(period: string, day: number): Temporal.PlainDate {
|
||||||
|
const billingPeriod = BillingPeriod.fromString(period)
|
||||||
|
const [yearRaw, monthRaw] = billingPeriod.toString().split('-')
|
||||||
|
const year = Number(yearRaw)
|
||||||
|
const month = Number(monthRaw)
|
||||||
|
const yearMonth = new Temporal.PlainYearMonth(year, month)
|
||||||
|
const boundedDay = Math.min(Math.max(day, 1), yearMonth.daysInMonth)
|
||||||
|
|
||||||
|
return new Temporal.PlainDate(year, month, boundedDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustmentApplies(
|
||||||
|
policy: HouseholdPaymentBalanceAdjustmentPolicy,
|
||||||
|
kind: 'rent' | 'utilities'
|
||||||
|
): boolean {
|
||||||
|
return (policy === 'utilities' && kind === 'utilities') || (policy === 'rent' && kind === 'rent')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMemberPaymentGuidance(input: {
|
||||||
|
kind: 'rent' | 'utilities'
|
||||||
|
period: string
|
||||||
|
memberLine: FinanceDashboardMemberLine
|
||||||
|
settings: HouseholdBillingSettingsRecord
|
||||||
|
referenceInstant?: Temporal.Instant
|
||||||
|
}): MemberPaymentGuidance {
|
||||||
|
const policy = input.settings.paymentBalanceAdjustmentPolicy ?? 'utilities'
|
||||||
|
const baseAmount =
|
||||||
|
input.kind === 'rent' ? input.memberLine.rentShare : input.memberLine.utilityShare
|
||||||
|
const purchaseOffset = input.memberLine.purchaseOffset
|
||||||
|
const proposalAmount = adjustmentApplies(policy, input.kind)
|
||||||
|
? baseAmount.add(purchaseOffset)
|
||||||
|
: baseAmount
|
||||||
|
|
||||||
|
const reminderDay =
|
||||||
|
input.kind === 'rent' ? input.settings.rentWarningDay : input.settings.utilitiesReminderDay
|
||||||
|
const dueDay = input.kind === 'rent' ? input.settings.rentDueDay : input.settings.utilitiesDueDay
|
||||||
|
const reminderDate = cycleDate(input.period, reminderDay)
|
||||||
|
const dueDate = cycleDate(input.period, dueDay)
|
||||||
|
const localDate = (input.referenceInstant ?? Temporal.Now.instant())
|
||||||
|
.toZonedDateTimeISO(input.settings.timezone)
|
||||||
|
.toPlainDate()
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: input.kind,
|
||||||
|
adjustmentPolicy: policy,
|
||||||
|
baseAmount,
|
||||||
|
purchaseOffset,
|
||||||
|
proposalAmount,
|
||||||
|
totalRemaining: input.memberLine.remaining,
|
||||||
|
reminderDate: reminderDate.toString(),
|
||||||
|
dueDate: dueDate.toString(),
|
||||||
|
paymentWindowOpen: Temporal.PlainDate.compare(localDate, reminderDate) >= 0,
|
||||||
|
paymentDue: Temporal.PlainDate.compare(localDate, dueDate) >= 0
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/db/drizzle/0017_gigantic_selene.sql
Normal file
1
packages/db/drizzle/0017_gigantic_selene.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "household_billing_settings" ADD COLUMN "payment_balance_adjustment_policy" text DEFAULT 'utilities' NOT NULL;
|
||||||
3222
packages/db/drizzle/meta/0017_snapshot.json
Normal file
3222
packages/db/drizzle/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1773225121790,
|
"when": 1773225121790,
|
||||||
"tag": "0016_equal_susan_delgado",
|
"tag": "0016_equal_susan_delgado",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773226133315,
|
||||||
|
"tag": "0017_gigantic_selene",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const householdBillingSettings = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => households.id, { onDelete: 'cascade' }),
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
settlementCurrency: text('settlement_currency').default('GEL').notNull(),
|
settlementCurrency: text('settlement_currency').default('GEL').notNull(),
|
||||||
|
paymentBalanceAdjustmentPolicy: text('payment_balance_adjustment_policy')
|
||||||
|
.default('utilities')
|
||||||
|
.notNull(),
|
||||||
rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }),
|
rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }),
|
||||||
rentCurrency: text('rent_currency').default('USD').notNull(),
|
rentCurrency: text('rent_currency').default('USD').notNull(),
|
||||||
rentDueDay: integer('rent_due_day').default(20).notNull(),
|
rentDueDay: integer('rent_due_day').default(20).notNull(),
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ export const HOUSEHOLD_MEMBER_ABSENCE_POLICIES = [
|
|||||||
'away_rent_only',
|
'away_rent_only',
|
||||||
'inactive'
|
'inactive'
|
||||||
] as const
|
] as const
|
||||||
|
export const HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES = [
|
||||||
|
'utilities',
|
||||||
|
'rent',
|
||||||
|
'separate'
|
||||||
|
] as const
|
||||||
|
|
||||||
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
|
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
|
||||||
export type HouseholdMemberLifecycleStatus = (typeof HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES)[number]
|
export type HouseholdMemberLifecycleStatus = (typeof HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES)[number]
|
||||||
export type HouseholdMemberAbsencePolicy = (typeof HOUSEHOLD_MEMBER_ABSENCE_POLICIES)[number]
|
export type HouseholdMemberAbsencePolicy = (typeof HOUSEHOLD_MEMBER_ABSENCE_POLICIES)[number]
|
||||||
|
export type HouseholdPaymentBalanceAdjustmentPolicy =
|
||||||
|
(typeof HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES)[number]
|
||||||
|
|
||||||
export interface HouseholdTelegramChatRecord {
|
export interface HouseholdTelegramChatRecord {
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -69,6 +76,7 @@ export interface HouseholdMemberAbsencePolicyRecord {
|
|||||||
export interface HouseholdBillingSettingsRecord {
|
export interface HouseholdBillingSettingsRecord {
|
||||||
householdId: string
|
householdId: string
|
||||||
settlementCurrency: CurrencyCode
|
settlementCurrency: CurrencyCode
|
||||||
|
paymentBalanceAdjustmentPolicy?: HouseholdPaymentBalanceAdjustmentPolicy
|
||||||
rentAmountMinor: bigint | null
|
rentAmountMinor: bigint | null
|
||||||
rentCurrency: CurrencyCode
|
rentCurrency: CurrencyCode
|
||||||
rentDueDay: number
|
rentDueDay: number
|
||||||
@@ -161,6 +169,7 @@ export interface HouseholdConfigurationRepository {
|
|||||||
updateHouseholdBillingSettings(input: {
|
updateHouseholdBillingSettings(input: {
|
||||||
householdId: string
|
householdId: string
|
||||||
settlementCurrency?: CurrencyCode
|
settlementCurrency?: CurrencyCode
|
||||||
|
paymentBalanceAdjustmentPolicy?: HouseholdPaymentBalanceAdjustmentPolicy
|
||||||
rentAmountMinor?: bigint | null
|
rentAmountMinor?: bigint | null
|
||||||
rentCurrency?: CurrencyCode
|
rentCurrency?: CurrencyCode
|
||||||
rentDueDay?: number
|
rentDueDay?: number
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ export type {
|
|||||||
export {
|
export {
|
||||||
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
|
HOUSEHOLD_MEMBER_ABSENCE_POLICIES,
|
||||||
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
|
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
|
||||||
|
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
|
||||||
HOUSEHOLD_TOPIC_ROLES,
|
HOUSEHOLD_TOPIC_ROLES,
|
||||||
type HouseholdMemberAbsencePolicy,
|
type HouseholdMemberAbsencePolicy,
|
||||||
type HouseholdMemberAbsencePolicyRecord,
|
type HouseholdMemberAbsencePolicyRecord,
|
||||||
|
type HouseholdPaymentBalanceAdjustmentPolicy,
|
||||||
type HouseholdConfigurationRepository,
|
type HouseholdConfigurationRepository,
|
||||||
type HouseholdBillingSettingsRecord,
|
type HouseholdBillingSettingsRecord,
|
||||||
type HouseholdJoinTokenRecord,
|
type HouseholdJoinTokenRecord,
|
||||||
|
|||||||
Reference in New Issue
Block a user