mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
Stabilize purchase functionality: fix ID prefix, uniqueness, and split participant inclusion
This commit is contained in:
@@ -331,6 +331,11 @@ function createFinanceService(): FinanceCommandService {
|
|||||||
currency: (currencyArg ?? 'GEL') as 'GEL' | 'USD',
|
currency: (currencyArg ?? 'GEL') as 'GEL' | 'USD',
|
||||||
period: '2026-03'
|
period: '2026-03'
|
||||||
}),
|
}),
|
||||||
|
addPurchase: async () => ({
|
||||||
|
purchaseId: 'test-purchase',
|
||||||
|
amount: Money.fromMinor(0n, 'GEL'),
|
||||||
|
currency: 'GEL'
|
||||||
|
}),
|
||||||
updatePayment: async () => null,
|
updatePayment: async () => null,
|
||||||
deletePayment: async () => false,
|
deletePayment: async () => false,
|
||||||
generateDashboard: async () => ({
|
generateDashboard: async () => ({
|
||||||
|
|||||||
@@ -235,6 +235,11 @@ function createFinanceService(): FinanceCommandService {
|
|||||||
updatePurchase: async () => null,
|
updatePurchase: async () => null,
|
||||||
deletePurchase: async () => false,
|
deletePurchase: async () => false,
|
||||||
addPayment: async () => null,
|
addPayment: async () => null,
|
||||||
|
addPurchase: async () => ({
|
||||||
|
purchaseId: 'test-purchase',
|
||||||
|
amount: Money.fromMinor(0n, 'GEL'),
|
||||||
|
currency: 'GEL'
|
||||||
|
}),
|
||||||
updatePayment: async () => null,
|
updatePayment: async () => null,
|
||||||
deletePayment: async () => false,
|
deletePayment: async () => false,
|
||||||
generateDashboard: async () => createDashboard(),
|
generateDashboard: async () => createDashboard(),
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
} from './miniapp-admin'
|
} from './miniapp-admin'
|
||||||
import {
|
import {
|
||||||
createMiniAppAddPaymentHandler,
|
createMiniAppAddPaymentHandler,
|
||||||
|
createMiniAppAddPurchaseHandler,
|
||||||
createMiniAppAddUtilityBillHandler,
|
createMiniAppAddUtilityBillHandler,
|
||||||
createMiniAppBillingCycleHandler,
|
createMiniAppBillingCycleHandler,
|
||||||
createMiniAppCloseCycleHandler,
|
createMiniAppCloseCycleHandler,
|
||||||
@@ -731,6 +732,15 @@ const server = createBotWebhookServer({
|
|||||||
logger: getLogger('miniapp-billing')
|
logger: getLogger('miniapp-billing')
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
miniAppAddPurchase: householdOnboardingService
|
||||||
|
? createMiniAppAddPurchaseHandler({
|
||||||
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
botToken: runtime.telegramBotToken,
|
||||||
|
onboardingService: householdOnboardingService,
|
||||||
|
financeServiceForHousehold,
|
||||||
|
logger: getLogger('miniapp-billing')
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
miniAppUpdatePurchase: householdOnboardingService
|
miniAppUpdatePurchase: householdOnboardingService
|
||||||
? createMiniAppUpdatePurchaseHandler({
|
? createMiniAppUpdatePurchaseHandler({
|
||||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createMiniAppAddPurchaseHandler,
|
||||||
createMiniAppAddUtilityBillHandler,
|
createMiniAppAddUtilityBillHandler,
|
||||||
createMiniAppBillingCycleHandler,
|
createMiniAppBillingCycleHandler,
|
||||||
createMiniAppDeleteUtilityBillHandler,
|
createMiniAppDeleteUtilityBillHandler,
|
||||||
@@ -213,6 +214,11 @@ function createFinanceServiceStub(): FinanceCommandService {
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
period: '2026-03'
|
period: '2026-03'
|
||||||
}),
|
}),
|
||||||
|
addPurchase: async () => ({
|
||||||
|
purchaseId: 'test-purchase',
|
||||||
|
amount: Money.fromMinor(0n, 'GEL'),
|
||||||
|
currency: 'GEL'
|
||||||
|
}),
|
||||||
updatePayment: async () => ({
|
updatePayment: async () => ({
|
||||||
paymentId: 'payment-1',
|
paymentId: 'payment-1',
|
||||||
amount: Money.fromMinor(10000n, 'USD'),
|
amount: Money.fromMinor(10000n, 'USD'),
|
||||||
@@ -559,3 +565,74 @@ describe('createMiniAppUpdatePurchaseHandler', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('createMiniAppAddPurchaseHandler', () => {
|
||||||
|
test('forwards purchase creation with split to the finance service', async () => {
|
||||||
|
const repository = onboardingRepository()
|
||||||
|
let capturedArgs: any = null
|
||||||
|
|
||||||
|
const handler = createMiniAppAddPurchaseHandler({
|
||||||
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
|
botToken: 'test-bot-token',
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository
|
||||||
|
}),
|
||||||
|
financeServiceForHousehold: () => ({
|
||||||
|
...createFinanceServiceStub(),
|
||||||
|
addPurchase: async (
|
||||||
|
description: string,
|
||||||
|
amountArg: string,
|
||||||
|
payerMemberId: string,
|
||||||
|
currencyArg?: string,
|
||||||
|
split?: any
|
||||||
|
) => {
|
||||||
|
capturedArgs = { description, amountArg, payerMemberId, currencyArg, split }
|
||||||
|
return {
|
||||||
|
purchaseId: 'new-purchase-1',
|
||||||
|
amount: Money.fromMinor(3000n, 'GEL'),
|
||||||
|
currency: 'GEL' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handler(
|
||||||
|
new Request('http://localhost/api/miniapp/admin/purchases/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: initData(),
|
||||||
|
description: 'Pizza',
|
||||||
|
amountMajor: '30',
|
||||||
|
currency: 'GEL',
|
||||||
|
split: {
|
||||||
|
mode: 'equal',
|
||||||
|
participants: [
|
||||||
|
{ memberId: 'member-123456', included: true },
|
||||||
|
{ memberId: 'member-999', included: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({ ok: true, authorized: true })
|
||||||
|
expect(capturedArgs).toEqual({
|
||||||
|
description: 'Pizza',
|
||||||
|
amountArg: '30',
|
||||||
|
payerMemberId: 'member-123456',
|
||||||
|
currencyArg: 'GEL',
|
||||||
|
split: {
|
||||||
|
mode: 'equal',
|
||||||
|
participants: [
|
||||||
|
{ memberId: 'member-123456', included: true },
|
||||||
|
{ memberId: 'member-999', included: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -283,6 +283,70 @@ async function readUtilityBillDeletePayload(request: Request): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readAddPurchasePayload(request: Request): Promise<{
|
||||||
|
initData: string
|
||||||
|
description: string
|
||||||
|
amountMajor: string
|
||||||
|
currency?: string
|
||||||
|
split?: {
|
||||||
|
mode: 'equal' | 'custom_amounts'
|
||||||
|
participants: {
|
||||||
|
memberId: string
|
||||||
|
included?: boolean
|
||||||
|
shareAmountMajor?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
const parsed = await parseJsonBody<{
|
||||||
|
initData?: string
|
||||||
|
description?: string
|
||||||
|
amountMajor?: string
|
||||||
|
currency?: string
|
||||||
|
split?: {
|
||||||
|
mode?: string
|
||||||
|
participants?: {
|
||||||
|
memberId?: string
|
||||||
|
included?: boolean
|
||||||
|
shareAmountMajor?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}>(request)
|
||||||
|
const initData = parsed.initData?.trim()
|
||||||
|
if (!initData) {
|
||||||
|
throw new Error('Missing initData')
|
||||||
|
}
|
||||||
|
const description = parsed.description?.trim()
|
||||||
|
if (!description) {
|
||||||
|
throw new Error('Missing description')
|
||||||
|
}
|
||||||
|
const amountMajor = parsed.amountMajor?.trim()
|
||||||
|
if (!amountMajor) {
|
||||||
|
throw new Error('Missing amountMajor')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initData,
|
||||||
|
description,
|
||||||
|
amountMajor,
|
||||||
|
...(parsed.currency !== undefined
|
||||||
|
? {
|
||||||
|
currency: parsed.currency
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(parsed.split !== undefined
|
||||||
|
? {
|
||||||
|
split: {
|
||||||
|
mode: (parsed.split.mode ?? 'equal') as 'equal' | 'custom_amounts',
|
||||||
|
participants: (parsed.split.participants ?? []).filter(
|
||||||
|
(p): p is { memberId: string; included?: boolean; shareAmountMajor?: string } =>
|
||||||
|
p.memberId !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function readPurchaseMutationPayload(request: Request): Promise<{
|
async function readPurchaseMutationPayload(request: Request): Promise<{
|
||||||
initData: string
|
initData: string
|
||||||
purchaseId: string
|
purchaseId: string
|
||||||
@@ -854,6 +918,62 @@ export function createMiniAppDeleteUtilityBillHandler(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createMiniAppAddPurchaseHandler(options: {
|
||||||
|
allowedOrigins: readonly string[]
|
||||||
|
botToken: string
|
||||||
|
financeServiceForHousehold: (householdId: string) => FinanceCommandService
|
||||||
|
onboardingService: HouseholdOnboardingService
|
||||||
|
logger?: Logger
|
||||||
|
}): {
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
} {
|
||||||
|
const sessionService = createMiniAppSessionService({
|
||||||
|
botToken: options.botToken,
|
||||||
|
onboardingService: options.onboardingService
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
handler: async (request) => {
|
||||||
|
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return miniAppJsonResponse({ ok: true }, 204, origin)
|
||||||
|
}
|
||||||
|
if (request.method !== 'POST') {
|
||||||
|
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await authenticateAdminSession(
|
||||||
|
request.clone() as Request,
|
||||||
|
sessionService,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
if (auth instanceof Response) {
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readAddPurchasePayload(request)
|
||||||
|
if (!payload.description || !payload.amountMajor) {
|
||||||
|
return miniAppJsonResponse({ ok: false, error: 'Missing purchase fields' }, 400, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = options.financeServiceForHousehold(auth.member.householdId)
|
||||||
|
await service.addPurchase(
|
||||||
|
payload.description,
|
||||||
|
payload.amountMajor,
|
||||||
|
auth.member.id,
|
||||||
|
payload.currency,
|
||||||
|
payload.split
|
||||||
|
)
|
||||||
|
|
||||||
|
return miniAppJsonResponse({ ok: true, authorized: true }, 200, origin)
|
||||||
|
} catch (error) {
|
||||||
|
return miniAppErrorResponse(error, origin, options.logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createMiniAppUpdatePurchaseHandler(options: {
|
export function createMiniAppUpdatePurchaseHandler(options: {
|
||||||
allowedOrigins: readonly string[]
|
allowedOrigins: readonly string[]
|
||||||
botToken: string
|
botToken: string
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ function repository(
|
|||||||
saveCycleExchangeRate: async (input) => input,
|
saveCycleExchangeRate: async (input) => input,
|
||||||
addUtilityBill: async () => {},
|
addUtilityBill: async () => {},
|
||||||
updateParsedPurchase: async () => null,
|
updateParsedPurchase: async () => null,
|
||||||
|
addParsedPurchase: async (input) => ({
|
||||||
|
id: 'purchase-new',
|
||||||
|
payerMemberId: input.payerMemberId,
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
currency: input.currency,
|
||||||
|
description: input.description,
|
||||||
|
occurredAt: input.occurredAt,
|
||||||
|
splitMode: input.splitMode ?? 'equal',
|
||||||
|
participants:
|
||||||
|
input.participants?.map((p) => ({
|
||||||
|
memberId: p.memberId,
|
||||||
|
included: p.included ?? true,
|
||||||
|
shareAmountMinor: p.shareAmountMinor ?? null
|
||||||
|
})) ?? []
|
||||||
|
}),
|
||||||
deleteParsedPurchase: async () => false,
|
deleteParsedPurchase: async () => false,
|
||||||
updateUtilityBill: async () => null,
|
updateUtilityBill: async () => null,
|
||||||
deleteUtilityBill: async () => false,
|
deleteUtilityBill: async () => false,
|
||||||
|
|||||||
@@ -163,6 +163,11 @@ function createFinanceService(): FinanceCommandService {
|
|||||||
updatePurchase: async () => null,
|
updatePurchase: async () => null,
|
||||||
deletePurchase: async () => false,
|
deletePurchase: async () => false,
|
||||||
addPayment: async () => null,
|
addPayment: async () => null,
|
||||||
|
addPurchase: async () => ({
|
||||||
|
purchaseId: 'test-purchase',
|
||||||
|
amount: Money.fromMinor(0n, 'GEL'),
|
||||||
|
currency: 'GEL'
|
||||||
|
}),
|
||||||
updatePayment: async () => null,
|
updatePayment: async () => null,
|
||||||
deletePayment: async () => false,
|
deletePayment: async () => false,
|
||||||
generateDashboard: async () => ({
|
generateDashboard: async () => ({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import type { FinanceCommandService } from '@household/application'
|
import type { FinanceCommandService } from '@household/application'
|
||||||
import { instantFromIso, nowInstant } from '@household/domain'
|
import { instantFromIso, Money, nowInstant } from '@household/domain'
|
||||||
import type { TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports'
|
import type { TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports'
|
||||||
|
|
||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
@@ -233,6 +233,11 @@ function createFinanceService(): FinanceCommandService & {
|
|||||||
updatePurchase: async () => null,
|
updatePurchase: async () => null,
|
||||||
deletePurchase: async () => false,
|
deletePurchase: async () => false,
|
||||||
addPayment: async () => null,
|
addPayment: async () => null,
|
||||||
|
addPurchase: async () => ({
|
||||||
|
purchaseId: 'test-purchase',
|
||||||
|
amount: Money.fromMinor(0n, 'GEL'),
|
||||||
|
currency: 'GEL'
|
||||||
|
}),
|
||||||
updatePayment: async () => null,
|
updatePayment: async () => null,
|
||||||
deletePayment: async () => false,
|
deletePayment: async () => false,
|
||||||
generateDashboard: async () => null,
|
generateDashboard: async () => null,
|
||||||
|
|||||||
@@ -243,7 +243,23 @@ export function rebalancePurchaseSplit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Special case: if it's 'equal' mode and we aren't handling a specific change, force equal
|
// Special case: if it's 'equal' mode and we aren't handling a specific change, force equal
|
||||||
if (draft.splitInputMode === 'equal' && changedMemberId === null) {
|
// Also initialize equal split for exact/percentage modes when no specific change provided
|
||||||
|
if (draft.splitInputMode !== 'equal' && changedMemberId === null) {
|
||||||
|
const active = participants.map((p, idx) => ({ ...p, idx })).filter((p) => p.included)
|
||||||
|
if (active.length > 0) {
|
||||||
|
const count = BigInt(active.length)
|
||||||
|
const baseShare = totalMinor / count
|
||||||
|
const remainder = totalMinor % count
|
||||||
|
active.forEach((p, i) => {
|
||||||
|
const share = baseShare + (BigInt(i) < remainder ? 1n : 0n)
|
||||||
|
participants[p.idx] = {
|
||||||
|
...participants[p.idx]!,
|
||||||
|
shareAmountMajor: minorToMajorString(share),
|
||||||
|
isAutoCalculated: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (draft.splitInputMode === 'equal' && changedMemberId === null) {
|
||||||
const active = participants.map((p, idx) => ({ ...p, idx })).filter((p) => p.included)
|
const active = participants.map((p, idx) => ({ ...p, idx })).filter((p) => p.included)
|
||||||
if (active.length > 0) {
|
if (active.length > 0) {
|
||||||
const count = BigInt(active.length)
|
const count = BigInt(active.length)
|
||||||
|
|||||||
@@ -266,18 +266,19 @@ export default function LedgerRoute() {
|
|||||||
const [addingPayment, setAddingPayment] = createSignal(false)
|
const [addingPayment, setAddingPayment] = createSignal(false)
|
||||||
|
|
||||||
const addPurchaseButtonText = createMemo(() => {
|
const addPurchaseButtonText = createMemo(() => {
|
||||||
if (addingPurchase()) return copy().purchaseSaveAction // or maybe adding...
|
if (addingPurchase()) return copy().savingPurchase
|
||||||
if (newPurchase().splitInputMode === 'equal') return copy().purchaseSaveAction
|
if (newPurchase().splitInputMode !== 'equal' && !validatePurchaseDraft(newPurchase()).valid) {
|
||||||
if (!validatePurchaseDraft(newPurchase()).valid) return copy().purchaseBalanceAction
|
return copy().purchaseBalanceAction
|
||||||
|
}
|
||||||
return copy().purchaseSaveAction
|
return copy().purchaseSaveAction
|
||||||
})
|
})
|
||||||
|
|
||||||
const editPurchaseButtonText = createMemo(() => {
|
const editPurchaseButtonText = createMemo(() => {
|
||||||
const draft = purchaseDraft()
|
|
||||||
if (savingPurchase()) return copy().savingPurchase
|
if (savingPurchase()) return copy().savingPurchase
|
||||||
if (!draft) return copy().purchaseSaveAction
|
const draft = purchaseDraft()
|
||||||
if (draft.splitInputMode === 'equal') return copy().purchaseSaveAction
|
if (draft && draft.splitInputMode !== 'equal' && !validatePurchaseDraft(draft).valid) {
|
||||||
if (!validatePurchaseDraft(draft).valid) return copy().purchaseBalanceAction
|
return copy().purchaseBalanceAction
|
||||||
|
}
|
||||||
return copy().purchaseSaveAction
|
return copy().purchaseSaveAction
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -387,8 +388,8 @@ export default function LedgerRoute() {
|
|||||||
participants: draft.participants.map((p) => ({
|
participants: draft.participants.map((p) => ({
|
||||||
memberId: p.memberId,
|
memberId: p.memberId,
|
||||||
included: p.included,
|
included: p.included,
|
||||||
...(p.shareAmountMajor && draft.splitMode === 'custom_amounts'
|
...(draft.splitMode === 'custom_amounts'
|
||||||
? { shareAmountMajor: p.shareAmountMajor }
|
? { shareAmountMajor: p.shareAmountMajor || '0.00' }
|
||||||
: {})
|
: {})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -433,8 +434,8 @@ export default function LedgerRoute() {
|
|||||||
participants: draft.participants.map((p) => ({
|
participants: draft.participants.map((p) => ({
|
||||||
memberId: p.memberId,
|
memberId: p.memberId,
|
||||||
included: p.included,
|
included: p.included,
|
||||||
...(p.shareAmountMajor && draft.splitMode === 'custom_amounts'
|
...(draft.splitMode === 'custom_amounts'
|
||||||
? { shareAmountMajor: p.shareAmountMajor }
|
? { shareAmountMajor: p.shareAmountMajor || '0.00' }
|
||||||
: {})
|
: {})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -714,11 +715,13 @@ export default function LedgerRoute() {
|
|||||||
loading={addingPurchase()}
|
loading={addingPurchase()}
|
||||||
disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()}
|
disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
const draft = newPurchase()
|
||||||
newPurchase().splitInputMode !== 'equal' &&
|
if (draft.splitInputMode !== 'equal' && !validatePurchaseDraft(draft).valid) {
|
||||||
!validatePurchaseDraft(newPurchase()).valid
|
const rebalanced = rebalancePurchaseSplit(draft, null, null)
|
||||||
) {
|
setNewPurchase(rebalanced)
|
||||||
setNewPurchase((p) => rebalancePurchaseSplit(p, null, null))
|
if (validatePurchaseDraft(rebalanced).valid) {
|
||||||
|
void handleAddPurchase()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
void handleAddPurchase()
|
void handleAddPurchase()
|
||||||
}
|
}
|
||||||
@@ -816,7 +819,11 @@ export default function LedgerRoute() {
|
|||||||
draft.splitInputMode !== 'equal' &&
|
draft.splitInputMode !== 'equal' &&
|
||||||
!validatePurchaseDraft(draft).valid
|
!validatePurchaseDraft(draft).valid
|
||||||
) {
|
) {
|
||||||
setPurchaseDraft((d) => (d ? rebalancePurchaseSplit(d, null, null) : d))
|
const rebalanced = rebalancePurchaseSplit(draft, null, null)
|
||||||
|
setPurchaseDraft(rebalanced)
|
||||||
|
if (validatePurchaseDraft(rebalanced).valid) {
|
||||||
|
void handleSavePurchase()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
void handleSavePurchase()
|
void handleSavePurchase()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
nowInstant,
|
nowInstant,
|
||||||
type CurrencyCode
|
type CurrencyCode
|
||||||
} from '@household/domain'
|
} from '@household/domain'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
function toCurrencyCode(raw: string): CurrencyCode {
|
function toCurrencyCode(raw: string): CurrencyCode {
|
||||||
const normalized = raw.trim().toUpperCase()
|
const normalized = raw.trim().toUpperCase()
|
||||||
@@ -339,6 +340,94 @@ export function createDbFinanceRepository(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async addParsedPurchase(input) {
|
||||||
|
const purchaseId = randomUUID()
|
||||||
|
|
||||||
|
const memberRows = await db
|
||||||
|
.select({ displayName: schema.members.displayName })
|
||||||
|
.from(schema.members)
|
||||||
|
.where(eq(schema.members.id, input.payerMemberId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const member = memberRows[0]
|
||||||
|
|
||||||
|
await db.insert(schema.purchaseMessages).values({
|
||||||
|
id: purchaseId,
|
||||||
|
householdId,
|
||||||
|
senderMemberId: input.payerMemberId,
|
||||||
|
senderTelegramUserId: 'miniapp',
|
||||||
|
senderDisplayName: member?.displayName ?? 'Mini App',
|
||||||
|
telegramChatId: 'miniapp',
|
||||||
|
telegramMessageId: purchaseId,
|
||||||
|
telegramThreadId: 'miniapp',
|
||||||
|
telegramUpdateId: purchaseId,
|
||||||
|
rawText: input.description ?? '',
|
||||||
|
messageSentAt: instantToDate(input.occurredAt),
|
||||||
|
parsedItemDescription: input.description,
|
||||||
|
parsedAmountMinor: input.amountMinor,
|
||||||
|
parsedCurrency: input.currency,
|
||||||
|
participantSplitMode: input.splitMode ?? 'equal',
|
||||||
|
processingStatus: 'confirmed',
|
||||||
|
parserError: null,
|
||||||
|
needsReview: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.participants && input.participants.length > 0) {
|
||||||
|
await db.insert(schema.purchaseMessageParticipants).values(
|
||||||
|
input.participants.map(
|
||||||
|
(p: { memberId: string; included?: boolean; shareAmountMinor: bigint | null }) => ({
|
||||||
|
purchaseMessageId: purchaseId,
|
||||||
|
memberId: p.memberId,
|
||||||
|
included: (p.included ?? true) ? 1 : 0,
|
||||||
|
shareAmountMinor: p.shareAmountMinor
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
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
|
||||||
|
})
|
||||||
|
.from(schema.purchaseMessages)
|
||||||
|
.where(eq(schema.purchaseMessages.id, purchaseId))
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row || !row.payerMemberId || row.amountMinor == null || row.currency == null) {
|
||||||
|
throw new Error('Failed to create purchase')
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantRows = await db
|
||||||
|
.select({
|
||||||
|
memberId: schema.purchaseMessageParticipants.memberId,
|
||||||
|
included: schema.purchaseMessageParticipants.included,
|
||||||
|
shareAmountMinor: schema.purchaseMessageParticipants.shareAmountMinor
|
||||||
|
})
|
||||||
|
.from(schema.purchaseMessageParticipants)
|
||||||
|
.where(eq(schema.purchaseMessageParticipants.purchaseMessageId, purchaseId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
payerMemberId: row.payerMemberId,
|
||||||
|
amountMinor: row.amountMinor,
|
||||||
|
currency: toCurrencyCode(row.currency),
|
||||||
|
description: row.description,
|
||||||
|
occurredAt: row.occurredAt ? instantFromDatabaseValue(row.occurredAt) : null,
|
||||||
|
splitMode: row.splitMode as 'equal' | 'custom_amounts',
|
||||||
|
participants: participantRows.map((p) => ({
|
||||||
|
memberId: p.memberId,
|
||||||
|
included: p.included === 1,
|
||||||
|
shareAmountMinor: p.shareAmountMinor
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async updateParsedPurchase(input) {
|
async updateParsedPurchase(input) {
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(async (tx) => {
|
||||||
const rows = await tx
|
const rows = await tx
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
replacedSnapshot: SettlementSnapshotRecord | null = null
|
replacedSnapshot: SettlementSnapshotRecord | null = null
|
||||||
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
|
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
|
||||||
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
|
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
|
||||||
|
lastAddedPurchaseInput: Parameters<FinanceRepository['addParsedPurchase']>[0] | null = null
|
||||||
|
|
||||||
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
|
||||||
return this.member
|
return this.member
|
||||||
@@ -131,6 +132,24 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
this.lastUtilityBill = input
|
this.lastUtilityBill = input
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addParsedPurchase(input: Parameters<FinanceRepository['addParsedPurchase']>[0]) {
|
||||||
|
this.lastAddedPurchaseInput = input
|
||||||
|
return {
|
||||||
|
id: 'purchase-1',
|
||||||
|
payerMemberId: input.payerMemberId,
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
currency: input.currency,
|
||||||
|
description: input.description,
|
||||||
|
occurredAt: input.occurredAt,
|
||||||
|
splitMode: input.splitMode ?? 'equal',
|
||||||
|
participants: (input.participants ?? []).map((p) => ({
|
||||||
|
memberId: p.memberId,
|
||||||
|
included: p.included ?? true,
|
||||||
|
shareAmountMinor: p.shareAmountMinor
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateUtilityBill() {
|
async updateUtilityBill() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -655,10 +674,12 @@ describe('createFinanceCommandService', () => {
|
|||||||
participants: [
|
participants: [
|
||||||
{
|
{
|
||||||
memberId: 'alice',
|
memberId: 'alice',
|
||||||
|
included: true,
|
||||||
shareAmountMinor: 2000n
|
shareAmountMinor: 2000n
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
memberId: 'bob',
|
memberId: 'bob',
|
||||||
|
included: true,
|
||||||
shareAmountMinor: 1000n
|
shareAmountMinor: 1000n
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -644,6 +644,7 @@ export interface FinanceCommandService {
|
|||||||
mode: 'equal' | 'custom_amounts'
|
mode: 'equal' | 'custom_amounts'
|
||||||
participants: readonly {
|
participants: readonly {
|
||||||
memberId: string
|
memberId: string
|
||||||
|
included?: boolean
|
||||||
shareAmountMajor?: string
|
shareAmountMajor?: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
@@ -652,6 +653,24 @@ export interface FinanceCommandService {
|
|||||||
amount: Money
|
amount: Money
|
||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
} | null>
|
} | null>
|
||||||
|
addPurchase(
|
||||||
|
description: string,
|
||||||
|
amountArg: string,
|
||||||
|
payerMemberId: string,
|
||||||
|
currencyArg?: string,
|
||||||
|
split?: {
|
||||||
|
mode: 'equal' | 'custom_amounts'
|
||||||
|
participants: readonly {
|
||||||
|
memberId: string
|
||||||
|
included?: boolean
|
||||||
|
shareAmountMajor?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
purchaseId: string
|
||||||
|
amount: Money
|
||||||
|
currency: CurrencyCode
|
||||||
|
}>
|
||||||
deletePurchase(purchaseId: string): Promise<boolean>
|
deletePurchase(purchaseId: string): Promise<boolean>
|
||||||
addPayment(
|
addPayment(
|
||||||
memberId: string,
|
memberId: string,
|
||||||
@@ -900,6 +919,7 @@ export function createFinanceCommandService(
|
|||||||
splitMode: split.mode,
|
splitMode: split.mode,
|
||||||
participants: split.participants.map((participant) => ({
|
participants: split.participants.map((participant) => ({
|
||||||
memberId: participant.memberId,
|
memberId: participant.memberId,
|
||||||
|
included: participant.included ?? true,
|
||||||
shareAmountMinor:
|
shareAmountMinor:
|
||||||
participant.shareAmountMajor !== undefined
|
participant.shareAmountMajor !== undefined
|
||||||
? Money.fromMajor(participant.shareAmountMajor, currency).amountMinor
|
? Money.fromMajor(participant.shareAmountMajor, currency).amountMinor
|
||||||
@@ -920,6 +940,67 @@ export function createFinanceCommandService(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async addPurchase(description, amountArg, payerMemberId, currencyArg, split) {
|
||||||
|
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
||||||
|
dependencies.householdId
|
||||||
|
)
|
||||||
|
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
|
||||||
|
const amount = Money.fromMajor(amountArg, currency)
|
||||||
|
|
||||||
|
const openCycle = await repository.getOpenCycle()
|
||||||
|
if (!openCycle) {
|
||||||
|
throw new DomainError(DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT, 'No open billing cycle')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (split?.mode === 'custom_amounts') {
|
||||||
|
if (split.participants.some((p) => p.shareAmountMajor === undefined)) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
'Purchase custom split must include explicit share amounts for every participant'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinor = split.participants.reduce(
|
||||||
|
(sum, p) => sum + Money.fromMajor(p.shareAmountMajor!, currency).amountMinor,
|
||||||
|
0n
|
||||||
|
)
|
||||||
|
if (totalMinor !== amount.amountMinor) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
'Purchase custom split must add up to the full amount'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await repository.addParsedPurchase({
|
||||||
|
cycleId: openCycle.id,
|
||||||
|
payerMemberId,
|
||||||
|
amountMinor: amount.amountMinor,
|
||||||
|
currency,
|
||||||
|
description: description.trim().length > 0 ? description.trim() : null,
|
||||||
|
occurredAt: nowInstant(),
|
||||||
|
...(split
|
||||||
|
? {
|
||||||
|
splitMode: split.mode,
|
||||||
|
participants: split.participants.map((participant) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participant.included ?? true,
|
||||||
|
shareAmountMinor:
|
||||||
|
participant.shareAmountMajor !== undefined
|
||||||
|
? Money.fromMajor(participant.shareAmountMajor, currency).amountMinor
|
||||||
|
: null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
purchaseId: created.id,
|
||||||
|
amount,
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
deletePurchase(purchaseId) {
|
deletePurchase(purchaseId) {
|
||||||
return repository.deleteParsedPurchase(purchaseId)
|
return repository.deleteParsedPurchase(purchaseId)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -172,6 +172,20 @@ export interface FinanceRepository {
|
|||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
createdByMemberId: string
|
createdByMemberId: string
|
||||||
}): Promise<void>
|
}): Promise<void>
|
||||||
|
addParsedPurchase(input: {
|
||||||
|
cycleId: string
|
||||||
|
payerMemberId: string
|
||||||
|
amountMinor: bigint
|
||||||
|
currency: CurrencyCode
|
||||||
|
description: string | null
|
||||||
|
occurredAt: Instant
|
||||||
|
splitMode?: 'equal' | 'custom_amounts'
|
||||||
|
participants?: readonly {
|
||||||
|
memberId: string
|
||||||
|
included?: boolean
|
||||||
|
shareAmountMinor: bigint | null
|
||||||
|
}[]
|
||||||
|
}): Promise<FinanceParsedPurchaseRecord>
|
||||||
updateParsedPurchase(input: {
|
updateParsedPurchase(input: {
|
||||||
purchaseId: string
|
purchaseId: string
|
||||||
amountMinor: bigint
|
amountMinor: bigint
|
||||||
|
|||||||
Reference in New Issue
Block a user