feat(purchase): add per-purchase participant splits

This commit is contained in:
2026-03-11 14:34:27 +04:00
parent 98988159eb
commit 8401688032
26 changed files with 5050 additions and 114 deletions

View File

@@ -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) ?? []
}))
},

View File

@@ -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
}
])
})
})

View File

@@ -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) {

View File

@@ -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])
})
})

View File

@@ -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())

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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',
{

View File

@@ -22,5 +22,6 @@ export type {
SettlementMemberLine,
SettlementPurchaseInput,
SettlementResult,
PurchaseSplitMode,
UtilitySplitMode
} from './settlement-primitives'

View File

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

View File

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