Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 60 additions & 36 deletions src/controllers/billController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,6 +78,23 @@ const participantIdsOf = async (models: ReturnType<typeof Models>, 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<typeof Models>, 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 = (
Expand Down Expand Up @@ -113,14 +130,50 @@ const safely = async (context: string, work: () => Promise<unknown>) => {
const notifyCompleted = async (
req: AuthenticatedRequest,
models: ReturnType<typeof Models>,
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(
participants.map((userId) =>
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 <title>"
* 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. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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(
Expand Down
23 changes: 21 additions & 2 deletions src/controllers/transactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
Expand Down
160 changes: 160 additions & 0 deletions src/utils/billHistory.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading