mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +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
|
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) {
|
||||||
|
|||||||
@@ -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: 'Добавить оплату',
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user