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:
@@ -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