mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(purchase): add per-purchase participant splits
This commit is contained in:
@@ -397,7 +397,15 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
|||||||
parsedCurrency: 'GEL' as const,
|
parsedCurrency: 'GEL' as const,
|
||||||
parsedItemDescription: 'door handle',
|
parsedItemDescription: 'door handle',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'llm' as const
|
parserMode: 'llm' as const,
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
id: 'participant-1',
|
||||||
|
memberId: 'member-1',
|
||||||
|
displayName: 'Mia',
|
||||||
|
included: true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +441,15 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
|||||||
parsedCurrency: 'GEL' as const,
|
parsedCurrency: 'GEL' as const,
|
||||||
parsedItemDescription: 'sausages',
|
parsedItemDescription: 'sausages',
|
||||||
parserConfidence: 88,
|
parserConfidence: 88,
|
||||||
parserMode: 'llm' as const
|
parserMode: 'llm' as const,
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
id: 'participant-1',
|
||||||
|
memberId: 'member-1',
|
||||||
|
displayName: 'Mia',
|
||||||
|
included: true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +551,9 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
|||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'llm' as const
|
parserMode: 'llm' as const
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -768,7 +787,8 @@ describe('registerDmAssistant', () => {
|
|||||||
method: 'sendMessage',
|
method: 'sendMessage',
|
||||||
payload: {
|
payload: {
|
||||||
chat_id: 123456,
|
chat_id: 123456,
|
||||||
text: 'I think this shared purchase was: door handle - 30.00 GEL. Confirm or cancel below.',
|
text: `I think this shared purchase was: door handle - 30.00 GEL.
|
||||||
|
Confirm or cancel below.`,
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[
|
[
|
||||||
@@ -830,7 +850,8 @@ describe('registerDmAssistant', () => {
|
|||||||
method: 'sendMessage',
|
method: 'sendMessage',
|
||||||
payload: {
|
payload: {
|
||||||
chat_id: 123456,
|
chat_id: 123456,
|
||||||
text: 'I think this shared purchase was: sausages - 45.00 GEL. Confirm or cancel below.',
|
text: `I think this shared purchase was: sausages - 45.00 GEL.
|
||||||
|
Confirm or cancel below.`,
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -958,7 +958,8 @@ export function registerDmAssistant(options: {
|
|||||||
const purchaseText =
|
const purchaseText =
|
||||||
purchaseResult.status === 'pending_confirmation'
|
purchaseResult.status === 'pending_confirmation'
|
||||||
? getBotTranslations(locale).purchase.proposal(
|
? getBotTranslations(locale).purchase.proposal(
|
||||||
formatPurchaseSummary(locale, purchaseResult)
|
formatPurchaseSummary(locale, purchaseResult),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
: purchaseResult.status === 'clarification_needed'
|
: purchaseResult.status === 'clarification_needed'
|
||||||
? buildPurchaseClarificationText(locale, purchaseResult)
|
? buildPurchaseClarificationText(locale, purchaseResult)
|
||||||
|
|||||||
@@ -235,7 +235,8 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'shared purchase',
|
sharedPurchaseFallback: 'shared purchase',
|
||||||
processing: 'Checking that purchase...',
|
processing: 'Checking that purchase...',
|
||||||
proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`,
|
proposal: (summary, participants) =>
|
||||||
|
`I think this shared purchase was: ${summary}.${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`,
|
||||||
clarification: (question) => question,
|
clarification: (question) => question,
|
||||||
clarificationMissingAmountAndCurrency:
|
clarificationMissingAmountAndCurrency:
|
||||||
'What amount and currency should I record for this shared purchase?',
|
'What amount and currency should I record for this shared purchase?',
|
||||||
@@ -244,6 +245,11 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
clarificationMissingItem: 'What exactly was purchased?',
|
clarificationMissingItem: 'What exactly was purchased?',
|
||||||
clarificationLowConfidence:
|
clarificationLowConfidence:
|
||||||
'I am not confident I understood this. Please restate the shared purchase with item, amount, and currency.',
|
'I am not confident I understood this. Please restate the shared purchase with item, amount, and currency.',
|
||||||
|
participantsHeading: 'Participants:',
|
||||||
|
participantIncluded: (displayName) => `- ${displayName}`,
|
||||||
|
participantExcluded: (displayName) => `- ${displayName} (excluded)`,
|
||||||
|
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
||||||
|
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
||||||
confirmButton: 'Confirm',
|
confirmButton: 'Confirm',
|
||||||
cancelButton: 'Cancel',
|
cancelButton: 'Cancel',
|
||||||
confirmed: (summary) => `Purchase confirmed: ${summary}`,
|
confirmed: (summary) => `Purchase confirmed: ${summary}`,
|
||||||
@@ -252,6 +258,7 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
cancelledToast: 'Purchase cancelled.',
|
cancelledToast: 'Purchase cancelled.',
|
||||||
alreadyConfirmed: 'This purchase was already confirmed.',
|
alreadyConfirmed: 'This purchase was already confirmed.',
|
||||||
alreadyCancelled: 'This purchase was already cancelled.',
|
alreadyCancelled: 'This purchase was already cancelled.',
|
||||||
|
atLeastOneParticipant: 'Keep at least one participant in the purchase split.',
|
||||||
notYourProposal: 'Only the original sender can confirm or cancel this purchase.',
|
notYourProposal: 'Only the original sender can confirm or cancel this purchase.',
|
||||||
proposalUnavailable: 'This purchase proposal is no longer available.',
|
proposalUnavailable: 'This purchase proposal is no longer available.',
|
||||||
parseFailed:
|
parseFailed:
|
||||||
|
|||||||
@@ -238,7 +238,8 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'общая покупка',
|
sharedPurchaseFallback: 'общая покупка',
|
||||||
processing: 'Проверяю покупку...',
|
processing: 'Проверяю покупку...',
|
||||||
proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`,
|
proposal: (summary, participants) =>
|
||||||
|
`Похоже, это общая покупка: ${summary}.${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`,
|
||||||
clarification: (question) => question,
|
clarification: (question) => question,
|
||||||
clarificationMissingAmountAndCurrency:
|
clarificationMissingAmountAndCurrency:
|
||||||
'Какую сумму и валюту нужно записать для этой общей покупки?',
|
'Какую сумму и валюту нужно записать для этой общей покупки?',
|
||||||
@@ -247,6 +248,11 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
clarificationMissingItem: 'Что именно было куплено?',
|
clarificationMissingItem: 'Что именно было куплено?',
|
||||||
clarificationLowConfidence:
|
clarificationLowConfidence:
|
||||||
'Я не уверен, что правильно понял сообщение. Переформулируйте покупку с предметом, суммой и валютой.',
|
'Я не уверен, что правильно понял сообщение. Переформулируйте покупку с предметом, суммой и валютой.',
|
||||||
|
participantsHeading: 'Участники:',
|
||||||
|
participantIncluded: (displayName) => `- ${displayName}`,
|
||||||
|
participantExcluded: (displayName) => `- ${displayName} (не участвует)`,
|
||||||
|
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
||||||
|
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
||||||
confirmButton: 'Подтвердить',
|
confirmButton: 'Подтвердить',
|
||||||
cancelButton: 'Отменить',
|
cancelButton: 'Отменить',
|
||||||
confirmed: (summary) => `Покупка подтверждена: ${summary}`,
|
confirmed: (summary) => `Покупка подтверждена: ${summary}`,
|
||||||
@@ -255,6 +261,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
cancelledToast: 'Покупка отменена.',
|
cancelledToast: 'Покупка отменена.',
|
||||||
alreadyConfirmed: 'Эта покупка уже подтверждена.',
|
alreadyConfirmed: 'Эта покупка уже подтверждена.',
|
||||||
alreadyCancelled: 'Это предложение покупки уже отменено.',
|
alreadyCancelled: 'Это предложение покупки уже отменено.',
|
||||||
|
atLeastOneParticipant: 'В распределении покупки должен остаться хотя бы один участник.',
|
||||||
notYourProposal: 'Подтвердить или отменить эту покупку может только отправитель сообщения.',
|
notYourProposal: 'Подтвердить или отменить эту покупку может только отправитель сообщения.',
|
||||||
proposalUnavailable: 'Это предложение покупки уже недоступно.',
|
proposalUnavailable: 'Это предложение покупки уже недоступно.',
|
||||||
parseFailed:
|
parseFailed:
|
||||||
|
|||||||
@@ -227,13 +227,18 @@ export interface BotTranslationCatalog {
|
|||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: string
|
sharedPurchaseFallback: string
|
||||||
processing: string
|
processing: string
|
||||||
proposal: (summary: string) => string
|
proposal: (summary: string, participants: string | null) => string
|
||||||
clarification: (question: string) => string
|
clarification: (question: string) => string
|
||||||
clarificationMissingAmountAndCurrency: string
|
clarificationMissingAmountAndCurrency: string
|
||||||
clarificationMissingAmount: string
|
clarificationMissingAmount: string
|
||||||
clarificationMissingCurrency: string
|
clarificationMissingCurrency: string
|
||||||
clarificationMissingItem: string
|
clarificationMissingItem: string
|
||||||
clarificationLowConfidence: string
|
clarificationLowConfidence: string
|
||||||
|
participantsHeading: string
|
||||||
|
participantIncluded: (displayName: string) => string
|
||||||
|
participantExcluded: (displayName: string) => string
|
||||||
|
participantToggleIncluded: (displayName: string) => string
|
||||||
|
participantToggleExcluded: (displayName: string) => string
|
||||||
confirmButton: string
|
confirmButton: string
|
||||||
cancelButton: string
|
cancelButton: string
|
||||||
confirmed: (summary: string) => string
|
confirmed: (summary: string) => string
|
||||||
@@ -242,6 +247,7 @@ export interface BotTranslationCatalog {
|
|||||||
cancelledToast: string
|
cancelledToast: string
|
||||||
alreadyConfirmed: string
|
alreadyConfirmed: string
|
||||||
alreadyCancelled: string
|
alreadyCancelled: string
|
||||||
|
atLeastOneParticipant: string
|
||||||
notYourProposal: string
|
notYourProposal: string
|
||||||
proposalUnavailable: string
|
proposalUnavailable: string
|
||||||
parseFailed: string
|
parseFailed: string
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
createMiniAppDeleteUtilityBillHandler,
|
createMiniAppDeleteUtilityBillHandler,
|
||||||
createMiniAppOpenCycleHandler,
|
createMiniAppOpenCycleHandler,
|
||||||
createMiniAppRentUpdateHandler,
|
createMiniAppRentUpdateHandler,
|
||||||
|
createMiniAppUpdatePurchaseHandler,
|
||||||
createMiniAppUpdateUtilityBillHandler
|
createMiniAppUpdateUtilityBillHandler
|
||||||
} from './miniapp-billing'
|
} from './miniapp-billing'
|
||||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
@@ -476,3 +477,83 @@ describe('createMiniAppAddUtilityBillHandler', () => {
|
|||||||
expect(payload.cycleState.utilityBills).toHaveLength(1)
|
expect(payload.cycleState.utilityBills).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('createMiniAppUpdatePurchaseHandler', () => {
|
||||||
|
test('forwards purchase split edits to the finance service', async () => {
|
||||||
|
const repository = onboardingRepository()
|
||||||
|
let capturedSplit: Parameters<FinanceCommandService['updatePurchase']>[4] | undefined
|
||||||
|
|
||||||
|
const handler = createMiniAppUpdatePurchaseHandler({
|
||||||
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
|
botToken: 'test-bot-token',
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository
|
||||||
|
}),
|
||||||
|
financeServiceForHousehold: () => ({
|
||||||
|
...createFinanceServiceStub(),
|
||||||
|
updatePurchase: async (_purchaseId, _description, _amountArg, _currencyArg, split) => {
|
||||||
|
capturedSplit = split
|
||||||
|
return {
|
||||||
|
purchaseId: 'purchase-1',
|
||||||
|
amount: Money.fromMinor(3000n, 'GEL'),
|
||||||
|
currency: 'GEL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handler(
|
||||||
|
new Request('http://localhost/api/miniapp/admin/purchases/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: initData(),
|
||||||
|
purchaseId: 'purchase-1',
|
||||||
|
description: 'Kettle',
|
||||||
|
amountMajor: '30',
|
||||||
|
currency: 'GEL',
|
||||||
|
split: {
|
||||||
|
mode: 'custom_amounts',
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
memberId: 'member-123456',
|
||||||
|
included: true,
|
||||||
|
shareAmountMajor: '20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'member-999',
|
||||||
|
included: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'member-888',
|
||||||
|
included: true,
|
||||||
|
shareAmountMajor: '10'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(capturedSplit).toEqual({
|
||||||
|
mode: 'custom_amounts',
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
memberId: 'member-123456',
|
||||||
|
shareAmountMajor: '20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'member-999'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'member-888',
|
||||||
|
shareAmountMajor: '10'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -289,6 +289,13 @@ async function readPurchaseMutationPayload(request: Request): Promise<{
|
|||||||
description?: string
|
description?: string
|
||||||
amountMajor?: string
|
amountMajor?: string
|
||||||
currency?: string
|
currency?: string
|
||||||
|
split?: {
|
||||||
|
mode: 'equal' | 'custom_amounts'
|
||||||
|
participants: {
|
||||||
|
memberId: string
|
||||||
|
shareAmountMajor?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
}> {
|
}> {
|
||||||
const parsed = await parseJsonBody<{
|
const parsed = await parseJsonBody<{
|
||||||
initData?: string
|
initData?: string
|
||||||
@@ -296,6 +303,13 @@ async function readPurchaseMutationPayload(request: Request): Promise<{
|
|||||||
description?: string
|
description?: string
|
||||||
amountMajor?: string
|
amountMajor?: string
|
||||||
currency?: string
|
currency?: string
|
||||||
|
split?: {
|
||||||
|
mode?: string
|
||||||
|
participants?: {
|
||||||
|
memberId?: string
|
||||||
|
shareAmountMajor?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
}>(request)
|
}>(request)
|
||||||
const initData = parsed.initData?.trim()
|
const initData = parsed.initData?.trim()
|
||||||
if (!initData) {
|
if (!initData) {
|
||||||
@@ -323,6 +337,32 @@ async function readPurchaseMutationPayload(request: Request): Promise<{
|
|||||||
? {
|
? {
|
||||||
currency: parsed.currency.trim()
|
currency: parsed.currency.trim()
|
||||||
}
|
}
|
||||||
|
: {}),
|
||||||
|
...(parsed.split &&
|
||||||
|
(parsed.split.mode === 'equal' || parsed.split.mode === 'custom_amounts') &&
|
||||||
|
Array.isArray(parsed.split.participants)
|
||||||
|
? {
|
||||||
|
split: {
|
||||||
|
mode: parsed.split.mode,
|
||||||
|
participants: parsed.split.participants
|
||||||
|
.map((participant) => {
|
||||||
|
const memberId = participant.memberId?.trim()
|
||||||
|
if (!memberId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
memberId,
|
||||||
|
...(participant.shareAmountMajor?.trim()
|
||||||
|
? {
|
||||||
|
shareAmountMajor: participant.shareAmountMajor.trim()
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((participant) => participant !== null)
|
||||||
|
}
|
||||||
|
}
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -858,7 +898,8 @@ export function createMiniAppUpdatePurchaseHandler(options: {
|
|||||||
payload.purchaseId,
|
payload.purchaseId,
|
||||||
payload.description,
|
payload.description,
|
||||||
payload.amountMajor,
|
payload.amountMajor,
|
||||||
payload.currency
|
payload.currency,
|
||||||
|
payload.split
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
isAdmin: true
|
isAdmin: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const dashboard = createMiniAppDashboardHandler({
|
const dashboard = createMiniAppDashboardHandler({
|
||||||
allowedOrigins: ['http://localhost:5173'],
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
botToken: 'test-bot-token',
|
botToken: 'test-bot-token',
|
||||||
@@ -350,6 +349,190 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('serializes purchase split details into the mini app dashboard', async () => {
|
||||||
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
|
const householdRepository = onboardingRepository()
|
||||||
|
const financeRepository = repository({
|
||||||
|
id: 'member-1',
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: true
|
||||||
|
})
|
||||||
|
|
||||||
|
financeRepository.listParsedPurchasesForRange = async () => [
|
||||||
|
{
|
||||||
|
id: 'purchase-1',
|
||||||
|
payerMemberId: 'member-1',
|
||||||
|
amountMinor: 3000n,
|
||||||
|
currency: 'GEL',
|
||||||
|
description: 'Kettle',
|
||||||
|
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
|
||||||
|
splitMode: 'custom_amounts',
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
memberId: 'member-1',
|
||||||
|
included: true,
|
||||||
|
shareAmountMinor: 2000n
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'member-2',
|
||||||
|
included: false,
|
||||||
|
shareAmountMinor: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'member-3',
|
||||||
|
included: true,
|
||||||
|
shareAmountMinor: 1000n
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
financeRepository.listMembers = async () => [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-2',
|
||||||
|
telegramUserId: '456789',
|
||||||
|
displayName: 'Dima',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-3',
|
||||||
|
telegramUserId: '789123',
|
||||||
|
displayName: 'Chorbanaut',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const financeService = createFinanceCommandService({
|
||||||
|
householdId: 'household-1',
|
||||||
|
repository: financeRepository,
|
||||||
|
householdConfigurationRepository: householdRepository,
|
||||||
|
exchangeRateProvider
|
||||||
|
})
|
||||||
|
|
||||||
|
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
status: 'active',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
householdRepository.listHouseholdMembers = async () => [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
status: 'active',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-2',
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '456789',
|
||||||
|
displayName: 'Dima',
|
||||||
|
status: 'active',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-3',
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '789123',
|
||||||
|
displayName: 'Chorbanaut',
|
||||||
|
status: 'active',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const dashboard = createMiniAppDashboardHandler({
|
||||||
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
|
botToken: 'test-bot-token',
|
||||||
|
financeServiceForHousehold: () => financeService,
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository: householdRepository
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await dashboard.handler(
|
||||||
|
new Request('http://localhost/api/miniapp/dashboard', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||||
|
id: 123456,
|
||||||
|
first_name: 'Stan',
|
||||||
|
username: 'stanislav',
|
||||||
|
language_code: 'ru'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
dashboard: {
|
||||||
|
ledger: [
|
||||||
|
{
|
||||||
|
id: 'purchase-1',
|
||||||
|
purchaseSplitMode: 'custom_amounts',
|
||||||
|
purchaseParticipants: [
|
||||||
|
{
|
||||||
|
memberId: 'member-1',
|
||||||
|
included: true,
|
||||||
|
shareAmountMajor: '20.00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'member-2',
|
||||||
|
included: false,
|
||||||
|
shareAmountMajor: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'member-3',
|
||||||
|
included: true,
|
||||||
|
shareAmountMajor: '10.00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Electricity'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'payment'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('returns 400 for malformed JSON bodies', async () => {
|
test('returns 400 for malformed JSON bodies', async () => {
|
||||||
const householdRepository = onboardingRepository()
|
const householdRepository = onboardingRepository()
|
||||||
const financeService = createFinanceCommandService({
|
const financeService = createFinanceCommandService({
|
||||||
|
|||||||
@@ -120,7 +120,18 @@ export function createMiniAppDashboardHandler(options: {
|
|||||||
fxRateMicros: entry.fxRateMicros?.toString() ?? null,
|
fxRateMicros: entry.fxRateMicros?.toString() ?? null,
|
||||||
fxEffectiveDate: entry.fxEffectiveDate,
|
fxEffectiveDate: entry.fxEffectiveDate,
|
||||||
actorDisplayName: entry.actorDisplayName,
|
actorDisplayName: entry.actorDisplayName,
|
||||||
occurredAt: entry.occurredAt
|
occurredAt: entry.occurredAt,
|
||||||
|
...(entry.kind === 'purchase'
|
||||||
|
? {
|
||||||
|
purchaseSplitMode: entry.purchaseSplitMode ?? 'equal',
|
||||||
|
purchaseParticipants:
|
||||||
|
entry.purchaseParticipants?.map((participant) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participant.included,
|
||||||
|
shareAmountMajor: participant.shareAmount?.toMajorString() ?? null
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
: {})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ function candidate(overrides: Partial<PurchaseTopicCandidate> = {}): PurchaseTop
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function participants() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'participant-1',
|
||||||
|
memberId: 'member-1',
|
||||||
|
displayName: 'Mia',
|
||||||
|
included: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'participant-2',
|
||||||
|
memberId: 'member-2',
|
||||||
|
displayName: 'Dima',
|
||||||
|
included: false
|
||||||
|
}
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
function purchaseUpdate(text: string) {
|
function purchaseUpdate(text: string) {
|
||||||
const commandToken = text.split(' ')[0] ?? text
|
const commandToken = text.split(' ')[0] ?? text
|
||||||
|
|
||||||
@@ -180,12 +197,16 @@ describe('buildPurchaseAcknowledgement', () => {
|
|||||||
parsedCurrency: 'GEL',
|
parsedCurrency: 'GEL',
|
||||||
parsedItemDescription: 'toilet paper',
|
parsedItemDescription: 'toilet paper',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'llm'
|
parserMode: 'llm',
|
||||||
|
participants: participants()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result).toBe(
|
expect(result).toBe(`I think this shared purchase was: toilet paper - 30.00 GEL.
|
||||||
'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.'
|
|
||||||
)
|
Participants:
|
||||||
|
- Mia
|
||||||
|
- Dima (excluded)
|
||||||
|
Confirm or cancel below.`)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns explicit clarification text from the interpreter', () => {
|
test('returns explicit clarification text from the interpreter', () => {
|
||||||
@@ -253,14 +274,18 @@ describe('buildPurchaseAcknowledgement', () => {
|
|||||||
parsedCurrency: 'GEL',
|
parsedCurrency: 'GEL',
|
||||||
parsedItemDescription: 'туалетная бумага',
|
parsedItemDescription: 'туалетная бумага',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'llm'
|
parserMode: 'llm',
|
||||||
|
participants: participants()
|
||||||
},
|
},
|
||||||
'ru'
|
'ru'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toBe(
|
expect(result).toBe(`Похоже, это общая покупка: туалетная бумага - 30.00 GEL.
|
||||||
'Похоже, это общая покупка: туалетная бумага - 30.00 GEL. Подтвердите или отмените ниже.'
|
|
||||||
)
|
Участники:
|
||||||
|
- Mia
|
||||||
|
- Dima (не участвует)
|
||||||
|
Подтвердите или отмените ниже.`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,7 +323,8 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
parsedCurrency: 'GEL',
|
parsedCurrency: 'GEL',
|
||||||
parsedItemDescription: 'toilet paper',
|
parsedItemDescription: 'toilet paper',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'llm'
|
parserMode: 'llm',
|
||||||
|
participants: participants()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async confirm() {
|
async confirm() {
|
||||||
@@ -306,6 +332,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
},
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,9 +348,26 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
reply_parameters: {
|
reply_parameters: {
|
||||||
message_id: 55
|
message_id: 55
|
||||||
},
|
},
|
||||||
text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.',
|
text: `I think this shared purchase was: toilet paper - 30.00 GEL.
|
||||||
|
|
||||||
|
Participants:
|
||||||
|
- Mia
|
||||||
|
- Dima (excluded)
|
||||||
|
Confirm or cancel below.`,
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '✅ Mia',
|
||||||
|
callback_data: 'purchase:participant:participant-1'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '⬜ Dima',
|
||||||
|
callback_data: 'purchase:participant:participant-2'
|
||||||
|
}
|
||||||
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: 'Confirm',
|
text: 'Confirm',
|
||||||
@@ -379,6 +425,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
},
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +480,8 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
parsedCurrency: 'GEL',
|
parsedCurrency: 'GEL',
|
||||||
parsedItemDescription: 'toilet paper',
|
parsedItemDescription: 'toilet paper',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'llm'
|
parserMode: 'llm',
|
||||||
|
participants: participants()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async confirm() {
|
async confirm() {
|
||||||
@@ -439,6 +489,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
},
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,9 +533,26 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
payload: {
|
payload: {
|
||||||
chat_id: Number(config.householdChatId),
|
chat_id: Number(config.householdChatId),
|
||||||
message_id: 2,
|
message_id: 2,
|
||||||
text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.',
|
text: `I think this shared purchase was: toilet paper - 30.00 GEL.
|
||||||
|
|
||||||
|
Participants:
|
||||||
|
- Mia
|
||||||
|
- Dima (excluded)
|
||||||
|
Confirm or cancel below.`,
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '✅ Mia',
|
||||||
|
callback_data: 'purchase:participant:participant-1'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '⬜ Dima',
|
||||||
|
callback_data: 'purchase:participant:participant-2'
|
||||||
|
}
|
||||||
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: 'Confirm',
|
text: 'Confirm',
|
||||||
@@ -532,6 +602,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
},
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +644,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
},
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,7 +682,8 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
parsedCurrency: 'GEL',
|
parsedCurrency: 'GEL',
|
||||||
parsedItemDescription: 'toilet paper',
|
parsedItemDescription: 'toilet paper',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'llm'
|
parserMode: 'llm',
|
||||||
|
participants: participants()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async confirm() {
|
async confirm() {
|
||||||
@@ -614,6 +691,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
},
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +706,162 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
expect(calls[0]).toMatchObject({
|
expect(calls[0]).toMatchObject({
|
||||||
method: 'sendMessage',
|
method: 'sendMessage',
|
||||||
payload: {
|
payload: {
|
||||||
text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.'
|
text: `I think this shared purchase was: toilet paper - 30.00 GEL.
|
||||||
|
|
||||||
|
Participants:
|
||||||
|
- Mia
|
||||||
|
- Dima (excluded)
|
||||||
|
Confirm or cancel below.`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggles purchase participants before confirmation', 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
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
|
async hasClarificationContext() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
return {
|
||||||
|
status: 'updated' as const,
|
||||||
|
purchaseMessageId: 'proposal-1',
|
||||||
|
householdId: config.householdId,
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL' as const,
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
parserConfidence: 92,
|
||||||
|
parserMode: 'llm' as const,
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
id: 'participant-1',
|
||||||
|
memberId: 'member-1',
|
||||||
|
displayName: 'Mia',
|
||||||
|
included: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'participant-2',
|
||||||
|
memberId: 'member-2',
|
||||||
|
displayName: 'Dima',
|
||||||
|
included: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(callbackUpdate('purchase:participant:participant-2') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(2)
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
method: 'answerCallbackQuery',
|
||||||
|
payload: {
|
||||||
|
callback_query_id: 'callback-1'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(calls[1]).toMatchObject({
|
||||||
|
method: 'editMessageText',
|
||||||
|
payload: {
|
||||||
|
text: `I think this shared purchase was: toilet paper - 30.00 GEL.
|
||||||
|
|
||||||
|
Participants:
|
||||||
|
- Mia
|
||||||
|
- Dima
|
||||||
|
Confirm or cancel below.`,
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '✅ Mia',
|
||||||
|
callback_data: 'purchase:participant:participant-1'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '✅ Dima',
|
||||||
|
callback_data: 'purchase:participant:participant-2'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Confirm',
|
||||||
|
callback_data: 'purchase:confirm:proposal-1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
callback_data: 'purchase:cancel:proposal-1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blocks removing the last included participant', 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
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
|
async hasClarificationContext() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
return {
|
||||||
|
status: 'at_least_one_required' as const,
|
||||||
|
householdId: config.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(callbackUpdate('purchase:participant:participant-1') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
method: 'answerCallbackQuery',
|
||||||
|
payload: {
|
||||||
|
callback_query_id: 'callback-1',
|
||||||
|
text: 'Keep at least one participant in the purchase split.',
|
||||||
|
show_alert: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -656,7 +891,8 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
parsedCurrency: 'GEL',
|
parsedCurrency: 'GEL',
|
||||||
parsedItemDescription: 'toilet paper',
|
parsedItemDescription: 'toilet paper',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'llm'
|
parserMode: 'llm',
|
||||||
|
participants: participants()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async confirm() {
|
async confirm() {
|
||||||
@@ -673,6 +909,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
},
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,6 +973,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
},
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +1031,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'llm' as const
|
parserMode: 'llm' as const
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async toggleParticipant() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { stripExplicitBotMention } from './telegram-mentions'
|
|||||||
|
|
||||||
const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:'
|
const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:'
|
||||||
const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:'
|
const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:'
|
||||||
|
const PURCHASE_PARTICIPANT_CALLBACK_PREFIX = 'purchase:participant:'
|
||||||
const MIN_PROPOSAL_CONFIDENCE = 70
|
const MIN_PROPOSAL_CONFIDENCE = 70
|
||||||
|
|
||||||
type StoredPurchaseProcessingStatus =
|
type StoredPurchaseProcessingStatus =
|
||||||
@@ -64,6 +65,14 @@ interface PurchasePendingConfirmationResult extends PurchaseProposalFields {
|
|||||||
parsedItemDescription: string
|
parsedItemDescription: string
|
||||||
parserConfidence: number
|
parserConfidence: number
|
||||||
parserMode: 'llm'
|
parserMode: 'llm'
|
||||||
|
participants: readonly PurchaseProposalParticipant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseProposalParticipant {
|
||||||
|
id: string
|
||||||
|
memberId: string
|
||||||
|
displayName: string
|
||||||
|
included: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseTopicIngestionConfig {
|
export interface PurchaseTopicIngestionConfig {
|
||||||
@@ -120,6 +129,29 @@ export type PurchaseProposalActionResult =
|
|||||||
status: 'not_found'
|
status: 'not_found'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PurchaseProposalParticipantToggleResult =
|
||||||
|
| ({
|
||||||
|
status: 'updated'
|
||||||
|
purchaseMessageId: string
|
||||||
|
householdId: string
|
||||||
|
participants: readonly PurchaseProposalParticipant[]
|
||||||
|
} & PurchaseProposalFields)
|
||||||
|
| {
|
||||||
|
status: 'at_least_one_required'
|
||||||
|
householdId: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'forbidden'
|
||||||
|
householdId: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'not_pending'
|
||||||
|
householdId: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'not_found'
|
||||||
|
}
|
||||||
|
|
||||||
export interface PurchaseMessageIngestionRepository {
|
export interface PurchaseMessageIngestionRepository {
|
||||||
hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean>
|
hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean>
|
||||||
save(
|
save(
|
||||||
@@ -135,6 +167,10 @@ export interface PurchaseMessageIngestionRepository {
|
|||||||
purchaseMessageId: string,
|
purchaseMessageId: string,
|
||||||
actorTelegramUserId: string
|
actorTelegramUserId: string
|
||||||
): Promise<PurchaseProposalActionResult>
|
): Promise<PurchaseProposalActionResult>
|
||||||
|
toggleParticipant(
|
||||||
|
participantId: string,
|
||||||
|
actorTelegramUserId: string
|
||||||
|
): Promise<PurchaseProposalParticipantToggleResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PurchasePersistenceDecision {
|
interface PurchasePersistenceDecision {
|
||||||
@@ -149,9 +185,23 @@ interface PurchasePersistenceDecision {
|
|||||||
needsReview: boolean
|
needsReview: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StoredPurchaseParticipantRow {
|
||||||
|
id: string
|
||||||
|
purchaseMessageId: string
|
||||||
|
memberId: string
|
||||||
|
displayName: string
|
||||||
|
telegramUserId: string
|
||||||
|
included: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const CLARIFICATION_CONTEXT_MAX_AGE_MS = 30 * 60_000
|
const CLARIFICATION_CONTEXT_MAX_AGE_MS = 30 * 60_000
|
||||||
const MAX_CLARIFICATION_CONTEXT_MESSAGES = 3
|
const MAX_CLARIFICATION_CONTEXT_MESSAGES = 3
|
||||||
|
|
||||||
|
function periodFromInstant(instant: Instant, timezone: string): string {
|
||||||
|
const localDate = instant.toZonedDateTimeISO(timezone).toPlainDate()
|
||||||
|
return `${localDate.year}-${String(localDate.month).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeInterpretation(
|
function normalizeInterpretation(
|
||||||
interpretation: PurchaseInterpretation | null,
|
interpretation: PurchaseInterpretation | null,
|
||||||
parserError: string | null
|
parserError: string | null
|
||||||
@@ -224,6 +274,10 @@ function needsReviewAsInt(value: boolean): number {
|
|||||||
return value ? 1 : 0
|
return value ? 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function participantIncludedAsInt(value: boolean): number {
|
||||||
|
return value ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
function toStoredPurchaseRow(row: {
|
function toStoredPurchaseRow(row: {
|
||||||
id: string
|
id: string
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -269,6 +323,17 @@ function toProposalFields(row: StoredPurchaseMessageRow): PurchaseProposalFields
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toProposalParticipants(
|
||||||
|
rows: readonly StoredPurchaseParticipantRow[]
|
||||||
|
): readonly PurchaseProposalParticipant[] {
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
memberId: row.memberId,
|
||||||
|
displayName: row.displayName,
|
||||||
|
included: row.included
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async function replyToPurchaseMessage(
|
async function replyToPurchaseMessage(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -529,6 +594,128 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
return row ? toStoredPurchaseRow(row) : null
|
return row ? toStoredPurchaseRow(row) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStoredParticipants(
|
||||||
|
purchaseMessageId: string
|
||||||
|
): Promise<readonly StoredPurchaseParticipantRow[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: schema.purchaseMessageParticipants.id,
|
||||||
|
purchaseMessageId: schema.purchaseMessageParticipants.purchaseMessageId,
|
||||||
|
memberId: schema.purchaseMessageParticipants.memberId,
|
||||||
|
displayName: schema.members.displayName,
|
||||||
|
telegramUserId: schema.members.telegramUserId,
|
||||||
|
included: schema.purchaseMessageParticipants.included
|
||||||
|
})
|
||||||
|
.from(schema.purchaseMessageParticipants)
|
||||||
|
.innerJoin(schema.members, eq(schema.purchaseMessageParticipants.memberId, schema.members.id))
|
||||||
|
.where(eq(schema.purchaseMessageParticipants.purchaseMessageId, purchaseMessageId))
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
purchaseMessageId: row.purchaseMessageId,
|
||||||
|
memberId: row.memberId,
|
||||||
|
displayName: row.displayName,
|
||||||
|
telegramUserId: row.telegramUserId,
|
||||||
|
included: row.included === 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultProposalParticipants(input: {
|
||||||
|
householdId: string
|
||||||
|
senderTelegramUserId: string
|
||||||
|
senderMemberId: string | null
|
||||||
|
messageSentAt: Instant
|
||||||
|
}): Promise<readonly { memberId: string; included: boolean }[]> {
|
||||||
|
const [members, settingsRows, policyRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: schema.members.id,
|
||||||
|
telegramUserId: schema.members.telegramUserId,
|
||||||
|
lifecycleStatus: schema.members.lifecycleStatus
|
||||||
|
})
|
||||||
|
.from(schema.members)
|
||||||
|
.where(eq(schema.members.householdId, input.householdId)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
timezone: schema.householdBillingSettings.timezone
|
||||||
|
})
|
||||||
|
.from(schema.householdBillingSettings)
|
||||||
|
.where(eq(schema.householdBillingSettings.householdId, input.householdId))
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
memberId: schema.memberAbsencePolicies.memberId,
|
||||||
|
effectiveFromPeriod: schema.memberAbsencePolicies.effectiveFromPeriod,
|
||||||
|
policy: schema.memberAbsencePolicies.policy
|
||||||
|
})
|
||||||
|
.from(schema.memberAbsencePolicies)
|
||||||
|
.where(eq(schema.memberAbsencePolicies.householdId, input.householdId))
|
||||||
|
])
|
||||||
|
|
||||||
|
const timezone = settingsRows[0]?.timezone ?? 'Asia/Tbilisi'
|
||||||
|
const period = periodFromInstant(input.messageSentAt, timezone)
|
||||||
|
const policyByMemberId = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
effectiveFromPeriod: string
|
||||||
|
policy: string
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const row of policyRows) {
|
||||||
|
if (row.effectiveFromPeriod.localeCompare(period) > 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = policyByMemberId.get(row.memberId)
|
||||||
|
if (!current || current.effectiveFromPeriod.localeCompare(row.effectiveFromPeriod) < 0) {
|
||||||
|
policyByMemberId.set(row.memberId, {
|
||||||
|
effectiveFromPeriod: row.effectiveFromPeriod,
|
||||||
|
policy: row.policy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = members
|
||||||
|
.filter((member) => member.lifecycleStatus !== 'left')
|
||||||
|
.map((member) => {
|
||||||
|
const policy = policyByMemberId.get(member.id)?.policy ?? 'resident'
|
||||||
|
const included =
|
||||||
|
member.lifecycleStatus === 'away'
|
||||||
|
? policy === 'resident'
|
||||||
|
: member.lifecycleStatus === 'active'
|
||||||
|
|
||||||
|
return {
|
||||||
|
memberId: member.id,
|
||||||
|
telegramUserId: member.telegramUserId,
|
||||||
|
included
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (participants.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (participants.some((participant) => participant.included)) {
|
||||||
|
return participants.map(({ memberId, included }) => ({
|
||||||
|
memberId,
|
||||||
|
included
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackParticipant =
|
||||||
|
participants.find((participant) => participant.memberId === input.senderMemberId) ??
|
||||||
|
participants.find(
|
||||||
|
(participant) => participant.telegramUserId === input.senderTelegramUserId
|
||||||
|
) ??
|
||||||
|
participants[0]
|
||||||
|
|
||||||
|
return participants.map(({ memberId }) => ({
|
||||||
|
memberId,
|
||||||
|
included: memberId === fallbackParticipant?.memberId
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async function mutateProposalStatus(
|
async function mutateProposalStatus(
|
||||||
purchaseMessageId: string,
|
purchaseMessageId: string,
|
||||||
actorTelegramUserId: string,
|
actorTelegramUserId: string,
|
||||||
@@ -725,7 +912,24 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
parserConfidence: decision.parserConfidence,
|
parserConfidence: decision.parserConfidence,
|
||||||
parserMode: decision.parserMode
|
parserMode: decision.parserMode
|
||||||
}
|
}
|
||||||
case 'pending_confirmation':
|
case 'pending_confirmation': {
|
||||||
|
const participants = await defaultProposalParticipants({
|
||||||
|
householdId: record.householdId,
|
||||||
|
senderTelegramUserId: record.senderTelegramUserId,
|
||||||
|
senderMemberId,
|
||||||
|
messageSentAt: record.messageSentAt
|
||||||
|
})
|
||||||
|
|
||||||
|
if (participants.length > 0) {
|
||||||
|
await db.insert(schema.purchaseMessageParticipants).values(
|
||||||
|
participants.map((participant) => ({
|
||||||
|
purchaseMessageId: insertedRow.id,
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participantIncludedAsInt(participant.included)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'pending_confirmation',
|
status: 'pending_confirmation',
|
||||||
purchaseMessageId: insertedRow.id,
|
purchaseMessageId: insertedRow.id,
|
||||||
@@ -733,7 +937,9 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
parsedCurrency: decision.parsedCurrency!,
|
parsedCurrency: decision.parsedCurrency!,
|
||||||
parsedItemDescription: decision.parsedItemDescription!,
|
parsedItemDescription: decision.parsedItemDescription!,
|
||||||
parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE,
|
parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE,
|
||||||
parserMode: decision.parserMode ?? 'llm'
|
parserMode: decision.parserMode ?? 'llm',
|
||||||
|
participants: toProposalParticipants(await getStoredParticipants(insertedRow.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case 'parse_failed':
|
case 'parse_failed':
|
||||||
return {
|
return {
|
||||||
@@ -749,6 +955,104 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
|
|
||||||
async cancel(purchaseMessageId, actorTelegramUserId) {
|
async cancel(purchaseMessageId, actorTelegramUserId) {
|
||||||
return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'cancelled')
|
return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'cancelled')
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleParticipant(participantId, actorTelegramUserId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
participantId: schema.purchaseMessageParticipants.id,
|
||||||
|
purchaseMessageId: schema.purchaseMessageParticipants.purchaseMessageId,
|
||||||
|
memberId: schema.purchaseMessageParticipants.memberId,
|
||||||
|
included: schema.purchaseMessageParticipants.included,
|
||||||
|
householdId: schema.purchaseMessages.householdId,
|
||||||
|
senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId,
|
||||||
|
parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||||
|
parsedCurrency: schema.purchaseMessages.parsedCurrency,
|
||||||
|
parsedItemDescription: schema.purchaseMessages.parsedItemDescription,
|
||||||
|
parserConfidence: schema.purchaseMessages.parserConfidence,
|
||||||
|
parserMode: schema.purchaseMessages.parserMode,
|
||||||
|
processingStatus: schema.purchaseMessages.processingStatus
|
||||||
|
})
|
||||||
|
.from(schema.purchaseMessageParticipants)
|
||||||
|
.innerJoin(
|
||||||
|
schema.purchaseMessages,
|
||||||
|
eq(schema.purchaseMessageParticipants.purchaseMessageId, schema.purchaseMessages.id)
|
||||||
|
)
|
||||||
|
.where(eq(schema.purchaseMessageParticipants.id, participantId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const existing = rows[0]
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
status: 'not_found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.processingStatus !== 'pending_confirmation') {
|
||||||
|
return {
|
||||||
|
status: 'not_pending',
|
||||||
|
householdId: existing.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorRows = await db
|
||||||
|
.select({
|
||||||
|
memberId: schema.members.id,
|
||||||
|
isAdmin: schema.members.isAdmin
|
||||||
|
})
|
||||||
|
.from(schema.members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.members.householdId, existing.householdId),
|
||||||
|
eq(schema.members.telegramUserId, actorTelegramUserId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const actor = actorRows[0]
|
||||||
|
if (existing.senderTelegramUserId !== actorTelegramUserId && actor?.isAdmin !== 1) {
|
||||||
|
return {
|
||||||
|
status: 'forbidden',
|
||||||
|
householdId: existing.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentParticipants = await getStoredParticipants(existing.purchaseMessageId)
|
||||||
|
const currentlyIncludedCount = currentParticipants.filter(
|
||||||
|
(participant) => participant.included
|
||||||
|
).length
|
||||||
|
|
||||||
|
if (existing.included === 1 && currentlyIncludedCount <= 1) {
|
||||||
|
return {
|
||||||
|
status: 'at_least_one_required',
|
||||||
|
householdId: existing.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.purchaseMessageParticipants)
|
||||||
|
.set({
|
||||||
|
included: existing.included === 1 ? 0 : 1,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(schema.purchaseMessageParticipants.id, participantId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'updated',
|
||||||
|
purchaseMessageId: existing.purchaseMessageId,
|
||||||
|
householdId: existing.householdId,
|
||||||
|
parsedAmountMinor: existing.parsedAmountMinor,
|
||||||
|
parsedCurrency:
|
||||||
|
existing.parsedCurrency === 'GEL' || existing.parsedCurrency === 'USD'
|
||||||
|
? existing.parsedCurrency
|
||||||
|
: null,
|
||||||
|
parsedItemDescription: existing.parsedItemDescription,
|
||||||
|
parserConfidence: existing.parserConfidence,
|
||||||
|
parserMode: existing.parserMode === 'llm' ? 'llm' : null,
|
||||||
|
participants: toProposalParticipants(
|
||||||
|
await getStoredParticipants(existing.purchaseMessageId)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,6 +1106,24 @@ function clarificationFallback(locale: BotLocale, result: PurchaseClarificationR
|
|||||||
return t.clarificationLowConfidence
|
return t.clarificationLowConfidence
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPurchaseParticipants(
|
||||||
|
locale: BotLocale,
|
||||||
|
participants: readonly PurchaseProposalParticipant[]
|
||||||
|
): string | null {
|
||||||
|
if (participants.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = getBotTranslations(locale).purchase
|
||||||
|
const lines = participants.map((participant) =>
|
||||||
|
participant.included
|
||||||
|
? t.participantIncluded(participant.displayName)
|
||||||
|
: t.participantExcluded(participant.displayName)
|
||||||
|
)
|
||||||
|
|
||||||
|
return `${t.participantsHeading}\n${lines.join('\n')}`
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPurchaseAcknowledgement(
|
export function buildPurchaseAcknowledgement(
|
||||||
result: PurchaseMessageIngestionResult,
|
result: PurchaseMessageIngestionResult,
|
||||||
locale: BotLocale = 'en'
|
locale: BotLocale = 'en'
|
||||||
@@ -813,7 +1135,10 @@ export function buildPurchaseAcknowledgement(
|
|||||||
case 'ignored_not_purchase':
|
case 'ignored_not_purchase':
|
||||||
return null
|
return null
|
||||||
case 'pending_confirmation':
|
case 'pending_confirmation':
|
||||||
return t.proposal(formatPurchaseSummary(locale, result))
|
return t.proposal(
|
||||||
|
formatPurchaseSummary(locale, result),
|
||||||
|
formatPurchaseParticipants(locale, result.participants)
|
||||||
|
)
|
||||||
case 'clarification_needed':
|
case 'clarification_needed':
|
||||||
return t.clarification(result.clarificationQuestion ?? clarificationFallback(locale, result))
|
return t.clarification(result.clarificationQuestion ?? clarificationFallback(locale, result))
|
||||||
case 'parse_failed':
|
case 'parse_failed':
|
||||||
@@ -821,11 +1146,23 @@ export function buildPurchaseAcknowledgement(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function purchaseProposalReplyMarkup(locale: BotLocale, purchaseMessageId: string) {
|
function purchaseProposalReplyMarkup(
|
||||||
|
locale: BotLocale,
|
||||||
|
purchaseMessageId: string,
|
||||||
|
participants: readonly PurchaseProposalParticipant[]
|
||||||
|
) {
|
||||||
const t = getBotTranslations(locale).purchase
|
const t = getBotTranslations(locale).purchase
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
|
...participants.map((participant) => [
|
||||||
|
{
|
||||||
|
text: participant.included
|
||||||
|
? t.participantToggleIncluded(participant.displayName)
|
||||||
|
: t.participantToggleExcluded(participant.displayName),
|
||||||
|
callback_data: `${PURCHASE_PARTICIPANT_CALLBACK_PREFIX}${participant.id}`
|
||||||
|
}
|
||||||
|
]),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: t.confirmButton,
|
text: t.confirmButton,
|
||||||
@@ -883,7 +1220,7 @@ async function handlePurchaseMessageResult(
|
|||||||
pendingReply,
|
pendingReply,
|
||||||
acknowledgement,
|
acknowledgement,
|
||||||
result.status === 'pending_confirmation'
|
result.status === 'pending_confirmation'
|
||||||
? purchaseProposalReplyMarkup(locale, result.purchaseMessageId)
|
? purchaseProposalReplyMarkup(locale, result.purchaseMessageId, result.participants)
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -911,12 +1248,85 @@ function buildPurchaseActionMessage(
|
|||||||
return t.cancelled(summary)
|
return t.cancelled(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPurchaseToggleMessage(
|
||||||
|
locale: BotLocale,
|
||||||
|
result: Extract<PurchaseProposalParticipantToggleResult, { status: 'updated' }>
|
||||||
|
): string {
|
||||||
|
return getBotTranslations(locale).purchase.proposal(
|
||||||
|
formatPurchaseSummary(locale, result),
|
||||||
|
formatPurchaseParticipants(locale, result.participants)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function registerPurchaseProposalCallbacks(
|
function registerPurchaseProposalCallbacks(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
repository: PurchaseMessageIngestionRepository,
|
repository: PurchaseMessageIngestionRepository,
|
||||||
resolveLocale: (householdId: string) => Promise<BotLocale>,
|
resolveLocale: (householdId: string) => Promise<BotLocale>,
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
): void {
|
): void {
|
||||||
|
bot.callbackQuery(new RegExp(`^${PURCHASE_PARTICIPANT_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => {
|
||||||
|
const participantId = ctx.match[1]
|
||||||
|
const actorTelegramUserId = ctx.from?.id?.toString()
|
||||||
|
|
||||||
|
if (!actorTelegramUserId || !participantId) {
|
||||||
|
await ctx.answerCallbackQuery({
|
||||||
|
text: getBotTranslations('en').purchase.proposalUnavailable,
|
||||||
|
show_alert: true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await repository.toggleParticipant(participantId, actorTelegramUserId)
|
||||||
|
const locale = 'householdId' in result ? await resolveLocale(result.householdId) : 'en'
|
||||||
|
const t = getBotTranslations(locale).purchase
|
||||||
|
|
||||||
|
if (result.status === 'not_found' || result.status === 'not_pending') {
|
||||||
|
await ctx.answerCallbackQuery({
|
||||||
|
text: t.proposalUnavailable,
|
||||||
|
show_alert: true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'forbidden') {
|
||||||
|
await ctx.answerCallbackQuery({
|
||||||
|
text: t.notYourProposal,
|
||||||
|
show_alert: true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'at_least_one_required') {
|
||||||
|
await ctx.answerCallbackQuery({
|
||||||
|
text: t.atLeastOneParticipant,
|
||||||
|
show_alert: true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.answerCallbackQuery()
|
||||||
|
|
||||||
|
if (ctx.msg) {
|
||||||
|
await ctx.editMessageText(buildPurchaseToggleMessage(locale, result), {
|
||||||
|
reply_markup: purchaseProposalReplyMarkup(
|
||||||
|
locale,
|
||||||
|
result.purchaseMessageId,
|
||||||
|
result.participants
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger?.info(
|
||||||
|
{
|
||||||
|
event: 'purchase.participant_toggled',
|
||||||
|
participantId,
|
||||||
|
purchaseMessageId: result.purchaseMessageId,
|
||||||
|
actorTelegramUserId
|
||||||
|
},
|
||||||
|
'Purchase proposal participant toggled'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
bot.callbackQuery(new RegExp(`^${PURCHASE_CONFIRM_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => {
|
bot.callbackQuery(new RegExp(`^${PURCHASE_CONFIRM_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => {
|
||||||
const purchaseMessageId = ctx.match[1]
|
const purchaseMessageId = ctx.match[1]
|
||||||
const actorTelegramUserId = ctx.from?.id?.toString()
|
const actorTelegramUserId = ctx.from?.id?.toString()
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ type PurchaseDraft = {
|
|||||||
description: string
|
description: string
|
||||||
amountMajor: string
|
amountMajor: string
|
||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
|
splitMode: 'equal' | 'custom_amounts'
|
||||||
|
participants: {
|
||||||
|
memberId: string
|
||||||
|
shareAmountMajor: string
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaymentDraft = {
|
type PaymentDraft = {
|
||||||
@@ -244,12 +249,36 @@ function purchaseDrafts(
|
|||||||
{
|
{
|
||||||
description: entry.title,
|
description: entry.title,
|
||||||
amountMajor: entry.amountMajor,
|
amountMajor: entry.amountMajor,
|
||||||
currency: entry.currency
|
currency: entry.currency,
|
||||||
|
splitMode: entry.purchaseSplitMode ?? 'equal',
|
||||||
|
participants:
|
||||||
|
entry.purchaseParticipants
|
||||||
|
?.filter((participant) => participant.included)
|
||||||
|
.map((participant) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
shareAmountMajor: participant.shareAmountMajor ?? ''
|
||||||
|
})) ?? []
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function purchaseDraftForEntry(entry: MiniAppDashboard['ledger'][number]): PurchaseDraft {
|
||||||
|
return {
|
||||||
|
description: entry.title,
|
||||||
|
amountMajor: entry.amountMajor,
|
||||||
|
currency: entry.currency,
|
||||||
|
splitMode: entry.purchaseSplitMode ?? 'equal',
|
||||||
|
participants:
|
||||||
|
entry.purchaseParticipants
|
||||||
|
?.filter((participant) => participant.included)
|
||||||
|
.map((participant) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
shareAmountMajor: participant.shareAmountMajor ?? ''
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function paymentDrafts(
|
function paymentDrafts(
|
||||||
entries: readonly MiniAppDashboard['ledger'][number][]
|
entries: readonly MiniAppDashboard['ledger'][number][]
|
||||||
): Record<string, PaymentDraft> {
|
): Record<string, PaymentDraft> {
|
||||||
@@ -1086,7 +1115,10 @@ function App() {
|
|||||||
!currentReady.member.isAdmin ||
|
!currentReady.member.isAdmin ||
|
||||||
!draft ||
|
!draft ||
|
||||||
draft.description.trim().length === 0 ||
|
draft.description.trim().length === 0 ||
|
||||||
draft.amountMajor.trim().length === 0
|
draft.amountMajor.trim().length === 0 ||
|
||||||
|
draft.participants.length === 0 ||
|
||||||
|
(draft.splitMode === 'custom_amounts' &&
|
||||||
|
draft.participants.some((participant) => participant.shareAmountMajor.trim().length === 0))
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1098,7 +1130,25 @@ function App() {
|
|||||||
purchaseId,
|
purchaseId,
|
||||||
description: draft.description,
|
description: draft.description,
|
||||||
amountMajor: draft.amountMajor,
|
amountMajor: draft.amountMajor,
|
||||||
currency: draft.currency
|
currency: draft.currency,
|
||||||
|
split: {
|
||||||
|
mode: draft.splitMode,
|
||||||
|
participants: (adminSettings()?.members ?? []).map((member) => {
|
||||||
|
const participant = draft.participants.find(
|
||||||
|
(currentParticipant) => currentParticipant.memberId === member.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
memberId: member.id,
|
||||||
|
included: Boolean(participant),
|
||||||
|
...(draft.splitMode === 'custom_amounts' && participant
|
||||||
|
? {
|
||||||
|
shareAmountMajor: participant.shareAmountMajor
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
await refreshHouseholdData(initData, true)
|
await refreshHouseholdData(initData, true)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1385,6 +1435,34 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function purchaseSplitPreview(purchaseId: string): { memberId: string; amountMajor: string }[] {
|
||||||
|
const draft = purchaseDraftMap()[purchaseId]
|
||||||
|
if (!draft || draft.participants.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.splitMode === 'custom_amounts') {
|
||||||
|
return draft.participants.map((participant) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
amountMajor: participant.shareAmountMajor
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinor = majorStringToMinor(draft.amountMajor)
|
||||||
|
const count = BigInt(draft.participants.length)
|
||||||
|
if (count <= 0n) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = totalMinor / count
|
||||||
|
const remainder = totalMinor % count
|
||||||
|
|
||||||
|
return draft.participants.map((participant, index) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
amountMajor: minorToMajorString(base + (BigInt(index) < remainder ? 1n : 0n))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const renderPanel = () => {
|
const renderPanel = () => {
|
||||||
switch (activeNav()) {
|
switch (activeNav()) {
|
||||||
case 'balances':
|
case 'balances':
|
||||||
@@ -1521,11 +1599,7 @@ function App() {
|
|||||||
setPurchaseDraftMap((current) => ({
|
setPurchaseDraftMap((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[entry.id]: {
|
[entry.id]: {
|
||||||
...(current[entry.id] ?? {
|
...(current[entry.id] ?? purchaseDraftForEntry(entry)),
|
||||||
description: entry.title,
|
|
||||||
amountMajor: entry.amountMajor,
|
|
||||||
currency: entry.currency
|
|
||||||
}),
|
|
||||||
description: event.currentTarget.value
|
description: event.currentTarget.value
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -1543,11 +1617,7 @@ function App() {
|
|||||||
setPurchaseDraftMap((current) => ({
|
setPurchaseDraftMap((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[entry.id]: {
|
[entry.id]: {
|
||||||
...(current[entry.id] ?? {
|
...(current[entry.id] ?? purchaseDraftForEntry(entry)),
|
||||||
description: entry.title,
|
|
||||||
amountMajor: entry.amountMajor,
|
|
||||||
currency: entry.currency
|
|
||||||
}),
|
|
||||||
amountMajor: event.currentTarget.value
|
amountMajor: event.currentTarget.value
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -1564,11 +1634,7 @@ function App() {
|
|||||||
setPurchaseDraftMap((current) => ({
|
setPurchaseDraftMap((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[entry.id]: {
|
[entry.id]: {
|
||||||
...(current[entry.id] ?? {
|
...(current[entry.id] ?? purchaseDraftForEntry(entry)),
|
||||||
description: entry.title,
|
|
||||||
amountMajor: entry.amountMajor,
|
|
||||||
currency: entry.currency
|
|
||||||
}),
|
|
||||||
currency: event.currentTarget.value as 'USD' | 'GEL'
|
currency: event.currentTarget.value as 'USD' | 'GEL'
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -1579,6 +1645,147 @@ function App() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="balance-item admin-card--wide">
|
||||||
|
<header>
|
||||||
|
<strong>{copy().purchaseSplitTitle}</strong>
|
||||||
|
<span>
|
||||||
|
{purchaseDraftMap()[entry.id]?.splitMode === 'custom_amounts'
|
||||||
|
? copy().purchaseSplitCustom
|
||||||
|
: copy().purchaseSplitEqual}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="settings-field settings-field--wide">
|
||||||
|
<span>{copy().purchaseSplitModeLabel}</span>
|
||||||
|
<select
|
||||||
|
value={purchaseDraftMap()[entry.id]?.splitMode ?? 'equal'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setPurchaseDraftMap((current) => ({
|
||||||
|
...current,
|
||||||
|
[entry.id]: {
|
||||||
|
...(current[entry.id] ??
|
||||||
|
purchaseDraftForEntry(entry)),
|
||||||
|
splitMode: event.currentTarget.value as
|
||||||
|
| 'equal'
|
||||||
|
| 'custom_amounts'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="equal">{copy().purchaseSplitEqual}</option>
|
||||||
|
<option value="custom_amounts">
|
||||||
|
{copy().purchaseSplitCustom}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="balance-list admin-sublist">
|
||||||
|
{(adminSettings()?.members ?? []).map((member) => {
|
||||||
|
const draft =
|
||||||
|
purchaseDraftMap()[entry.id] ?? purchaseDraftForEntry(entry)
|
||||||
|
const included = draft.participants.some(
|
||||||
|
(participant) => participant.memberId === member.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article class="utility-bill-row">
|
||||||
|
<header>
|
||||||
|
<strong>{member.displayName}</strong>
|
||||||
|
<span>
|
||||||
|
{purchaseSplitPreview(entry.id).find(
|
||||||
|
(participant) => participant.memberId === member.id
|
||||||
|
)?.amountMajor ?? '0.00'}{' '}
|
||||||
|
{draft.currency}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="settings-field settings-field--wide">
|
||||||
|
<span>{copy().purchaseParticipantLabel}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={included}
|
||||||
|
onChange={(event) =>
|
||||||
|
setPurchaseDraftMap((current) => {
|
||||||
|
const currentDraft =
|
||||||
|
current[entry.id] ??
|
||||||
|
purchaseDraftForEntry(entry)
|
||||||
|
const nextParticipants = event.currentTarget
|
||||||
|
.checked
|
||||||
|
? [
|
||||||
|
...currentDraft.participants.filter(
|
||||||
|
(participant) =>
|
||||||
|
participant.memberId !== member.id
|
||||||
|
),
|
||||||
|
{
|
||||||
|
memberId: member.id,
|
||||||
|
shareAmountMajor: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: currentDraft.participants.filter(
|
||||||
|
(participant) =>
|
||||||
|
participant.memberId !== member.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[entry.id]: {
|
||||||
|
...currentDraft,
|
||||||
|
participants: nextParticipants
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
included &&
|
||||||
|
(purchaseDraftMap()[entry.id]?.splitMode ??
|
||||||
|
'equal') === 'custom_amounts'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<label class="settings-field">
|
||||||
|
<span>{copy().purchaseCustomShareLabel}</span>
|
||||||
|
<input
|
||||||
|
value={
|
||||||
|
draft.participants.find(
|
||||||
|
(participant) =>
|
||||||
|
participant.memberId === member.id
|
||||||
|
)?.shareAmountMajor ?? ''
|
||||||
|
}
|
||||||
|
onInput={(event) =>
|
||||||
|
setPurchaseDraftMap((current) => {
|
||||||
|
const currentDraft =
|
||||||
|
current[entry.id] ??
|
||||||
|
purchaseDraftForEntry(entry)
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[entry.id]: {
|
||||||
|
...currentDraft,
|
||||||
|
participants:
|
||||||
|
currentDraft.participants.map(
|
||||||
|
(participant) =>
|
||||||
|
participant.memberId === member.id
|
||||||
|
? {
|
||||||
|
...participant,
|
||||||
|
shareAmountMajor:
|
||||||
|
event.currentTarget.value
|
||||||
|
}
|
||||||
|
: participant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
<button
|
<button
|
||||||
class="ghost-button"
|
class="ghost-button"
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export const dictionary = {
|
|||||||
latestActivityEmpty: 'Recent utility and purchase entries will appear here.',
|
latestActivityEmpty: 'Recent utility and purchase entries will appear here.',
|
||||||
purchaseReviewTitle: 'Purchases',
|
purchaseReviewTitle: 'Purchases',
|
||||||
purchaseReviewBody: 'Edit or remove purchases if the bot recorded the wrong item.',
|
purchaseReviewBody: 'Edit or remove purchases if the bot recorded the wrong item.',
|
||||||
|
purchaseSplitTitle: 'Split',
|
||||||
|
purchaseSplitModeLabel: 'Split mode',
|
||||||
|
purchaseSplitEqual: 'Equal split',
|
||||||
|
purchaseSplitCustom: 'Custom amounts',
|
||||||
|
purchaseParticipantLabel: 'Participates',
|
||||||
|
purchaseCustomShareLabel: 'Custom amount',
|
||||||
paymentsAdminTitle: 'Payments',
|
paymentsAdminTitle: 'Payments',
|
||||||
paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.',
|
paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.',
|
||||||
paymentsAddAction: 'Add payment',
|
paymentsAddAction: 'Add payment',
|
||||||
@@ -247,6 +253,12 @@ export const dictionary = {
|
|||||||
purchaseReviewTitle: 'Покупки',
|
purchaseReviewTitle: 'Покупки',
|
||||||
purchaseReviewBody:
|
purchaseReviewBody:
|
||||||
'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.',
|
'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.',
|
||||||
|
purchaseSplitTitle: 'Разделение',
|
||||||
|
purchaseSplitModeLabel: 'Режим разделения',
|
||||||
|
purchaseSplitEqual: 'Поровну',
|
||||||
|
purchaseSplitCustom: 'Свои суммы',
|
||||||
|
purchaseParticipantLabel: 'Участвует',
|
||||||
|
purchaseCustomShareLabel: 'Своя сумма',
|
||||||
paymentsAdminTitle: 'Оплаты',
|
paymentsAdminTitle: 'Оплаты',
|
||||||
paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.',
|
paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.',
|
||||||
paymentsAddAction: 'Добавить оплату',
|
paymentsAddAction: 'Добавить оплату',
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ export interface MiniAppDashboard {
|
|||||||
fxEffectiveDate: string | null
|
fxEffectiveDate: string | null
|
||||||
actorDisplayName: string | null
|
actorDisplayName: string | null
|
||||||
occurredAt: string | null
|
occurredAt: string | null
|
||||||
|
purchaseSplitMode?: 'equal' | 'custom_amounts'
|
||||||
|
purchaseParticipants?: readonly {
|
||||||
|
memberId: string
|
||||||
|
included: boolean
|
||||||
|
shareAmountMajor: string | null
|
||||||
|
}[]
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +826,14 @@ export async function updateMiniAppPurchase(
|
|||||||
description: string
|
description: string
|
||||||
amountMajor: string
|
amountMajor: string
|
||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
|
split?: {
|
||||||
|
mode: 'equal' | 'custom_amounts'
|
||||||
|
participants: readonly {
|
||||||
|
memberId: string
|
||||||
|
included?: boolean
|
||||||
|
shareAmountMajor?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/purchases/update`, {
|
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/purchases/update`, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, or, sql } from 'drizzle-orm'
|
import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
import type { FinanceRepository } from '@household/ports'
|
import type { FinanceRepository } from '@household/ports'
|
||||||
@@ -31,6 +31,49 @@ export function createDbFinanceRepository(
|
|||||||
prepare: false
|
prepare: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadPurchaseParticipants(purchaseIds: readonly string[]): Promise<
|
||||||
|
ReadonlyMap<
|
||||||
|
string,
|
||||||
|
readonly {
|
||||||
|
id: string
|
||||||
|
memberId: string
|
||||||
|
shareAmountMinor: bigint | null
|
||||||
|
}[]
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
if (purchaseIds.length === 0) {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: schema.purchaseMessageParticipants.id,
|
||||||
|
purchaseMessageId: schema.purchaseMessageParticipants.purchaseMessageId,
|
||||||
|
memberId: schema.purchaseMessageParticipants.memberId,
|
||||||
|
included: schema.purchaseMessageParticipants.included,
|
||||||
|
shareAmountMinor: schema.purchaseMessageParticipants.shareAmountMinor
|
||||||
|
})
|
||||||
|
.from(schema.purchaseMessageParticipants)
|
||||||
|
.where(inArray(schema.purchaseMessageParticipants.purchaseMessageId, [...purchaseIds]))
|
||||||
|
|
||||||
|
const grouped = new Map<
|
||||||
|
string,
|
||||||
|
{ id: string; memberId: string; included: boolean; shareAmountMinor: bigint | null }[]
|
||||||
|
>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const current = grouped.get(row.purchaseMessageId) ?? []
|
||||||
|
current.push({
|
||||||
|
id: row.id,
|
||||||
|
memberId: row.memberId,
|
||||||
|
included: row.included === 1,
|
||||||
|
shareAmountMinor: row.shareAmountMinor
|
||||||
|
})
|
||||||
|
grouped.set(row.purchaseMessageId, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
const repository: FinanceRepository = {
|
const repository: FinanceRepository = {
|
||||||
async getMemberByTelegramUserId(telegramUserId) {
|
async getMemberByTelegramUserId(telegramUserId) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -297,12 +340,18 @@ export function createDbFinanceRepository(
|
|||||||
},
|
},
|
||||||
|
|
||||||
async updateParsedPurchase(input) {
|
async updateParsedPurchase(input) {
|
||||||
const rows = await db
|
return await db.transaction(async (tx) => {
|
||||||
|
const rows = await tx
|
||||||
.update(schema.purchaseMessages)
|
.update(schema.purchaseMessages)
|
||||||
.set({
|
.set({
|
||||||
parsedAmountMinor: input.amountMinor,
|
parsedAmountMinor: input.amountMinor,
|
||||||
parsedCurrency: input.currency,
|
parsedCurrency: input.currency,
|
||||||
parsedItemDescription: input.description,
|
parsedItemDescription: input.description,
|
||||||
|
...(input.splitMode
|
||||||
|
? {
|
||||||
|
participantSplitMode: input.splitMode
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
needsReview: 0,
|
needsReview: 0,
|
||||||
processingStatus: 'confirmed',
|
processingStatus: 'confirmed',
|
||||||
parserError: null
|
parserError: null
|
||||||
@@ -319,7 +368,8 @@ export function createDbFinanceRepository(
|
|||||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||||
currency: schema.purchaseMessages.parsedCurrency,
|
currency: schema.purchaseMessages.parsedCurrency,
|
||||||
description: schema.purchaseMessages.parsedItemDescription,
|
description: schema.purchaseMessages.parsedItemDescription,
|
||||||
occurredAt: schema.purchaseMessages.messageSentAt
|
occurredAt: schema.purchaseMessages.messageSentAt,
|
||||||
|
splitMode: schema.purchaseMessages.participantSplitMode
|
||||||
})
|
})
|
||||||
|
|
||||||
const row = rows[0]
|
const row = rows[0]
|
||||||
@@ -327,14 +377,49 @@ export function createDbFinanceRepository(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.participants) {
|
||||||
|
await tx
|
||||||
|
.delete(schema.purchaseMessageParticipants)
|
||||||
|
.where(eq(schema.purchaseMessageParticipants.purchaseMessageId, input.purchaseId))
|
||||||
|
|
||||||
|
if (input.participants.length > 0) {
|
||||||
|
await tx.insert(schema.purchaseMessageParticipants).values(
|
||||||
|
input.participants.map((participant) => ({
|
||||||
|
purchaseMessageId: input.purchaseId,
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participant.included === false ? 0 : 1,
|
||||||
|
shareAmountMinor: participant.shareAmountMinor
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await tx
|
||||||
|
.select({
|
||||||
|
id: schema.purchaseMessageParticipants.id,
|
||||||
|
memberId: schema.purchaseMessageParticipants.memberId,
|
||||||
|
included: schema.purchaseMessageParticipants.included,
|
||||||
|
shareAmountMinor: schema.purchaseMessageParticipants.shareAmountMinor
|
||||||
|
})
|
||||||
|
.from(schema.purchaseMessageParticipants)
|
||||||
|
.where(eq(schema.purchaseMessageParticipants.purchaseMessageId, input.purchaseId))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
payerMemberId: row.payerMemberId,
|
payerMemberId: row.payerMemberId,
|
||||||
amountMinor: row.amountMinor,
|
amountMinor: row.amountMinor,
|
||||||
currency: toCurrencyCode(row.currency),
|
currency: toCurrencyCode(row.currency),
|
||||||
description: row.description,
|
description: row.description,
|
||||||
occurredAt: instantFromDatabaseValue(row.occurredAt)
|
occurredAt: instantFromDatabaseValue(row.occurredAt),
|
||||||
|
splitMode: row.splitMode === 'custom_amounts' ? 'custom_amounts' : 'equal',
|
||||||
|
participants: participants.map((participant) => ({
|
||||||
|
id: participant.id,
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participant.included === 1,
|
||||||
|
shareAmountMinor: participant.shareAmountMinor
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteParsedPurchase(purchaseId) {
|
async deleteParsedPurchase(purchaseId) {
|
||||||
@@ -588,7 +673,8 @@ export function createDbFinanceRepository(
|
|||||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||||
currency: schema.purchaseMessages.parsedCurrency,
|
currency: schema.purchaseMessages.parsedCurrency,
|
||||||
description: schema.purchaseMessages.parsedItemDescription,
|
description: schema.purchaseMessages.parsedItemDescription,
|
||||||
occurredAt: schema.purchaseMessages.messageSentAt
|
occurredAt: schema.purchaseMessages.messageSentAt,
|
||||||
|
splitMode: schema.purchaseMessages.participantSplitMode
|
||||||
})
|
})
|
||||||
.from(schema.purchaseMessages)
|
.from(schema.purchaseMessages)
|
||||||
.where(
|
.where(
|
||||||
@@ -606,13 +692,17 @@ export function createDbFinanceRepository(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const participantsByPurchaseId = await loadPurchaseParticipants(rows.map((row) => row.id))
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
payerMemberId: row.payerMemberId!,
|
payerMemberId: row.payerMemberId!,
|
||||||
amountMinor: row.amountMinor!,
|
amountMinor: row.amountMinor!,
|
||||||
currency: toCurrencyCode(row.currency!),
|
currency: toCurrencyCode(row.currency!),
|
||||||
description: row.description,
|
description: row.description,
|
||||||
occurredAt: instantFromDatabaseValue(row.occurredAt)
|
occurredAt: instantFromDatabaseValue(row.occurredAt),
|
||||||
|
splitMode: row.splitMode === 'custom_amounts' ? 'custom_amounts' : 'equal',
|
||||||
|
participants: participantsByPurchaseId.get(row.id) ?? []
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import { instantFromIso, type Instant } from '@household/domain'
|
import { instantFromIso, Money, type Instant } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
ExchangeRateProvider,
|
ExchangeRateProvider,
|
||||||
FinanceCycleExchangeRateRecord,
|
FinanceCycleExchangeRateRecord,
|
||||||
@@ -60,6 +60,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
} | null = null
|
} | null = null
|
||||||
replacedSnapshot: SettlementSnapshotRecord | null = null
|
replacedSnapshot: SettlementSnapshotRecord | null = null
|
||||||
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
|
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
|
||||||
|
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
|
||||||
|
|
||||||
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
||||||
return this.member
|
return this.member
|
||||||
@@ -138,8 +139,23 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateParsedPurchase() {
|
async updateParsedPurchase(input) {
|
||||||
return null
|
this.lastUpdatedPurchaseInput = input
|
||||||
|
return {
|
||||||
|
id: input.purchaseId,
|
||||||
|
payerMemberId: 'alice',
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
currency: input.currency,
|
||||||
|
description: input.description,
|
||||||
|
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
|
||||||
|
splitMode: input.splitMode ?? 'equal',
|
||||||
|
participants: input.participants?.map((participant, index) => ({
|
||||||
|
id: `participant-${index + 1}`,
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participant.included !== false,
|
||||||
|
shareAmountMinor: participant.shareAmountMinor
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteParsedPurchase() {
|
async deleteParsedPurchase() {
|
||||||
@@ -603,4 +619,133 @@ describe('createFinanceCommandService', () => {
|
|||||||
{ memberId: 'carol', utility: 0n, purchaseOffset: 0n }
|
{ memberId: 'carol', utility: 0n, purchaseOffset: 0n }
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('updatePurchase persists explicit participant splits', async () => {
|
||||||
|
const repository = new FinanceRepositoryStub()
|
||||||
|
const service = createService(repository)
|
||||||
|
|
||||||
|
const result = await service.updatePurchase('purchase-1', 'Kitchen towels', '30.00', 'GEL', {
|
||||||
|
mode: 'custom_amounts',
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
memberId: 'alice',
|
||||||
|
shareAmountMajor: '20.00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'bob',
|
||||||
|
shareAmountMajor: '10.00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
purchaseId: 'purchase-1',
|
||||||
|
currency: 'GEL'
|
||||||
|
})
|
||||||
|
expect(repository.lastUpdatedPurchaseInput).toEqual({
|
||||||
|
purchaseId: 'purchase-1',
|
||||||
|
amountMinor: 3000n,
|
||||||
|
currency: 'GEL',
|
||||||
|
description: 'Kitchen towels',
|
||||||
|
splitMode: 'custom_amounts',
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
memberId: 'alice',
|
||||||
|
shareAmountMinor: 2000n
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'bob',
|
||||||
|
shareAmountMinor: 1000n
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateDashboard exposes purchase participant splits in the ledger', async () => {
|
||||||
|
const repository = new FinanceRepositoryStub()
|
||||||
|
repository.members = [
|
||||||
|
{
|
||||||
|
id: 'alice',
|
||||||
|
telegramUserId: '1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bob',
|
||||||
|
telegramUserId: '2',
|
||||||
|
displayName: 'Bob',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'carol',
|
||||||
|
telegramUserId: '3',
|
||||||
|
displayName: 'Carol',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
repository.openCycleRecord = {
|
||||||
|
id: 'cycle-2026-03',
|
||||||
|
period: '2026-03',
|
||||||
|
currency: 'GEL'
|
||||||
|
}
|
||||||
|
repository.rentRule = {
|
||||||
|
amountMinor: 90000n,
|
||||||
|
currency: 'GEL'
|
||||||
|
}
|
||||||
|
repository.purchases = [
|
||||||
|
{
|
||||||
|
id: 'purchase-1',
|
||||||
|
payerMemberId: 'alice',
|
||||||
|
amountMinor: 3000n,
|
||||||
|
currency: 'GEL',
|
||||||
|
description: 'Kettle',
|
||||||
|
occurredAt: instantFromIso('2026-03-10T12:00:00.000Z'),
|
||||||
|
splitMode: 'custom_amounts',
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
memberId: 'alice',
|
||||||
|
included: true,
|
||||||
|
shareAmountMinor: 2000n
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'bob',
|
||||||
|
included: true,
|
||||||
|
shareAmountMinor: 1000n
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'carol',
|
||||||
|
included: false,
|
||||||
|
shareAmountMinor: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const service = createService(repository)
|
||||||
|
const dashboard = await service.generateDashboard()
|
||||||
|
const purchaseEntry = dashboard?.ledger.find((entry) => entry.id === 'purchase-1')
|
||||||
|
|
||||||
|
expect(purchaseEntry?.kind).toBe('purchase')
|
||||||
|
expect(purchaseEntry?.purchaseSplitMode).toBe('custom_amounts')
|
||||||
|
expect(purchaseEntry?.purchaseParticipants).toEqual([
|
||||||
|
{
|
||||||
|
memberId: 'alice',
|
||||||
|
included: true,
|
||||||
|
shareAmount: Money.fromMinor(2000n, 'GEL')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'bob',
|
||||||
|
included: true,
|
||||||
|
shareAmount: Money.fromMinor(1000n, 'GEL')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'carol',
|
||||||
|
included: false,
|
||||||
|
shareAmount: null
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ export interface FinanceDashboardLedgerEntry {
|
|||||||
actorDisplayName: string | null
|
actorDisplayName: string | null
|
||||||
occurredAt: string | null
|
occurredAt: string | null
|
||||||
paymentKind: FinancePaymentKind | null
|
paymentKind: FinancePaymentKind | null
|
||||||
|
purchaseSplitMode?: 'equal' | 'custom_amounts'
|
||||||
|
purchaseParticipants?: readonly {
|
||||||
|
memberId: string
|
||||||
|
included: boolean
|
||||||
|
shareAmount: Money | null
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceDashboard {
|
export interface FinanceDashboard {
|
||||||
@@ -380,11 +386,41 @@ async function buildFinanceDashboard(
|
|||||||
participatesInPurchases: member.status === 'active',
|
participatesInPurchases: member.status === 'active',
|
||||||
rentWeight: member.rentShareWeight
|
rentWeight: member.rentShareWeight
|
||||||
})),
|
})),
|
||||||
purchases: convertedPurchases.map(({ purchase, converted }) => ({
|
purchases: convertedPurchases.map(({ purchase, converted }) => {
|
||||||
|
const nextPurchase: {
|
||||||
|
purchaseId: PurchaseEntryId
|
||||||
|
payerId: MemberId
|
||||||
|
amount: Money
|
||||||
|
splitMode: 'equal' | 'custom_amounts'
|
||||||
|
participants?: {
|
||||||
|
memberId: MemberId
|
||||||
|
shareAmount?: Money
|
||||||
|
}[]
|
||||||
|
} = {
|
||||||
purchaseId: PurchaseEntryId.from(purchase.id),
|
purchaseId: PurchaseEntryId.from(purchase.id),
|
||||||
payerId: MemberId.from(purchase.payerMemberId),
|
payerId: MemberId.from(purchase.payerMemberId),
|
||||||
amount: converted.settlementAmount
|
amount: converted.settlementAmount,
|
||||||
|
splitMode: purchase.splitMode ?? 'equal'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purchase.participants) {
|
||||||
|
nextPurchase.participants = purchase.participants
|
||||||
|
.filter((participant) => participant.included !== false)
|
||||||
|
.map((participant) => ({
|
||||||
|
memberId: MemberId.from(participant.memberId),
|
||||||
|
...(participant.shareAmountMinor !== null
|
||||||
|
? {
|
||||||
|
shareAmount: Money.fromMinor(
|
||||||
|
participant.shareAmountMinor,
|
||||||
|
converted.settlementAmount.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: {})
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextPurchase
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
await dependencies.repository.replaceSettlementSnapshot({
|
await dependencies.repository.replaceSettlementSnapshot({
|
||||||
@@ -465,9 +501,10 @@ async function buildFinanceDashboard(
|
|||||||
occurredAt: bill.createdAt.toString(),
|
occurredAt: bill.createdAt.toString(),
|
||||||
paymentKind: null
|
paymentKind: null
|
||||||
})),
|
})),
|
||||||
...convertedPurchases.map(({ purchase, converted }) => ({
|
...convertedPurchases.map(({ purchase, converted }) => {
|
||||||
|
const entry: FinanceDashboardLedgerEntry = {
|
||||||
id: purchase.id,
|
id: purchase.id,
|
||||||
kind: 'purchase' as const,
|
kind: 'purchase',
|
||||||
title: purchase.description ?? 'Shared purchase',
|
title: purchase.description ?? 'Shared purchase',
|
||||||
memberId: purchase.payerMemberId,
|
memberId: purchase.payerMemberId,
|
||||||
amount: converted.originalAmount,
|
amount: converted.originalAmount,
|
||||||
@@ -478,8 +515,23 @@ async function buildFinanceDashboard(
|
|||||||
fxEffectiveDate: converted.fxEffectiveDate,
|
fxEffectiveDate: converted.fxEffectiveDate,
|
||||||
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
||||||
occurredAt: purchase.occurredAt?.toString() ?? null,
|
occurredAt: purchase.occurredAt?.toString() ?? null,
|
||||||
paymentKind: null
|
paymentKind: null,
|
||||||
})),
|
purchaseSplitMode: purchase.splitMode ?? 'equal'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purchase.participants) {
|
||||||
|
entry.purchaseParticipants = purchase.participants.map((participant) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participant.included !== false,
|
||||||
|
shareAmount:
|
||||||
|
participant.shareAmountMinor !== null
|
||||||
|
? Money.fromMinor(participant.shareAmountMinor, converted.settlementAmount.currency)
|
||||||
|
: null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}),
|
||||||
...paymentRecords.map((payment) => ({
|
...paymentRecords.map((payment) => ({
|
||||||
id: payment.id,
|
id: payment.id,
|
||||||
kind: 'payment' as const,
|
kind: 'payment' as const,
|
||||||
@@ -565,7 +617,14 @@ export interface FinanceCommandService {
|
|||||||
purchaseId: string,
|
purchaseId: string,
|
||||||
description: string,
|
description: string,
|
||||||
amountArg: string,
|
amountArg: string,
|
||||||
currencyArg?: string
|
currencyArg?: string,
|
||||||
|
split?: {
|
||||||
|
mode: 'equal' | 'custom_amounts'
|
||||||
|
participants: readonly {
|
||||||
|
memberId: string
|
||||||
|
shareAmountMajor?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
purchaseId: string
|
purchaseId: string
|
||||||
amount: Money
|
amount: Money
|
||||||
@@ -782,7 +841,7 @@ export function createFinanceCommandService(
|
|||||||
return repository.deleteUtilityBill(billId)
|
return repository.deleteUtilityBill(billId)
|
||||||
},
|
},
|
||||||
|
|
||||||
async updatePurchase(purchaseId, description, amountArg, currencyArg) {
|
async updatePurchase(purchaseId, description, amountArg, currencyArg, split) {
|
||||||
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
||||||
dependencies.householdId
|
dependencies.householdId
|
||||||
)
|
)
|
||||||
@@ -792,7 +851,19 @@ export function createFinanceCommandService(
|
|||||||
purchaseId,
|
purchaseId,
|
||||||
amountMinor: amount.amountMinor,
|
amountMinor: amount.amountMinor,
|
||||||
currency,
|
currency,
|
||||||
description: description.trim().length > 0 ? description.trim() : null
|
description: description.trim().length > 0 ? description.trim() : null,
|
||||||
|
...(split
|
||||||
|
? {
|
||||||
|
splitMode: split.mode,
|
||||||
|
participants: split.participants.map((participant) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
shareAmountMinor:
|
||||||
|
participant.shareAmountMajor !== undefined
|
||||||
|
? Money.fromMajor(participant.shareAmountMajor, currency).amountMinor
|
||||||
|
: null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
: {})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|||||||
@@ -237,4 +237,38 @@ describe('calculateMonthlySettlement', () => {
|
|||||||
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([12000n, 0n])
|
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([12000n, 0n])
|
||||||
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([82000n, 0n])
|
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([82000n, 0n])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('supports custom purchase splits across selected participants', () => {
|
||||||
|
const input = {
|
||||||
|
...fixtureBase(),
|
||||||
|
utilitySplitMode: 'equal' as const,
|
||||||
|
members: [
|
||||||
|
{ memberId: MemberId.from('alice'), active: true },
|
||||||
|
{ memberId: MemberId.from('bob'), active: true },
|
||||||
|
{ memberId: MemberId.from('carol'), active: true }
|
||||||
|
],
|
||||||
|
purchases: [
|
||||||
|
{
|
||||||
|
purchaseId: PurchaseEntryId.from('p1'),
|
||||||
|
payerId: MemberId.from('alice'),
|
||||||
|
amount: Money.fromMajor('30.00', 'USD'),
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
memberId: MemberId.from('alice'),
|
||||||
|
shareAmount: Money.fromMajor('20.00', 'USD')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: MemberId.from('bob'),
|
||||||
|
shareAmount: Money.fromMajor('10.00', 'USD')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = calculateMonthlySettlement(input)
|
||||||
|
|
||||||
|
expect(result.lines.map((line) => line.purchaseOffset.amountMinor)).toEqual([-1000n, 1000n, 0n])
|
||||||
|
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([26334n, 28333n, 27333n])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -91,6 +91,37 @@ function purchaseParticipants(
|
|||||||
return participants
|
return participants
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function purchaseParticipantMembers(
|
||||||
|
activeMembers: readonly SettlementMemberInput[],
|
||||||
|
purchase: SettlementInput['purchases'][number]
|
||||||
|
): readonly SettlementMemberInput[] {
|
||||||
|
if (!purchase.participants || purchase.participants.length === 0) {
|
||||||
|
return purchaseParticipants(activeMembers, purchase.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersById = new Map(activeMembers.map((member) => [member.memberId.toString(), member]))
|
||||||
|
const participants = purchase.participants.map((participant) => {
|
||||||
|
const matched = membersById.get(participant.memberId.toString())
|
||||||
|
if (!matched) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
`Purchase participant is not an active member: ${participant.memberId.toString()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched
|
||||||
|
})
|
||||||
|
|
||||||
|
if (participants.length === 0 && purchase.amount.amountMinor > 0n) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
'Settlement must include at least one purchase participant when purchases are present'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return participants
|
||||||
|
}
|
||||||
|
|
||||||
function ensureNonNegativeMoney(label: string, value: Money): void {
|
function ensureNonNegativeMoney(label: string, value: Money): void {
|
||||||
if (value.isNegative()) {
|
if (value.isNegative()) {
|
||||||
throw new DomainError(
|
throw new DomainError(
|
||||||
@@ -227,7 +258,42 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
|
|||||||
|
|
||||||
payer.purchasePaid = payer.purchasePaid.add(purchase.amount)
|
payer.purchasePaid = payer.purchasePaid.add(purchase.amount)
|
||||||
|
|
||||||
const participants = purchaseParticipants(activeMembers, purchase.amount)
|
const participants = purchaseParticipantMembers(activeMembers, purchase)
|
||||||
|
const explicitShareAmounts = purchase.participants?.map(
|
||||||
|
(participant) => participant.shareAmount
|
||||||
|
)
|
||||||
|
|
||||||
|
if (explicitShareAmounts && explicitShareAmounts.some((amount) => amount !== undefined)) {
|
||||||
|
if (explicitShareAmounts.some((amount) => amount === undefined)) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
`Purchase custom split must include explicit share amounts for every participant: ${purchase.purchaseId.toString()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shares = explicitShareAmounts as readonly Money[]
|
||||||
|
const shareTotal = sumMoney(shares, currency)
|
||||||
|
if (!shareTotal.equals(purchase.amount)) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
`Purchase custom split must add up to the full amount: ${purchase.purchaseId.toString()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, member] of participants.entries()) {
|
||||||
|
const state = membersById.get(member.memberId.toString())
|
||||||
|
if (!state) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
state.purchaseSharedCost = state.purchaseSharedCost.add(
|
||||||
|
shares[index] ?? Money.zero(currency)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const purchaseShares = purchase.amount.splitEvenly(participants.length)
|
const purchaseShares = purchase.amount.splitEvenly(participants.length)
|
||||||
for (const [index, member] of participants.entries()) {
|
for (const [index, member] of participants.entries()) {
|
||||||
const state = membersById.get(member.memberId.toString())
|
const state = membersById.get(member.memberId.toString())
|
||||||
|
|||||||
16
packages/db/drizzle/0016_equal_susan_delgado.sql
Normal file
16
packages/db/drizzle/0016_equal_susan_delgado.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "purchase_message_participants" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"purchase_message_id" uuid NOT NULL,
|
||||||
|
"member_id" uuid NOT NULL,
|
||||||
|
"included" integer DEFAULT 1 NOT NULL,
|
||||||
|
"share_amount_minor" bigint,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "purchase_messages" ADD COLUMN "participant_split_mode" text DEFAULT 'equal' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "purchase_message_participants" ADD CONSTRAINT "purchase_message_participants_purchase_message_id_purchase_messages_id_fk" FOREIGN KEY ("purchase_message_id") REFERENCES "public"."purchase_messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "purchase_message_participants" ADD CONSTRAINT "purchase_message_participants_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "purchase_message_participants_purchase_member_unique" ON "purchase_message_participants" USING btree ("purchase_message_id","member_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "purchase_message_participants_purchase_idx" ON "purchase_message_participants" USING btree ("purchase_message_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "purchase_message_participants_member_idx" ON "purchase_message_participants" USING btree ("member_id");
|
||||||
3215
packages/db/drizzle/meta/0016_snapshot.json
Normal file
3215
packages/db/drizzle/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
|||||||
"when": 1773223414625,
|
"when": 1773223414625,
|
||||||
"tag": "0015_white_owl",
|
"tag": "0015_white_owl",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773225121790,
|
||||||
|
"tag": "0016_equal_susan_delgado",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ export const purchaseMessages = pgTable(
|
|||||||
parsedAmountMinor: bigint('parsed_amount_minor', { mode: 'bigint' }),
|
parsedAmountMinor: bigint('parsed_amount_minor', { mode: 'bigint' }),
|
||||||
parsedCurrency: text('parsed_currency'),
|
parsedCurrency: text('parsed_currency'),
|
||||||
parsedItemDescription: text('parsed_item_description'),
|
parsedItemDescription: text('parsed_item_description'),
|
||||||
|
participantSplitMode: text('participant_split_mode').default('equal').notNull(),
|
||||||
parserMode: text('parser_mode'),
|
parserMode: text('parser_mode'),
|
||||||
parserConfidence: integer('parser_confidence'),
|
parserConfidence: integer('parser_confidence'),
|
||||||
needsReview: integer('needs_review').default(1).notNull(),
|
needsReview: integer('needs_review').default(1).notNull(),
|
||||||
@@ -447,6 +448,31 @@ export const purchaseMessages = pgTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const purchaseMessageParticipants = pgTable(
|
||||||
|
'purchase_message_participants',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
purchaseMessageId: uuid('purchase_message_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => purchaseMessages.id, { onDelete: 'cascade' }),
|
||||||
|
memberId: uuid('member_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => members.id, { onDelete: 'cascade' }),
|
||||||
|
included: integer('included').default(1).notNull(),
|
||||||
|
shareAmountMinor: bigint('share_amount_minor', { mode: 'bigint' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
purchaseMemberUnique: uniqueIndex('purchase_message_participants_purchase_member_unique').on(
|
||||||
|
table.purchaseMessageId,
|
||||||
|
table.memberId
|
||||||
|
),
|
||||||
|
purchaseIdx: index('purchase_message_participants_purchase_idx').on(table.purchaseMessageId),
|
||||||
|
memberIdx: index('purchase_message_participants_member_idx').on(table.memberId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const processedBotMessages = pgTable(
|
export const processedBotMessages = pgTable(
|
||||||
'processed_bot_messages',
|
'processed_bot_messages',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ export type {
|
|||||||
SettlementMemberLine,
|
SettlementMemberLine,
|
||||||
SettlementPurchaseInput,
|
SettlementPurchaseInput,
|
||||||
SettlementResult,
|
SettlementResult,
|
||||||
|
PurchaseSplitMode,
|
||||||
UtilitySplitMode
|
UtilitySplitMode
|
||||||
} from './settlement-primitives'
|
} from './settlement-primitives'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BillingCycleId, MemberId, PurchaseEntryId } from './ids'
|
|||||||
import type { Money } from './money'
|
import type { Money } from './money'
|
||||||
|
|
||||||
export type UtilitySplitMode = 'equal' | 'weighted_by_days'
|
export type UtilitySplitMode = 'equal' | 'weighted_by_days'
|
||||||
|
export type PurchaseSplitMode = 'equal' | 'custom_amounts'
|
||||||
|
|
||||||
export interface SettlementMemberInput {
|
export interface SettlementMemberInput {
|
||||||
memberId: MemberId
|
memberId: MemberId
|
||||||
@@ -19,6 +20,11 @@ export interface SettlementPurchaseInput {
|
|||||||
payerId: MemberId
|
payerId: MemberId
|
||||||
amount: Money
|
amount: Money
|
||||||
description?: string
|
description?: string
|
||||||
|
splitMode?: PurchaseSplitMode
|
||||||
|
participants?: readonly {
|
||||||
|
memberId: MemberId
|
||||||
|
shareAmount?: Money
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettlementInput {
|
export interface SettlementInput {
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ export interface FinanceParsedPurchaseRecord {
|
|||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
description: string | null
|
description: string | null
|
||||||
occurredAt: Instant | null
|
occurredAt: Instant | null
|
||||||
|
splitMode?: 'equal' | 'custom_amounts'
|
||||||
|
participants?: readonly {
|
||||||
|
id?: string
|
||||||
|
memberId: string
|
||||||
|
included?: boolean
|
||||||
|
shareAmountMinor: bigint | null
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceUtilityBillRecord {
|
export interface FinanceUtilityBillRecord {
|
||||||
@@ -170,6 +177,12 @@ export interface FinanceRepository {
|
|||||||
amountMinor: bigint
|
amountMinor: bigint
|
||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
description: string | null
|
description: string | null
|
||||||
|
splitMode?: 'equal' | 'custom_amounts'
|
||||||
|
participants?: readonly {
|
||||||
|
memberId: string
|
||||||
|
included?: boolean
|
||||||
|
shareAmountMinor: bigint | null
|
||||||
|
}[]
|
||||||
}): Promise<FinanceParsedPurchaseRecord | null>
|
}): Promise<FinanceParsedPurchaseRecord | null>
|
||||||
deleteParsedPurchase(purchaseId: string): Promise<boolean>
|
deleteParsedPurchase(purchaseId: string): Promise<boolean>
|
||||||
updateUtilityBill(input: {
|
updateUtilityBill(input: {
|
||||||
|
|||||||
Reference in New Issue
Block a user