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

View File

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

View File

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

View File

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

View File

@@ -202,7 +202,7 @@ function ParticipantSplitInputs(props: ParticipantSplitInputsProps) {
}
export default function LedgerRoute() {
const { initData, refreshHouseholdData } = useSession()
const { initData, refreshHouseholdData, session } = useSession()
const { copy } = useI18n()
const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } =
useDashboard()
@@ -384,6 +384,11 @@ export default function LedgerRoute() {
description: draft.description,
amountMajor: draft.amountMajor,
currency: draft.currency,
...(draft.payerMemberId
? {
payerMemberId: draft.payerMemberId
}
: {}),
split: {
mode: draft.splitMode,
participants: draft.participants.map((p) => ({
@@ -428,6 +433,11 @@ export default function LedgerRoute() {
description: draft.description,
amountMajor: draft.amountMajor,
currency: draft.currency,
...(draft.payerMemberId
? {
payerMemberId: draft.payerMemberId
}
: {}),
...(draft.participants.length > 0
? {
split: {
@@ -444,10 +454,12 @@ export default function LedgerRoute() {
: {})
})
setAddPurchaseOpen(false)
const currentSession = session()
setNewPurchase({
description: '',
amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
...(currentSession.status === 'ready' ? { payerMemberId: currentSession.member.id } : {}),
splitMode: 'equal',
splitInputMode: 'equal',
participants: []
@@ -785,6 +797,25 @@ export default function LedgerRoute() {
}
/>
</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' }}>
<Field label="Split By">
<Select
@@ -892,6 +923,26 @@ export default function LedgerRoute() {
}
/>
</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' }}>
<Field label="Split By">
<Select