feat: add payer control for purchases

- Add explicit payerMemberId field to purchase ledger entries
- Add 'Paid by' selector in mini app purchase add/edit forms
- Default payer to current user when creating new purchases
- Allow admins to change who made existing purchases
- Update backend handlers to accept and persist payerMemberId
- Add i18n translations for 'Paid by' label (EN/RU)

All quality gates pass: build, typecheck, lint, format, test

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-16 17:44:20 +04:00
parent 64dc3a3813
commit 02c79ae629
8 changed files with 95 additions and 5 deletions

View File

@@ -322,6 +322,7 @@ async function readAddPurchasePayload(request: Request): Promise<{
description: string description: string
amountMajor: string amountMajor: string
currency?: string currency?: string
payerMemberId?: string
split?: { split?: {
mode: 'equal' | 'custom_amounts' mode: 'equal' | 'custom_amounts'
participants: { participants: {
@@ -336,6 +337,7 @@ async function readAddPurchasePayload(request: Request): Promise<{
description?: string description?: string
amountMajor?: string amountMajor?: string
currency?: string currency?: string
payerMemberId?: string
split?: { split?: {
mode?: string mode?: string
participants?: { participants?: {
@@ -367,6 +369,11 @@ async function readAddPurchasePayload(request: Request): Promise<{
currency: parsed.currency currency: parsed.currency
} }
: {}), : {}),
...(parsed.payerMemberId !== undefined
? {
payerMemberId: parsed.payerMemberId
}
: {}),
...(parsed.split !== undefined ...(parsed.split !== undefined
? { ? {
split: { split: {
@@ -387,6 +394,7 @@ async function readPurchaseMutationPayload(request: Request): Promise<{
description?: string description?: string
amountMajor?: string amountMajor?: string
currency?: string currency?: string
payerMemberId?: string
split?: { split?: {
mode: 'equal' | 'custom_amounts' mode: 'equal' | 'custom_amounts'
participants: { participants: {
@@ -401,6 +409,7 @@ async function readPurchaseMutationPayload(request: Request): Promise<{
description?: string description?: string
amountMajor?: string amountMajor?: string
currency?: string currency?: string
payerMemberId?: string
split?: { split?: {
mode?: string mode?: string
participants?: { participants?: {
@@ -436,6 +445,11 @@ async function readPurchaseMutationPayload(request: Request): Promise<{
currency: parsed.currency.trim() currency: parsed.currency.trim()
} }
: {}), : {}),
...(parsed.payerMemberId !== undefined
? {
payerMemberId: parsed.payerMemberId
}
: {}),
...(parsed.split && ...(parsed.split &&
(parsed.split.mode === 'equal' || parsed.split.mode === 'custom_amounts') && (parsed.split.mode === 'equal' || parsed.split.mode === 'custom_amounts') &&
Array.isArray(parsed.split.participants) Array.isArray(parsed.split.participants)
@@ -1187,10 +1201,11 @@ export function createMiniAppAddPurchaseHandler(options: {
} }
const service = options.financeServiceForHousehold(auth.member.householdId) const service = options.financeServiceForHousehold(auth.member.householdId)
const payerMemberId = payload.payerMemberId ?? auth.member.id
await service.addPurchase( await service.addPurchase(
payload.description, payload.description,
payload.amountMajor, payload.amountMajor,
auth.member.id, payerMemberId,
payload.currency, payload.currency,
payload.split payload.split
) )
@@ -1243,12 +1258,14 @@ export function createMiniAppUpdatePurchaseHandler(options: {
} }
const service = options.financeServiceForHousehold(auth.member.householdId) const service = options.financeServiceForHousehold(auth.member.householdId)
const payerMemberId = payload.payerMemberId
const updated = await service.updatePurchase( const updated = await service.updatePurchase(
payload.purchaseId, payload.purchaseId,
payload.description, payload.description,
payload.amountMajor, payload.amountMajor,
payload.currency, payload.currency,
payload.split payload.split,
payerMemberId
) )
if (!updated) { if (!updated) {

View File

@@ -169,6 +169,7 @@ export const dictionary = {
participantExcluded: 'Excluded', participantExcluded: 'Excluded',
purchaseCustomShareLabel: 'Custom amount', purchaseCustomShareLabel: 'Custom amount',
purchaseEditorBody: 'Review the purchase details and adjust the split only when needed.', purchaseEditorBody: 'Review the purchase details and adjust the split only when needed.',
purchasePayerLabel: 'Paid by',
paymentsAdminTitle: 'Payments', paymentsAdminTitle: 'Payments',
paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.', paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.',
paymentsAddAction: 'Add payment', paymentsAddAction: 'Add payment',
@@ -516,6 +517,7 @@ export const dictionary = {
purchaseCustomShareLabel: 'Своя сумма', purchaseCustomShareLabel: 'Своя сумма',
purchaseEditorBody: purchaseEditorBody:
'Проверь покупку и меняй детали разделения только если это действительно нужно.', 'Проверь покупку и меняй детали разделения только если это действительно нужно.',
purchasePayerLabel: 'Оплатил',
paymentsAdminTitle: 'Оплаты', paymentsAdminTitle: 'Оплаты',
paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.', paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.',
paymentsAddAction: 'Добавить оплату', paymentsAddAction: 'Добавить оплату',

View File

@@ -33,6 +33,7 @@ export type PurchaseDraft = {
description: string description: string
amountMajor: string amountMajor: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
payerMemberId?: string
splitMode: 'equal' | 'custom_amounts' splitMode: 'equal' | 'custom_amounts'
splitInputMode: 'equal' | 'exact' | 'percentage' splitInputMode: 'equal' | 'exact' | 'percentage'
participants: ParticipantShare[] participants: ParticipantShare[]
@@ -110,6 +111,7 @@ export function purchaseDrafts(
description: entry.title, description: entry.title,
amountMajor: entry.amountMajor, amountMajor: entry.amountMajor,
currency: entry.currency, currency: entry.currency,
...(entry.payerMemberId !== undefined ? { payerMemberId: entry.payerMemberId } : {}),
splitMode: entry.purchaseSplitMode ?? 'equal', splitMode: entry.purchaseSplitMode ?? 'equal',
splitInputMode: (entry.purchaseSplitMode ?? 'equal') === 'equal' ? 'equal' : 'exact', splitInputMode: (entry.purchaseSplitMode ?? 'equal') === 'equal' ? 'equal' : 'exact',
participants: participants:
@@ -129,6 +131,7 @@ export function purchaseDraftForEntry(entry: MiniAppDashboard['ledger'][number])
description: entry.title, description: entry.title,
amountMajor: entry.amountMajor, amountMajor: entry.amountMajor,
currency: entry.currency, currency: entry.currency,
...(entry.payerMemberId !== undefined ? { payerMemberId: entry.payerMemberId } : {}),
splitMode: entry.purchaseSplitMode ?? 'equal', splitMode: entry.purchaseSplitMode ?? 'equal',
splitInputMode: (entry.purchaseSplitMode ?? 'equal') === 'equal' ? 'equal' : 'exact', splitInputMode: (entry.purchaseSplitMode ?? 'equal') === 'equal' ? 'equal' : 'exact',
participants: participants:

View File

@@ -152,6 +152,7 @@ export interface MiniAppDashboard {
included: boolean included: boolean
shareAmountMajor: string | null shareAmountMajor: string | null
}[] }[]
payerMemberId?: string
}[] }[]
} }
@@ -996,6 +997,7 @@ export async function addMiniAppPurchase(
description: string description: string
amountMajor: string amountMajor: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
payerMemberId?: string
split?: { split?: {
mode: 'equal' | 'custom_amounts' mode: 'equal' | 'custom_amounts'
participants: readonly { participants: readonly {
@@ -1030,6 +1032,7 @@ export async function updateMiniAppPurchase(
description: string description: string
amountMajor: string amountMajor: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
payerMemberId?: string
split?: { split?: {
mode: 'equal' | 'custom_amounts' mode: 'equal' | 'custom_amounts'
participants: readonly { participants: readonly {

View File

@@ -202,7 +202,7 @@ function ParticipantSplitInputs(props: ParticipantSplitInputsProps) {
} }
export default function LedgerRoute() { export default function LedgerRoute() {
const { initData, refreshHouseholdData } = useSession() const { initData, refreshHouseholdData, session } = useSession()
const { copy } = useI18n() const { copy } = useI18n()
const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } = const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } =
useDashboard() useDashboard()
@@ -384,6 +384,11 @@ export default function LedgerRoute() {
description: draft.description, description: draft.description,
amountMajor: draft.amountMajor, amountMajor: draft.amountMajor,
currency: draft.currency, currency: draft.currency,
...(draft.payerMemberId
? {
payerMemberId: draft.payerMemberId
}
: {}),
split: { split: {
mode: draft.splitMode, mode: draft.splitMode,
participants: draft.participants.map((p) => ({ participants: draft.participants.map((p) => ({
@@ -428,6 +433,11 @@ export default function LedgerRoute() {
description: draft.description, description: draft.description,
amountMajor: draft.amountMajor, amountMajor: draft.amountMajor,
currency: draft.currency, currency: draft.currency,
...(draft.payerMemberId
? {
payerMemberId: draft.payerMemberId
}
: {}),
...(draft.participants.length > 0 ...(draft.participants.length > 0
? { ? {
split: { split: {
@@ -444,10 +454,12 @@ export default function LedgerRoute() {
: {}) : {})
}) })
setAddPurchaseOpen(false) setAddPurchaseOpen(false)
const currentSession = session()
setNewPurchase({ setNewPurchase({
description: '', description: '',
amountMajor: '', amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL', currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
...(currentSession.status === 'ready' ? { payerMemberId: currentSession.member.id } : {}),
splitMode: 'equal', splitMode: 'equal',
splitInputMode: 'equal', splitInputMode: 'equal',
participants: [] participants: []
@@ -785,6 +797,25 @@ export default function LedgerRoute() {
} }
/> />
</Field> </Field>
<Field label={copy().purchasePayerLabel}>
<Select
value={newPurchase().payerMemberId ?? ''}
ariaLabel={copy().purchasePayerLabel}
placeholder="—"
options={[{ value: '', label: '—' }, ...memberOptions()]}
onChange={(value) =>
setNewPurchase((p) => {
const base = { ...p }
if (value) {
return { ...base, payerMemberId: value }
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { payerMemberId, ...rest } = base
return rest as PurchaseDraft
})
}
/>
</Field>
<div style={{ 'grid-column': '1 / -1' }}> <div style={{ 'grid-column': '1 / -1' }}>
<Field label="Split By"> <Field label="Split By">
<Select <Select
@@ -892,6 +923,26 @@ export default function LedgerRoute() {
} }
/> />
</Field> </Field>
<Field label={copy().purchasePayerLabel}>
<Select
value={draft().payerMemberId ?? ''}
ariaLabel={copy().purchasePayerLabel}
placeholder="—"
options={[{ value: '', label: '—' }, ...memberOptions()]}
onChange={(value) =>
setPurchaseDraft((d) => {
if (!d) return d
const base = { ...d }
if (value) {
return { ...base, payerMemberId: value }
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { payerMemberId, ...rest } = base
return rest as PurchaseDraft
})
}
/>
</Field>
<div style={{ 'grid-column': '1 / -1' }}> <div style={{ 'grid-column': '1 / -1' }}>
<Field label="Split By"> <Field label="Split By">
<Select <Select

View File

@@ -441,6 +441,11 @@ export function createDbFinanceRepository(
participantSplitMode: input.splitMode participantSplitMode: input.splitMode
} }
: {}), : {}),
...(input.payerMemberId
? {
senderMemberId: input.payerMemberId
}
: {}),
needsReview: 0, needsReview: 0,
processingStatus: 'confirmed', processingStatus: 'confirmed',
parserError: null parserError: null

View File

@@ -139,6 +139,7 @@ export interface FinanceDashboardLedgerEntry {
included: boolean included: boolean
shareAmount: Money | null shareAmount: Money | null
}[] }[]
payerMemberId?: string
} }
export interface FinanceDashboard { export interface FinanceDashboard {
@@ -528,6 +529,7 @@ async function buildFinanceDashboard(
kind: 'purchase', kind: 'purchase',
title: purchase.description ?? 'Shared purchase', title: purchase.description ?? 'Shared purchase',
memberId: purchase.payerMemberId, memberId: purchase.payerMemberId,
payerMemberId: purchase.payerMemberId,
amount: converted.originalAmount, amount: converted.originalAmount,
currency: purchase.currency, currency: purchase.currency,
displayAmount: converted.settlementAmount, displayAmount: converted.settlementAmount,
@@ -653,7 +655,8 @@ export interface FinanceCommandService {
included?: boolean included?: boolean
shareAmountMajor?: string shareAmountMajor?: string
}[] }[]
} },
payerMemberId?: string
): Promise<{ ): Promise<{
purchaseId: string purchaseId: string
amount: Money amount: Money
@@ -888,7 +891,7 @@ export function createFinanceCommandService(
return repository.deleteUtilityBill(billId) return repository.deleteUtilityBill(billId)
}, },
async updatePurchase(purchaseId, description, amountArg, currencyArg, split) { async updatePurchase(purchaseId, description, amountArg, currencyArg, split, payerMemberId) {
const settings = await householdConfigurationRepository.getHouseholdBillingSettings( const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
dependencies.householdId dependencies.householdId
) )
@@ -920,6 +923,11 @@ export function createFinanceCommandService(
amountMinor: amount.amountMinor, amountMinor: amount.amountMinor,
currency, currency,
description: description.trim().length > 0 ? description.trim() : null, description: description.trim().length > 0 ? description.trim() : null,
...(payerMemberId
? {
payerMemberId
}
: {}),
...(split ...(split
? { ? {
splitMode: split.mode, splitMode: split.mode,

View File

@@ -191,6 +191,7 @@ export interface FinanceRepository {
amountMinor: bigint amountMinor: bigint
currency: CurrencyCode currency: CurrencyCode
description: string | null description: string | null
payerMemberId?: string
splitMode?: 'equal' | 'custom_amounts' splitMode?: 'equal' | 'custom_amounts'
participants?: readonly { participants?: readonly {
memberId: string memberId: string