mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +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,
|
||||
parsedItemDescription: 'door handle',
|
||||
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,
|
||||
parsedItemDescription: 'sausages',
|
||||
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,
|
||||
parserMode: 'llm' as const
|
||||
}
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -768,7 +787,8 @@ describe('registerDmAssistant', () => {
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
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: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
@@ -830,7 +850,8 @@ describe('registerDmAssistant', () => {
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
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: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
|
||||
@@ -958,7 +958,8 @@ export function registerDmAssistant(options: {
|
||||
const purchaseText =
|
||||
purchaseResult.status === 'pending_confirmation'
|
||||
? getBotTranslations(locale).purchase.proposal(
|
||||
formatPurchaseSummary(locale, purchaseResult)
|
||||
formatPurchaseSummary(locale, purchaseResult),
|
||||
null
|
||||
)
|
||||
: purchaseResult.status === 'clarification_needed'
|
||||
? buildPurchaseClarificationText(locale, purchaseResult)
|
||||
|
||||
@@ -235,7 +235,8 @@ export const enBotTranslations: BotTranslationCatalog = {
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'shared 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,
|
||||
clarificationMissingAmountAndCurrency:
|
||||
'What amount and currency should I record for this shared purchase?',
|
||||
@@ -244,6 +245,11 @@ export const enBotTranslations: BotTranslationCatalog = {
|
||||
clarificationMissingItem: 'What exactly was purchased?',
|
||||
clarificationLowConfidence:
|
||||
'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',
|
||||
cancelButton: 'Cancel',
|
||||
confirmed: (summary) => `Purchase confirmed: ${summary}`,
|
||||
@@ -252,6 +258,7 @@ export const enBotTranslations: BotTranslationCatalog = {
|
||||
cancelledToast: 'Purchase cancelled.',
|
||||
alreadyConfirmed: 'This purchase was already confirmed.',
|
||||
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.',
|
||||
proposalUnavailable: 'This purchase proposal is no longer available.',
|
||||
parseFailed:
|
||||
|
||||
@@ -238,7 +238,8 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'общая покупка',
|
||||
processing: 'Проверяю покупку...',
|
||||
proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`,
|
||||
proposal: (summary, participants) =>
|
||||
`Похоже, это общая покупка: ${summary}.${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`,
|
||||
clarification: (question) => question,
|
||||
clarificationMissingAmountAndCurrency:
|
||||
'Какую сумму и валюту нужно записать для этой общей покупки?',
|
||||
@@ -247,6 +248,11 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
||||
clarificationMissingItem: 'Что именно было куплено?',
|
||||
clarificationLowConfidence:
|
||||
'Я не уверен, что правильно понял сообщение. Переформулируйте покупку с предметом, суммой и валютой.',
|
||||
participantsHeading: 'Участники:',
|
||||
participantIncluded: (displayName) => `- ${displayName}`,
|
||||
participantExcluded: (displayName) => `- ${displayName} (не участвует)`,
|
||||
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
||||
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
||||
confirmButton: 'Подтвердить',
|
||||
cancelButton: 'Отменить',
|
||||
confirmed: (summary) => `Покупка подтверждена: ${summary}`,
|
||||
@@ -255,6 +261,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
||||
cancelledToast: 'Покупка отменена.',
|
||||
alreadyConfirmed: 'Эта покупка уже подтверждена.',
|
||||
alreadyCancelled: 'Это предложение покупки уже отменено.',
|
||||
atLeastOneParticipant: 'В распределении покупки должен остаться хотя бы один участник.',
|
||||
notYourProposal: 'Подтвердить или отменить эту покупку может только отправитель сообщения.',
|
||||
proposalUnavailable: 'Это предложение покупки уже недоступно.',
|
||||
parseFailed:
|
||||
|
||||
@@ -227,13 +227,18 @@ export interface BotTranslationCatalog {
|
||||
purchase: {
|
||||
sharedPurchaseFallback: string
|
||||
processing: string
|
||||
proposal: (summary: string) => string
|
||||
proposal: (summary: string, participants: string | null) => string
|
||||
clarification: (question: string) => string
|
||||
clarificationMissingAmountAndCurrency: string
|
||||
clarificationMissingAmount: string
|
||||
clarificationMissingCurrency: string
|
||||
clarificationMissingItem: string
|
||||
clarificationLowConfidence: string
|
||||
participantsHeading: string
|
||||
participantIncluded: (displayName: string) => string
|
||||
participantExcluded: (displayName: string) => string
|
||||
participantToggleIncluded: (displayName: string) => string
|
||||
participantToggleExcluded: (displayName: string) => string
|
||||
confirmButton: string
|
||||
cancelButton: string
|
||||
confirmed: (summary: string) => string
|
||||
@@ -242,6 +247,7 @@ export interface BotTranslationCatalog {
|
||||
cancelledToast: string
|
||||
alreadyConfirmed: string
|
||||
alreadyCancelled: string
|
||||
atLeastOneParticipant: string
|
||||
notYourProposal: string
|
||||
proposalUnavailable: string
|
||||
parseFailed: string
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createMiniAppDeleteUtilityBillHandler,
|
||||
createMiniAppOpenCycleHandler,
|
||||
createMiniAppRentUpdateHandler,
|
||||
createMiniAppUpdatePurchaseHandler,
|
||||
createMiniAppUpdateUtilityBillHandler
|
||||
} from './miniapp-billing'
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
@@ -476,3 +477,83 @@ describe('createMiniAppAddUtilityBillHandler', () => {
|
||||
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
|
||||
amountMajor?: string
|
||||
currency?: string
|
||||
split?: {
|
||||
mode: 'equal' | 'custom_amounts'
|
||||
participants: {
|
||||
memberId: string
|
||||
shareAmountMajor?: string
|
||||
}[]
|
||||
}
|
||||
}> {
|
||||
const parsed = await parseJsonBody<{
|
||||
initData?: string
|
||||
@@ -296,6 +303,13 @@ async function readPurchaseMutationPayload(request: Request): Promise<{
|
||||
description?: string
|
||||
amountMajor?: string
|
||||
currency?: string
|
||||
split?: {
|
||||
mode?: string
|
||||
participants?: {
|
||||
memberId?: string
|
||||
shareAmountMajor?: string
|
||||
}[]
|
||||
}
|
||||
}>(request)
|
||||
const initData = parsed.initData?.trim()
|
||||
if (!initData) {
|
||||
@@ -323,6 +337,32 @@ async function readPurchaseMutationPayload(request: Request): Promise<{
|
||||
? {
|
||||
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.description,
|
||||
payload.amountMajor,
|
||||
payload.currency
|
||||
payload.currency,
|
||||
payload.split
|
||||
)
|
||||
|
||||
if (!updated) {
|
||||
|
||||
@@ -275,7 +275,6 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
|
||||
const dashboard = createMiniAppDashboardHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
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 () => {
|
||||
const householdRepository = onboardingRepository()
|
||||
const financeService = createFinanceCommandService({
|
||||
|
||||
@@ -120,7 +120,18 @@ export function createMiniAppDashboardHandler(options: {
|
||||
fxRateMicros: entry.fxRateMicros?.toString() ?? null,
|
||||
fxEffectiveDate: entry.fxEffectiveDate,
|
||||
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) {
|
||||
const commandToken = text.split(' ')[0] ?? text
|
||||
|
||||
@@ -180,12 +197,16 @@ describe('buildPurchaseAcknowledgement', () => {
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm'
|
||||
parserMode: 'llm',
|
||||
participants: participants()
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.'
|
||||
)
|
||||
expect(result).toBe(`I think this shared purchase was: toilet paper - 30.00 GEL.
|
||||
|
||||
Participants:
|
||||
- Mia
|
||||
- Dima (excluded)
|
||||
Confirm or cancel below.`)
|
||||
})
|
||||
|
||||
test('returns explicit clarification text from the interpreter', () => {
|
||||
@@ -253,14 +274,18 @@ describe('buildPurchaseAcknowledgement', () => {
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'туалетная бумага',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm'
|
||||
parserMode: 'llm',
|
||||
participants: participants()
|
||||
},
|
||||
'ru'
|
||||
)
|
||||
|
||||
expect(result).toBe(
|
||||
'Похоже, это общая покупка: туалетная бумага - 30.00 GEL. Подтвердите или отмените ниже.'
|
||||
)
|
||||
expect(result).toBe(`Похоже, это общая покупка: туалетная бумага - 30.00 GEL.
|
||||
|
||||
Участники:
|
||||
- Mia
|
||||
- Dima (не участвует)
|
||||
Подтвердите или отмените ниже.`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -298,7 +323,8 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm'
|
||||
parserMode: 'llm',
|
||||
participants: participants()
|
||||
}
|
||||
},
|
||||
async confirm() {
|
||||
@@ -306,6 +332,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,9 +348,26 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
reply_parameters: {
|
||||
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: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: '✅ Mia',
|
||||
callback_data: 'purchase:participant:participant-1'
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
text: '⬜ Dima',
|
||||
callback_data: 'purchase:participant:participant-2'
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'Confirm',
|
||||
@@ -379,6 +425,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +480,8 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm'
|
||||
parserMode: 'llm',
|
||||
participants: participants()
|
||||
}
|
||||
},
|
||||
async confirm() {
|
||||
@@ -439,6 +489,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,9 +533,26 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
payload: {
|
||||
chat_id: Number(config.householdChatId),
|
||||
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: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: '✅ Mia',
|
||||
callback_data: 'purchase:participant:participant-1'
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
text: '⬜ Dima',
|
||||
callback_data: 'purchase:participant:participant-2'
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'Confirm',
|
||||
@@ -532,6 +602,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +644,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,7 +682,8 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm'
|
||||
parserMode: 'llm',
|
||||
participants: participants()
|
||||
}
|
||||
},
|
||||
async confirm() {
|
||||
@@ -614,6 +691,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +706,162 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
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',
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm'
|
||||
parserMode: 'llm',
|
||||
participants: participants()
|
||||
}
|
||||
},
|
||||
async confirm() {
|
||||
@@ -673,6 +909,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,6 +973,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,6 +1031,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
parserConfidence: 92,
|
||||
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_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:'
|
||||
const PURCHASE_PARTICIPANT_CALLBACK_PREFIX = 'purchase:participant:'
|
||||
const MIN_PROPOSAL_CONFIDENCE = 70
|
||||
|
||||
type StoredPurchaseProcessingStatus =
|
||||
@@ -64,6 +65,14 @@ interface PurchasePendingConfirmationResult extends PurchaseProposalFields {
|
||||
parsedItemDescription: string
|
||||
parserConfidence: number
|
||||
parserMode: 'llm'
|
||||
participants: readonly PurchaseProposalParticipant[]
|
||||
}
|
||||
|
||||
interface PurchaseProposalParticipant {
|
||||
id: string
|
||||
memberId: string
|
||||
displayName: string
|
||||
included: boolean
|
||||
}
|
||||
|
||||
export interface PurchaseTopicIngestionConfig {
|
||||
@@ -120,6 +129,29 @@ export type PurchaseProposalActionResult =
|
||||
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 {
|
||||
hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean>
|
||||
save(
|
||||
@@ -135,6 +167,10 @@ export interface PurchaseMessageIngestionRepository {
|
||||
purchaseMessageId: string,
|
||||
actorTelegramUserId: string
|
||||
): Promise<PurchaseProposalActionResult>
|
||||
toggleParticipant(
|
||||
participantId: string,
|
||||
actorTelegramUserId: string
|
||||
): Promise<PurchaseProposalParticipantToggleResult>
|
||||
}
|
||||
|
||||
interface PurchasePersistenceDecision {
|
||||
@@ -149,9 +185,23 @@ interface PurchasePersistenceDecision {
|
||||
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 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(
|
||||
interpretation: PurchaseInterpretation | null,
|
||||
parserError: string | null
|
||||
@@ -224,6 +274,10 @@ function needsReviewAsInt(value: boolean): number {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
|
||||
function participantIncludedAsInt(value: boolean): number {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
|
||||
function toStoredPurchaseRow(row: {
|
||||
id: 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(
|
||||
ctx: Context,
|
||||
text: string,
|
||||
@@ -529,6 +594,128 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
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(
|
||||
purchaseMessageId: string,
|
||||
actorTelegramUserId: string,
|
||||
@@ -725,7 +912,24 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
parserConfidence: decision.parserConfidence,
|
||||
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 {
|
||||
status: 'pending_confirmation',
|
||||
purchaseMessageId: insertedRow.id,
|
||||
@@ -733,8 +937,10 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
parsedCurrency: decision.parsedCurrency!,
|
||||
parsedItemDescription: decision.parsedItemDescription!,
|
||||
parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE,
|
||||
parserMode: decision.parserMode ?? 'llm'
|
||||
parserMode: decision.parserMode ?? 'llm',
|
||||
participants: toProposalParticipants(await getStoredParticipants(insertedRow.id))
|
||||
}
|
||||
}
|
||||
case 'parse_failed':
|
||||
return {
|
||||
status: 'parse_failed',
|
||||
@@ -749,6 +955,104 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
|
||||
async cancel(purchaseMessageId, actorTelegramUserId) {
|
||||
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
|
||||
}
|
||||
|
||||
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(
|
||||
result: PurchaseMessageIngestionResult,
|
||||
locale: BotLocale = 'en'
|
||||
@@ -813,7 +1135,10 @@ export function buildPurchaseAcknowledgement(
|
||||
case 'ignored_not_purchase':
|
||||
return null
|
||||
case 'pending_confirmation':
|
||||
return t.proposal(formatPurchaseSummary(locale, result))
|
||||
return t.proposal(
|
||||
formatPurchaseSummary(locale, result),
|
||||
formatPurchaseParticipants(locale, result.participants)
|
||||
)
|
||||
case 'clarification_needed':
|
||||
return t.clarification(result.clarificationQuestion ?? clarificationFallback(locale, result))
|
||||
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
|
||||
|
||||
return {
|
||||
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,
|
||||
@@ -883,7 +1220,7 @@ async function handlePurchaseMessageResult(
|
||||
pendingReply,
|
||||
acknowledgement,
|
||||
result.status === 'pending_confirmation'
|
||||
? purchaseProposalReplyMarkup(locale, result.purchaseMessageId)
|
||||
? purchaseProposalReplyMarkup(locale, result.purchaseMessageId, result.participants)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
@@ -911,12 +1248,85 @@ function buildPurchaseActionMessage(
|
||||
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(
|
||||
bot: Bot,
|
||||
repository: PurchaseMessageIngestionRepository,
|
||||
resolveLocale: (householdId: string) => Promise<BotLocale>,
|
||||
logger?: Logger
|
||||
): 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) => {
|
||||
const purchaseMessageId = ctx.match[1]
|
||||
const actorTelegramUserId = ctx.from?.id?.toString()
|
||||
|
||||
@@ -84,6 +84,11 @@ type PurchaseDraft = {
|
||||
description: string
|
||||
amountMajor: string
|
||||
currency: 'USD' | 'GEL'
|
||||
splitMode: 'equal' | 'custom_amounts'
|
||||
participants: {
|
||||
memberId: string
|
||||
shareAmountMajor: string
|
||||
}[]
|
||||
}
|
||||
|
||||
type PaymentDraft = {
|
||||
@@ -244,12 +249,36 @@ function purchaseDrafts(
|
||||
{
|
||||
description: entry.title,
|
||||
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(
|
||||
entries: readonly MiniAppDashboard['ledger'][number][]
|
||||
): Record<string, PaymentDraft> {
|
||||
@@ -1086,7 +1115,10 @@ function App() {
|
||||
!currentReady.member.isAdmin ||
|
||||
!draft ||
|
||||
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
|
||||
}
|
||||
@@ -1098,7 +1130,25 @@ function App() {
|
||||
purchaseId,
|
||||
description: draft.description,
|
||||
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)
|
||||
} 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 = () => {
|
||||
switch (activeNav()) {
|
||||
case 'balances':
|
||||
@@ -1521,11 +1599,7 @@ function App() {
|
||||
setPurchaseDraftMap((current) => ({
|
||||
...current,
|
||||
[entry.id]: {
|
||||
...(current[entry.id] ?? {
|
||||
description: entry.title,
|
||||
amountMajor: entry.amountMajor,
|
||||
currency: entry.currency
|
||||
}),
|
||||
...(current[entry.id] ?? purchaseDraftForEntry(entry)),
|
||||
description: event.currentTarget.value
|
||||
}
|
||||
}))
|
||||
@@ -1543,11 +1617,7 @@ function App() {
|
||||
setPurchaseDraftMap((current) => ({
|
||||
...current,
|
||||
[entry.id]: {
|
||||
...(current[entry.id] ?? {
|
||||
description: entry.title,
|
||||
amountMajor: entry.amountMajor,
|
||||
currency: entry.currency
|
||||
}),
|
||||
...(current[entry.id] ?? purchaseDraftForEntry(entry)),
|
||||
amountMajor: event.currentTarget.value
|
||||
}
|
||||
}))
|
||||
@@ -1564,11 +1634,7 @@ function App() {
|
||||
setPurchaseDraftMap((current) => ({
|
||||
...current,
|
||||
[entry.id]: {
|
||||
...(current[entry.id] ?? {
|
||||
description: entry.title,
|
||||
amountMajor: entry.amountMajor,
|
||||
currency: entry.currency
|
||||
}),
|
||||
...(current[entry.id] ?? purchaseDraftForEntry(entry)),
|
||||
currency: event.currentTarget.value as 'USD' | 'GEL'
|
||||
}
|
||||
}))
|
||||
@@ -1579,6 +1645,147 @@ function App() {
|
||||
</select>
|
||||
</label>
|
||||
</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">
|
||||
<button
|
||||
class="ghost-button"
|
||||
|
||||
@@ -75,6 +75,12 @@ export const dictionary = {
|
||||
latestActivityEmpty: 'Recent utility and purchase entries will appear here.',
|
||||
purchaseReviewTitle: 'Purchases',
|
||||
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',
|
||||
paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.',
|
||||
paymentsAddAction: 'Add payment',
|
||||
@@ -247,6 +253,12 @@ export const dictionary = {
|
||||
purchaseReviewTitle: 'Покупки',
|
||||
purchaseReviewBody:
|
||||
'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.',
|
||||
purchaseSplitTitle: 'Разделение',
|
||||
purchaseSplitModeLabel: 'Режим разделения',
|
||||
purchaseSplitEqual: 'Поровну',
|
||||
purchaseSplitCustom: 'Свои суммы',
|
||||
purchaseParticipantLabel: 'Участвует',
|
||||
purchaseCustomShareLabel: 'Своя сумма',
|
||||
paymentsAdminTitle: 'Оплаты',
|
||||
paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.',
|
||||
paymentsAddAction: 'Добавить оплату',
|
||||
|
||||
@@ -120,6 +120,12 @@ export interface MiniAppDashboard {
|
||||
fxEffectiveDate: string | null
|
||||
actorDisplayName: 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
|
||||
amountMajor: string
|
||||
currency: 'USD' | 'GEL'
|
||||
split?: {
|
||||
mode: 'equal' | 'custom_amounts'
|
||||
participants: readonly {
|
||||
memberId: string
|
||||
included?: boolean
|
||||
shareAmountMajor?: string
|
||||
}[]
|
||||
}
|
||||
}
|
||||
): Promise<void> {
|
||||
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 type { FinanceRepository } from '@household/ports'
|
||||
@@ -31,6 +31,49 @@ export function createDbFinanceRepository(
|
||||
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 = {
|
||||
async getMemberByTelegramUserId(telegramUserId) {
|
||||
const rows = await db
|
||||
@@ -297,44 +340,86 @@ export function createDbFinanceRepository(
|
||||
},
|
||||
|
||||
async updateParsedPurchase(input) {
|
||||
const rows = await db
|
||||
.update(schema.purchaseMessages)
|
||||
.set({
|
||||
parsedAmountMinor: input.amountMinor,
|
||||
parsedCurrency: input.currency,
|
||||
parsedItemDescription: input.description,
|
||||
needsReview: 0,
|
||||
processingStatus: 'confirmed',
|
||||
parserError: null
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.purchaseMessages.householdId, householdId),
|
||||
eq(schema.purchaseMessages.id, input.purchaseId)
|
||||
return await db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
.update(schema.purchaseMessages)
|
||||
.set({
|
||||
parsedAmountMinor: input.amountMinor,
|
||||
parsedCurrency: input.currency,
|
||||
parsedItemDescription: input.description,
|
||||
...(input.splitMode
|
||||
? {
|
||||
participantSplitMode: input.splitMode
|
||||
}
|
||||
: {}),
|
||||
needsReview: 0,
|
||||
processingStatus: 'confirmed',
|
||||
parserError: null
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.purchaseMessages.householdId, householdId),
|
||||
eq(schema.purchaseMessages.id, input.purchaseId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: schema.purchaseMessages.id,
|
||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||
currency: schema.purchaseMessages.parsedCurrency,
|
||||
description: schema.purchaseMessages.parsedItemDescription,
|
||||
occurredAt: schema.purchaseMessages.messageSentAt
|
||||
})
|
||||
.returning({
|
||||
id: schema.purchaseMessages.id,
|
||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||
currency: schema.purchaseMessages.parsedCurrency,
|
||||
description: schema.purchaseMessages.parsedItemDescription,
|
||||
occurredAt: schema.purchaseMessages.messageSentAt,
|
||||
splitMode: schema.purchaseMessages.participantSplitMode
|
||||
})
|
||||
|
||||
const row = rows[0]
|
||||
if (!row || !row.payerMemberId || row.amountMinor == null || row.currency == null) {
|
||||
return null
|
||||
}
|
||||
const row = rows[0]
|
||||
if (!row || !row.payerMemberId || row.amountMinor == null || row.currency == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
payerMemberId: row.payerMemberId,
|
||||
amountMinor: row.amountMinor,
|
||||
currency: toCurrencyCode(row.currency),
|
||||
description: row.description,
|
||||
occurredAt: instantFromDatabaseValue(row.occurredAt)
|
||||
}
|
||||
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 {
|
||||
id: row.id,
|
||||
payerMemberId: row.payerMemberId,
|
||||
amountMinor: row.amountMinor,
|
||||
currency: toCurrencyCode(row.currency),
|
||||
description: row.description,
|
||||
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) {
|
||||
@@ -588,7 +673,8 @@ export function createDbFinanceRepository(
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||
currency: schema.purchaseMessages.parsedCurrency,
|
||||
description: schema.purchaseMessages.parsedItemDescription,
|
||||
occurredAt: schema.purchaseMessages.messageSentAt
|
||||
occurredAt: schema.purchaseMessages.messageSentAt,
|
||||
splitMode: schema.purchaseMessages.participantSplitMode
|
||||
})
|
||||
.from(schema.purchaseMessages)
|
||||
.where(
|
||||
@@ -606,13 +692,17 @@ export function createDbFinanceRepository(
|
||||
)
|
||||
)
|
||||
|
||||
const participantsByPurchaseId = await loadPurchaseParticipants(rows.map((row) => row.id))
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
payerMemberId: row.payerMemberId!,
|
||||
amountMinor: row.amountMinor!,
|
||||
currency: toCurrencyCode(row.currency!),
|
||||
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 { instantFromIso, type Instant } from '@household/domain'
|
||||
import { instantFromIso, Money, type Instant } from '@household/domain'
|
||||
import type {
|
||||
ExchangeRateProvider,
|
||||
FinanceCycleExchangeRateRecord,
|
||||
@@ -60,6 +60,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
} | null = null
|
||||
replacedSnapshot: SettlementSnapshotRecord | null = null
|
||||
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
|
||||
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
|
||||
|
||||
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
||||
return this.member
|
||||
@@ -138,8 +139,23 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
return false
|
||||
}
|
||||
|
||||
async updateParsedPurchase() {
|
||||
return null
|
||||
async updateParsedPurchase(input) {
|
||||
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() {
|
||||
@@ -603,4 +619,133 @@ describe('createFinanceCommandService', () => {
|
||||
{ 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
|
||||
occurredAt: string | null
|
||||
paymentKind: FinancePaymentKind | null
|
||||
purchaseSplitMode?: 'equal' | 'custom_amounts'
|
||||
purchaseParticipants?: readonly {
|
||||
memberId: string
|
||||
included: boolean
|
||||
shareAmount: Money | null
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface FinanceDashboard {
|
||||
@@ -380,11 +386,41 @@ async function buildFinanceDashboard(
|
||||
participatesInPurchases: member.status === 'active',
|
||||
rentWeight: member.rentShareWeight
|
||||
})),
|
||||
purchases: convertedPurchases.map(({ purchase, converted }) => ({
|
||||
purchaseId: PurchaseEntryId.from(purchase.id),
|
||||
payerId: MemberId.from(purchase.payerMemberId),
|
||||
amount: converted.settlementAmount
|
||||
}))
|
||||
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),
|
||||
payerId: MemberId.from(purchase.payerMemberId),
|
||||
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({
|
||||
@@ -465,21 +501,37 @@ async function buildFinanceDashboard(
|
||||
occurredAt: bill.createdAt.toString(),
|
||||
paymentKind: null
|
||||
})),
|
||||
...convertedPurchases.map(({ purchase, converted }) => ({
|
||||
id: purchase.id,
|
||||
kind: 'purchase' as const,
|
||||
title: purchase.description ?? 'Shared purchase',
|
||||
memberId: purchase.payerMemberId,
|
||||
amount: converted.originalAmount,
|
||||
currency: purchase.currency,
|
||||
displayAmount: converted.settlementAmount,
|
||||
displayCurrency: cycle.currency,
|
||||
fxRateMicros: converted.fxRateMicros,
|
||||
fxEffectiveDate: converted.fxEffectiveDate,
|
||||
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
||||
occurredAt: purchase.occurredAt?.toString() ?? null,
|
||||
paymentKind: null
|
||||
})),
|
||||
...convertedPurchases.map(({ purchase, converted }) => {
|
||||
const entry: FinanceDashboardLedgerEntry = {
|
||||
id: purchase.id,
|
||||
kind: 'purchase',
|
||||
title: purchase.description ?? 'Shared purchase',
|
||||
memberId: purchase.payerMemberId,
|
||||
amount: converted.originalAmount,
|
||||
currency: purchase.currency,
|
||||
displayAmount: converted.settlementAmount,
|
||||
displayCurrency: cycle.currency,
|
||||
fxRateMicros: converted.fxRateMicros,
|
||||
fxEffectiveDate: converted.fxEffectiveDate,
|
||||
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
|
||||
occurredAt: purchase.occurredAt?.toString() ?? 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) => ({
|
||||
id: payment.id,
|
||||
kind: 'payment' as const,
|
||||
@@ -565,7 +617,14 @@ export interface FinanceCommandService {
|
||||
purchaseId: string,
|
||||
description: string,
|
||||
amountArg: string,
|
||||
currencyArg?: string
|
||||
currencyArg?: string,
|
||||
split?: {
|
||||
mode: 'equal' | 'custom_amounts'
|
||||
participants: readonly {
|
||||
memberId: string
|
||||
shareAmountMajor?: string
|
||||
}[]
|
||||
}
|
||||
): Promise<{
|
||||
purchaseId: string
|
||||
amount: Money
|
||||
@@ -782,7 +841,7 @@ export function createFinanceCommandService(
|
||||
return repository.deleteUtilityBill(billId)
|
||||
},
|
||||
|
||||
async updatePurchase(purchaseId, description, amountArg, currencyArg) {
|
||||
async updatePurchase(purchaseId, description, amountArg, currencyArg, split) {
|
||||
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
||||
dependencies.householdId
|
||||
)
|
||||
@@ -792,7 +851,19 @@ export function createFinanceCommandService(
|
||||
purchaseId,
|
||||
amountMinor: amount.amountMinor,
|
||||
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) {
|
||||
|
||||
@@ -237,4 +237,38 @@ describe('calculateMonthlySettlement', () => {
|
||||
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([12000n, 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
|
||||
}
|
||||
|
||||
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 {
|
||||
if (value.isNegative()) {
|
||||
throw new DomainError(
|
||||
@@ -227,7 +258,42 @@ export function calculateMonthlySettlement(input: SettlementInput): SettlementRe
|
||||
|
||||
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)
|
||||
for (const [index, member] of participants.entries()) {
|
||||
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,
|
||||
"tag": "0015_white_owl",
|
||||
"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' }),
|
||||
parsedCurrency: text('parsed_currency'),
|
||||
parsedItemDescription: text('parsed_item_description'),
|
||||
participantSplitMode: text('participant_split_mode').default('equal').notNull(),
|
||||
parserMode: text('parser_mode'),
|
||||
parserConfidence: integer('parser_confidence'),
|
||||
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(
|
||||
'processed_bot_messages',
|
||||
{
|
||||
|
||||
@@ -22,5 +22,6 @@ export type {
|
||||
SettlementMemberLine,
|
||||
SettlementPurchaseInput,
|
||||
SettlementResult,
|
||||
PurchaseSplitMode,
|
||||
UtilitySplitMode
|
||||
} from './settlement-primitives'
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BillingCycleId, MemberId, PurchaseEntryId } from './ids'
|
||||
import type { Money } from './money'
|
||||
|
||||
export type UtilitySplitMode = 'equal' | 'weighted_by_days'
|
||||
export type PurchaseSplitMode = 'equal' | 'custom_amounts'
|
||||
|
||||
export interface SettlementMemberInput {
|
||||
memberId: MemberId
|
||||
@@ -19,6 +20,11 @@ export interface SettlementPurchaseInput {
|
||||
payerId: MemberId
|
||||
amount: Money
|
||||
description?: string
|
||||
splitMode?: PurchaseSplitMode
|
||||
participants?: readonly {
|
||||
memberId: MemberId
|
||||
shareAmount?: Money
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface SettlementInput {
|
||||
|
||||
@@ -35,6 +35,13 @@ export interface FinanceParsedPurchaseRecord {
|
||||
currency: CurrencyCode
|
||||
description: string | null
|
||||
occurredAt: Instant | null
|
||||
splitMode?: 'equal' | 'custom_amounts'
|
||||
participants?: readonly {
|
||||
id?: string
|
||||
memberId: string
|
||||
included?: boolean
|
||||
shareAmountMinor: bigint | null
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface FinanceUtilityBillRecord {
|
||||
@@ -170,6 +177,12 @@ export interface FinanceRepository {
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
description: string | null
|
||||
splitMode?: 'equal' | 'custom_amounts'
|
||||
participants?: readonly {
|
||||
memberId: string
|
||||
included?: boolean
|
||||
shareAmountMinor: bigint | null
|
||||
}[]
|
||||
}): Promise<FinanceParsedPurchaseRecord | null>
|
||||
deleteParsedPurchase(purchaseId: string): Promise<boolean>
|
||||
updateUtilityBill(input: {
|
||||
|
||||
Reference in New Issue
Block a user