diff --git a/src/controllers/billController.ts b/src/controllers/billController.ts index f5832ec..9ded1a5 100644 --- a/src/controllers/billController.ts +++ b/src/controllers/billController.ts @@ -7,7 +7,7 @@ import { resolveWalletWhere, validateFundsAvailability } from "../utils/transfer import { getAvailableBalance } from "../utils/walletBalance"; import { NotificationType } from "../utils/notificationConfig"; import { createAndSendNotification } from "../utils/notificationService"; -import { notifyPaymentReceived, notifyPaymentRequestReceived } from "../utils/notificationHelpers"; +import { notifyPaymentRequestReceived } from "../utils/notificationHelpers"; import { broadcastToBillParticipants } from "../utils/billBroadcast"; const MAX_SHARES = 30; @@ -78,6 +78,23 @@ const participantIdsOf = async (models: ReturnType, bill: { id: s return [bill.organizerId, ...shares.map((s) => s.payerId)]; }; +/** Every id must be an existing user with an active wallet, i.e. someone who can pay a share. */ +const assertPayableUsers = async (models: ReturnType, userIds: string[], t?: DbTransaction) => { + if (userIds.length === 0) return; + const users = await models.User.findAll({ where: { id: { [Op.in]: userIds } }, attributes: ["id"], transaction: t }); + const wallets = await models.Wallet.findAll({ + where: { userId: { [Op.in]: userIds }, isActive: true }, + attributes: ["userId"], + transaction: t, + }); + const found = new Set(users.map((u) => u.id)); + const withWallet = new Set(wallets.map((w) => w.userId)); + if (userIds.some((id) => !found.has(id))) throw new BillError(404, "One of the people in this split was not found"); + if (userIds.some((id) => !withWallet.has(id))) { + throw new BillError(400, "One of the people in this split doesn't have an active wallet"); + } +}; + const billUrl = (billId: string) => `/home/bills/${billId}`; const notifyBill = ( @@ -113,7 +130,7 @@ const safely = async (context: string, work: () => Promise) => { const notifyCompleted = async ( req: AuthenticatedRequest, models: ReturnType, - bill: { id: string; title: string; organizerId: string } + bill: { id: string; title: string; organizerId: string; currency: string; recipientUserId?: string | null } ) => { const participants = Array.from(new Set(await participantIdsOf(models, bill))); await Promise.all( @@ -121,6 +138,42 @@ const notifyCompleted = async ( notifyBill(req, userId, NotificationType.BILL_COMPLETED, bill, `"${bill.title}" has been fully settled`) ) ); + await notifyRecipientOfSplit(req, models, bill); +}; + +/** + * The recipient hears about a split once, when it is settled, as a single "Payment for " + * rather than one payment per share. A business recipient has no in-app notifications (they are + * user-only); it sees the grouped payment in its wallet history. + */ +const notifyRecipientOfSplit = async ( + req: AuthenticatedRequest, + models: ReturnType<typeof Models>, + bill: { id: string; title: string; currency: string; recipientUserId?: string | null } +) => { + if (!bill.recipientUserId) return; + const paidShares = await models.BillShare.findAll({ + where: { billId: bill.id, status: { [Op.in]: SETTLED_IN_APP_STATUSES } }, + order: [["settledAt", "DESC"]], + }); + if (paidShares.length === 0) return; + const received = paidShares.reduce((sum, s) => sum + toNumber(s.amount), 0); + // History shows the split under its latest payment, so link to that one. + const latestTransactionId = paidShares[0].transactionId; + const people = paidShares.length === 1 ? "1 person" : `${paidShares.length} people`; + await createAndSendNotification(req.app, { + type: NotificationType.PAYMENT_RECEIVED, + recipientId: bill.recipientUserId, + data: { + billId: bill.id, + transactionId: latestTransactionId || undefined, + amount: received, + currency: bill.currency, + title: `Payment for ${bill.title}`, + message: `You received ${received} ${bill.currency} for "${bill.title}" from ${people}`, + url: latestTransactionId ? `/transactions?transactionId=${latestTransactionId}` : "/transactions", + }, + }); }; /** If nothing is left to pay, mark the bill completed. Must run inside the bill-locked transaction. */ @@ -251,22 +304,10 @@ export const createBill = async (req: AuthenticatedRequest, res: Response): Prom throw new BillError(400, "The shares must add up exactly to the total"); } - // Same trust model as shared wallets: you can only assign shares to your own contacts. + // Payers don't have to be contacts: the organizer can add anyone whose profile link or QR + // code they have, as long as that person can actually pay. const others: string[] = payerIds.filter((id: string) => id !== organizerId); - if (others.length > 0) { - const contacts = await models.Contact.findAll({ - where: { - [Op.or]: [ - { userAId: organizerId, status: "active" }, - { userBId: organizerId, status: "active" }, - ], - }, - }); - const contactUserIds = new Set(contacts.map((c) => (c.userAId === organizerId ? c.userBId : c.userAId))); - if (others.some((id) => !contactUserIds.has(id))) { - throw new BillError(400, "Everyone in the split must be one of your contacts"); - } - } + await assertPayableUsers(models, others); const recipientWallet = await models.Wallet.findOne({ where: resolveWalletWhere({ userId: recipientUserId, organizationId: recipientOrganizationId }) as any, @@ -586,12 +627,7 @@ const executeSharePayment = async ( } } - // A business recipient has no in-app notifications (they are user-only); it simply - // sees the payment land in its wallet. - if (bill.recipientUserId) { - await notifyPaymentReceived(req.app, bill.recipientUserId, transaction.id, amount, currency, fullName(payingUser)); - } - + // The recipient isn't told per share: notifyCompleted sends them one "Payment for <title>". if (completed) await notifyCompleted(req, models, bill); }); @@ -717,19 +753,7 @@ export const reassignShare = async (req: AuthenticatedRequest, res: Response): P }); if (alreadyOnBill > 0) throw new BillError(400, "That person already has a share in this split"); - if (newPayerId !== userId) { - const contact = await models.Contact.findOne({ - where: { - [Op.or]: [ - { userAId: userId, userBId: newPayerId }, - { userAId: newPayerId, userBId: userId }, - ], - status: "active", - }, - transaction: t, - }); - if (!contact) throw new BillError(400, "That person must be one of your contacts"); - } + if (newPayerId !== userId) await assertPayableUsers(models, [newPayerId], t); const previousPayerId = share.payerId; await share.update( diff --git a/src/controllers/transactionController.ts b/src/controllers/transactionController.ts index ebe3de9..e4c80d7 100644 --- a/src/controllers/transactionController.ts +++ b/src/controllers/transactionController.ts @@ -15,6 +15,7 @@ import { } from '../utils/notificationHelpers'; import { AuthRequest } from '../middleware/auth.unified.middleware'; import { getAvailableBalance } from '../utils/walletBalance'; +import { buildBillHistoryPlan, shapeBillHistoryRow } from '../utils/billHistory'; const { Wallet, @@ -915,10 +916,24 @@ const getTransactionHistory = async ( const contactIdStr = contactId ? String(contactId) : undefined; + // Split bills: fold incoming split payments into one row per split, and pull in payments + // others made to cover this wallet owner's share. A contact filter shows the raw rows. + const historyWallet = await Wallet.findByPk(walletId as string, { attributes: ["id", "userId"] }); + const billPlan = historyWallet + ? await buildBillHistoryPlan(database_models, historyWallet, { groupIncoming: !contactIdStr }) + : null; + // Build where clause const whereClause: any = { - [Op.or]: [{ senderWalletId: walletId }, { receiverWalletId: walletId }], + [Op.or]: [ + { senderWalletId: walletId }, + { receiverWalletId: walletId }, + ...(billPlan && billPlan.coveredIds.length > 0 ? [{ id: { [Op.in]: billPlan.coveredIds } }] : []), + ], }; + if (billPlan && billPlan.hiddenIds.length > 0) { + whereClause.id = { [Op.notIn]: billPlan.hiddenIds }; + } // Filter by contact if provided if (contactIdStr) { @@ -1062,10 +1077,14 @@ const getTransactionHistory = async ( ], }); + const shapedTransactions = billPlan + ? transactions.map((tx: any) => shapeBillHistoryRow(tx.toJSON(), billPlan)) + : transactions; + res.status(200).json({ success: true, data: { - transactions, + transactions: shapedTransactions, pagination: { page: parseInt(page as string), limit: parseInt(limit as string), diff --git a/src/utils/billHistory.ts b/src/utils/billHistory.ts new file mode 100644 index 0000000..37749cc --- /dev/null +++ b/src/utils/billHistory.ts @@ -0,0 +1,160 @@ +import { Op } from "sequelize"; + +/** + * Split-bill shaping for a wallet's transaction history. + * + * Each share of a split is its own transfer straight into the recipient's wallet, so on the + * recipient's side one split would show up as N payments from N people. History folds those + * into a single "Payment for <title>" row per split (the latest payment stands in for the group). + * + * A covered share is paid from someone else's wallet, so it would never appear in the payer's + * own history. History pulls those transactions in and marks them as covered (no balance effect). + */ + +const fullName = (user: any): string | null => + user ? `${user.firstName || ""} ${user.lastName || ""}`.trim() || null : null; + +export interface SplitBillGroup { + billId: string; + title: string; + status: string; + currency: string; + totalAmount: number; + receivedAmount: number; + paymentsCount: number; + payments: { + transactionId: string; + payerName: string | null; + paidByName: string | null; + covered: boolean; + amount: number; + paidAt: Date; + }[]; +} + +export interface CoveredShareInfo { + billId: string; + title: string; + amount: number; + coveredById: string | null; + coveredByName: string | null; +} + +export interface BillHistoryPlan { + /** Incoming split payments folded into their group's representative row. */ + hiddenIds: string[]; + /** Payments someone else made for this wallet owner's share. */ + coveredIds: string[]; + groups: Map<string, SplitBillGroup>; + covered: Map<string, CoveredShareInfo>; +} + +export const buildBillHistoryPlan = async ( + models: any, + wallet: { id: string; userId?: string | null }, + { groupIncoming }: { groupIncoming: boolean } +): Promise<BillHistoryPlan> => { + const plan: BillHistoryPlan = { hiddenIds: [], coveredIds: [], groups: new Map(), covered: new Map() }; + + if (groupIncoming) { + const incoming = await models.Transaction.findAll({ + where: { receiverWalletId: wallet.id, billShareId: { [Op.ne]: null }, status: "completed" }, + attributes: ["id", "amount", "createdAt", "billShareId"], + include: [ + { + model: models.BillShare, + as: "billShare", + attributes: ["id", "billId", "status"], + required: true, + include: [ + { model: models.Bill, as: "bill", attributes: ["id", "title", "status", "currency", "totalAmount"] }, + { model: models.User, as: "payer", attributes: ["id", "firstName", "lastName"] }, + { model: models.User, as: "paidBy", attributes: ["id", "firstName", "lastName"] }, + ], + }, + ], + order: [["createdAt", "DESC"]], + }); + + const byBill = new Map<string, any[]>(); + for (const tx of incoming) { + const billId = tx.billShare.billId; + if (!byBill.has(billId)) byBill.set(billId, []); + byBill.get(billId)!.push(tx); + } + + for (const [billId, txs] of byBill) { + // Newest first, so the group sits where its latest payment landed. + const [representative, ...rest] = txs; + const bill = representative.billShare.bill; + plan.hiddenIds.push(...rest.map((tx: any) => tx.id)); + plan.groups.set(representative.id, { + billId, + title: bill?.title || "Split bill", + status: bill?.status || "open", + currency: bill?.currency || "RWF", + totalAmount: Number(bill?.totalAmount || 0), + receivedAmount: txs.reduce((sum: number, tx: any) => sum + Number(tx.amount), 0), + paymentsCount: txs.length, + payments: txs.map((tx: any) => ({ + transactionId: tx.id, + payerName: fullName(tx.billShare.payer), + paidByName: fullName(tx.billShare.paidBy), + covered: tx.billShare.status === "covered", + amount: Number(tx.amount), + paidAt: tx.createdAt, + })), + }); + } + } + + if (wallet.userId) { + const coveredShares = await models.BillShare.findAll({ + where: { payerId: wallet.userId, status: "covered", transactionId: { [Op.ne]: null } }, + attributes: ["id", "billId", "amount", "transactionId", "paidByUserId"], + include: [ + { model: models.Bill, as: "bill", attributes: ["id", "title"] }, + { model: models.User, as: "paidBy", attributes: ["id", "firstName", "lastName"] }, + ], + }); + for (const share of coveredShares) { + plan.coveredIds.push(share.transactionId); + plan.covered.set(share.transactionId, { + billId: share.billId, + title: share.bill?.title || "Split bill", + amount: Number(share.amount), + coveredById: share.paidByUserId || null, + coveredByName: fullName(share.paidBy), + }); + } + } + + return plan; +}; + +/** Applies the plan to one serialized history row. */ +export const shapeBillHistoryRow = (row: any, plan: BillHistoryPlan): any => { + const group = plan.groups.get(row.id); + if (group) { + return { + ...row, + amount: group.receivedAmount, + fee: 0, + totalAmount: group.receivedAmount, + description: `Payment for ${group.title}`, + // No single sender: the row stands for everyone who paid into the split. + senderWallet: null, + senderWalletId: null, + splitBill: group, + }; + } + const covered = plan.covered.get(row.id); + if (covered) { + return { + ...row, + description: `Your share of "${covered.title}" was covered by ${covered.coveredByName || "someone"}`, + coveredShare: covered, + }; + } + return row; +};