mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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: 'Добавить оплату',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -441,6 +441,11 @@ export function createDbFinanceRepository(
|
||||
participantSplitMode: input.splitMode
|
||||
}
|
||||
: {}),
|
||||
...(input.payerMemberId
|
||||
? {
|
||||
senderMemberId: input.payerMemberId
|
||||
}
|
||||
: {}),
|
||||
needsReview: 0,
|
||||
processingStatus: 'confirmed',
|
||||
parserError: null
|
||||
|
||||
@@ -139,6 +139,7 @@ export interface FinanceDashboardLedgerEntry {
|
||||
included: boolean
|
||||
shareAmount: Money | null
|
||||
}[]
|
||||
payerMemberId?: string
|
||||
}
|
||||
|
||||
export interface FinanceDashboard {
|
||||
@@ -528,6 +529,7 @@ async function buildFinanceDashboard(
|
||||
kind: 'purchase',
|
||||
title: purchase.description ?? 'Shared purchase',
|
||||
memberId: purchase.payerMemberId,
|
||||
payerMemberId: purchase.payerMemberId,
|
||||
amount: converted.originalAmount,
|
||||
currency: purchase.currency,
|
||||
displayAmount: converted.settlementAmount,
|
||||
@@ -653,7 +655,8 @@ export interface FinanceCommandService {
|
||||
included?: boolean
|
||||
shareAmountMajor?: string
|
||||
}[]
|
||||
}
|
||||
},
|
||||
payerMemberId?: string
|
||||
): Promise<{
|
||||
purchaseId: string
|
||||
amount: Money
|
||||
@@ -888,7 +891,7 @@ export function createFinanceCommandService(
|
||||
return repository.deleteUtilityBill(billId)
|
||||
},
|
||||
|
||||
async updatePurchase(purchaseId, description, amountArg, currencyArg, split) {
|
||||
async updatePurchase(purchaseId, description, amountArg, currencyArg, split, payerMemberId) {
|
||||
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
||||
dependencies.householdId
|
||||
)
|
||||
@@ -920,6 +923,11 @@ export function createFinanceCommandService(
|
||||
amountMinor: amount.amountMinor,
|
||||
currency,
|
||||
description: description.trim().length > 0 ? description.trim() : null,
|
||||
...(payerMemberId
|
||||
? {
|
||||
payerMemberId
|
||||
}
|
||||
: {}),
|
||||
...(split
|
||||
? {
|
||||
splitMode: split.mode,
|
||||
|
||||
@@ -191,6 +191,7 @@ export interface FinanceRepository {
|
||||
amountMinor: bigint
|
||||
currency: CurrencyCode
|
||||
description: string | null
|
||||
payerMemberId?: string
|
||||
splitMode?: 'equal' | 'custom_amounts'
|
||||
participants?: readonly {
|
||||
memberId: string
|
||||
|
||||
Reference in New Issue
Block a user