From 02c79ae6298c4deca8cbce8768cf9d0d63970170 Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 16 Mar 2026 17:44:20 +0400 Subject: [PATCH] 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 --- apps/bot/src/miniapp-billing.ts | 21 +++++++- apps/miniapp/src/i18n.ts | 2 + apps/miniapp/src/lib/ledger-helpers.ts | 3 ++ apps/miniapp/src/miniapp-api.ts | 3 ++ apps/miniapp/src/routes/ledger.tsx | 53 ++++++++++++++++++- .../adapters-db/src/finance-repository.ts | 5 ++ .../src/finance-command-service.ts | 12 ++++- packages/ports/src/finance.ts | 1 + 8 files changed, 95 insertions(+), 5 deletions(-) diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts index a6ba350..87fb554 100644 --- a/apps/bot/src/miniapp-billing.ts +++ b/apps/bot/src/miniapp-billing.ts @@ -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) { diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index a3ea245..0a5986c 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -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: 'Добавить оплату', diff --git a/apps/miniapp/src/lib/ledger-helpers.ts b/apps/miniapp/src/lib/ledger-helpers.ts index 61bedc2..e13c5a3 100644 --- a/apps/miniapp/src/lib/ledger-helpers.ts +++ b/apps/miniapp/src/lib/ledger-helpers.ts @@ -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: diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 833373d..ebbb84d 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -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 { diff --git a/apps/miniapp/src/routes/ledger.tsx b/apps/miniapp/src/routes/ledger.tsx index 8a74526..d574614 100644 --- a/apps/miniapp/src/routes/ledger.tsx +++ b/apps/miniapp/src/routes/ledger.tsx @@ -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() { } /> + + + + 0 ? description.trim() : null, + ...(payerMemberId + ? { + payerMemberId + } + : {}), ...(split ? { splitMode: split.mode, diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts index c7b27e0..3730af6 100644 --- a/packages/ports/src/finance.ts +++ b/packages/ports/src/finance.ts @@ -191,6 +191,7 @@ export interface FinanceRepository { amountMinor: bigint currency: CurrencyCode description: string | null + payerMemberId?: string splitMode?: 'equal' | 'custom_amounts' participants?: readonly { memberId: string