From 4add201b84ddfd6ea05280afc2704ae1852ddadc Mon Sep 17 00:00:00 2001 From: Olivier BYIRINGIRO Date: Mon, 21 Sep 2026 15:23:15 +0200 Subject: [PATCH 1/2] feat: enhance escrow and scheduled transfer functionalities with settlement proposals and chat message integration --- src/controllers/batchTransferController.ts | 38 +++++- src/controllers/escrowController.ts | 122 +++++++++++++++--- .../scheduledTransferController.ts | 61 +++++++++ ...-add-chat-fields-to-scheduled-transfers.js | 17 +++ ...000006-add-settlement-fields-to-escrows.js | 65 ++++++++++ src/database/models/escrow.model.ts | 20 ++- .../models/scheduledTransfer.model.ts | 2 + src/jobs/escrowJobs.ts | 3 + src/routes/escrow.routes.ts | 4 +- src/services/chatService.ts | 95 ++++++++++++++ src/services/escrowService.ts | 63 +++++++++ src/services/moneyChatMessageService.ts | 82 ++++++++++++ .../scheduledTransferExecutionService.ts | 63 +++++++++ src/types/model.ts | 18 ++- 14 files changed, 632 insertions(+), 21 deletions(-) create mode 100644 src/database/migrations/20260813000005-add-chat-fields-to-scheduled-transfers.js create mode 100644 src/database/migrations/20260813000006-add-settlement-fields-to-escrows.js create mode 100644 src/services/moneyChatMessageService.ts diff --git a/src/controllers/batchTransferController.ts b/src/controllers/batchTransferController.ts index f81626e..75ad411 100644 --- a/src/controllers/batchTransferController.ts +++ b/src/controllers/batchTransferController.ts @@ -6,6 +6,8 @@ import { AuthRequest } from "../middleware/auth.unified.middleware"; import { getAvailableBalance } from "../utils/walletBalance"; import { resolveWalletWhere, validateFundsAvailability } from "../utils/transferValidation"; import { notifyPaymentReceived, notifyBatchTransferCompleted } from "../utils/notificationHelpers"; +import { findOrCreateDMChat } from "../services/chatService"; +import { postMoneyChatMessage } from "../services/moneyChatMessageService"; const { Wallet, @@ -479,7 +481,13 @@ export const createBatchTransfer = async ( totalSent += transferAmount; successCount += 1; - results.push({ ...recipientIdentity, amount: transferAmount, status: "success", transactionId: newTransaction.id }); + results.push({ + ...recipientIdentity, + amount: transferAmount, + status: "success", + transactionId: newTransaction.id, + referenceId: newTransaction.referenceId, + }); } const failureCount = recipients.length - successCount; @@ -538,6 +546,34 @@ export const createBatchTransfer = async ( console.error("Batch transfer notification error:", notificationError); } + // Best-effort: post a normal money-sent message in each recipient's DM, same as an + // individual chat send would. Never let this affect the response - the transfers + // already committed above. + if (senderUserId) { + const io = req.app.get("io"); + for (const result of results) { + if (result.status !== "success" || !result.receiverUserId) continue; + try { + const recipientUser = await User.findByPk(result.receiverUserId); + const senderUser = await User.findByPk(senderUserId); + const chat = await findOrCreateDMChat(database_models, senderUserId, result.receiverUserId); + await postMoneyChatMessage(io, database_models, chat.id, senderUserId, { + type: "money_transfer", + amount: result.amount, + currency: senderWallet.currency || "RWF", + senderName: senderUser ? `${senderUser.firstName} ${senderUser.lastName}` : "A user", + recipientName: recipientUser ? `${recipientUser.firstName} ${recipientUser.lastName}` : "Recipient", + note: description || "", + transactionId: result.transactionId, + referenceId: result.referenceId, + timestamp: new Date().toISOString(), + }); + } catch (messageError) { + console.error(`Batch transfer chat message error (recipient ${result.receiverUserId}):`, messageError); + } + } + } + res.status(200).json({ success: true, message: `Batch transfer processed: ${successCount} succeeded, ${failureCount} failed`, diff --git a/src/controllers/escrowController.ts b/src/controllers/escrowController.ts index 205c5ae..efa2e9a 100644 --- a/src/controllers/escrowController.ts +++ b/src/controllers/escrowController.ts @@ -4,7 +4,7 @@ import * as bcrypt from "bcrypt"; import database_models from "../database/config/db.config"; import { AuthenticatedRequest } from "../types/requests"; import { getAvailableBalance } from "../utils/walletBalance"; -import { holdEscrowFunds, releaseEscrowFunds, refundEscrowFunds } from "../services/escrowService"; +import { holdEscrowFunds, releaseEscrowFunds, refundEscrowFunds, settleEscrowFunds } from "../services/escrowService"; import { notifyPaymentReceived, notifyPaymentSent } from "../utils/notificationHelpers"; const { sequelize, Wallet, Escrow, User, ChatParticipant, ChatMessage } = database_models as any; @@ -379,11 +379,56 @@ const fulfillEscrow = async (req: AuthenticatedRequest, res: Response): Promise< }; /** - * Only the payer can cancel, and only before the payee has marked the escrow - * fulfilled - once that happens, a cancel would let the payer take back money for - * something they already received. Disputes go through an admin instead. + * Neither party can unilaterally take money back once it's held - only give it away + * (release) unilaterally. Getting money back always goes through this propose/accept + * negotiation, or a dispute if they can't agree. Either party may propose at any time + * while held, regardless of fulfilled state; a new proposal from the same proposer + * simply overwrites their own pending one. */ -const refundEscrow = async (req: AuthenticatedRequest, res: Response): Promise => { +const proposeSettlement = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const { payeeAmount, note } = req.body as { payeeAmount?: number | string; note?: string }; + const authenticatedUserId = req.user.id; + + if (payeeAmount === undefined || payeeAmount === null || isNaN(Number(payeeAmount))) { + throw new ControllerError(400, "payeeAmount is required"); + } + const proposedPayeeAmount = parseFloat(payeeAmount.toString()); + if (proposedPayeeAmount < 0) { + throw new ControllerError(400, "payeeAmount cannot be negative"); + } + + const escrow = await Escrow.findByPk(id); + if (!escrow) throw new ControllerError(404, "Escrow not found"); + if (escrow.payerUserId !== authenticatedUserId && escrow.payeeUserId !== authenticatedUserId) { + throw new ControllerError(403, "Only the payer or payee can propose a settlement"); + } + if (escrow.status !== "held") { + throw new ControllerError(400, `Escrow cannot be settled from status '${escrow.status}'`); + } + + const totalAmount = parseFloat(escrow.amount.toString()); + if (proposedPayeeAmount > totalAmount) { + throw new ControllerError(400, `payeeAmount cannot exceed the escrow amount (${totalAmount})`); + } + + await escrow.update({ + proposedByUserId: authenticatedUserId, + proposedPayeeAmount, + proposedPayerAmount: totalAmount - proposedPayeeAmount, + proposedNote: note || null, + proposedAt: new Date(), + }); + + res.status(200).json({ success: true, message: "Settlement proposed", data: escrow }); + } catch (error) { + handleControllerError(res, error, "Propose escrow settlement"); + } +}; + +/** Only the party who did NOT make the proposal can accept it - executes the split immediately. */ +const acceptSettlement = async (req: AuthenticatedRequest, res: Response): Promise => { try { const { id } = req.params; const authenticatedUserId = req.user.id; @@ -392,24 +437,67 @@ const refundEscrow = async (req: AuthenticatedRequest, res: Response): Promise { const escrow = await Escrow.findByPk(id, { lock: t.LOCK.UPDATE, transaction: t }); if (!escrow) throw new ControllerError(404, "Escrow not found"); - if (escrow.payerUserId !== authenticatedUserId) { - throw new ControllerError(403, "Only the payer can cancel this escrow"); + if (escrow.payerUserId !== authenticatedUserId && escrow.payeeUserId !== authenticatedUserId) { + throw new ControllerError(403, "Only the payer or payee can accept a settlement"); } - if (escrow.status !== "held") { - throw new ControllerError(400, `Escrow cannot be refunded from status '${escrow.status}'`); + if (!escrow.proposedByUserId) { + throw new ControllerError(400, "There is no pending settlement proposal"); } - if (escrow.fulfilledAt) { - throw new ControllerError(400, "The payee has marked this escrow fulfilled - release the funds or raise a dispute instead"); + if (escrow.proposedByUserId === authenticatedUserId) { + throw new ControllerError(400, "You made this proposal - waiting for the other party to accept it"); + } + if (escrow.status !== "held") { + throw new ControllerError(400, `Escrow cannot be settled from status '${escrow.status}'`); } - await refundEscrowFunds(escrow, t); - await escrow.update({ status: "refunded", refundedAt: new Date() }, { transaction: t }); + const payeeAmount = parseFloat(escrow.proposedPayeeAmount.toString()); + const result = await settleEscrowFunds(escrow, payeeAmount, t); + await escrow.update( + { + status: "settled", + settledAt: new Date(), + settlementTransactionId: result.transactionId, + }, + { transaction: t } + ); updated = escrow; }); - res.status(200).json({ success: true, message: "Escrow refunded", data: updated }); + res.status(200).json({ success: true, message: "Settlement accepted", data: updated }); + } catch (error) { + handleControllerError(res, error, "Accept escrow settlement"); + } +}; + +/** Only the party who did NOT make the proposal can decline it - clears it, escrow stays held. */ +const declineSettlement = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const authenticatedUserId = req.user.id; + + const escrow = await Escrow.findByPk(id); + if (!escrow) throw new ControllerError(404, "Escrow not found"); + if (escrow.payerUserId !== authenticatedUserId && escrow.payeeUserId !== authenticatedUserId) { + throw new ControllerError(403, "Only the payer or payee can decline a settlement"); + } + if (!escrow.proposedByUserId) { + throw new ControllerError(400, "There is no pending settlement proposal"); + } + if (escrow.proposedByUserId === authenticatedUserId) { + throw new ControllerError(400, "You made this proposal - the other party is the one who can decline it"); + } + + await escrow.update({ + proposedByUserId: null, + proposedPayeeAmount: null, + proposedPayerAmount: null, + proposedNote: null, + proposedAt: null, + }); + + res.status(200).json({ success: true, message: "Settlement declined", data: escrow }); } catch (error) { - handleControllerError(res, error, "Refund escrow"); + handleControllerError(res, error, "Decline escrow settlement"); } }; @@ -546,7 +634,9 @@ export default { getEscrowById, releaseEscrow, fulfillEscrow, - refundEscrow, + proposeSettlement, + acceptSettlement, + declineSettlement, raiseDispute, respondToDispute, resolveDispute, diff --git a/src/controllers/scheduledTransferController.ts b/src/controllers/scheduledTransferController.ts index 357bc54..bdebc4a 100644 --- a/src/controllers/scheduledTransferController.ts +++ b/src/controllers/scheduledTransferController.ts @@ -19,6 +19,8 @@ import { notifyScheduledTransferResumed, notifyScheduledBatchCreated, } from "../utils/notificationHelpers"; +import { findOrCreateDMChat } from "../services/chatService"; +import { postMoneyChatMessage, updateScheduledTransferMessage } from "../services/moneyChatMessageService"; const { sequelize, @@ -111,6 +113,42 @@ const validateScheduledFor = (value: unknown): Date => { return date; }; +/** + * Best-effort: posts a "payment scheduled for [date]" card in the sender/receiver DM + * right when a scheduled transfer is created, and remembers the message id so + * execution can update it in place later instead of posting a second message. + * Never throws - a messaging failure shouldn't affect the scheduled transfer itself. + */ +const postScheduledPendingMessage = async ( + req: AuthenticatedRequest, + scheduledTransfer: any, + senderUserId: string, + receiverUserId: string +): Promise => { + try { + const [senderUser, recipientUser] = await Promise.all([ + User.findByPk(senderUserId), + User.findByPk(receiverUserId), + ]); + const chat = await findOrCreateDMChat(database_models, senderUserId, receiverUserId); + const io = req.app.get("io"); + const message = await postMoneyChatMessage(io, database_models, chat.id, senderUserId, { + type: "scheduled_transfer", + scheduledTransferId: scheduledTransfer.id, + amount: parseFloat(scheduledTransfer.amount.toString()), + currency: scheduledTransfer.currency, + senderName: senderUser ? `${senderUser.firstName} ${senderUser.lastName}` : "A user", + recipientName: recipientUser ? `${recipientUser.firstName} ${recipientUser.lastName}` : "Recipient", + scheduledFor: scheduledTransfer.scheduledFor, + status: "scheduled", + timestamp: new Date().toISOString(), + }); + await scheduledTransfer.update({ chatMessageId: message.id }); + } catch (error) { + console.error(`Failed to post scheduled-transfer pending message (${scheduledTransfer.id}):`, error); + } +}; + /** * Schedule a one-time or recurring transfer. Funds for the first occurrence are * reserved immediately - creation fails outright if they can't be, rather than @@ -134,6 +172,7 @@ const createScheduledTransfer = async (req: AuthenticatedRequest, res: Response) scheduledFor, timezone = "Africa/Kigali", recurrence, + notifyRecipientNow = false, } = req.body; const senderTargets = [senderUserId, senderOrganizationId, senderSubActionId].filter(Boolean).length; @@ -269,6 +308,10 @@ const createScheduledTransfer = async (req: AuthenticatedRequest, res: Response) !!recurrence ); + if (notifyRecipientNow && senderUserId && receiverUserId) { + await postScheduledPendingMessage(req, created, senderUserId, receiverUserId); + } + res.status(201).json({ success: true, message: "Transfer scheduled successfully", data: created }); } catch (error) { handleControllerError(res, error, "Create scheduled transfer"); @@ -298,6 +341,7 @@ const createScheduledBatchTransfer = async (req: AuthenticatedRequest, res: Resp scheduledFor, timezone = "Africa/Kigali", recurrence, + notifyRecipientNow = false, } = req.body as { senderUserId?: string; senderOrganizationId?: string; @@ -317,6 +361,7 @@ const createScheduledBatchTransfer = async (req: AuthenticatedRequest, res: Resp scheduledFor: string; timezone?: string; recurrence?: any; + notifyRecipientNow?: boolean; }; const senderTargets = [senderUserId, senderOrganizationId, senderSubActionId].filter(Boolean).length; @@ -484,6 +529,10 @@ const createScheduledBatchTransfer = async (req: AuthenticatedRequest, res: Resp status: "success", scheduledTransferId: created.id, }); + + if (notifyRecipientNow && senderUserId && recipient.receiverUserId) { + await postScheduledPendingMessage(req, created, senderUserId, recipient.receiverUserId); + } } catch (err: any) { const reason = err?.message || "Failed to schedule this recipient"; failures.push({ ...recipientIdentity, amount: transferAmount, reason }); @@ -763,6 +812,18 @@ const cancelScheduledTransfer = async (req: AuthenticatedRequest, res: Response) }); await notifyScheduledTransferCancelled(req.app, userId, cancelled.id, cancelled.amount, cancelled.currency); + + if (cancelled.chatMessageId) { + try { + await updateScheduledTransferMessage(req.app.get("io"), database_models, cancelled.chatMessageId, { + status: "cancelled", + }); + await cancelled.update({ chatMessageId: null }); + } catch (error) { + console.error(`Failed to update scheduled-transfer chat message on cancel (${cancelled.id}):`, error); + } + } + res.status(200).json({ success: true, message: "Scheduled transfer cancelled", data: cancelled }); } catch (error) { handleControllerError(res, error, "Cancel scheduled transfer"); diff --git a/src/database/migrations/20260813000005-add-chat-fields-to-scheduled-transfers.js b/src/database/migrations/20260813000005-add-chat-fields-to-scheduled-transfers.js new file mode 100644 index 0000000..b7abf59 --- /dev/null +++ b/src/database/migrations/20260813000005-add-chat-fields-to-scheduled-transfers.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("ScheduledTransfers", "chatMessageId", { + type: Sequelize.UUID, + allowNull: true, + references: { model: "ChatMessages", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn("ScheduledTransfers", "chatMessageId"); + }, +}; diff --git a/src/database/migrations/20260813000006-add-settlement-fields-to-escrows.js b/src/database/migrations/20260813000006-add-settlement-fields-to-escrows.js new file mode 100644 index 0000000..86e635f --- /dev/null +++ b/src/database/migrations/20260813000006-add-settlement-fields-to-escrows.js @@ -0,0 +1,65 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'settled' + AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_Escrows_status') + ) THEN + ALTER TYPE "enum_Escrows_status" ADD VALUE 'settled'; + END IF; + END + $$; + `); + + await queryInterface.addColumn("Escrows", "proposedByUserId", { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }); + await queryInterface.addColumn("Escrows", "proposedPayeeAmount", { + type: Sequelize.DECIMAL(15, 2), + allowNull: true, + }); + await queryInterface.addColumn("Escrows", "proposedPayerAmount", { + type: Sequelize.DECIMAL(15, 2), + allowNull: true, + }); + await queryInterface.addColumn("Escrows", "proposedNote", { + type: Sequelize.STRING, + allowNull: true, + }); + await queryInterface.addColumn("Escrows", "proposedAt", { + type: Sequelize.DATE, + allowNull: true, + }); + await queryInterface.addColumn("Escrows", "settledAt", { + type: Sequelize.DATE, + allowNull: true, + }); + await queryInterface.addColumn("Escrows", "settlementTransactionId", { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Transactions", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn("Escrows", "settlementTransactionId"); + await queryInterface.removeColumn("Escrows", "settledAt"); + await queryInterface.removeColumn("Escrows", "proposedAt"); + await queryInterface.removeColumn("Escrows", "proposedNote"); + await queryInterface.removeColumn("Escrows", "proposedPayerAmount"); + await queryInterface.removeColumn("Escrows", "proposedPayeeAmount"); + await queryInterface.removeColumn("Escrows", "proposedByUserId"); + // Postgres does not support removing a value from an enum type; no-op for status. + }, +}; diff --git a/src/database/models/escrow.model.ts b/src/database/models/escrow.model.ts index a49ad85..309bcaa 100644 --- a/src/database/models/escrow.model.ts +++ b/src/database/models/escrow.model.ts @@ -18,7 +18,8 @@ class Escrow extends Model { | "refunded" | "disputed" | "cancelled" - | "expired"; + | "expired" + | "settled"; public releaseMode!: "manual" | "auto_timeout"; public autoReleaseAt?: Date | null; public fundedAt!: Date; @@ -35,6 +36,13 @@ class Escrow extends Model { public resolvedByAdminId?: string | null; public resolutionNote?: string | null; public resolvedAt?: Date | null; + public proposedByUserId?: string | null; + public proposedPayeeAmount?: number | null; + public proposedPayerAmount?: number | null; + public proposedNote?: string | null; + public proposedAt?: Date | null; + public settledAt?: Date | null; + public settlementTransactionId?: string | null; public createdAt?: Date; public updatedAt?: Date; } @@ -58,7 +66,8 @@ const escrow_model = (sequelize: Sequelize) => { "refunded", "disputed", "cancelled", - "expired" + "expired", + "settled" ), defaultValue: "held", }, @@ -81,6 +90,13 @@ const escrow_model = (sequelize: Sequelize) => { resolvedByAdminId: { type: DataTypes.UUID, allowNull: true }, resolutionNote: { type: DataTypes.STRING, allowNull: true }, resolvedAt: { type: DataTypes.DATE, allowNull: true }, + proposedByUserId: { type: DataTypes.UUID, allowNull: true }, + proposedPayeeAmount: { type: DataTypes.DECIMAL(15, 2), allowNull: true }, + proposedPayerAmount: { type: DataTypes.DECIMAL(15, 2), allowNull: true }, + proposedNote: { type: DataTypes.STRING, allowNull: true }, + proposedAt: { type: DataTypes.DATE, allowNull: true }, + settledAt: { type: DataTypes.DATE, allowNull: true }, + settlementTransactionId: { type: DataTypes.UUID, allowNull: true }, }, { sequelize, tableName: "Escrows" } ); diff --git a/src/database/models/scheduledTransfer.model.ts b/src/database/models/scheduledTransfer.model.ts index d233be5..1a1488a 100644 --- a/src/database/models/scheduledTransfer.model.ts +++ b/src/database/models/scheduledTransfer.model.ts @@ -55,6 +55,7 @@ class ScheduledTransfer extends Model< public lastFailureReason?: string | null; public pinVerifiedAt?: Date | null; public scheduledBatchId?: string | null; + public chatMessageId?: string | null; public createdAt?: Date; public updatedAt?: Date; } @@ -111,6 +112,7 @@ const scheduledTransfer_model = (sequelize: Sequelize) => { lastFailureReason: DataTypes.STRING, pinVerifiedAt: { type: DataTypes.DATE, allowNull: true }, scheduledBatchId: { type: DataTypes.UUID, allowNull: true }, + chatMessageId: { type: DataTypes.UUID, allowNull: true }, }, { sequelize, tableName: "ScheduledTransfers" } ); diff --git a/src/jobs/escrowJobs.ts b/src/jobs/escrowJobs.ts index 0e9c0d5..49cce3f 100644 --- a/src/jobs/escrowJobs.ts +++ b/src/jobs/escrowJobs.ts @@ -21,6 +21,9 @@ const runAutoReleasePoll = async (app: Application): Promise => { status: "held", releaseMode: "auto_timeout", autoReleaseAt: { [Op.lte]: new Date() }, + // An open settlement proposal pauses auto-release until it's accepted or + // declined - firing underneath an active negotiation would be surprising. + proposedByUserId: null, }, limit: AUTO_RELEASE_BATCH_SIZE, }); diff --git a/src/routes/escrow.routes.ts b/src/routes/escrow.routes.ts index d2a9383..2e6116e 100644 --- a/src/routes/escrow.routes.ts +++ b/src/routes/escrow.routes.ts @@ -11,7 +11,9 @@ router.get("/", escrowController.listMyEscrows as RequestHandler); router.get("/:id", escrowController.getEscrowById as RequestHandler); router.post("/:id/release", escrowController.releaseEscrow as RequestHandler); router.post("/:id/fulfill", escrowController.fulfillEscrow as RequestHandler); -router.post("/:id/refund", escrowController.refundEscrow as RequestHandler); +router.post("/:id/propose", escrowController.proposeSettlement as RequestHandler); +router.post("/:id/accept", escrowController.acceptSettlement as RequestHandler); +router.post("/:id/decline", escrowController.declineSettlement as RequestHandler); router.post("/:id/dispute", escrowController.raiseDispute as RequestHandler); router.post("/:id/dispute/respond", escrowController.respondToDispute as RequestHandler); router.post("/:id/resolve", adminOnly as RequestHandler, escrowController.resolveDispute as RequestHandler); diff --git a/src/services/chatService.ts b/src/services/chatService.ts index d74df28..565d14d 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -7,6 +7,101 @@ import bcrypt from "bcryptjs"; import { uploadChatMedia } from "./mediaUploadService"; import { Application } from "express"; import { notifyChatMessageReceived } from "../utils/notificationHelpers"; +import { SECURE_DM_PROTOCOL_VERSION, supportsSecureDmBetweenUsers } from "./e2eeMessage.service"; +import { sequelizeConnection } from "../database/config/db.config"; + +/** + * Finds the existing 1:1 chat between two users (preferring a secure_dm_v1 one if both + * exist), or creates one if none exists and they're contacts. Used by non-chat-initiated + * money flows (batch/scheduled transfers) that still want to post a message somewhere - + * kept independent from the createOrGetDMChat HTTP handler in chatController.ts so that + * well-tested endpoint's behavior can't be affected by this. + * + * Throws if the two users aren't contacts and have no existing chat - callers doing this + * as a best-effort side effect (not the primary action) should catch and ignore/log rather + * than let it block the real work (e.g. a money transfer that already succeeded). + */ +export const findOrCreateDMChat = async ( + models: any, + userId: string, + participantId: string +): Promise => { + if (participantId === userId) { + throw new Error("Cannot create chat with yourself"); + } + + const userChats = await models.ChatParticipant.findAll({ + where: { userId }, + include: [{ model: models.Chat, as: "chat", where: { isGroup: false } }], + }); + const participantChats = await models.ChatParticipant.findAll({ + where: { userId: participantId }, + include: [{ model: models.Chat, as: "chat", where: { isGroup: false } }], + }); + + const userChatIds = userChats.map((cp: any) => cp.chatId); + const participantChatIds = participantChats.map((cp: any) => cp.chatId); + const commonChatIds = userChatIds.filter((id: string) => participantChatIds.includes(id)); + + let existingSecureChat: any = null; + let existingLegacyChat: any = null; + if (commonChatIds.length > 0) { + for (const chatId of commonChatIds) { + const participantCount = await models.ChatParticipant.count({ where: { chatId } }); + if (participantCount === 2) { + const candidateChat = await models.Chat.findByPk(chatId); + if (candidateChat?.securityMode === "secure_dm_v1") { + existingSecureChat = candidateChat; + break; + } + if (!existingLegacyChat) existingLegacyChat = candidateChat; + } + } + } + + const resolvedChat = existingSecureChat || existingLegacyChat; + if (resolvedChat) return resolvedChat; + + const otherUser = await models.User.findByPk(participantId); + if (!otherUser) throw new Error("User not found"); + + const contactRelation = await models.Contact.findOne({ + where: { + [Op.or]: [ + { userAId: userId, userBId: participantId }, + { userAId: participantId, userBId: userId }, + ], + status: "active", + }, + }); + if (!contactRelation) throw new Error("Users are not contacts - cannot start a chat"); + + const shouldCreateSecureChat = await supportsSecureDmBetweenUsers(models, [userId, participantId]); + + const dbTransaction = await sequelizeConnection.transaction(); + try { + const newChat = await models.Chat.create( + { + isGroup: false, + securityMode: shouldCreateSecureChat ? "secure_dm_v1" : "legacy", + protocolVersion: shouldCreateSecureChat ? SECURE_DM_PROTOCOL_VERSION : null, + }, + { transaction: dbTransaction } + ); + await models.ChatParticipant.bulkCreate( + [ + { chatId: newChat.id, userId, joinedAt: new Date() }, + { chatId: newChat.id, userId: participantId, joinedAt: new Date() }, + ], + { transaction: dbTransaction } + ); + await dbTransaction.commit(); + return newChat; + } catch (error) { + await dbTransaction.rollback(); + throw error; + } +}; export class ChatService { private static instance: ChatService; diff --git a/src/services/escrowService.ts b/src/services/escrowService.ts index 9d5f821..4fd456e 100644 --- a/src/services/escrowService.ts +++ b/src/services/escrowService.ts @@ -66,6 +66,69 @@ export const releaseEscrowFunds = async ( return { transactionId: releaseTransaction.id }; }; +/** + * Executes an agreed settlement: releases the *entire* hold, but only moves + * `payeeAmount` of it into the payee's balance - the rest just becomes spendable + * again for the payer, since it never actually left their balance. `payeeAmount` of 0 + * behaves exactly like a full refund (no Transaction written, nothing moved); the full + * escrow amount behaves exactly like a full release. Anything in between is a real + * negotiated split, in which case the Transaction records only the portion that moved. + */ +export const settleEscrowFunds = async ( + escrow: any, + payeeAmount: number, + dbTransaction: DbTransaction +): Promise<{ transactionId: string | null }> => { + const payerWallet = await Wallet.findByPk(escrow.payerWalletId, { + lock: dbTransaction.LOCK.UPDATE, + transaction: dbTransaction, + }); + if (!payerWallet) throw new Error("Escrow payer wallet not found"); + + const totalAmount = parseFloat(escrow.amount.toString()); + await payerWallet.update( + { + balance: parseFloat(payerWallet.balance.toString()) - payeeAmount, + heldBalance: Math.max(0, parseFloat(payerWallet.heldBalance.toString()) - totalAmount), + }, + { transaction: dbTransaction } + ); + + if (payeeAmount <= 0) { + return { transactionId: null }; + } + + const payeeWallet = await Wallet.findByPk(escrow.payeeWalletId, { + lock: dbTransaction.LOCK.UPDATE, + transaction: dbTransaction, + }); + if (!payeeWallet) throw new Error("Escrow payee wallet not found"); + + await payeeWallet.update( + { balance: parseFloat(payeeWallet.balance.toString()) + payeeAmount }, + { transaction: dbTransaction } + ); + + const settlementTransaction = await TransactionModel.create( + { + referenceId: `ESCROW-SETTLE-${escrow.id.substring(0, 8)}-${Date.now()}`, + senderWalletId: payerWallet.id, + receiverWalletId: payeeWallet.id, + amount: payeeAmount, + fee: 0, + totalAmount: payeeAmount, + currency: escrow.currency, + status: "completed", + type: "transfer", + description: escrow.description || "Escrow settlement", + escrowId: escrow.id, + } as any, + { transaction: dbTransaction } + ); + + return { transactionId: settlementTransaction.id }; +}; + /** * Releases the hold back to the payer's spendable balance. Nothing ever left * payer.balance, so no ledger entry is needed - the Escrow row is the audit trail. diff --git a/src/services/moneyChatMessageService.ts b/src/services/moneyChatMessageService.ts new file mode 100644 index 0000000..d32eeab --- /dev/null +++ b/src/services/moneyChatMessageService.ts @@ -0,0 +1,82 @@ +/** + * Shared helpers for money-movement flows that aren't themselves chat-initiated + * (batch transfers, scheduled transfers) but still need to post/update a chat message + * so the recipient sees it the same way a normal chat send already works + * (chatMoneyController.sendMoneyInChat, escrowController's hold messages). + * + * Best-effort by design: callers should catch and log failures here rather than let + * them block the actual money movement, which has already happened by the time these + * run. + */ + +const broadcastToChatParticipants = async (io: any, models: any, chatId: string, event: string, payload: any) => { + if (!io) return; + const participants = await models.ChatParticipant.findAll({ where: { chatId }, attributes: ["userId"] }); + for (const p of participants) { + io.to(`user_${p.userId}`).emit(event, payload); + } +}; + +/** + * Posts a new `messageType: "money"` chat message with the given content and broadcasts + * it. `content` should already be a plain object (e.g. `{ type: "money_transfer", ... }` + * or `{ type: "scheduled_transfer", ... }`) - this function just persists and fans it out. + */ +export const postMoneyChatMessage = async ( + io: any, + models: any, + chatId: string, + senderId: string, + content: Record +): Promise => { + const message = await models.ChatMessage.create({ + chatId, + senderId, + content: JSON.stringify(content), + messageType: "money", + isEncrypted: false, + encryptionIv: "", + status: "sent", + }); + + const messageWithSender = await models.ChatMessage.findByPk(message.id, { + include: [{ model: models.User, as: "sender", attributes: ["id", "firstName", "lastName"] }], + }); + + await broadcastToChatParticipants(io, models, chatId, "new_message", messageWithSender.toJSON()); + + return messageWithSender; +}; + +/** + * Patches an already-posted scheduled_transfer message's content in place (e.g. + * "scheduled" -> "completed"/"failed"/"cancelled") and tells connected clients via the + * same live-update pattern already used for money requests (payment_request_updated). + */ +export const updateScheduledTransferMessage = async ( + io: any, + models: any, + chatMessageId: string, + updates: { status: string; transactionId?: string | null } +): Promise => { + const message = await models.ChatMessage.findByPk(chatMessageId); + if (!message) return; + + let parsed: any; + try { + parsed = JSON.parse(message.content); + } catch { + return; + } + if (parsed?.type !== "scheduled_transfer") return; + + const updatedContent = { ...parsed, status: updates.status, transactionId: updates.transactionId ?? parsed.transactionId }; + await message.update({ content: JSON.stringify(updatedContent) }); + + await broadcastToChatParticipants(io, models, message.chatId, "scheduled_transfer_updated", { + scheduledTransferId: parsed.scheduledTransferId, + status: updates.status, + transactionId: updates.transactionId ?? parsed.transactionId ?? null, + chatId: message.chatId, + }); +}; diff --git a/src/services/scheduledTransferExecutionService.ts b/src/services/scheduledTransferExecutionService.ts index 9732446..68e1d21 100644 --- a/src/services/scheduledTransferExecutionService.ts +++ b/src/services/scheduledTransferExecutionService.ts @@ -12,6 +12,8 @@ import { notifyScheduledSeriesAutoPaused, notifyPaymentReceived, } from "../utils/notificationHelpers"; +import { findOrCreateDMChat } from "./chatService"; +import { postMoneyChatMessage, updateScheduledTransferMessage } from "./moneyChatMessageService"; const { sequelize, @@ -192,6 +194,59 @@ export const attemptHold = async (app: Application, scheduledTransferId: string) } }; +/** + * Best-effort: resolves the scheduled-transfer chat message for this occurrence - updates + * the pending card in place if one was posted at scheduling time (then clears + * chatMessageId, since a recurring series' next occurrence needs a fresh message rather + * than re-touching an already-resolved card), otherwise posts a fresh one. Never throws - + * a messaging failure must never affect the actual scheduled-transfer state machine. + */ +const postOrUpdateScheduledTransferChatMessage = async ( + app: Application, + scheduledTransfer: any, + status: "completed" | "failed" | "cancelled", + transactionId: string | null +): Promise => { + if (!scheduledTransfer.senderUserId || !scheduledTransfer.receiverUserId) return; + + try { + const io = app.get("io"); + + if (scheduledTransfer.chatMessageId) { + await updateScheduledTransferMessage(io, database_models, scheduledTransfer.chatMessageId, { + status, + transactionId, + }); + await scheduledTransfer.update({ chatMessageId: null }); + return; + } + + const [senderUser, recipientUser] = await Promise.all([ + User.findByPk(scheduledTransfer.senderUserId), + User.findByPk(scheduledTransfer.receiverUserId), + ]); + const chat = await findOrCreateDMChat( + database_models, + scheduledTransfer.senderUserId, + scheduledTransfer.receiverUserId + ); + await postMoneyChatMessage(io, database_models, chat.id, scheduledTransfer.senderUserId, { + type: "scheduled_transfer", + scheduledTransferId: scheduledTransfer.id, + amount: parseFloat(scheduledTransfer.amount.toString()), + currency: scheduledTransfer.currency, + senderName: senderUser ? `${senderUser.firstName} ${senderUser.lastName}` : "A user", + recipientName: recipientUser ? `${recipientUser.firstName} ${recipientUser.lastName}` : "Recipient", + scheduledFor: scheduledTransfer.scheduledFor, + status, + transactionId, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error(`Failed to post/update scheduled-transfer chat message (${scheduledTransfer.id}):`, error); + } +}; + /** * An occurrence definitively did not happen (hard execution failure, or it went past * its due date without ever being held). Releases any hold, then either ends the series @@ -266,6 +321,12 @@ export const finalizeFailedOccurrence = async ( } else { await notifyScheduledTransferFailed(app, fresh.createdByUserId, fresh.id, fresh.amount, fresh.currency, reason); } + + // Only a terminal failure resolves the chat card - paused/rolling-forward recurring + // series are still "in progress" as far as the recipient should be told. + if (resultStatus === "failed") { + await postOrUpdateScheduledTransferChatMessage(app, fresh, "failed", null); + } }; /** @@ -496,4 +557,6 @@ export const executeOccurrence = async (app: Application, scheduledTransferId: s if (receiverUserId) { await notifyPaymentReceived(app, receiverUserId, executedTransactionId, executedAmount, executedCurrency, senderName); } + + await postOrUpdateScheduledTransferChatMessage(app, fresh, "completed", executedTransactionId); }; diff --git a/src/types/model.ts b/src/types/model.ts index 6d5753c..ea2ed29 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -704,6 +704,7 @@ export interface ScheduledTransferAttributes { lastFailureReason?: string | null; pinVerifiedAt?: Date | null; scheduledBatchId?: string | null; + chatMessageId?: string | null; createdAt?: Date; updatedAt?: Date; } @@ -726,6 +727,7 @@ export type ScheduledTransferCreationAttributes = Optional< | "lastFailureReason" | "pinVerifiedAt" | "scheduledBatchId" + | "chatMessageId" | "createdAt" | "updatedAt" >; @@ -740,7 +742,7 @@ export interface EscrowAttributes { amount: number; currency: string; description?: string | null; - status: "held" | "released" | "refunded" | "disputed" | "cancelled" | "expired"; + status: "held" | "released" | "refunded" | "disputed" | "cancelled" | "expired" | "settled"; releaseMode: "manual" | "auto_timeout"; autoReleaseAt?: Date | null; fundedAt: Date; @@ -757,6 +759,13 @@ export interface EscrowAttributes { resolvedByAdminId?: string | null; resolutionNote?: string | null; resolvedAt?: Date | null; + proposedByUserId?: string | null; + proposedPayeeAmount?: number | null; + proposedPayerAmount?: number | null; + proposedNote?: string | null; + proposedAt?: Date | null; + settledAt?: Date | null; + settlementTransactionId?: string | null; createdAt?: Date; updatedAt?: Date; } @@ -783,6 +792,13 @@ export type EscrowCreationAttributes = Optional< | "resolvedByAdminId" | "resolutionNote" | "resolvedAt" + | "proposedByUserId" + | "proposedPayeeAmount" + | "proposedPayerAmount" + | "proposedNote" + | "proposedAt" + | "settledAt" + | "settlementTransactionId" | "createdAt" | "updatedAt" >; From a036b5668d83a4e3afdf7e42333640f23f13ac6f Mon Sep 17 00:00:00 2001 From: Olivier BYIRINGIRO Date: Mon, 21 Sep 2026 15:24:02 +0200 Subject: [PATCH 2/2] feat: Implement shared wallet functionality - Add SharedWallet model for managing shared wallets. - Create SharedWalletMember model to handle membership in shared wallets. - Introduce SharedWalletPolicyChange model for tracking policy changes. - Implement SharedWalletWithdrawal model for managing withdrawal requests. - Add SharedWalletWithdrawalVote model for voting on withdrawal requests. - Extend Transaction model to include shared wallet withdrawals. - Update Wallet model to associate with shared wallets. - Create sharedWallet.routes for handling shared wallet operations. - Implement sharedWalletService for executing deposits and withdrawals. - Add sharedWalletBroadcast utility for notifying members of events. - Create sharedWalletContext utility for managing shared wallet membership. - Implement verifyPin utility for transaction PIN verification. - Add notification types and payloads for shared wallet events. --- src/controllers/chatMoneyController.ts | 79 +- src/controllers/groupController.ts | 65 +- src/controllers/sharedWalletController.ts | 1552 +++++++++++++++++ src/controllers/walletItemsController.ts | 12 +- .../20260831000000-add-shared-wallets.js | 339 ++++ ...pending-status-to-shared-wallet-members.js | 15 + ...000000-add-shared-wallet-policy-changes.js | 117 ++ src/database/models/group.model.ts | 2 + src/database/models/index.ts | 70 + src/database/models/sharedWallet.model.ts | 41 + .../models/sharedWalletMember.model.ts | 48 + .../models/sharedWalletPolicyChange.model.ts | 48 + .../sharedWalletPolicyChangeVote.model.ts | 41 + .../models/sharedWalletWithdrawal.model.ts | 58 + .../sharedWalletWithdrawalVote.model.ts | 41 + src/database/models/transaction.model.ts | 9 + src/database/models/wallet.model.ts | 2 + src/routes/index.ts | 2 + src/routes/sharedWallet.routes.ts | 43 + src/services/moneyChatMessageService.ts | 41 + src/services/sharedWalletService.ts | 118 ++ src/types/group.ts | 5 + src/types/model.ts | 120 ++ src/utils/notificationConfig.ts | 85 + src/utils/sharedWalletBroadcast.ts | 24 + src/utils/sharedWalletContext.ts | 119 ++ src/utils/verifyPin.ts | 52 + 27 files changed, 3114 insertions(+), 34 deletions(-) create mode 100644 src/controllers/sharedWalletController.ts create mode 100644 src/database/migrations/20260831000000-add-shared-wallets.js create mode 100644 src/database/migrations/20261001000000-add-pending-status-to-shared-wallet-members.js create mode 100644 src/database/migrations/20261002000000-add-shared-wallet-policy-changes.js create mode 100644 src/database/models/sharedWallet.model.ts create mode 100644 src/database/models/sharedWalletMember.model.ts create mode 100644 src/database/models/sharedWalletPolicyChange.model.ts create mode 100644 src/database/models/sharedWalletPolicyChangeVote.model.ts create mode 100644 src/database/models/sharedWalletWithdrawal.model.ts create mode 100644 src/database/models/sharedWalletWithdrawalVote.model.ts create mode 100644 src/routes/sharedWallet.routes.ts create mode 100644 src/services/sharedWalletService.ts create mode 100644 src/utils/sharedWalletBroadcast.ts create mode 100644 src/utils/sharedWalletContext.ts create mode 100644 src/utils/verifyPin.ts diff --git a/src/controllers/chatMoneyController.ts b/src/controllers/chatMoneyController.ts index a13254f..bcfccc8 100644 --- a/src/controllers/chatMoneyController.ts +++ b/src/controllers/chatMoneyController.ts @@ -106,12 +106,13 @@ export const sendMoneyInChat = async ( let recipientUser: any; let recipientWallet: any; let isGroupFundraising = false; + let isSharedWalletDeposit = false; let groupDetails: any; - // Handle group chats with fundraising + // Handle group chats with fundraising or a shared wallet if (chat.isGroup) { const group = (chat as any).group; - + if (!group) { await dbTransaction.rollback(); res.status(404).json({ @@ -121,12 +122,12 @@ export const sendMoneyInChat = async ( return; } - // Check if group has fundraising enabled - if (!group.hasFundraising) { + // Check if group accepts incoming money at all + if (!group.hasFundraising && !group.sharedWalletId) { await dbTransaction.rollback(); res.status(400).json({ success: false, - message: "This group does not accept donations. Money can only be sent to fundraising groups." + message: "This group does not accept money. Money can only be sent to fundraising groups or shared wallets." }); return; } @@ -150,7 +151,8 @@ export const sendMoneyInChat = async ( return; } - isGroupFundraising = true; + isGroupFundraising = !!group.hasFundraising; + isSharedWalletDeposit = !isGroupFundraising && !!group.sharedWalletId; groupDetails = group; recipientWallet = group.wallet; } else { @@ -171,6 +173,11 @@ export const sendMoneyInChat = async ( recipientUser = recipientParticipant.get('user') as any; } + // True whenever the recipient is a group (fundraising or shared wallet) rather + // than a DM participant - used wherever the code needs "group name" instead of + // "recipientUser's name", regardless of which kind of group wallet it is. + const isGroupRecipient = isGroupFundraising || isSharedWalletDeposit; + // Get authenticated user for PIN verification const authenticatedUser = await models.User.findByPk(userId, { transaction: dbTransaction @@ -275,7 +282,7 @@ export const sendMoneyInChat = async ( } // Get or verify receiver wallet based on chat type - if (!isGroupFundraising) { + if (!isGroupRecipient) { recipientWallet = await models.Wallet.findOne({ where: { userId: recipientId, isActive: true }, lock: dbTransaction.LOCK.UPDATE, @@ -339,9 +346,11 @@ export const sendMoneyInChat = async ( // Determine transaction type and description const transactionType = isGroupFundraising ? 'donation' : 'transfer'; - const description = note || (isGroupFundraising - ? `Donation to ${groupDetails.name}` - : `Money sent via chat`); + const description = note || (isGroupFundraising + ? `Donation to ${groupDetails.name}` + : isSharedWalletDeposit + ? `Deposit to ${groupDetails.name}` + : `Money sent via chat`); // Create transaction record const transactionRecord = await models.Transaction.create({ @@ -394,8 +403,8 @@ export const sendMoneyInChat = async ( try { console.log('Starting PDF receipt generation...'); - const recipientName = isGroupFundraising - ? groupDetails.name + const recipientName = isGroupRecipient + ? groupDetails.name : `${recipientUser.firstName} ${recipientUser.lastName}`; const newBalance = parseFloat(recipientWallet.balance.toString()); @@ -416,8 +425,8 @@ export const sendMoneyInChat = async ( status: 'completed', note: note, isGroupDonation: isGroupFundraising, - groupName: isGroupFundraising ? groupDetails.name : undefined, - fundraisingTarget: isGroupFundraising && groupDetails.fundraisingTarget + groupName: isGroupRecipient ? groupDetails.name : undefined, + fundraisingTarget: isGroupFundraising && groupDetails.fundraisingTarget ? parseFloat(groupDetails.fundraisingTarget.toString()) : undefined, currentProgress: isGroupFundraising ? newBalance : undefined @@ -459,18 +468,18 @@ export const sendMoneyInChat = async ( console.log('Preparing message content...'); // Create well-structured message content - const recipientName = isGroupFundraising - ? groupDetails.name + const recipientName = isGroupRecipient + ? groupDetails.name : `${recipientUser.firstName} ${recipientUser.lastName}`; const messageContent = JSON.stringify({ - type: isGroupFundraising ? 'group_donation' : 'money_transfer', + type: isGroupFundraising ? 'group_donation' : isSharedWalletDeposit ? 'shared_wallet_deposit' : 'money_transfer', amount: transferAmount, currency: 'RWF', senderName: `${authenticatedUser.firstName} ${authenticatedUser.lastName}`, recipientName: recipientName, - groupId: isGroupFundraising ? groupDetails.id : undefined, - groupName: isGroupFundraising ? groupDetails.name : undefined, + groupId: isGroupRecipient ? groupDetails.id : undefined, + groupName: isGroupRecipient ? groupDetails.name : undefined, note: note || '', transactionId: transactionRecord.id, referenceId: transactionRecord.referenceId, @@ -532,14 +541,18 @@ export const sendMoneyInChat = async ( io.to(`user_${p.userId}`).emit('new_message', broadcastMessage); // Send notification to recipient (for DMs) or all group members (for groups) - if (isGroupFundraising || p.userId === recipientId) { - const notificationEvent = isGroupFundraising ? 'group_donation_received' : 'money_received'; + if (isGroupRecipient || p.userId === recipientId) { + const notificationEvent = isGroupFundraising + ? 'group_donation_received' + : isSharedWalletDeposit + ? 'shared_wallet_deposit_received' + : 'money_received'; io.to(`user_${p.userId}`).emit(notificationEvent, { amount: transferAmount, currency: 'RWF', from: `${authenticatedUser.firstName} ${authenticatedUser.lastName}`, - groupId: isGroupFundraising ? groupDetails.id : undefined, - groupName: isGroupFundraising ? groupDetails.name : undefined, + groupId: isGroupRecipient ? groupDetails.id : undefined, + groupName: isGroupRecipient ? groupDetails.name : undefined, transactionId: transactionRecord.id, referenceId: transactionRecord.referenceId, receiptUrl: receiptUrl || '', @@ -565,15 +578,27 @@ export const sendMoneyInChat = async ( donationAmount: transferAmount }); } + + // Emit balance update for shared wallet deposits, so the chat header badge + // (SharedWalletBalanceBadge) refreshes live instead of only on next load. + if (isSharedWalletDeposit) { + const newBalance = parseFloat(recipientWallet.balance.toString()); + io.to(`group:${groupDetails.id}`).emit('shared_wallet_balance_update', { + groupId: groupDetails.id, + balance: newBalance + }); + } } - const recipientDisplayName = isGroupFundraising - ? groupDetails.name + const recipientDisplayName = isGroupRecipient + ? groupDetails.name : `${recipientUser.firstName} ${recipientUser.lastName}`; const successMessage = isGroupFundraising ? `Successfully donated RWF ${transferAmount.toLocaleString('en-US', { minimumFractionDigits: 2 })} to ${recipientDisplayName}` - : `Successfully sent RWF ${transferAmount.toLocaleString('en-US', { minimumFractionDigits: 2 })} to ${recipientDisplayName}`; + : isSharedWalletDeposit + ? `Successfully added RWF ${transferAmount.toLocaleString('en-US', { minimumFractionDigits: 2 })} to ${recipientDisplayName}` + : `Successfully sent RWF ${transferAmount.toLocaleString('en-US', { minimumFractionDigits: 2 })} to ${recipientDisplayName}`; res.status(201).json({ success: true, @@ -589,7 +614,7 @@ export const sendMoneyInChat = async ( type: transactionType, recipientName: recipientDisplayName, isGroupDonation: isGroupFundraising, - groupId: isGroupFundraising ? groupDetails.id : undefined, + groupId: isGroupRecipient ? groupDetails.id : undefined, status: 'completed', timestamp: new Date().toISOString() }, diff --git a/src/controllers/groupController.ts b/src/controllers/groupController.ts index 3bc5038..346b683 100644 --- a/src/controllers/groupController.ts +++ b/src/controllers/groupController.ts @@ -28,6 +28,7 @@ import { notifyGroupMemberRemoved } from "../utils/notificationHelpers"; import CloudinaryService from "../services/cloudinaryService"; +import { verifyTransactionPin, PinVerificationError } from "../utils/verifyPin"; const createGroup = async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { try { @@ -42,6 +43,9 @@ const createGroup = async (req: AuthenticatedRequest, res: Response, next: NextF adminId, hasFundraising = false, fundraisingTarget, + hasSharedWallet = false, + withdrawalPolicy, + pin, expirationDate, expirationType = GroupExpirationType.NEVER, hasAdditionalInfo = false, @@ -61,6 +65,29 @@ const createGroup = async (req: AuthenticatedRequest, res: Response, next: NextF return; } + // Validate shared wallet data + if (hasSharedWallet) { + if (withdrawalPolicy !== "free" && withdrawalPolicy !== "approval") { + res.status(400).json({ message: "withdrawalPolicy must be 'free' or 'approval' when a shared wallet is enabled" }); + return; + } + if (!memberIds || memberIds.length < 1) { + res.status(400).json({ message: "A shared wallet needs at least one other member" }); + return; + } + try { + await verifyTransactionPin(ownerId, pin as string); + } catch (pinError) { + if (pinError instanceof PinVerificationError) { + const body: Record = { success: false, message: pinError.message }; + if (pinError.requiresPinSetup) body.requiresPinSetup = true; + res.status(pinError.status).json(body); + return; + } + throw pinError; + } + } + // Validate expiration data if (expirationType === GroupExpirationType.CUSTOM_DATE && !expirationDate) { res.status(400).json({ message: "Expiration date is required for custom date expiration" }); @@ -158,16 +185,33 @@ const createGroup = async (req: AuthenticatedRequest, res: Response, next: NextF console.log('Group created with QR code:', !!group.qrCode, 'Length:', group.qrCode?.length); - // Create wallet for fundraising groups + // Create wallet eagerly for fundraising groups and shared wallets (both need a + // balance from the moment members can send money in); every other group creates + // its wallet lazily on first use. let walletId: string | undefined; - if (hasFundraising) { + if (hasFundraising || hasSharedWallet) { const wallet = await models.Wallet.create({ groupId: group.id, balance: 0 }); walletId = wallet.id; await group.update({ walletId: wallet.id }); - console.log('Created fundraising wallet for group:', wallet.id); + console.log('Created wallet for group:', wallet.id); + } + + // Shared wallet is its own entity (SharedWallet), optionally attached to a group - + // this is the group-attached path. The pooled wallet stays the group's Wallet + // row created above (Wallet.groupId), unchanged from before this was split out. + let sharedWalletId: string | undefined; + if (hasSharedWallet) { + const sharedWallet = await models.SharedWallet.create({ + name: group.name, + withdrawalPolicy, + createdByUserId: ownerId, + groupId: group.id, + } as any); + sharedWalletId = sharedWallet.id; + await group.update({ sharedWalletId: sharedWallet.id }); } // Create owner membership @@ -234,6 +278,8 @@ const createGroup = async (req: AuthenticatedRequest, res: Response, next: NextF hasFundraising: group.hasFundraising, fundraisingTarget: group.fundraisingTarget, fundraisingCurrentAmount: group.fundraisingCurrentAmount, + sharedWalletId: sharedWalletId || null, + withdrawalPolicy: hasSharedWallet ? withdrawalPolicy : undefined, expirationDate: group.expirationDate, expirationType: group.expirationType, hasAdditionalInfo: group.hasAdditionalInfo, @@ -898,20 +944,24 @@ const getGroupDetails = async (req: AuthenticatedRequest, res: Response, next: N // Get owner's user record const owner = await models.User.findByPk(group.ownerId); - // Get wallet balance if fundraising group + // Get wallet balance if fundraising group or shared wallet let walletBalance: number | undefined; let fundraisingProgress: number | undefined; - if (group.hasFundraising && group.walletId) { + if ((group.hasFundraising || group.sharedWalletId) && group.walletId) { const wallet = await models.Wallet.findByPk(group.walletId); if (wallet) { walletBalance = parseFloat(wallet.balance.toString()); - if (group.fundraisingTarget) { + if (group.hasFundraising && group.fundraisingTarget) { const target = parseFloat(group.fundraisingTarget.toString()); fundraisingProgress = target > 0 ? Math.min((walletBalance / target) * 100, 100) : 0; } } } + const sharedWallet = group.sharedWalletId + ? await models.SharedWallet.findByPk(group.sharedWalletId) + : null; + // Get user's membership status const userMembership = await models.GroupMember.findOne({ where: { @@ -949,6 +999,9 @@ const getGroupDetails = async (req: AuthenticatedRequest, res: Response, next: N fundraisingTarget: group.fundraisingTarget ? parseFloat(group.fundraisingTarget.toString()) : undefined, fundraisingCurrentAmount: walletBalance !== undefined ? walletBalance : parseFloat(group.fundraisingCurrentAmount.toString()), fundraisingProgress: fundraisingProgress, + sharedWalletId: group.sharedWalletId || null, + withdrawalPolicy: sharedWallet?.withdrawalPolicy, + walletBalance: walletBalance, walletId: group.walletId, expirationDate: group.expirationDate?.toISOString(), expirationType: group.expirationType, diff --git a/src/controllers/sharedWalletController.ts b/src/controllers/sharedWalletController.ts new file mode 100644 index 0000000..d312de9 --- /dev/null +++ b/src/controllers/sharedWalletController.ts @@ -0,0 +1,1552 @@ +import { Response } from "express"; +import { Op, Transaction as DbTransaction } from "sequelize"; +import Models from "../database/models"; +import { AuthenticatedRequest } from "../types/requests"; +import { verifyTransactionPin, PinVerificationError } from "../utils/verifyPin"; +import { + executeSharedWalletWithdrawal, + depositIntoSharedWallet as depositIntoSharedWalletService, +} from "../services/sharedWalletService"; +import { postMoneyChatMessage, updateSharedWalletWithdrawalMessage } from "../services/moneyChatMessageService"; +import { + SharedWalletError, + resolveSharedWallet, + resolveMembership, + countOtherActiveMembers, + listActiveMembers, + getChatIdForSharedWallet, +} from "../utils/sharedWalletContext"; +import { broadcastToSharedWalletMembers } from "../utils/sharedWalletBroadcast"; +import { NotificationType } from "../utils/notificationConfig"; +import { createAndSendNotification } from "../utils/notificationService"; + +const getModels = (req: AuthenticatedRequest) => req.app.get("models") as ReturnType; +const getIo = (req: AuthenticatedRequest) => req.app.get("io"); + +const handleControllerError = (res: Response, error: unknown, context: string): void => { + if (error instanceof SharedWalletError || error instanceof PinVerificationError) { + const body: Record = { success: false, message: error.message }; + if (error instanceof PinVerificationError && error.requiresPinSetup) { + body.requiresPinSetup = true; + } + res.status(error.status).json(body); + return; + } + console.error(`${context} error:`, error); + res.status(500).json({ success: false, message: "Internal server error" }); +}; + +/** Majority of the *other* active members (excluding the requester): floor(n/2) + 1. */ +const computeRequiredApprovals = (otherActiveMemberCount: number): number => + Math.floor(otherActiveMemberCount / 2) + 1; + +/** + * POST /shared-wallets + * Creates a STANDALONE shared wallet (no group). Group-attached shared wallets are + * still created via `POST /groups` with `hasSharedWallet: true` - see groupController. + */ +export const createSharedWallet = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const creatorId = req.user.id; + const { name, withdrawalPolicy, memberIds, pin } = req.body; + + if (!name || String(name).trim().length < 2) { + throw new SharedWalletError(400, "A wallet name is required"); + } + if (withdrawalPolicy !== "free" && withdrawalPolicy !== "approval") { + throw new SharedWalletError(400, "withdrawalPolicy must be 'free' or 'approval'"); + } + if (!Array.isArray(memberIds) || memberIds.length < 1) { + throw new SharedWalletError(400, "A shared wallet needs at least one other member"); + } + await verifyTransactionPin(creatorId, pin); + + // Only add members who are actually the creator's contacts - same trust model as + // the existing "pick from contacts" creation flow. + const contacts = await models.Contact.findAll({ + where: { + [Op.or]: [ + { userAId: creatorId, status: "active" }, + { userBId: creatorId, status: "active" }, + ], + }, + }); + const contactUserIds = new Set( + contacts.map((c) => (c.userAId === creatorId ? c.userBId : c.userAId)) + ); + const validMemberIds = Array.from(new Set(memberIds as string[])).filter( + (id) => id !== creatorId && contactUserIds.has(id) + ); + if (validMemberIds.length < 1) { + throw new SharedWalletError(400, "Select at least one member from your contacts"); + } + + let sharedWallet: any; + const invitedMemberships: { memberId: string; membershipId: string }[] = []; + await models.sequelize.transaction(async (t: DbTransaction) => { + sharedWallet = await models.SharedWallet.create( + { name: String(name).trim(), withdrawalPolicy, createdByUserId: creatorId, groupId: null } as any, + { transaction: t } + ); + await models.Wallet.create( + { sharedWalletId: sharedWallet.id, balance: 0 } as any, + { transaction: t } + ); + await models.SharedWalletMember.create( + { sharedWalletId: sharedWallet.id, userId: creatorId, role: "owner", status: "active" } as any, + { transaction: t } + ); + for (const memberId of validMemberIds) { + const membership = await models.SharedWalletMember.create( + { sharedWalletId: sharedWallet.id, userId: memberId, role: "member", status: "pending" } as any, + { transaction: t } + ); + invitedMemberships.push({ memberId, membershipId: membership.id }); + } + }); + + const creator = await models.User.findByPk(creatorId); + await Promise.all( + invitedMemberships.map(({ memberId, membershipId }) => + createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_INVITATION, + recipientId: memberId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + membershipId, + userId: creatorId, + userName: creator ? `${creator.firstName} ${creator.lastName}` : "Someone", + message: `${creator ? `${creator.firstName} ${creator.lastName}` : "Someone"} invited you to join the shared wallet '${sharedWallet.name}'`, + }, + }) + ) + ); + + res.status(201).json({ success: true, message: "Shared wallet created", data: { id: sharedWallet.id } }); + } catch (error) { + handleControllerError(res, error, "Create shared wallet"); + } +}; + +/** + * GET /shared-wallets + * Lists every shared wallet the user is an active member of - both standalone and + * group-attached - for the Wallet page's "Shared wallets" section. + */ +export const listMySharedWallets = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const userId = req.user.id; + + const [groupMemberships, standaloneMemberships] = await Promise.all([ + models.GroupMember.findAll({ where: { userId, status: "active" }, attributes: ["groupId"] }), + models.SharedWalletMember.findAll({ where: { userId, status: "active" }, attributes: ["sharedWalletId"] }), + ]); + const groupIds = groupMemberships.map((m) => m.groupId); + const standaloneIds = standaloneMemberships.map((m) => m.sharedWalletId); + + const whereOr: any[] = []; + if (groupIds.length > 0) whereOr.push({ groupId: { [Op.in]: groupIds } }); + if (standaloneIds.length > 0) whereOr.push({ id: { [Op.in]: standaloneIds } }); + + if (whereOr.length === 0) { + res.status(200).json({ success: true, data: [] }); + return; + } + + const sharedWallets = await models.SharedWallet.findAll({ + where: { [Op.or]: whereOr }, + include: [ + { model: models.Group, as: "group", include: [{ model: models.Wallet, as: "wallet" }] }, + { model: models.Wallet, as: "wallet" }, + ], + order: [["createdAt", "DESC"]], + }); + + const data = await Promise.all( + sharedWallets.map(async (sw: any) => { + const wallet = sw.groupId ? sw.group?.wallet : sw.wallet; + const memberCount = sw.groupId + ? sw.group?.memberCount ?? 0 + : await models.SharedWalletMember.count({ where: { sharedWalletId: sw.id, status: "active" } }); + return { + id: sw.id, + name: sw.name, + groupId: sw.groupId || null, + groupName: sw.group?.name || null, + memberCount, + withdrawalPolicy: sw.withdrawalPolicy, + balance: wallet ? parseFloat(wallet.balance.toString()) : 0, + currency: wallet ? wallet.currency : "RWF", + }; + }) + ); + + res.status(200).json({ success: true, data }); + } catch (error) { + handleControllerError(res, error, "List my shared wallets"); + } +}; + +/** GET /shared-wallets/:id */ +export const getSharedWalletById = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet, group, wallet } = await resolveSharedWallet(models, id); + const membership = await resolveMembership(models, sharedWallet, authenticatedUserId); + const memberCount = sharedWallet.groupId + ? group?.memberCount ?? 0 + : await models.SharedWalletMember.count({ where: { sharedWalletId: sharedWallet.id, status: "active" } }); + + res.status(200).json({ + success: true, + data: { + id: sharedWallet.id, + name: sharedWallet.name, + withdrawalPolicy: sharedWallet.withdrawalPolicy, + groupId: sharedWallet.groupId || null, + groupName: group?.name || null, + memberCount, + balance: parseFloat(wallet.balance.toString()), + currency: wallet.currency || "RWF", + userRole: membership.role, + createdAt: sharedWallet.createdAt, + }, + }); + } catch (error) { + handleControllerError(res, error, "Get shared wallet"); + } +}; + +/** POST /shared-wallets/:id/deposit - any active member may add money in, no approval needed. */ +export const deposit = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + const { amount, pin, note } = req.body; + + const parsedAmount = parseFloat(amount); + if (!amount || isNaN(parsedAmount) || parsedAmount <= 0) { + throw new SharedWalletError(400, "A positive amount is required"); + } + + const { sharedWallet, wallet } = await resolveSharedWallet(models, id); + await resolveMembership(models, sharedWallet, authenticatedUserId); + await verifyTransactionPin(authenticatedUserId, pin); + + const memberWallet = await models.Wallet.findOne({ where: { userId: authenticatedUserId, isActive: true } }); + if (!memberWallet) throw new SharedWalletError(404, "Your wallet not found or inactive"); + + const description = note || `Deposit into ${sharedWallet.name}`; + + let result: { transactionId: string } | null = null; + await models.sequelize.transaction(async (t: DbTransaction) => { + result = await depositIntoSharedWalletService( + sharedWallet.id, + wallet.id, + memberWallet.id, + parsedAmount, + description, + t + ); + }); + + const authenticatedUser = await models.User.findByPk(authenticatedUserId); + const chatId = await getChatIdForSharedWallet(models, sharedWallet); + if (chatId) { + await postMoneyChatMessage(getIo(req), models, chatId, authenticatedUserId, { + type: "shared_wallet_deposit", + amount: parsedAmount, + currency: wallet.currency || "RWF", + senderName: authenticatedUser ? `${authenticatedUser.firstName} ${authenticatedUser.lastName}` : "A member", + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + groupId: sharedWallet.groupId || null, + note: note || "", + transactionId: (result as any)?.transactionId, + timestamp: new Date().toISOString(), + }); + } + + const newBalance = parseFloat(wallet.balance.toString()) + parsedAmount; + await broadcastToSharedWalletMembers(getIo(req), models, sharedWallet, "shared_wallet_balance_update", { + balance: newBalance, + }); + + const otherMembers = (await listActiveMembers(models, sharedWallet)).filter( + (m) => m.userId !== authenticatedUserId + ); + await Promise.all( + otherMembers.map((m) => + createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_DEPOSIT_RECEIVED, + recipientId: m.userId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + amount: parsedAmount, + currency: wallet.currency || "RWF", + message: `${authenticatedUser ? `${authenticatedUser.firstName} ${authenticatedUser.lastName}` : "A member"} deposited ${parsedAmount} ${wallet.currency || "RWF"} into ${sharedWallet.name}`, + }, + }) + ) + ); + + res.status(200).json({ success: true, message: "Deposit completed", data: result }); + } catch (error) { + handleControllerError(res, error, "Shared wallet deposit"); + } +}; + +/** + * POST /shared-wallets/:id/withdraw + * Free-mode withdrawal: moves money immediately, no approval step. + */ +export const withdrawFree = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + const { amount, pin, note } = req.body; + + const parsedAmount = parseFloat(amount); + if (!amount || isNaN(parsedAmount) || parsedAmount <= 0) { + throw new SharedWalletError(400, "A positive amount is required"); + } + + const { sharedWallet, wallet } = await resolveSharedWallet(models, id); + if (sharedWallet.withdrawalPolicy !== "free") { + throw new SharedWalletError(400, "This shared wallet requires approval for withdrawals"); + } + await resolveMembership(models, sharedWallet, authenticatedUserId); + await verifyTransactionPin(authenticatedUserId, pin); + + const memberWallet = await models.Wallet.findOne({ where: { userId: authenticatedUserId, isActive: true } }); + if (!memberWallet) throw new SharedWalletError(404, "Your wallet not found or inactive"); + + const description = note || `Withdrawal from ${sharedWallet.name}`; + + let result: { transactionId: string } | null = null; + await models.sequelize.transaction(async (t: DbTransaction) => { + result = await executeSharedWalletWithdrawal(wallet.id, memberWallet.id, parsedAmount, description, t); + }); + + const authenticatedUser = await models.User.findByPk(authenticatedUserId); + const chatId = await getChatIdForSharedWallet(models, sharedWallet); + if (chatId) { + await postMoneyChatMessage(getIo(req), models, chatId, authenticatedUserId, { + type: "shared_wallet_withdrawal", + amount: parsedAmount, + currency: wallet.currency || "RWF", + senderName: sharedWallet.name, + recipientName: authenticatedUser ? `${authenticatedUser.firstName} ${authenticatedUser.lastName}` : "A member", + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + groupId: sharedWallet.groupId || null, + note: note || "", + transactionId: (result as any)?.transactionId, + timestamp: new Date().toISOString(), + }); + } + + await broadcastToSharedWalletMembers(getIo(req), models, sharedWallet, "shared_wallet_balance_update", { + balance: parseFloat(wallet.balance.toString()) - parsedAmount, + }); + + res.status(200).json({ success: true, message: "Withdrawal completed", data: result }); + } catch (error) { + handleControllerError(res, error, "Shared wallet free withdrawal"); + } +}; + +/** POST /shared-wallets/:id/withdrawals - approval-mode: opens a request, no funds move yet. */ +export const proposeWithdrawal = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + const { amount, pin, note } = req.body; + + const parsedAmount = parseFloat(amount); + if (!amount || isNaN(parsedAmount) || parsedAmount <= 0) { + throw new SharedWalletError(400, "A positive amount is required"); + } + + const { sharedWallet, wallet } = await resolveSharedWallet(models, id); + if (sharedWallet.withdrawalPolicy !== "approval") { + throw new SharedWalletError(400, "This shared wallet allows free withdrawals - no approval needed"); + } + await resolveMembership(models, sharedWallet, authenticatedUserId); + await verifyTransactionPin(authenticatedUserId, pin); + + const otherActiveCount = await countOtherActiveMembers(models, sharedWallet, authenticatedUserId); + if (otherActiveCount < 1) { + throw new SharedWalletError(400, "No other active members to approve this withdrawal"); + } + const requiredApprovals = computeRequiredApprovals(otherActiveCount); + + const withdrawal = await models.SharedWalletWithdrawal.create({ + sharedWalletId: sharedWallet.id, + walletId: wallet.id, + requestedByUserId: authenticatedUserId, + amount: parsedAmount, + currency: wallet.currency || "RWF", + note: note || null, + status: "pending", + requiredApprovals, + approveCount: 0, + declineCount: 0, + } as any); + + const authenticatedUser = await models.User.findByPk(authenticatedUserId); + const chatId = await getChatIdForSharedWallet(models, sharedWallet); + if (chatId) { + const message = await postMoneyChatMessage(getIo(req), models, chatId, authenticatedUserId, { + type: "shared_wallet_withdrawal_request", + withdrawalId: withdrawal.id, + sharedWalletId: sharedWallet.id, + groupId: sharedWallet.groupId || null, + amount: parsedAmount, + currency: withdrawal.currency, + requesterId: authenticatedUserId, + requesterName: authenticatedUser ? `${authenticatedUser.firstName} ${authenticatedUser.lastName}` : "A member", + note: note || "", + status: "pending", + requiredApprovals, + approveCount: 0, + declineCount: 0, + timestamp: new Date().toISOString(), + }); + await withdrawal.update({ chatMessageId: message.id }); + } + + const otherMembers = (await listActiveMembers(models, sharedWallet)).filter( + (m) => m.userId !== authenticatedUserId + ); + await Promise.all( + otherMembers.map((m) => + createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_WITHDRAWAL_REQUESTED, + recipientId: m.userId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + withdrawalId: withdrawal.id, + amount: parsedAmount, + currency: withdrawal.currency, + message: `${authenticatedUser ? `${authenticatedUser.firstName} ${authenticatedUser.lastName}` : "A member"} requested to withdraw ${parsedAmount} ${withdrawal.currency} from ${sharedWallet.name}`, + }, + }) + ) + ); + + res.status(201).json({ success: true, message: "Withdrawal requested", data: withdrawal }); + } catch (error) { + handleControllerError(res, error, "Propose shared wallet withdrawal"); + } +}; + +const loadPendingWithdrawal = async ( + models: ReturnType, + sharedWalletId: string, + id: string, + t?: DbTransaction +) => { + const withdrawal = await models.SharedWalletWithdrawal.findByPk( + id, + t ? { lock: t.LOCK.UPDATE, transaction: t } : undefined + ); + if (!withdrawal || withdrawal.sharedWalletId !== sharedWalletId) { + throw new SharedWalletError(404, "Withdrawal request not found"); + } + return withdrawal; +}; + +/** POST /shared-wallets/:id/withdrawals/:wid/approve */ +export const approveWithdrawal = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id, wid } = req.params; + const authenticatedUserId = req.user.id; + const { pin } = req.body; + + const { sharedWallet } = await resolveSharedWallet(models, id); + await resolveMembership(models, sharedWallet, authenticatedUserId); + await verifyTransactionPin(authenticatedUserId, pin); + + let updated: any; + let executed = false; + + await models.sequelize.transaction(async (t: DbTransaction) => { + const withdrawal = await loadPendingWithdrawal(models, sharedWallet.id, wid, t); + if (withdrawal.status !== "pending") { + throw new SharedWalletError(400, `Withdrawal cannot be approved from status '${withdrawal.status}'`); + } + if (withdrawal.requestedByUserId === authenticatedUserId) { + throw new SharedWalletError(400, "You cannot approve your own withdrawal request"); + } + const existingVote = await models.SharedWalletWithdrawalVote.findOne({ + where: { withdrawalId: withdrawal.id, userId: authenticatedUserId }, + transaction: t, + }); + if (existingVote) throw new SharedWalletError(400, "You have already voted on this withdrawal request"); + + await models.SharedWalletWithdrawalVote.create( + { withdrawalId: withdrawal.id, userId: authenticatedUserId, decision: "approve" } as any, + { transaction: t } + ); + + const newApproveCount = withdrawal.approveCount + 1; + + if (newApproveCount >= withdrawal.requiredApprovals) { + const requesterWallet = await models.Wallet.findOne({ + where: { userId: withdrawal.requestedByUserId, isActive: true }, + transaction: t, + }); + if (!requesterWallet) throw new SharedWalletError(404, "Requester's wallet not found or inactive"); + + const result = await executeSharedWalletWithdrawal( + withdrawal.walletId, + requesterWallet.id, + parseFloat(withdrawal.amount.toString()), + withdrawal.note || `Withdrawal from ${sharedWallet.name}`, + t, + withdrawal.id + ); + + await withdrawal.update( + { + approveCount: newApproveCount, + status: "approved", + decidedAt: new Date(), + transactionId: result.transactionId, + }, + { transaction: t } + ); + executed = true; + } else { + await withdrawal.update({ approveCount: newApproveCount }, { transaction: t }); + } + + updated = withdrawal; + }); + + if (updated.chatMessageId) { + await updateSharedWalletWithdrawalMessage(getIo(req), models, updated.chatMessageId, { + status: updated.status, + approveCount: updated.approveCount, + declineCount: updated.declineCount, + transactionId: updated.transactionId, + }); + } + + await broadcastToSharedWalletMembers(getIo(req), models, sharedWallet, "shared_wallet_withdrawal_updated", { + withdrawalId: updated.id, + status: updated.status, + approveCount: updated.approveCount, + declineCount: updated.declineCount, + transactionId: updated.transactionId ?? null, + }); + + if (executed) { + const pooledWallet = await models.Wallet.findByPk(updated.walletId); + if (pooledWallet) { + await broadcastToSharedWalletMembers(getIo(req), models, sharedWallet, "shared_wallet_balance_update", { + balance: parseFloat(pooledWallet.balance.toString()), + }); + } + await createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_WITHDRAWAL_EXECUTED, + recipientId: updated.requestedByUserId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + withdrawalId: updated.id, + amount: parseFloat(updated.amount.toString()), + currency: updated.currency, + transactionId: updated.transactionId, + message: `Your withdrawal request for ${updated.amount} ${updated.currency} from ${sharedWallet.name} was approved and completed`, + }, + }); + } else { + const approver = await models.User.findByPk(authenticatedUserId); + await createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_WITHDRAWAL_APPROVED, + recipientId: updated.requestedByUserId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + withdrawalId: updated.id, + amount: parseFloat(updated.amount.toString()), + currency: updated.currency, + message: `${approver ? `${approver.firstName} ${approver.lastName}` : "A member"} approved your withdrawal request for ${updated.amount} ${updated.currency} from ${sharedWallet.name} (${updated.approveCount}/${updated.requiredApprovals})`, + }, + }); + } + + res.status(200).json({ success: true, message: "Vote recorded", data: updated }); + } catch (error) { + handleControllerError(res, error, "Approve shared wallet withdrawal"); + } +}; + +/** POST /shared-wallets/:id/withdrawals/:wid/decline */ +export const declineWithdrawal = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id, wid } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + await resolveMembership(models, sharedWallet, authenticatedUserId); + + let updated: any; + + await models.sequelize.transaction(async (t: DbTransaction) => { + const withdrawal = await loadPendingWithdrawal(models, sharedWallet.id, wid, t); + if (withdrawal.status !== "pending") { + throw new SharedWalletError(400, `Withdrawal cannot be declined from status '${withdrawal.status}'`); + } + if (withdrawal.requestedByUserId === authenticatedUserId) { + throw new SharedWalletError(400, "You cannot decline your own withdrawal request"); + } + const existingVote = await models.SharedWalletWithdrawalVote.findOne({ + where: { withdrawalId: withdrawal.id, userId: authenticatedUserId }, + transaction: t, + }); + if (existingVote) throw new SharedWalletError(400, "You have already voted on this withdrawal request"); + + await models.SharedWalletWithdrawalVote.create( + { withdrawalId: withdrawal.id, userId: authenticatedUserId, decision: "decline" } as any, + { transaction: t } + ); + + const newDeclineCount = withdrawal.declineCount + 1; + const otherActiveCount = await countOtherActiveMembers(models, sharedWallet, withdrawal.requestedByUserId); + const remainingVoters = otherActiveCount - (withdrawal.approveCount + newDeclineCount); + const stillPossible = withdrawal.approveCount + remainingVoters >= withdrawal.requiredApprovals; + + if (!stillPossible) { + await withdrawal.update( + { declineCount: newDeclineCount, status: "declined", decidedAt: new Date() }, + { transaction: t } + ); + } else { + await withdrawal.update({ declineCount: newDeclineCount }, { transaction: t }); + } + + updated = withdrawal; + }); + + if (updated.chatMessageId) { + await updateSharedWalletWithdrawalMessage(getIo(req), models, updated.chatMessageId, { + status: updated.status, + approveCount: updated.approveCount, + declineCount: updated.declineCount, + transactionId: updated.transactionId, + }); + } + + await broadcastToSharedWalletMembers(getIo(req), models, sharedWallet, "shared_wallet_withdrawal_updated", { + withdrawalId: updated.id, + status: updated.status, + approveCount: updated.approveCount, + declineCount: updated.declineCount, + transactionId: updated.transactionId ?? null, + }); + + if (updated.status === "declined") { + await createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_WITHDRAWAL_DECLINED, + recipientId: updated.requestedByUserId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + withdrawalId: updated.id, + amount: parseFloat(updated.amount.toString()), + currency: updated.currency, + message: `Your withdrawal request for ${updated.amount} ${updated.currency} from ${sharedWallet.name} was declined`, + }, + }); + } + + res.status(200).json({ success: true, message: "Vote recorded", data: updated }); + } catch (error) { + handleControllerError(res, error, "Decline shared wallet withdrawal"); + } +}; + +/** POST /shared-wallets/:id/withdrawals/:wid/cancel - only the requester can cancel their own pending request. */ +export const cancelWithdrawal = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id, wid } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + const withdrawal = await loadPendingWithdrawal(models, sharedWallet.id, wid); + if (withdrawal.requestedByUserId !== authenticatedUserId) { + throw new SharedWalletError(403, "Only the requester can cancel this withdrawal"); + } + if (withdrawal.status !== "pending") { + throw new SharedWalletError(400, `Withdrawal cannot be cancelled from status '${withdrawal.status}'`); + } + + await withdrawal.update({ status: "cancelled", decidedAt: new Date() }); + + if (withdrawal.chatMessageId) { + await updateSharedWalletWithdrawalMessage(getIo(req), models, withdrawal.chatMessageId, { + status: withdrawal.status, + approveCount: withdrawal.approveCount, + declineCount: withdrawal.declineCount, + transactionId: withdrawal.transactionId, + }); + } + + await broadcastToSharedWalletMembers(getIo(req), models, sharedWallet, "shared_wallet_withdrawal_updated", { + withdrawalId: withdrawal.id, + status: withdrawal.status, + approveCount: withdrawal.approveCount, + declineCount: withdrawal.declineCount, + transactionId: withdrawal.transactionId ?? null, + }); + + res.status(200).json({ success: true, message: "Withdrawal request cancelled", data: withdrawal }); + } catch (error) { + handleControllerError(res, error, "Cancel shared wallet withdrawal"); + } +}; + +/** GET /shared-wallets/:id/withdrawals/:wid - polled by the withdrawal-request card for live status. */ +export const getWithdrawalById = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id, wid } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + await resolveMembership(models, sharedWallet, authenticatedUserId); + + const withdrawal = await models.SharedWalletWithdrawal.findByPk(wid, { + include: [{ model: models.SharedWalletWithdrawalVote, as: "votes" }], + }); + if (!withdrawal || withdrawal.sharedWalletId !== sharedWallet.id) { + throw new SharedWalletError(404, "Withdrawal request not found"); + } + + res.status(200).json({ success: true, data: withdrawal }); + } catch (error) { + handleControllerError(res, error, "Get shared wallet withdrawal"); + } +}; + +/** + * GET /shared-wallets/:id/activity + * Unified feed: money that actually moved (Transactions on the pooled wallet) plus + * withdrawal requests (including still-pending ones, which have no Transaction yet). + */ +export const getActivity = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + const limit = Math.min(parseInt(String(req.query.limit || "30"), 10) || 30, 100); + + const { sharedWallet, wallet } = await resolveSharedWallet(models, id); + await resolveMembership(models, sharedWallet, authenticatedUserId); + + const userAttrs = ["id", "firstName", "lastName"]; + const [transactions, withdrawals] = await Promise.all([ + models.Transaction.findAll({ + where: { [Op.or]: [{ senderWalletId: wallet.id }, { receiverWalletId: wallet.id }] }, + order: [["createdAt", "DESC"]], + limit, + include: [ + { model: models.Wallet, as: "senderWallet", attributes: ["id", "userId"], include: [{ model: models.User, as: "user", attributes: userAttrs, required: false }] }, + { model: models.Wallet, as: "receiverWallet", attributes: ["id", "userId"], include: [{ model: models.User, as: "user", attributes: userAttrs, required: false }] }, + ], + }), + models.SharedWalletWithdrawal.findAll({ + where: { sharedWalletId: sharedWallet.id }, + order: [["createdAt", "DESC"]], + limit, + include: [{ model: models.User, as: "requestedBy", attributes: userAttrs, required: false }], + }), + ]); + + const nameOf = (user: any): string | null => (user ? `${user.firstName} ${user.lastName}` : null); + + const transactionEntries = transactions.map((t: any) => { + const isDeposit = t.receiverWalletId === wallet.id; + // The member on the "other side" of the pooled wallet - who deposited or who + // received the withdrawal - not the wallet itself. + const personUser = isDeposit ? t.senderWallet?.user : t.receiverWallet?.user; + return { + kind: "transaction", + id: t.id, + type: isDeposit ? "deposit" : "withdrawal", + amount: parseFloat(t.amount.toString()), + currency: t.currency, + description: t.description, + personName: nameOf(personUser), + createdAt: t.createdAt, + }; + }); + + // Pending requests have no Transaction yet - surface them too so the Activity tab + // shows "awaiting approval" items, not just completed money movements. + const pendingWithdrawalEntries = withdrawals + .filter((w: any) => w.status === "pending") + .map((w: any) => ({ + kind: "withdrawal_request", + id: w.id, + status: w.status, + amount: parseFloat(w.amount.toString()), + currency: w.currency, + requestedByUserId: w.requestedByUserId, + requestedByName: nameOf(w.requestedBy), + requiredApprovals: w.requiredApprovals, + approveCount: w.approveCount, + declineCount: w.declineCount, + note: w.note, + createdAt: w.createdAt, + })); + + const combined = [...transactionEntries, ...pendingWithdrawalEntries].sort( + (a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + res.status(200).json({ success: true, data: combined.slice(0, limit) }); + } catch (error) { + handleControllerError(res, error, "Get shared wallet activity"); + } +}; + +/** GET /shared-wallets/:id/members */ +export const listMembers = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + await resolveMembership(models, sharedWallet, authenticatedUserId); + + const members = await listActiveMembers(models, sharedWallet); + const users = await models.User.findAll({ + where: { id: { [Op.in]: members.map((m) => m.userId) } }, + attributes: ["id", "firstName", "lastName"], + }); + const usersById = new Map(users.map((u) => [u.id, u])); + + const data = members.map((m) => { + const user = usersById.get(m.userId); + return { + userId: m.userId, + userName: user ? `${user.firstName} ${user.lastName}` : "Unknown", + role: m.role, + status: m.status, + }; + }); + + res.status(200).json({ success: true, data }); + } catch (error) { + handleControllerError(res, error, "List shared wallet members"); + } +}; + +/** + * POST /shared-wallets/:id/members - STANDALONE wallets only. A group-attached shared + * wallet's membership follows the group - invite members via `POST /groups/invite`. + */ +export const addMember = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + const { userId: newMemberId } = req.body; + + const { sharedWallet } = await resolveSharedWallet(models, id); + if (sharedWallet.groupId) { + throw new SharedWalletError(400, "This shared wallet follows its group's membership - invite via the group instead"); + } + const membership = await resolveMembership(models, sharedWallet, authenticatedUserId); + if (membership.role !== "owner" && membership.role !== "admin") { + throw new SharedWalletError(403, "Only owners and admins can add members"); + } + if (!newMemberId) throw new SharedWalletError(400, "userId is required"); + + const contact = await models.Contact.findOne({ + where: { + [Op.or]: [ + { userAId: authenticatedUserId, userBId: newMemberId }, + { userAId: newMemberId, userBId: authenticatedUserId }, + ], + status: "active", + }, + }); + if (!contact) throw new SharedWalletError(400, "That user must be in your contacts"); + + const existing = await models.SharedWalletMember.findOne({ + where: { sharedWalletId: sharedWallet.id, userId: newMemberId }, + }); + let membershipId: string; + if (existing) { + if (existing.status === "active") throw new SharedWalletError(400, "Already a member"); + if (existing.status === "pending") throw new SharedWalletError(400, "Already has a pending invitation"); + await existing.update({ status: "pending", role: "member" }); + membershipId = existing.id; + } else { + const created = await models.SharedWalletMember.create( + { sharedWalletId: sharedWallet.id, userId: newMemberId, role: "member", status: "pending" } as any + ); + membershipId = created.id; + } + + const adder = await models.User.findByPk(authenticatedUserId); + await createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_INVITATION, + recipientId: newMemberId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + membershipId, + userId: authenticatedUserId, + userName: adder ? `${adder.firstName} ${adder.lastName}` : "Someone", + message: `${adder ? `${adder.firstName} ${adder.lastName}` : "Someone"} invited you to join the shared wallet '${sharedWallet.name}'`, + }, + }); + + res.status(200).json({ success: true, message: "Invitation sent" }); + } catch (error) { + handleControllerError(res, error, "Add shared wallet member"); + } +}; + +/** DELETE /shared-wallets/:id/members/:userId - STANDALONE wallets only. */ +export const removeMember = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id, userId: targetUserId } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + if (sharedWallet.groupId) { + throw new SharedWalletError(400, "This shared wallet follows its group's membership - remove via the group instead"); + } + const membership = await resolveMembership(models, sharedWallet, authenticatedUserId); + if (membership.role !== "owner" && membership.role !== "admin") { + throw new SharedWalletError(403, "Only owners and admins can remove members"); + } + + const target = await models.SharedWalletMember.findOne({ + where: { sharedWalletId: sharedWallet.id, userId: targetUserId, status: { [Op.in]: ["active", "pending"] } }, + }); + if (!target) throw new SharedWalletError(404, "Member not found"); + if (target.role === "owner") throw new SharedWalletError(400, "Cannot remove the wallet owner"); + + const wasPending = target.status === "pending"; + await target.update({ status: "removed" }); + + res.status(200).json({ success: true, message: wasPending ? "Invitation cancelled" : "Member removed" }); + } catch (error) { + handleControllerError(res, error, "Remove shared wallet member"); + } +}; + +/** GET /shared-wallets/:id/pending-members - STANDALONE wallets only, owner/admin-only. */ +export const listPendingMembers = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + if (sharedWallet.groupId) { + throw new SharedWalletError(400, "This shared wallet follows its group's membership"); + } + const membership = await resolveMembership(models, sharedWallet, authenticatedUserId); + if (membership.role !== "owner" && membership.role !== "admin") { + throw new SharedWalletError(403, "Only owners and admins can view pending invitations"); + } + + const pending = await models.SharedWalletMember.findAll({ + where: { sharedWalletId: sharedWallet.id, status: "pending" }, + }); + const users = await models.User.findAll({ + where: { id: { [Op.in]: pending.map((m) => m.userId) } }, + attributes: ["id", "firstName", "lastName"], + }); + const usersById = new Map(users.map((u) => [u.id, u])); + + const data = pending.map((m) => { + const user = usersById.get(m.userId); + return { + userId: m.userId, + userName: user ? `${user.firstName} ${user.lastName}` : "Unknown", + }; + }); + + res.status(200).json({ success: true, data }); + } catch (error) { + handleControllerError(res, error, "List pending shared wallet invitations"); + } +}; + +/** POST /shared-wallets/:id/leave - STANDALONE wallets only. */ +export const leaveSharedWallet = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + if (sharedWallet.groupId) { + throw new SharedWalletError(400, "This shared wallet follows its group's membership - leave via the group instead"); + } + const membership = await models.SharedWalletMember.findOne({ + where: { sharedWalletId: sharedWallet.id, userId: authenticatedUserId, status: "active" }, + }); + if (!membership) throw new SharedWalletError(404, "You are not a member of this shared wallet"); + if (membership.role === "owner") { + throw new SharedWalletError( + 400, + "The owner cannot leave directly - transfer ownership to another member, or delete the wallet (only possible at a zero balance) first." + ); + } + + await membership.update({ status: "left" }); + + res.status(200).json({ success: true, message: "Left the shared wallet" }); + } catch (error) { + handleControllerError(res, error, "Leave shared wallet"); + } +}; + +/** POST /shared-wallets/:id/transfer-ownership - STANDALONE wallets only. */ +export const transferOwnership = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + const { newOwnerUserId } = req.body; + + const { sharedWallet } = await resolveSharedWallet(models, id); + if (sharedWallet.groupId) { + throw new SharedWalletError(400, "This shared wallet follows its group's ownership - transfer ownership via the group instead"); + } + if (!newOwnerUserId) throw new SharedWalletError(400, "newOwnerUserId is required"); + if (newOwnerUserId === authenticatedUserId) throw new SharedWalletError(400, "You are already the owner"); + + await models.sequelize.transaction(async (t: DbTransaction) => { + const currentOwner = await models.SharedWalletMember.findOne({ + where: { sharedWalletId: sharedWallet.id, userId: authenticatedUserId, status: "active", role: "owner" }, + transaction: t, + lock: t.LOCK.UPDATE, + }); + if (!currentOwner) throw new SharedWalletError(403, "Only the current owner can transfer ownership"); + + const target = await models.SharedWalletMember.findOne({ + where: { sharedWalletId: sharedWallet.id, userId: newOwnerUserId, status: "active" }, + transaction: t, + lock: t.LOCK.UPDATE, + }); + if (!target) throw new SharedWalletError(404, "Target user is not an active member of this shared wallet"); + + await target.update({ role: "owner" }, { transaction: t }); + await currentOwner.update({ role: "admin" }, { transaction: t }); + }); + + await createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_OWNERSHIP_TRANSFERRED, + recipientId: newOwnerUserId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + message: `You are now the owner of the shared wallet '${sharedWallet.name}'`, + }, + }); + + res.status(200).json({ success: true, message: "Ownership transferred" }); + } catch (error) { + handleControllerError(res, error, "Transfer shared wallet ownership"); + } +}; + +/** DELETE /shared-wallets/:id - STANDALONE wallets only, owner-only, requires a zero balance. */ +export const deleteSharedWallet = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet, wallet } = await resolveSharedWallet(models, id); + if (sharedWallet.groupId) { + throw new SharedWalletError(400, "This shared wallet follows its group - delete the group instead"); + } + const membership = await resolveMembership(models, sharedWallet, authenticatedUserId); + if (membership.role !== "owner") { + throw new SharedWalletError(403, "Only the owner can delete this shared wallet"); + } + const currentBalance = parseFloat(wallet.balance.toString()); + if (currentBalance !== 0) { + throw new SharedWalletError(400, "Withdraw the remaining balance before deleting this wallet"); + } + const pendingCount = await models.SharedWalletWithdrawal.count({ + where: { sharedWalletId: sharedWallet.id, status: "pending" }, + }); + if (pendingCount > 0) { + throw new SharedWalletError(400, "Resolve pending withdrawal requests before deleting this wallet"); + } + + const activeMemberIds = (await listActiveMembers(models, sharedWallet)) + .map((m) => m.userId) + .filter((uid) => uid !== authenticatedUserId); + const pendingInviteeIds = ( + await models.SharedWalletMember.findAll({ + where: { sharedWalletId: sharedWallet.id, status: "pending" }, + attributes: ["userId"], + }) + ).map((m) => m.userId); + const notifyIds = Array.from(new Set([...activeMemberIds, ...pendingInviteeIds])); + + const walletName = sharedWallet.name; + await sharedWallet.destroy(); + + const deleter = await models.User.findByPk(authenticatedUserId); + await Promise.all( + notifyIds.map((uid) => + createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_DELETED, + recipientId: uid, + data: { + sharedWalletName: walletName, + message: `${deleter ? `${deleter.firstName} ${deleter.lastName}` : "The owner"} deleted the shared wallet '${walletName}'`, + }, + }) + ) + ); + + res.status(200).json({ success: true, message: "Shared wallet deleted" }); + } catch (error) { + handleControllerError(res, error, "Delete shared wallet"); + } +}; + +/** + * POST /shared-wallets/:id/invitations/:membershipId/respond + * Accept or decline a pending invitation to a STANDALONE shared wallet. + */ +export const respondToSharedWalletInvitation = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id, membershipId } = req.params; + const { action } = req.body; + const authenticatedUserId = req.user.id; + + if (action !== "accept" && action !== "reject") { + throw new SharedWalletError(400, "action must be 'accept' or 'reject'"); + } + + const { sharedWallet } = await resolveSharedWallet(models, id); + const membership = await models.SharedWalletMember.findOne({ + where: { id: membershipId, sharedWalletId: sharedWallet.id, userId: authenticatedUserId, status: "pending" }, + }); + if (!membership) throw new SharedWalletError(404, "Invitation not found or already responded to"); + + await membership.update({ status: action === "accept" ? "active" : "removed" }); + + const owner = await models.SharedWalletMember.findOne({ + where: { sharedWalletId: sharedWallet.id, role: "owner", status: "active" }, + }); + const responder = await models.User.findByPk(authenticatedUserId); + const responderName = responder ? `${responder.firstName} ${responder.lastName}` : "Someone"; + if (owner) { + await createAndSendNotification(req.app, { + type: action === "accept" ? NotificationType.SHARED_WALLET_INVITATION_ACCEPTED : NotificationType.SHARED_WALLET_INVITATION_REJECTED, + recipientId: owner.userId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + userId: authenticatedUserId, + userName: responderName, + message: `${responderName} ${action === "accept" ? "accepted" : "declined"} the invitation to '${sharedWallet.name}'`, + }, + }); + } + + res.status(200).json({ + success: true, + message: action === "accept" ? "Joined the shared wallet" : "Invitation declined", + }); + } catch (error) { + handleControllerError(res, error, "Respond to shared wallet invitation"); + } +}; + +/** + * GET /shared-wallets/invitations/pending + * Lists the authenticated user's still-pending shared wallet invitations, so the + * notification center can resolve a membershipId to accept/reject against. + */ +export const getPendingSharedWalletInvitations = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const userId = req.user.id; + + const pending = await models.SharedWalletMember.findAll({ + where: { userId, status: "pending" }, + include: [{ model: models.SharedWallet, as: "sharedWallet" }], + }); + + res.status(200).json({ + success: true, + data: pending.map((p: any) => ({ + id: p.id, + sharedWalletId: p.sharedWalletId, + sharedWalletName: p.sharedWallet?.name, + })), + }); + } catch (error) { + handleControllerError(res, error, "Get pending shared wallet invitations"); + } +}; + +/** + * PATCH /shared-wallets/:id/policy + * Instant tightening only (free -> approval). Owner/admin-only, no PIN (not a money + * movement). Loosening (approval -> free) always goes through a quorum vote instead - + * see proposePolicyChange below. + */ +export const updateWithdrawalPolicyInstant = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + const { policy } = req.body; + + const { sharedWallet } = await resolveSharedWallet(models, id); + if (sharedWallet.groupId) { + throw new SharedWalletError(400, "This shared wallet follows its group's policy settings"); + } + const membership = await resolveMembership(models, sharedWallet, authenticatedUserId); + if (membership.role !== "owner" && membership.role !== "admin") { + throw new SharedWalletError(403, "Only owners and admins can tighten the withdrawal policy"); + } + if (policy !== "approval") { + throw new SharedWalletError(400, "Only tightening (to 'approval') is instant - loosening requires a proposal"); + } + if (sharedWallet.withdrawalPolicy === "approval") { + throw new SharedWalletError(400, "This shared wallet already requires approval"); + } + + await sharedWallet.update({ withdrawalPolicy: "approval" }); + + const others = (await listActiveMembers(models, sharedWallet)).filter((m) => m.userId !== authenticatedUserId); + await Promise.all( + others.map((m) => + createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_POLICY_CHANGED, + recipientId: m.userId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + message: `'${sharedWallet.name}' now requires approval for withdrawals`, + }, + }) + ) + ); + + res.status(200).json({ success: true, message: "Withdrawal policy updated" }); + } catch (error) { + handleControllerError(res, error, "Update shared wallet policy"); + } +}; + +/** + * POST /shared-wallets/:id/policy-changes + * Proposes loosening the withdrawal policy (approval -> free). Requires the same + * majority quorum as a withdrawal request - see proposeWithdrawal for the pattern. + */ +export const proposePolicyChange = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + if (sharedWallet.groupId) { + throw new SharedWalletError(400, "This shared wallet follows its group's policy settings"); + } + await resolveMembership(models, sharedWallet, authenticatedUserId); + if (sharedWallet.withdrawalPolicy !== "approval") { + throw new SharedWalletError(400, "This shared wallet already allows free withdrawals"); + } + + const existingPending = await models.SharedWalletPolicyChange.count({ + where: { sharedWalletId: sharedWallet.id, status: "pending" }, + }); + if (existingPending > 0) { + throw new SharedWalletError(400, "A policy change is already pending a vote"); + } + + const otherActiveCount = await countOtherActiveMembers(models, sharedWallet, authenticatedUserId); + if (otherActiveCount < 1) { + throw new SharedWalletError(400, "No other active members to approve this change"); + } + const requiredApprovals = computeRequiredApprovals(otherActiveCount); + + const policyChange = await models.SharedWalletPolicyChange.create({ + sharedWalletId: sharedWallet.id, + proposedByUserId: authenticatedUserId, + targetPolicy: "free", + status: "pending", + requiredApprovals, + approveCount: 0, + declineCount: 0, + } as any); + + const proposer = await models.User.findByPk(authenticatedUserId); + const otherMembers = (await listActiveMembers(models, sharedWallet)).filter( + (m) => m.userId !== authenticatedUserId + ); + await Promise.all( + otherMembers.map((m) => + createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_POLICY_CHANGE_PROPOSED, + recipientId: m.userId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + message: `${proposer ? `${proposer.firstName} ${proposer.lastName}` : "A member"} proposed switching '${sharedWallet.name}' to free withdrawals`, + }, + }) + ) + ); + + res.status(201).json({ success: true, message: "Policy change proposed", data: policyChange }); + } catch (error) { + handleControllerError(res, error, "Propose shared wallet policy change"); + } +}; + +/** GET /shared-wallets/:id/policy-changes/pending - the current in-flight proposal, if any. */ +export const getPendingPolicyChange = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + await resolveMembership(models, sharedWallet, authenticatedUserId); + + const policyChange = await models.SharedWalletPolicyChange.findOne({ + where: { sharedWalletId: sharedWallet.id, status: "pending" }, + include: [{ model: models.User, as: "proposedBy", attributes: ["id", "firstName", "lastName"] }], + order: [["createdAt", "DESC"]], + }); + + res.status(200).json({ success: true, data: policyChange }); + } catch (error) { + handleControllerError(res, error, "Get pending shared wallet policy change"); + } +}; + +const loadPendingPolicyChange = async ( + models: ReturnType, + sharedWalletId: string, + id: string, + t?: DbTransaction +) => { + const policyChange = await models.SharedWalletPolicyChange.findByPk( + id, + t ? { lock: t.LOCK.UPDATE, transaction: t } : undefined + ); + if (!policyChange || policyChange.sharedWalletId !== sharedWalletId) { + throw new SharedWalletError(404, "Policy change request not found"); + } + return policyChange; +}; + +/** POST /shared-wallets/:id/policy-changes/:pcid/approve */ +export const approvePolicyChange = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id, pcid } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + await resolveMembership(models, sharedWallet, authenticatedUserId); + + let updated: any; + let executed = false; + + await models.sequelize.transaction(async (t: DbTransaction) => { + const policyChange = await loadPendingPolicyChange(models, sharedWallet.id, pcid, t); + if (policyChange.status !== "pending") { + throw new SharedWalletError(400, `Policy change cannot be approved from status '${policyChange.status}'`); + } + if (policyChange.proposedByUserId === authenticatedUserId) { + throw new SharedWalletError(400, "You cannot approve your own policy change proposal"); + } + const existingVote = await models.SharedWalletPolicyChangeVote.findOne({ + where: { policyChangeId: policyChange.id, userId: authenticatedUserId }, + transaction: t, + }); + if (existingVote) throw new SharedWalletError(400, "You have already voted on this policy change"); + + await models.SharedWalletPolicyChangeVote.create( + { policyChangeId: policyChange.id, userId: authenticatedUserId, decision: "approve" } as any, + { transaction: t } + ); + + const newApproveCount = policyChange.approveCount + 1; + + if (newApproveCount >= policyChange.requiredApprovals) { + await sharedWallet.update({ withdrawalPolicy: policyChange.targetPolicy }, { transaction: t }); + await policyChange.update( + { approveCount: newApproveCount, status: "approved", decidedAt: new Date() }, + { transaction: t } + ); + executed = true; + } else { + await policyChange.update({ approveCount: newApproveCount }, { transaction: t }); + } + + updated = policyChange; + }); + + await broadcastToSharedWalletMembers(getIo(req), models, sharedWallet, "shared_wallet_policy_change_updated", { + policyChangeId: updated.id, + status: updated.status, + approveCount: updated.approveCount, + declineCount: updated.declineCount, + }); + + if (executed) { + await createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_POLICY_CHANGE_APPROVED, + recipientId: updated.proposedByUserId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + message: `'${sharedWallet.name}' now allows free withdrawals`, + }, + }); + } + + res.status(200).json({ success: true, message: "Vote recorded", data: updated }); + } catch (error) { + handleControllerError(res, error, "Approve shared wallet policy change"); + } +}; + +/** POST /shared-wallets/:id/policy-changes/:pcid/decline */ +export const declinePolicyChange = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id, pcid } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + await resolveMembership(models, sharedWallet, authenticatedUserId); + + let updated: any; + + await models.sequelize.transaction(async (t: DbTransaction) => { + const policyChange = await loadPendingPolicyChange(models, sharedWallet.id, pcid, t); + if (policyChange.status !== "pending") { + throw new SharedWalletError(400, `Policy change cannot be declined from status '${policyChange.status}'`); + } + if (policyChange.proposedByUserId === authenticatedUserId) { + throw new SharedWalletError(400, "You cannot decline your own policy change proposal"); + } + const existingVote = await models.SharedWalletPolicyChangeVote.findOne({ + where: { policyChangeId: policyChange.id, userId: authenticatedUserId }, + transaction: t, + }); + if (existingVote) throw new SharedWalletError(400, "You have already voted on this policy change"); + + await models.SharedWalletPolicyChangeVote.create( + { policyChangeId: policyChange.id, userId: authenticatedUserId, decision: "decline" } as any, + { transaction: t } + ); + + const newDeclineCount = policyChange.declineCount + 1; + const otherActiveCount = await countOtherActiveMembers(models, sharedWallet, policyChange.proposedByUserId); + const remainingVoters = otherActiveCount - (policyChange.approveCount + newDeclineCount); + const stillPossible = policyChange.approveCount + remainingVoters >= policyChange.requiredApprovals; + + if (!stillPossible) { + await policyChange.update( + { declineCount: newDeclineCount, status: "declined", decidedAt: new Date() }, + { transaction: t } + ); + } else { + await policyChange.update({ declineCount: newDeclineCount }, { transaction: t }); + } + + updated = policyChange; + }); + + await broadcastToSharedWalletMembers(getIo(req), models, sharedWallet, "shared_wallet_policy_change_updated", { + policyChangeId: updated.id, + status: updated.status, + approveCount: updated.approveCount, + declineCount: updated.declineCount, + }); + + if (updated.status === "declined") { + await createAndSendNotification(req.app, { + type: NotificationType.SHARED_WALLET_POLICY_CHANGE_DECLINED, + recipientId: updated.proposedByUserId, + data: { + sharedWalletId: sharedWallet.id, + sharedWalletName: sharedWallet.name, + message: `Your proposal to switch '${sharedWallet.name}' to free withdrawals was declined`, + }, + }); + } + + res.status(200).json({ success: true, message: "Vote recorded", data: updated }); + } catch (error) { + handleControllerError(res, error, "Decline shared wallet policy change"); + } +}; + +/** POST /shared-wallets/:id/policy-changes/:pcid/cancel - only the proposer can cancel. */ +export const cancelPolicyChange = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const models = getModels(req); + const { id, pcid } = req.params; + const authenticatedUserId = req.user.id; + + const { sharedWallet } = await resolveSharedWallet(models, id); + const policyChange = await loadPendingPolicyChange(models, sharedWallet.id, pcid); + if (policyChange.proposedByUserId !== authenticatedUserId) { + throw new SharedWalletError(403, "Only the proposer can cancel this policy change"); + } + if (policyChange.status !== "pending") { + throw new SharedWalletError(400, `Policy change cannot be cancelled from status '${policyChange.status}'`); + } + + await policyChange.update({ status: "cancelled", decidedAt: new Date() }); + + await broadcastToSharedWalletMembers(getIo(req), models, sharedWallet, "shared_wallet_policy_change_updated", { + policyChangeId: policyChange.id, + status: policyChange.status, + approveCount: policyChange.approveCount, + declineCount: policyChange.declineCount, + }); + + res.status(200).json({ success: true, message: "Policy change cancelled", data: policyChange }); + } catch (error) { + handleControllerError(res, error, "Cancel shared wallet policy change"); + } +}; diff --git a/src/controllers/walletItemsController.ts b/src/controllers/walletItemsController.ts index 03247f6..372f542 100644 --- a/src/controllers/walletItemsController.ts +++ b/src/controllers/walletItemsController.ts @@ -3,6 +3,7 @@ import QRCode from "qrcode"; import database_models from "../database/config/db.config"; import cloudinary from "../helpers/cloudinary"; import { WalletItemCreationAttributes } from "../types/model"; +import { getAvailableBalance } from "../utils/walletBalance"; const { Wallet, @@ -159,6 +160,11 @@ const getWalletSummary = async (req: Request, res: Response): Promise => { (sum: number, r: any) => sum + num(r.amount), 0 ); + // Funds reserved by pending scheduled transfers/escrow holds - not spendable, + // same as the Home page's balance already accounts for (getWalletBalance in + // transactionController.ts). This endpoint previously ignored heldBalance entirely, + // so "Available" here could show more than what a transfer would actually allow. + const heldAmount = num(wallet.balance) - getAvailableBalance(wallet); // Purchased actions / tickets (canonical records live in ActionPurchases). // Users own purchases via buyerId; organizations via organizationId. @@ -205,6 +211,7 @@ const getWalletSummary = async (req: Request, res: Response): Promise => { wallet: { id: wallet.id, balance: totalBalance, + heldBalance: heldAmount, currency: wallet.currency, isActive: wallet.isActive, entityType, @@ -213,7 +220,10 @@ const getWalletSummary = async (req: Request, res: Response): Promise => { balanceBreakdown: { total: totalBalance, restricted: totalRestrictedAmount, - available: totalBalance - totalRestrictedAmount, + held: heldAmount, + // Not clamped to 0 - matches the pre-existing "restricted > total" behavior, + // which relies on `available` going negative to drive the overBudget alert. + available: totalBalance - totalRestrictedAmount - heldAmount, }, restrictions: restrictions.map((r: any) => ({ id: r.id, diff --git a/src/database/migrations/20260831000000-add-shared-wallets.js b/src/database/migrations/20260831000000-add-shared-wallets.js new file mode 100644 index 0000000..08a45cd --- /dev/null +++ b/src/database/migrations/20260831000000-add-shared-wallets.js @@ -0,0 +1,339 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + + if (!tables.includes("SharedWallets")) { + await queryInterface.createTable( + "SharedWallets", + { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + name: { type: Sequelize.STRING, allowNull: false }, + withdrawalPolicy: { + type: Sequelize.ENUM("free", "approval"), + allowNull: false, + defaultValue: "approval", + }, + createdByUserId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + groupId: { + type: Sequelize.UUID, + allowNull: true, + unique: true, + references: { model: "Groups", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + }, + { transaction } + ); + } + + if (!tables.includes("SharedWalletMembers")) { + await queryInterface.createTable( + "SharedWalletMembers", + { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + sharedWalletId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "SharedWallets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + role: { + type: Sequelize.ENUM("owner", "admin", "member"), + allowNull: false, + defaultValue: "member", + }, + status: { + type: Sequelize.ENUM("active", "left", "removed"), + allowNull: false, + defaultValue: "active", + }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + }, + { transaction } + ); + + await queryInterface.addIndex("SharedWalletMembers", ["sharedWalletId", "userId"], { + unique: true, + name: "shared_wallet_members_wallet_user_unique", + transaction, + }); + } + + const groupsColumns = await queryInterface.describeTable("Groups", { transaction }); + + if (!groupsColumns.sharedWalletId) { + await queryInterface.addColumn( + "Groups", + "sharedWalletId", + { + type: Sequelize.UUID, + allowNull: true, + unique: true, + references: { model: "SharedWallets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + { transaction } + ); + } + + if (groupsColumns.hasSharedWallet) { + await queryInterface.removeColumn("Groups", "hasSharedWallet", { transaction }); + } + + if (groupsColumns.withdrawalPolicy) { + await queryInterface.removeColumn("Groups", "withdrawalPolicy", { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_Groups_withdrawalPolicy"', + { transaction } + ); + } + + const walletsColumns = await queryInterface.describeTable("Wallets", { transaction }); + + if (!walletsColumns.sharedWalletId) { + await queryInterface.addColumn( + "Wallets", + "sharedWalletId", + { + type: Sequelize.UUID, + allowNull: true, + unique: true, + references: { model: "SharedWallets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + { transaction } + ); + } + + if (!tables.includes("SharedWalletWithdrawals")) { + await queryInterface.createTable( + "SharedWalletWithdrawals", + { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + sharedWalletId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "SharedWallets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + walletId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Wallets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + requestedByUserId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + amount: { type: Sequelize.DECIMAL(15, 2), allowNull: false }, + currency: { type: Sequelize.STRING, allowNull: false, defaultValue: "RWF" }, + note: { type: Sequelize.STRING, allowNull: true }, + status: { + type: Sequelize.ENUM("pending", "approved", "declined", "cancelled"), + allowNull: false, + defaultValue: "pending", + }, + requiredApprovals: { type: Sequelize.INTEGER, allowNull: false }, + approveCount: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + declineCount: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + chatMessageId: { type: Sequelize.UUID, allowNull: true }, + transactionId: { type: Sequelize.UUID, allowNull: true }, + decidedAt: { type: Sequelize.DATE, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + }, + { transaction } + ); + } + + if (!tables.includes("SharedWalletWithdrawalVotes")) { + await queryInterface.createTable( + "SharedWalletWithdrawalVotes", + { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + withdrawalId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "SharedWalletWithdrawals", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + decision: { + type: Sequelize.ENUM("approve", "decline"), + allowNull: false, + }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + }, + { transaction } + ); + + await queryInterface.addIndex("SharedWalletWithdrawalVotes", ["withdrawalId", "userId"], { + unique: true, + name: "shared_wallet_withdrawal_votes_withdrawal_user_unique", + transaction, + }); + } + + const transactionsColumns = await queryInterface.describeTable("Transactions", { transaction }); + + if (!transactionsColumns.sharedWalletWithdrawalId) { + await queryInterface.addColumn( + "Transactions", + "sharedWalletWithdrawalId", + { + type: Sequelize.UUID, + allowNull: true, + references: { model: "SharedWalletWithdrawals", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + { transaction } + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const transactionsColumns = await queryInterface.describeTable("Transactions", { transaction }); + if (transactionsColumns.sharedWalletWithdrawalId) { + await queryInterface.removeColumn("Transactions", "sharedWalletWithdrawalId", { transaction }); + } + + const tables = await queryInterface.showAllTables({ transaction }); + + if (tables.includes("SharedWalletWithdrawalVotes")) { + await queryInterface.dropTable("SharedWalletWithdrawalVotes", { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_SharedWalletWithdrawalVotes_decision"', + { transaction } + ); + } + + if (tables.includes("SharedWalletWithdrawals")) { + await queryInterface.dropTable("SharedWalletWithdrawals", { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_SharedWalletWithdrawals_status"', + { transaction } + ); + } + + const walletsColumns = await queryInterface.describeTable("Wallets", { transaction }); + if (walletsColumns.sharedWalletId) { + await queryInterface.removeColumn("Wallets", "sharedWalletId", { transaction }); + } + + const groupsColumns = await queryInterface.describeTable("Groups", { transaction }); + + if (groupsColumns.sharedWalletId) { + await queryInterface.removeColumn("Groups", "sharedWalletId", { transaction }); + } + + if (!groupsColumns.hasSharedWallet) { + await queryInterface.addColumn( + "Groups", + "hasSharedWallet", + { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + { transaction } + ); + } + + if (!groupsColumns.withdrawalPolicy) { + await queryInterface.addColumn( + "Groups", + "withdrawalPolicy", + { type: Sequelize.ENUM("free", "approval"), allowNull: true }, + { transaction } + ); + } + + if (tables.includes("SharedWalletMembers")) { + await queryInterface.dropTable("SharedWalletMembers", { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_SharedWalletMembers_role"', + { transaction } + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_SharedWalletMembers_status"', + { transaction } + ); + } + + if (tables.includes("SharedWallets")) { + await queryInterface.dropTable("SharedWallets", { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_SharedWallets_withdrawalPolicy"', + { transaction } + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/src/database/migrations/20261001000000-add-pending-status-to-shared-wallet-members.js b/src/database/migrations/20261001000000-add-pending-status-to-shared-wallet-members.js new file mode 100644 index 0000000..0f9ea46 --- /dev/null +++ b/src/database/migrations/20261001000000-add-pending-status-to-shared-wallet-members.js @@ -0,0 +1,15 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + ALTER TYPE "enum_SharedWalletMembers_status" ADD VALUE IF NOT EXISTS 'pending'; + `); + }, + + down: async (queryInterface, Sequelize) => { + console.log( + "Cannot remove enum value from PostgreSQL. Manual intervention required if rollback is needed.", + ); + }, +}; diff --git a/src/database/migrations/20261002000000-add-shared-wallet-policy-changes.js b/src/database/migrations/20261002000000-add-shared-wallet-policy-changes.js new file mode 100644 index 0000000..69d23f3 --- /dev/null +++ b/src/database/migrations/20261002000000-add-shared-wallet-policy-changes.js @@ -0,0 +1,117 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + const tables = await queryInterface.showAllTables({ transaction }); + + if (!tables.includes("SharedWalletPolicyChanges")) { + await queryInterface.createTable( + "SharedWalletPolicyChanges", + { + id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true }, + sharedWalletId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "SharedWallets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + proposedByUserId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + targetPolicy: { type: Sequelize.ENUM("free", "approval"), allowNull: false }, + status: { + type: Sequelize.ENUM("pending", "approved", "declined", "cancelled"), + allowNull: false, + defaultValue: "pending", + }, + requiredApprovals: { type: Sequelize.INTEGER, allowNull: false }, + approveCount: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + declineCount: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + decidedAt: { type: Sequelize.DATE, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + }, + { transaction } + ); + } + + if (!tables.includes("SharedWalletPolicyChangeVotes")) { + await queryInterface.createTable( + "SharedWalletPolicyChangeVotes", + { + id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true }, + policyChangeId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "SharedWalletPolicyChanges", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + decision: { type: Sequelize.ENUM("approve", "decline"), allowNull: false }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + }, + { transaction } + ); + + await queryInterface.addIndex("SharedWalletPolicyChangeVotes", ["policyChangeId", "userId"], { + unique: true, + name: "shared_wallet_policy_change_votes_unique", + transaction, + }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + const tables = await queryInterface.showAllTables({ transaction }); + + if (tables.includes("SharedWalletPolicyChangeVotes")) { + await queryInterface.dropTable("SharedWalletPolicyChangeVotes", { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_SharedWalletPolicyChangeVotes_decision"', + { transaction } + ); + } + + if (tables.includes("SharedWalletPolicyChanges")) { + await queryInterface.dropTable("SharedWalletPolicyChanges", { transaction }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_SharedWalletPolicyChanges_status"', + { transaction } + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_SharedWalletPolicyChanges_targetPolicy"', + { transaction } + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/src/database/models/group.model.ts b/src/database/models/group.model.ts index 451c44c..75e90aa 100644 --- a/src/database/models/group.model.ts +++ b/src/database/models/group.model.ts @@ -19,6 +19,7 @@ class Group extends Model { public hasFundraising!: boolean; public fundraisingTarget?: number; public fundraisingCurrentAmount!: number; + public sharedWalletId?: string; public expirationDate?: Date; public expirationType!: string; public hasAdditionalInfo!: boolean; @@ -53,6 +54,7 @@ const Group_model = (sequelize: Sequelize) => { hasFundraising: { type: DataTypes.BOOLEAN, defaultValue: false }, fundraisingTarget: DataTypes.DECIMAL(15, 2), fundraisingCurrentAmount: { type: DataTypes.DECIMAL(15, 2), defaultValue: 0.00 }, + sharedWalletId: { type: DataTypes.UUID, allowNull: true }, expirationDate: DataTypes.DATE, expirationType: { type: DataTypes.ENUM("custom_date", "target_reached", "deadline_reached", "never"), diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 9151282..2d2c03b 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -38,6 +38,12 @@ import auditLog_model from "./auditLog.model"; import paymentRequest_model from "./paymentRequest.model"; import scheduledTransfer_model from "./scheduledTransfer.model"; import escrow_model from "./escrow.model"; +import sharedWallet_model from "./sharedWallet.model"; +import sharedWalletMember_model from "./sharedWalletMember.model"; +import sharedWalletWithdrawal_model from "./sharedWalletWithdrawal.model"; +import sharedWalletWithdrawalVote_model from "./sharedWalletWithdrawalVote.model"; +import sharedWalletPolicyChange_model from "./sharedWalletPolicyChange.model"; +import sharedWalletPolicyChangeVote_model from "./sharedWalletPolicyChangeVote.model"; import transferBatch_model from "./transferBatch.model"; import scheduledTransferBatch_model from "./scheduledTransferBatch.model"; import galleryItem_model from "./galleryItem.model"; @@ -106,6 +112,14 @@ const Models = (sequelize: Sequelize) => { // Escrow model const Escrow = escrow_model(sequelize); + // Shared Wallet models (standalone-capable: can attach to a Group or not) + const SharedWallet = sharedWallet_model(sequelize); + const SharedWalletMember = sharedWalletMember_model(sequelize); + const SharedWalletWithdrawal = sharedWalletWithdrawal_model(sequelize); + const SharedWalletWithdrawalVote = sharedWalletWithdrawalVote_model(sequelize); + const SharedWalletPolicyChange = sharedWalletPolicyChange_model(sequelize); + const SharedWalletPolicyChangeVote = sharedWalletPolicyChangeVote_model(sequelize); + // Transfer Batch model const TransferBatch = transferBatch_model(sequelize); @@ -375,6 +389,56 @@ const Models = (sequelize: Sequelize) => { Escrow.hasMany(Transaction, { foreignKey: "escrowId", as: "transactions" }); Transaction.belongsTo(Escrow, { foreignKey: "escrowId", as: "escrow" }); + // Shared Wallets (standalone-capable: optionally attached to a Group) + SharedWallet.belongsTo(Group, { foreignKey: "groupId", as: "group" }); + Group.hasOne(SharedWallet, { foreignKey: "groupId", as: "sharedWallet" }); + + User.hasMany(SharedWallet, { foreignKey: "createdByUserId", as: "createdSharedWallets" }); + SharedWallet.belongsTo(User, { foreignKey: "createdByUserId", as: "createdBy" }); + + // The pooled Wallet for a STANDALONE shared wallet (group-attached ones keep using + // Group.wallet / Wallet.groupId, unchanged — see utils/sharedWalletContext.ts). + SharedWallet.hasOne(Wallet, { foreignKey: "sharedWalletId", as: "wallet" }); + Wallet.belongsTo(SharedWallet, { foreignKey: "sharedWalletId", as: "sharedWallet" }); + + // Membership for standalone shared wallets (group-attached ones use GroupMember). + SharedWallet.hasMany(SharedWalletMember, { foreignKey: "sharedWalletId", as: "members" }); + SharedWalletMember.belongsTo(SharedWallet, { foreignKey: "sharedWalletId", as: "sharedWallet" }); + + User.hasMany(SharedWalletMember, { foreignKey: "userId", as: "sharedWalletMemberships" }); + SharedWalletMember.belongsTo(User, { foreignKey: "userId", as: "user" }); + + // Shared Wallet Withdrawals (withdraw/approve/decline, group-attached or standalone) + SharedWallet.hasMany(SharedWalletWithdrawal, { foreignKey: "sharedWalletId", as: "walletWithdrawals" }); + SharedWalletWithdrawal.belongsTo(SharedWallet, { foreignKey: "sharedWalletId", as: "sharedWallet" }); + + Wallet.hasMany(SharedWalletWithdrawal, { foreignKey: "walletId", as: "withdrawals" }); + SharedWalletWithdrawal.belongsTo(Wallet, { foreignKey: "walletId", as: "wallet" }); + + User.hasMany(SharedWalletWithdrawal, { foreignKey: "requestedByUserId", as: "sharedWalletWithdrawalRequests" }); + SharedWalletWithdrawal.belongsTo(User, { foreignKey: "requestedByUserId", as: "requestedBy" }); + + SharedWalletWithdrawal.hasMany(SharedWalletWithdrawalVote, { foreignKey: "withdrawalId", as: "votes" }); + SharedWalletWithdrawalVote.belongsTo(SharedWalletWithdrawal, { foreignKey: "withdrawalId", as: "withdrawal" }); + + User.hasMany(SharedWalletWithdrawalVote, { foreignKey: "userId", as: "sharedWalletWithdrawalVotes" }); + SharedWalletWithdrawalVote.belongsTo(User, { foreignKey: "userId", as: "user" }); + + SharedWalletWithdrawal.hasOne(Transaction, { foreignKey: "sharedWalletWithdrawalId", as: "transaction" }); + Transaction.belongsTo(SharedWalletWithdrawal, { foreignKey: "sharedWalletWithdrawalId", as: "sharedWalletWithdrawal" }); + + SharedWallet.hasMany(SharedWalletPolicyChange, { foreignKey: "sharedWalletId", as: "policyChanges" }); + SharedWalletPolicyChange.belongsTo(SharedWallet, { foreignKey: "sharedWalletId", as: "sharedWallet" }); + + User.hasMany(SharedWalletPolicyChange, { foreignKey: "proposedByUserId", as: "sharedWalletPolicyChangeProposals" }); + SharedWalletPolicyChange.belongsTo(User, { foreignKey: "proposedByUserId", as: "proposedBy" }); + + SharedWalletPolicyChange.hasMany(SharedWalletPolicyChangeVote, { foreignKey: "policyChangeId", as: "votes" }); + SharedWalletPolicyChangeVote.belongsTo(SharedWalletPolicyChange, { foreignKey: "policyChangeId", as: "policyChange" }); + + User.hasMany(SharedWalletPolicyChangeVote, { foreignKey: "userId", as: "sharedWalletPolicyChangeVotes" }); + SharedWalletPolicyChangeVote.belongsTo(User, { foreignKey: "userId", as: "user" }); + // Transfer Batches User.hasMany(TransferBatch, { foreignKey: "createdByUserId", as: "createdTransferBatches" }); TransferBatch.belongsTo(User, { foreignKey: "createdByUserId", as: "createdBy" }); @@ -714,6 +778,12 @@ const Models = (sequelize: Sequelize) => { PaymentRequest, ScheduledTransfer, Escrow, + SharedWallet, + SharedWalletMember, + SharedWalletWithdrawal, + SharedWalletWithdrawalVote, + SharedWalletPolicyChange, + SharedWalletPolicyChangeVote, TransferBatch, ScheduledTransferBatch, OutsideMessage, diff --git a/src/database/models/sharedWallet.model.ts b/src/database/models/sharedWallet.model.ts new file mode 100644 index 0000000..4bed787 --- /dev/null +++ b/src/database/models/sharedWallet.model.ts @@ -0,0 +1,41 @@ +// sharedWallet.model.ts +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + SharedWalletAttributes, + SharedWalletCreationAttributes, +} from "../../types/model"; + +class SharedWallet extends Model< + SharedWalletAttributes, + SharedWalletCreationAttributes +> { + public id!: string; + public name!: string; + public withdrawalPolicy!: "free" | "approval"; + public createdByUserId!: string; + public groupId?: string | null; + public createdAt?: Date; + public updatedAt?: Date; +} + +const SharedWallet_model = (sequelize: Sequelize) => { + SharedWallet.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: false }, + withdrawalPolicy: { + type: DataTypes.ENUM("free", "approval"), + allowNull: false, + defaultValue: "approval", + }, + createdByUserId: { type: DataTypes.UUID, allowNull: false }, + // null when this shared wallet is standalone (no attached group). + groupId: { type: DataTypes.UUID, allowNull: true, unique: true }, + }, + { sequelize, tableName: "SharedWallets" } + ); + + return SharedWallet; +}; + +export default SharedWallet_model; diff --git a/src/database/models/sharedWalletMember.model.ts b/src/database/models/sharedWalletMember.model.ts new file mode 100644 index 0000000..6195aae --- /dev/null +++ b/src/database/models/sharedWalletMember.model.ts @@ -0,0 +1,48 @@ +// sharedWalletMember.model.ts +// Membership for STANDALONE shared wallets only (no attached Group). Group-attached +// shared wallets keep using GroupMember for membership/quorum — see utils/sharedWalletContext.ts. +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + SharedWalletMemberAttributes, + SharedWalletMemberCreationAttributes, +} from "../../types/model"; + +class SharedWalletMember extends Model< + SharedWalletMemberAttributes, + SharedWalletMemberCreationAttributes +> { + public id!: string; + public sharedWalletId!: string; + public userId!: string; + public role!: "owner" | "admin" | "member"; + public status!: "pending" | "active" | "left" | "removed"; + public createdAt?: Date; + public updatedAt?: Date; +} + +const SharedWalletMember_model = (sequelize: Sequelize) => { + SharedWalletMember.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + sharedWalletId: { type: DataTypes.UUID, allowNull: false }, + userId: { type: DataTypes.UUID, allowNull: false }, + role: { + type: DataTypes.ENUM("owner", "admin", "member"), + defaultValue: "member", + }, + status: { + type: DataTypes.ENUM("pending", "active", "left", "removed"), + defaultValue: "pending", + }, + }, + { + sequelize, + tableName: "SharedWalletMembers", + indexes: [{ unique: true, fields: ["sharedWalletId", "userId"] }], + } + ); + + return SharedWalletMember; +}; + +export default SharedWalletMember_model; diff --git a/src/database/models/sharedWalletPolicyChange.model.ts b/src/database/models/sharedWalletPolicyChange.model.ts new file mode 100644 index 0000000..94b57b3 --- /dev/null +++ b/src/database/models/sharedWalletPolicyChange.model.ts @@ -0,0 +1,48 @@ +// sharedWalletPolicyChange.model.ts +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + SharedWalletPolicyChangeAttributes, + SharedWalletPolicyChangeCreationAttributes, +} from "../../types/model"; + +class SharedWalletPolicyChange extends Model< + SharedWalletPolicyChangeAttributes, + SharedWalletPolicyChangeCreationAttributes +> { + public id!: string; + public sharedWalletId!: string; + public proposedByUserId!: string; + public targetPolicy!: "free" | "approval"; + public status!: "pending" | "approved" | "declined" | "cancelled"; + public requiredApprovals!: number; + public approveCount!: number; + public declineCount!: number; + public decidedAt?: Date | null; + public createdAt?: Date; + public updatedAt?: Date; +} + +const sharedWalletPolicyChange_model = (sequelize: Sequelize) => { + SharedWalletPolicyChange.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + sharedWalletId: { type: DataTypes.UUID, allowNull: false }, + proposedByUserId: { type: DataTypes.UUID, allowNull: false }, + targetPolicy: { type: DataTypes.ENUM("free", "approval"), allowNull: false }, + status: { + type: DataTypes.ENUM("pending", "approved", "declined", "cancelled"), + defaultValue: "pending", + }, + // Snapshotted at proposal time: floor(otherActiveMembers / 2) + 1. + requiredApprovals: { type: DataTypes.INTEGER, allowNull: false }, + approveCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + declineCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + decidedAt: { type: DataTypes.DATE, allowNull: true }, + }, + { sequelize, tableName: "SharedWalletPolicyChanges" } + ); + + return SharedWalletPolicyChange; +}; + +export default sharedWalletPolicyChange_model; diff --git a/src/database/models/sharedWalletPolicyChangeVote.model.ts b/src/database/models/sharedWalletPolicyChangeVote.model.ts new file mode 100644 index 0000000..22fc063 --- /dev/null +++ b/src/database/models/sharedWalletPolicyChangeVote.model.ts @@ -0,0 +1,41 @@ +// sharedWalletPolicyChangeVote.model.ts +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + SharedWalletPolicyChangeVoteAttributes, + SharedWalletPolicyChangeVoteCreationAttributes, +} from "../../types/model"; + +class SharedWalletPolicyChangeVote extends Model< + SharedWalletPolicyChangeVoteAttributes, + SharedWalletPolicyChangeVoteCreationAttributes +> { + public id!: string; + public policyChangeId!: string; + public userId!: string; + public decision!: "approve" | "decline"; + public createdAt?: Date; + public updatedAt?: Date; +} + +const sharedWalletPolicyChangeVote_model = (sequelize: Sequelize) => { + SharedWalletPolicyChangeVote.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + policyChangeId: { type: DataTypes.UUID, allowNull: false }, + userId: { type: DataTypes.UUID, allowNull: false }, + decision: { + type: DataTypes.ENUM("approve", "decline"), + allowNull: false, + }, + }, + { + sequelize, + tableName: "SharedWalletPolicyChangeVotes", + indexes: [{ unique: true, fields: ["policyChangeId", "userId"] }], + } + ); + + return SharedWalletPolicyChangeVote; +}; + +export default sharedWalletPolicyChangeVote_model; diff --git a/src/database/models/sharedWalletWithdrawal.model.ts b/src/database/models/sharedWalletWithdrawal.model.ts new file mode 100644 index 0000000..885ac80 --- /dev/null +++ b/src/database/models/sharedWalletWithdrawal.model.ts @@ -0,0 +1,58 @@ +// sharedWalletWithdrawal.model.ts +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + SharedWalletWithdrawalAttributes, + SharedWalletWithdrawalCreationAttributes, +} from "../../types/model"; + +class SharedWalletWithdrawal extends Model< + SharedWalletWithdrawalAttributes, + SharedWalletWithdrawalCreationAttributes +> { + public id!: string; + public sharedWalletId!: string; + public walletId!: string; + public requestedByUserId!: string; + public amount!: number; + public currency!: string; + public note?: string | null; + public status!: "pending" | "approved" | "declined" | "cancelled"; + public requiredApprovals!: number; + public approveCount!: number; + public declineCount!: number; + public chatMessageId?: string | null; + public transactionId?: string | null; + public decidedAt?: Date | null; + public createdAt?: Date; + public updatedAt?: Date; +} + +const sharedWalletWithdrawal_model = (sequelize: Sequelize) => { + SharedWalletWithdrawal.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + sharedWalletId: { type: DataTypes.UUID, allowNull: false }, + walletId: { type: DataTypes.UUID, allowNull: false }, + requestedByUserId: { type: DataTypes.UUID, allowNull: false }, + amount: { type: DataTypes.DECIMAL(15, 2), allowNull: false }, + currency: { type: DataTypes.STRING, defaultValue: "RWF" }, + note: { type: DataTypes.STRING, allowNull: true }, + status: { + type: DataTypes.ENUM("pending", "approved", "declined", "cancelled"), + defaultValue: "pending", + }, + // Snapshotted at proposal time: floor(otherActiveMembers / 2) + 1. + requiredApprovals: { type: DataTypes.INTEGER, allowNull: false }, + approveCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + declineCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + chatMessageId: { type: DataTypes.UUID, allowNull: true }, + transactionId: { type: DataTypes.UUID, allowNull: true }, + decidedAt: { type: DataTypes.DATE, allowNull: true }, + }, + { sequelize, tableName: "SharedWalletWithdrawals" } + ); + + return SharedWalletWithdrawal; +}; + +export default sharedWalletWithdrawal_model; diff --git a/src/database/models/sharedWalletWithdrawalVote.model.ts b/src/database/models/sharedWalletWithdrawalVote.model.ts new file mode 100644 index 0000000..a57f468 --- /dev/null +++ b/src/database/models/sharedWalletWithdrawalVote.model.ts @@ -0,0 +1,41 @@ +// sharedWalletWithdrawalVote.model.ts +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + SharedWalletWithdrawalVoteAttributes, + SharedWalletWithdrawalVoteCreationAttributes, +} from "../../types/model"; + +class SharedWalletWithdrawalVote extends Model< + SharedWalletWithdrawalVoteAttributes, + SharedWalletWithdrawalVoteCreationAttributes +> { + public id!: string; + public withdrawalId!: string; + public userId!: string; + public decision!: "approve" | "decline"; + public createdAt?: Date; + public updatedAt?: Date; +} + +const sharedWalletWithdrawalVote_model = (sequelize: Sequelize) => { + SharedWalletWithdrawalVote.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + withdrawalId: { type: DataTypes.UUID, allowNull: false }, + userId: { type: DataTypes.UUID, allowNull: false }, + decision: { + type: DataTypes.ENUM("approve", "decline"), + allowNull: false, + }, + }, + { + sequelize, + tableName: "SharedWalletWithdrawalVotes", + indexes: [{ unique: true, fields: ["withdrawalId", "userId"] }], + } + ); + + return SharedWalletWithdrawalVote; +}; + +export default sharedWalletWithdrawalVote_model; diff --git a/src/database/models/transaction.model.ts b/src/database/models/transaction.model.ts index 06a85e3..bf6dee9 100644 --- a/src/database/models/transaction.model.ts +++ b/src/database/models/transaction.model.ts @@ -25,6 +25,7 @@ class Transaction extends Model< public scheduledTransferId?: string | null; public batchId?: string | null; public escrowId?: string | null; + public sharedWalletWithdrawalId?: string | null; } const Transaction_model = (sequelize: Sequelize) => { @@ -121,6 +122,14 @@ const Transaction_model = (sequelize: Sequelize) => { key: "id", }, }, + sharedWalletWithdrawalId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: "SharedWalletWithdrawals", + key: "id", + }, + }, }, { sequelize, tableName: "Transactions" } ); diff --git a/src/database/models/wallet.model.ts b/src/database/models/wallet.model.ts index faa8b98..69fe460 100644 --- a/src/database/models/wallet.model.ts +++ b/src/database/models/wallet.model.ts @@ -9,6 +9,7 @@ class Wallet extends Model { public groupId?: string; public subActionId?: string; public publicContributionId?: string; + public sharedWalletId?: string; public balance!: number; public heldBalance!: number; public currency!: string; @@ -24,6 +25,7 @@ const Wallet_model = (sequelize: Sequelize) => { groupId: DataTypes.UUID, subActionId: DataTypes.UUID, publicContributionId: DataTypes.UUID, + sharedWalletId: DataTypes.UUID, balance: { type: DataTypes.DECIMAL(15, 2), defaultValue: 0 }, // Sum of funds currently reserved by pending scheduled transfers. // Spendable balance is always balance - heldBalance (see utils/walletBalance.ts). diff --git a/src/routes/index.ts b/src/routes/index.ts index 7403171..2584688 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -13,6 +13,7 @@ import organizationCategoryRouter from "./organizationCategory.routes"; import profileRouter from "./profile.routes"; import groupRouter from "./group.routes"; import groupContributionRouter from "./groupContribution.routes"; +import sharedWalletRouter from "./sharedWallet.routes"; import userContributionsRouter from "./userContributions.routes"; import publicContributionRouter from "./publicContribution.routes"; import notificationRouter from "./notification.routes"; @@ -63,6 +64,7 @@ router.use("/contacts", contactRouter); router.use("/contact-invitations", contactInvitationRouter); router.use("/groups", groupRouter); router.use("/groups/:groupId/contributions", groupContributionRouter); +router.use("/shared-wallets", sharedWalletRouter); router.use("/contributions", userContributionsRouter); router.use("/public-contributions", publicContributionRouter); router.use("/notifications", notificationRouter); diff --git a/src/routes/sharedWallet.routes.ts b/src/routes/sharedWallet.routes.ts new file mode 100644 index 0000000..ddf006e --- /dev/null +++ b/src/routes/sharedWallet.routes.ts @@ -0,0 +1,43 @@ +import { RequestHandler, Router } from "express"; +import { authenticate } from "../middleware/auth.middleware"; +import * as sharedWalletController from "../controllers/sharedWalletController"; + +const router = Router(); +router.use(authenticate); + +// Creation + listing +router.post("/", sharedWalletController.createSharedWallet as RequestHandler); +router.get("/", sharedWalletController.listMySharedWallets as RequestHandler); +// Must come before "/:id" so Express doesn't match :id = "invitations". +router.get("/invitations/pending", sharedWalletController.getPendingSharedWalletInvitations as RequestHandler); +router.get("/:id", sharedWalletController.getSharedWalletById as RequestHandler); + +// Money movement +router.post("/:id/deposit", sharedWalletController.deposit as RequestHandler); +router.post("/:id/withdraw", sharedWalletController.withdrawFree as RequestHandler); +router.post("/:id/withdrawals", sharedWalletController.proposeWithdrawal as RequestHandler); +router.post("/:id/withdrawals/:wid/approve", sharedWalletController.approveWithdrawal as RequestHandler); +router.post("/:id/withdrawals/:wid/decline", sharedWalletController.declineWithdrawal as RequestHandler); +router.post("/:id/withdrawals/:wid/cancel", sharedWalletController.cancelWithdrawal as RequestHandler); +router.get("/:id/withdrawals/:wid", sharedWalletController.getWithdrawalById as RequestHandler); + +// Activity + membership +router.get("/:id/activity", sharedWalletController.getActivity as RequestHandler); +router.get("/:id/members", sharedWalletController.listMembers as RequestHandler); +router.get("/:id/pending-members", sharedWalletController.listPendingMembers as RequestHandler); +router.post("/:id/members", sharedWalletController.addMember as RequestHandler); +router.post("/:id/invitations/:membershipId/respond", sharedWalletController.respondToSharedWalletInvitation as RequestHandler); +router.delete("/:id/members/:userId", sharedWalletController.removeMember as RequestHandler); +router.post("/:id/leave", sharedWalletController.leaveSharedWallet as RequestHandler); +router.post("/:id/transfer-ownership", sharedWalletController.transferOwnership as RequestHandler); +router.delete("/:id", sharedWalletController.deleteSharedWallet as RequestHandler); + +// Withdrawal policy changes +router.patch("/:id/policy", sharedWalletController.updateWithdrawalPolicyInstant as RequestHandler); +router.get("/:id/policy-changes/pending", sharedWalletController.getPendingPolicyChange as RequestHandler); +router.post("/:id/policy-changes", sharedWalletController.proposePolicyChange as RequestHandler); +router.post("/:id/policy-changes/:pcid/approve", sharedWalletController.approvePolicyChange as RequestHandler); +router.post("/:id/policy-changes/:pcid/decline", sharedWalletController.declinePolicyChange as RequestHandler); +router.post("/:id/policy-changes/:pcid/cancel", sharedWalletController.cancelPolicyChange as RequestHandler); + +export default router; diff --git a/src/services/moneyChatMessageService.ts b/src/services/moneyChatMessageService.ts index d32eeab..ca2820d 100644 --- a/src/services/moneyChatMessageService.ts +++ b/src/services/moneyChatMessageService.ts @@ -80,3 +80,44 @@ export const updateScheduledTransferMessage = async ( chatId: message.chatId, }); }; + +/** + * Patches an already-posted shared_wallet_withdrawal_request message's content in place + * (vote tally, and status once it resolves) and broadcasts the update, same live-update + * pattern as updateScheduledTransferMessage above. + */ +export const updateSharedWalletWithdrawalMessage = async ( + io: any, + models: any, + chatMessageId: string, + updates: { status: string; approveCount: number; declineCount: number; transactionId?: string | null } +): Promise => { + const message = await models.ChatMessage.findByPk(chatMessageId); + if (!message) return; + + let parsed: any; + try { + parsed = JSON.parse(message.content); + } catch { + return; + } + if (parsed?.type !== "shared_wallet_withdrawal_request") return; + + const updatedContent = { + ...parsed, + status: updates.status, + approveCount: updates.approveCount, + declineCount: updates.declineCount, + transactionId: updates.transactionId ?? parsed.transactionId, + }; + await message.update({ content: JSON.stringify(updatedContent) }); + + await broadcastToChatParticipants(io, models, message.chatId, "shared_wallet_withdrawal_updated", { + withdrawalId: parsed.withdrawalId, + status: updates.status, + approveCount: updates.approveCount, + declineCount: updates.declineCount, + transactionId: updates.transactionId ?? parsed.transactionId ?? null, + chatId: message.chatId, + }); +}; diff --git a/src/services/sharedWalletService.ts b/src/services/sharedWalletService.ts new file mode 100644 index 0000000..a611c3d --- /dev/null +++ b/src/services/sharedWalletService.ts @@ -0,0 +1,118 @@ +import { Transaction as DbTransaction } from "sequelize"; +import database_models from "../database/config/db.config"; +import { getAvailableBalance } from "../utils/walletBalance"; + +const { Wallet, Transaction: TransactionModel } = database_models as any; + +/** + * Moves `amount` from a shared wallet's pooled Wallet into a member's personal wallet + * and writes the one ledger entry for it. Locks both wallets - caller must already be + * inside a sequelize managed transaction. Used by both free-mode withdrawals (immediate) + * and approval-mode withdrawals (once the required number of approvals is reached) - + * `sharedWalletWithdrawalId` is only set for the latter. + */ +export const executeSharedWalletWithdrawal = async ( + sharedWalletWalletId: string, + memberWalletId: string, + amount: number, + description: string, + dbTransaction: DbTransaction, + sharedWalletWithdrawalId: string | null = null +): Promise<{ transactionId: string; referenceId: string }> => { + const [pooledWallet, memberWallet] = await Promise.all([ + Wallet.findByPk(sharedWalletWalletId, { lock: dbTransaction.LOCK.UPDATE, transaction: dbTransaction }), + Wallet.findByPk(memberWalletId, { lock: dbTransaction.LOCK.UPDATE, transaction: dbTransaction }), + ]); + if (!pooledWallet) throw new Error("Shared wallet not found"); + if (!memberWallet) throw new Error("Member wallet not found"); + + if (getAvailableBalance(pooledWallet) < amount) { + throw new Error( + `Insufficient shared wallet balance. Available: ${getAvailableBalance(pooledWallet)} ${pooledWallet.currency}, requested: ${amount}` + ); + } + + await pooledWallet.update( + { balance: parseFloat(pooledWallet.balance.toString()) - amount }, + { transaction: dbTransaction } + ); + await memberWallet.update( + { balance: parseFloat(memberWallet.balance.toString()) + amount }, + { transaction: dbTransaction } + ); + + const referenceId = `SWW-${(sharedWalletWithdrawalId || sharedWalletWalletId).substring(0, 8)}-${Date.now()}`; + const transaction = await TransactionModel.create( + { + referenceId, + senderWalletId: pooledWallet.id, + receiverWalletId: memberWallet.id, + amount, + fee: 0, + totalAmount: amount, + currency: pooledWallet.currency || "RWF", + status: "completed", + type: "withdrawal", + description, + sharedWalletWithdrawalId, + } as any, + { transaction: dbTransaction } + ); + + return { transactionId: transaction.id, referenceId }; +}; + +/** + * Moves `amount` from a member's personal wallet into a shared wallet's pooled Wallet. + * Symmetric to executeSharedWalletWithdrawal above. Any active member can deposit - + * there's no approval step for putting money in, only for taking it out. + */ +export const depositIntoSharedWallet = async ( + sharedWalletId: string, + sharedWalletWalletId: string, + fromMemberWalletId: string, + amount: number, + description: string, + dbTransaction: DbTransaction +): Promise<{ transactionId: string; referenceId: string }> => { + const [pooledWallet, memberWallet] = await Promise.all([ + Wallet.findByPk(sharedWalletWalletId, { lock: dbTransaction.LOCK.UPDATE, transaction: dbTransaction }), + Wallet.findByPk(fromMemberWalletId, { lock: dbTransaction.LOCK.UPDATE, transaction: dbTransaction }), + ]); + if (!pooledWallet) throw new Error("Shared wallet not found"); + if (!memberWallet) throw new Error("Member wallet not found"); + + if (getAvailableBalance(memberWallet) < amount) { + throw new Error( + `Insufficient balance. Available: ${getAvailableBalance(memberWallet)} ${memberWallet.currency}, requested: ${amount}` + ); + } + + await memberWallet.update( + { balance: parseFloat(memberWallet.balance.toString()) - amount }, + { transaction: dbTransaction } + ); + await pooledWallet.update( + { balance: parseFloat(pooledWallet.balance.toString()) + amount }, + { transaction: dbTransaction } + ); + + const referenceId = `SWD-${sharedWalletId.substring(0, 8)}-${Date.now()}`; + const transaction = await TransactionModel.create( + { + referenceId, + senderWalletId: memberWallet.id, + receiverWalletId: pooledWallet.id, + amount, + fee: 0, + totalAmount: amount, + currency: pooledWallet.currency || "RWF", + status: "completed", + type: "transfer", + description, + } as any, + { transaction: dbTransaction } + ); + + return { transactionId: transaction.id, referenceId }; +}; diff --git a/src/types/group.ts b/src/types/group.ts index e5b0e50..af27515 100644 --- a/src/types/group.ts +++ b/src/types/group.ts @@ -49,6 +49,7 @@ export interface GroupModelAttributes { hasFundraising: boolean; fundraisingTarget?: number; fundraisingCurrentAmount: number; + sharedWalletId?: string; expirationDate?: Date; expirationType: GroupExpirationType; hasAdditionalInfo: boolean; @@ -76,6 +77,7 @@ export interface GroupCreationAttributes { hasFundraising?: boolean; fundraisingTarget?: number; fundraisingCurrentAmount?: CreationOptional; + sharedWalletId?: string; expirationDate?: Date; expirationType?: GroupExpirationType; hasAdditionalInfo?: boolean; @@ -139,6 +141,9 @@ export interface CreateGroupRequest { adminId?: string; // Admin selected from contacts hasFundraising?: boolean; fundraisingTarget?: number; + hasSharedWallet?: boolean; + withdrawalPolicy?: 'free' | 'approval'; + pin?: string; // Required to confirm creation when hasSharedWallet is true expirationDate?: Date; expirationType?: GroupExpirationType; hasAdditionalInfo?: boolean; diff --git a/src/types/model.ts b/src/types/model.ts index ea2ed29..00a61bd 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -347,6 +347,7 @@ export interface GroupAttributes { hasFundraising: boolean; fundraisingTarget?: number; fundraisingCurrentAmount: number; + sharedWalletId?: string; expirationDate?: Date; expirationType: | "custom_date" @@ -652,6 +653,7 @@ export interface TransactionAttributes { scheduledTransferId?: string | null; batchId?: string | null; escrowId?: string | null; + sharedWalletWithdrawalId?: string | null; createdAt?: Date; updatedAt?: Date; } @@ -803,6 +805,123 @@ export type EscrowCreationAttributes = Optional< | "updatedAt" >; +export interface SharedWalletAttributes { + id: string; + name: string; + withdrawalPolicy: "free" | "approval"; + createdByUserId: string; + groupId?: string | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type SharedWalletCreationAttributes = Optional< + SharedWalletAttributes, + "id" | "groupId" | "createdAt" | "updatedAt" +>; + +export interface SharedWalletMemberAttributes { + id: string; + sharedWalletId: string; + userId: string; + role: "owner" | "admin" | "member"; + status: "pending" | "active" | "left" | "removed"; + createdAt?: Date; + updatedAt?: Date; +} + +export type SharedWalletMemberCreationAttributes = Optional< + SharedWalletMemberAttributes, + "id" | "status" | "createdAt" | "updatedAt" +>; + +export interface SharedWalletWithdrawalAttributes { + id: string; + sharedWalletId: string; + walletId: string; + requestedByUserId: string; + amount: number; + currency: string; + note?: string | null; + status: "pending" | "approved" | "declined" | "cancelled"; + requiredApprovals: number; + approveCount: number; + declineCount: number; + chatMessageId?: string | null; + transactionId?: string | null; + decidedAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type SharedWalletWithdrawalCreationAttributes = Optional< + SharedWalletWithdrawalAttributes, + | "id" + | "currency" + | "note" + | "status" + | "approveCount" + | "declineCount" + | "chatMessageId" + | "transactionId" + | "decidedAt" + | "createdAt" + | "updatedAt" +>; + +export interface SharedWalletWithdrawalVoteAttributes { + id: string; + withdrawalId: string; + userId: string; + decision: "approve" | "decline"; + createdAt?: Date; + updatedAt?: Date; +} + +export type SharedWalletWithdrawalVoteCreationAttributes = Optional< + SharedWalletWithdrawalVoteAttributes, + "id" | "createdAt" | "updatedAt" +>; + +export interface SharedWalletPolicyChangeAttributes { + id: string; + sharedWalletId: string; + proposedByUserId: string; + targetPolicy: "free" | "approval"; + status: "pending" | "approved" | "declined" | "cancelled"; + requiredApprovals: number; + approveCount: number; + declineCount: number; + decidedAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type SharedWalletPolicyChangeCreationAttributes = Optional< + SharedWalletPolicyChangeAttributes, + | "id" + | "status" + | "approveCount" + | "declineCount" + | "decidedAt" + | "createdAt" + | "updatedAt" +>; + +export interface SharedWalletPolicyChangeVoteAttributes { + id: string; + policyChangeId: string; + userId: string; + decision: "approve" | "decline"; + createdAt?: Date; + updatedAt?: Date; +} + +export type SharedWalletPolicyChangeVoteCreationAttributes = Optional< + SharedWalletPolicyChangeVoteAttributes, + "id" | "createdAt" | "updatedAt" +>; + export interface ScheduledTransferBatchFailureEntry { receiverUserId?: string | null; receiverOrganizationId?: string | null; @@ -954,6 +1073,7 @@ export interface WalletAttributes { groupId?: string; subActionId?: string; publicContributionId?: string; + sharedWalletId?: string; balance: number; heldBalance: number; currency: string; diff --git a/src/utils/notificationConfig.ts b/src/utils/notificationConfig.ts index 58cee9a..5381999 100644 --- a/src/utils/notificationConfig.ts +++ b/src/utils/notificationConfig.ts @@ -27,6 +27,23 @@ export enum NotificationType { GROUP_CONTRIBUTION_CLOSED = "GROUP_CONTRIBUTION_CLOSED", GROUP_CONTRIBUTION_UPDATED = "GROUP_CONTRIBUTION_UPDATED", + // Shared wallet notifications (group-attached or standalone) + SHARED_WALLET_MEMBER_ADDED = "SHARED_WALLET_MEMBER_ADDED", + SHARED_WALLET_DEPOSIT_RECEIVED = "SHARED_WALLET_DEPOSIT_RECEIVED", + SHARED_WALLET_WITHDRAWAL_REQUESTED = "SHARED_WALLET_WITHDRAWAL_REQUESTED", + SHARED_WALLET_WITHDRAWAL_APPROVED = "SHARED_WALLET_WITHDRAWAL_APPROVED", + SHARED_WALLET_WITHDRAWAL_DECLINED = "SHARED_WALLET_WITHDRAWAL_DECLINED", + SHARED_WALLET_WITHDRAWAL_EXECUTED = "SHARED_WALLET_WITHDRAWAL_EXECUTED", + SHARED_WALLET_OWNERSHIP_TRANSFERRED = "SHARED_WALLET_OWNERSHIP_TRANSFERRED", + SHARED_WALLET_DELETED = "SHARED_WALLET_DELETED", + SHARED_WALLET_INVITATION = "SHARED_WALLET_INVITATION", + SHARED_WALLET_INVITATION_ACCEPTED = "SHARED_WALLET_INVITATION_ACCEPTED", + SHARED_WALLET_INVITATION_REJECTED = "SHARED_WALLET_INVITATION_REJECTED", + SHARED_WALLET_POLICY_CHANGED = "SHARED_WALLET_POLICY_CHANGED", + SHARED_WALLET_POLICY_CHANGE_PROPOSED = "SHARED_WALLET_POLICY_CHANGE_PROPOSED", + SHARED_WALLET_POLICY_CHANGE_DECLINED = "SHARED_WALLET_POLICY_CHANGE_DECLINED", + SHARED_WALLET_POLICY_CHANGE_APPROVED = "SHARED_WALLET_POLICY_CHANGE_APPROVED", + // Public (standalone) contribution notifications PUBLIC_CONTRIBUTION_RECEIVED = "PUBLIC_CONTRIBUTION_RECEIVED", PUBLIC_CONTRIBUTION_COMPLETED = "PUBLIC_CONTRIBUTION_COMPLETED", @@ -226,6 +243,12 @@ export interface NotificationPayload { contributionId?: string; contributionTitle?: string; + // Shared wallet data + sharedWalletId?: string; + sharedWalletName?: string; + withdrawalId?: string; + membershipId?: string; + // General data message?: string; title?: string; @@ -338,6 +361,68 @@ const notificationConfig = { priority: "normal", }, + // Shared wallet notifications + [NotificationType.SHARED_WALLET_MEMBER_ADDED]: { + description: "You were added to a shared wallet", + priority: "normal", + }, + [NotificationType.SHARED_WALLET_DEPOSIT_RECEIVED]: { + description: "A member deposited into a shared wallet you're in", + priority: "normal", + }, + [NotificationType.SHARED_WALLET_WITHDRAWAL_REQUESTED]: { + description: "A withdrawal request needs your vote", + priority: "high", + }, + [NotificationType.SHARED_WALLET_WITHDRAWAL_APPROVED]: { + description: "A shared wallet withdrawal was approved", + priority: "normal", + }, + [NotificationType.SHARED_WALLET_WITHDRAWAL_DECLINED]: { + description: "A shared wallet withdrawal was declined", + priority: "normal", + }, + [NotificationType.SHARED_WALLET_WITHDRAWAL_EXECUTED]: { + description: "A shared wallet withdrawal was completed", + priority: "normal", + }, + [NotificationType.SHARED_WALLET_OWNERSHIP_TRANSFERRED]: { + description: "You are now the owner of a shared wallet", + priority: "high", + }, + [NotificationType.SHARED_WALLET_DELETED]: { + description: "A shared wallet you were in has been deleted", + priority: "high", + }, + [NotificationType.SHARED_WALLET_INVITATION]: { + description: "You have been invited to a shared wallet", + priority: "high", + }, + [NotificationType.SHARED_WALLET_INVITATION_ACCEPTED]: { + description: "Your shared wallet invitation was accepted", + priority: "normal", + }, + [NotificationType.SHARED_WALLET_INVITATION_REJECTED]: { + description: "Your shared wallet invitation was declined", + priority: "normal", + }, + [NotificationType.SHARED_WALLET_POLICY_CHANGED]: { + description: "A shared wallet's withdrawal policy was tightened", + priority: "normal", + }, + [NotificationType.SHARED_WALLET_POLICY_CHANGE_PROPOSED]: { + description: "A shared wallet policy change needs your vote", + priority: "high", + }, + [NotificationType.SHARED_WALLET_POLICY_CHANGE_DECLINED]: { + description: "A shared wallet policy change was declined", + priority: "normal", + }, + [NotificationType.SHARED_WALLET_POLICY_CHANGE_APPROVED]: { + description: "A shared wallet's withdrawal policy was changed", + priority: "normal", + }, + // Public contribution notifications [NotificationType.PUBLIC_CONTRIBUTION_RECEIVED]: { description: "Someone contributed to your campaign", diff --git a/src/utils/sharedWalletBroadcast.ts b/src/utils/sharedWalletBroadcast.ts new file mode 100644 index 0000000..028089a --- /dev/null +++ b/src/utils/sharedWalletBroadcast.ts @@ -0,0 +1,24 @@ +import Models from "../database/models"; +import { listActiveMembers } from "./sharedWalletContext"; + +/** + * Fans a shared-wallet event out to every active member's `user_${userId}` room - the + * one room pattern demonstrably wired up end-to-end on both client and server (unlike + * `group:${groupId}`, which nothing ever joins clients into). Payload always carries + * both `sharedWalletId` and `groupId` (null when standalone) so existing group-chat + * consumers (e.g. chat-header.tsx's balance badge) keep working unmodified. + */ +export const broadcastToSharedWalletMembers = async ( + io: any, + models: ReturnType, + sharedWallet: { id: string; groupId?: string | null }, + event: string, + payload: Record +): Promise => { + if (!io) return; + const members = await listActiveMembers(models, sharedWallet); + const fullPayload = { sharedWalletId: sharedWallet.id, groupId: sharedWallet.groupId ?? null, ...payload }; + for (const member of members) { + io.to(`user_${member.userId}`).emit(event, fullPayload); + } +}; diff --git a/src/utils/sharedWalletContext.ts b/src/utils/sharedWalletContext.ts new file mode 100644 index 0000000..5423384 --- /dev/null +++ b/src/utils/sharedWalletContext.ts @@ -0,0 +1,119 @@ +import { Op } from "sequelize"; +import Models from "../database/models"; + +/** + * Thrown by the resolvers below - the shared wallet controller maps `status`/`message` + * onto its HTTP response the same way escrowController's ControllerError is handled. + */ +export class SharedWalletError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export interface NormalizedMember { + userId: string; + role: "owner" | "admin" | "member"; + status: string; +} + +/** + * A shared wallet either belongs to a Group (membership/quorum comes from the existing + * GroupMember table, unchanged) or is standalone (membership comes from + * SharedWalletMember). This module is the single place that branch lives - controllers + * and services should always go through these helpers rather than checking + * `sharedWallet.groupId` themselves. + */ + +export const resolveSharedWallet = async ( + models: ReturnType, + sharedWalletId: string +) => { + const sharedWallet = await models.SharedWallet.findByPk(sharedWalletId, { + include: [ + { model: models.Group, as: "group", include: [{ model: models.Wallet, as: "wallet" }] }, + { model: models.Wallet, as: "wallet" }, + ], + }); + if (!sharedWallet) throw new SharedWalletError(404, "Shared wallet not found"); + + const group = (sharedWallet as any).group; + // Group-attached: the pooled wallet is the group's existing Wallet (Wallet.groupId), + // same as before this feature existed - unchanged so fundraising+shared-wallet groups + // keep sharing one physical pool. Standalone: the wallet's own Wallet.sharedWalletId row. + const wallet = group ? group.wallet : (sharedWallet as any).wallet; + + if (!wallet) throw new SharedWalletError(404, "Wallet not found for this shared wallet"); + if (!wallet.isActive) throw new SharedWalletError(400, "Shared wallet is inactive"); + + return { sharedWallet, group: group || null, wallet }; +}; + +/** Group-attached -> GroupMember; standalone -> SharedWalletMember. Normalizes both shapes. */ +export const resolveMembership = async ( + models: ReturnType, + sharedWallet: { id: string; groupId?: string | null }, + userId: string +): Promise => { + if (sharedWallet.groupId) { + const membership = await models.GroupMember.findOne({ + where: { groupId: sharedWallet.groupId, userId, status: "active" }, + }); + if (!membership) throw new SharedWalletError(403, "You are not an active member of this shared wallet"); + return { userId, role: membership.role, status: membership.status }; + } + + const membership = await models.SharedWalletMember.findOne({ + where: { sharedWalletId: sharedWallet.id, userId, status: "active" }, + }); + if (!membership) throw new SharedWalletError(403, "You are not an active member of this shared wallet"); + return { userId, role: membership.role, status: membership.status }; +}; + +export const countOtherActiveMembers = async ( + models: ReturnType, + sharedWallet: { id: string; groupId?: string | null }, + excludeUserId: string +): Promise => { + if (sharedWallet.groupId) { + return models.GroupMember.count({ + where: { groupId: sharedWallet.groupId, status: "active", userId: { [Op.ne]: excludeUserId } }, + }); + } + return models.SharedWalletMember.count({ + where: { sharedWalletId: sharedWallet.id, status: "active", userId: { [Op.ne]: excludeUserId } }, + }); +}; + +/** Full active-member list, normalized - used for the Members tab and realtime fan-out. */ +export const listActiveMembers = async ( + models: ReturnType, + sharedWallet: { id: string; groupId?: string | null } +): Promise => { + if (sharedWallet.groupId) { + const members = await models.GroupMember.findAll({ + where: { groupId: sharedWallet.groupId, status: "active" }, + }); + return members.map((m) => ({ userId: m.userId, role: m.role, status: m.status })); + } + const members = await models.SharedWalletMember.findAll({ + where: { sharedWalletId: sharedWallet.id, status: "active" }, + }); + return members.map((m) => ({ userId: m.userId, role: m.role, status: m.status })); +}; + +/** + * `Chat.groupId` is nullable (DM chats have it null too), so + * `Chat.findOne({ where: { groupId } })` with a null groupId would match an arbitrary + * DM. Guard against that explicitly rather than relying on callers to remember. + */ +export const getChatIdForSharedWallet = async ( + models: ReturnType, + sharedWallet: { groupId?: string | null } +): Promise => { + if (!sharedWallet.groupId) return null; + const chat = await models.Chat.findOne({ where: { groupId: sharedWallet.groupId } }); + return chat ? chat.id : null; +}; diff --git a/src/utils/verifyPin.ts b/src/utils/verifyPin.ts new file mode 100644 index 0000000..45a5fc3 --- /dev/null +++ b/src/utils/verifyPin.ts @@ -0,0 +1,52 @@ +import * as bcrypt from "bcrypt"; +import database_models from "../database/config/db.config"; + +const { User } = database_models as any; + +/** + * Thrown by verifyTransactionPin - callers should map `status`/`message` onto their + * own HTTP response the same way escrowController's ControllerError is handled. + */ +export class PinVerificationError extends Error { + status: number; + requiresPinSetup?: boolean; + constructor(status: number, message: string, requiresPinSetup?: boolean) { + super(message); + this.status = status; + this.requiresPinSetup = requiresPinSetup; + } +} + +/** + * Shared PIN check for money-moving actions (3-attempt lockout, 15 minutes). + * Extracted from escrowController's verifyPin so new money features don't + * reimplement PIN verification with a different lockout policy. + */ +export const verifyTransactionPin = async (userId: string, pin: string): Promise => { + const user = await User.findByPk(userId); + if (!user) throw new PinVerificationError(404, "Authenticated user not found"); + if (!user.hasPinSet) { + throw new PinVerificationError(403, "PIN not set up. Please set up your transaction PIN first.", true); + } + if (!pin) throw new PinVerificationError(400, "PIN is required"); + if (!/^\d{4}$/.test(pin)) throw new PinVerificationError(400, "PIN must be exactly 4 digits"); + + if (user.pinLockedUntil && user.pinLockedUntil > new Date()) { + const remainingMinutes = Math.ceil((user.pinLockedUntil.getTime() - Date.now()) / 60000); + throw new PinVerificationError(429, `PIN is temporarily locked. Try again in ${remainingMinutes} minutes.`); + } + + const isValid = await bcrypt.compare(pin, user.transactionPin!); + if (!isValid) { + const attempts = (user.pinAttempts || 0) + 1; + const maxAttempts = 3; + if (attempts >= maxAttempts) { + await user.update({ pinAttempts: attempts, pinLockedUntil: new Date(Date.now() + 15 * 60000) }); + throw new PinVerificationError(429, "Account locked due to too many failed PIN attempts. Please reset your PIN."); + } + await user.update({ pinAttempts: attempts }); + throw new PinVerificationError(400, `Invalid PIN. ${maxAttempts - attempts} attempts remaining.`); + } + + await user.update({ pinAttempts: 0, pinLockedUntil: null }); +};