diff --git a/package.json b/package.json index 84c2c2b..d464cf4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.18.0", + "@types/node-cron": "^3.0.11", "@types/node-rsa": "^1.1.4", "@types/nodemailer": "^6.4.16", "@types/passport": "^1.0.17", @@ -73,6 +74,7 @@ "cookie-parser": "^1.4.7", "cors": "^2.8.5", "crypto-js": "^4.2.0", + "dayjs": "^1.11.13", "dotenv": "^16.4.7", "express": "^4.21.2", "express-session": "^1.18.1", @@ -80,6 +82,7 @@ "firebase-admin": "^13.6.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "node-cron": "^3.0.3", "node-rsa": "^1.1.1", "nodemailer": "^6.9.16", "passport": "^0.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 491116a..c14c834 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: crypto-js: specifier: ^4.2.0 version: 4.2.0 + dayjs: + specifier: ^1.11.13 + version: 1.11.18 dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -68,6 +71,9 @@ importers: multer: specifier: ^1.4.5-lts.1 version: 1.4.5-lts.2 + node-cron: + specifier: ^3.0.3 + version: 3.0.3 node-rsa: specifier: ^1.1.1 version: 1.1.1 @@ -159,6 +165,9 @@ importers: '@types/node': specifier: ^22.18.0 version: 22.18.0 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 '@types/node-rsa': specifier: ^1.1.4 version: 1.1.4 @@ -749,6 +758,9 @@ packages: '@types/multer@1.4.13': resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node-rsa@1.1.4': resolution: {integrity: sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==} @@ -1765,6 +1777,10 @@ packages: resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} engines: {node: ^18 || ^20 || >= 21} + node-cron@3.0.3: + resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} + engines: {node: '>=6.0.0'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2471,10 +2487,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -3608,6 +3626,8 @@ snapshots: dependencies: '@types/express': 5.0.3 + '@types/node-cron@3.0.11': {} + '@types/node-rsa@1.1.4': dependencies: '@types/node': 22.18.0 @@ -4755,6 +4775,10 @@ snapshots: node-addon-api@8.5.0: {} + node-cron@3.0.3: + dependencies: + uuid: 8.3.2 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 diff --git a/src/controllers/admin.escrowDisputes.controller.ts b/src/controllers/admin.escrowDisputes.controller.ts new file mode 100644 index 0000000..bd8fdab --- /dev/null +++ b/src/controllers/admin.escrowDisputes.controller.ts @@ -0,0 +1,83 @@ +import { Response } from "express"; +import database_models from "../database/config/db.config"; +import { AuthenticatedRequest } from "../types/requests"; + +const { Escrow, User } = database_models as any; + +// Same shape as PARTY_INCLUDES in escrowController.ts. disputeRaisedBy/disputeRespondedBy/ +// resolvedByAdminId are plain UUID columns with no association defined - the admin UI +// can resolve those against payerUser/payeeUser (a dispute party) or fetch the admin +// separately if needed, rather than adding new associations for this. +const PARTY_INCLUDES = [ + { model: User, as: "payerUser", attributes: ["id", "firstName", "lastName", "email"] }, + { model: User, as: "payeeUser", attributes: ["id", "firstName", "lastName", "email"] }, +]; + +/** + * Lists escrows for admin review - defaults to disputed ones (the review queue), + * but accepts any status so already-resolved disputes can be looked back on too. + * GET /api/v1/admin/escrow-disputes + */ +const getAllDisputes = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { status = "disputed", page = 1, limit = 20 } = req.query as { + status?: string; + page?: string | number; + limit?: string | number; + }; + + const pageNumber = Math.max(1, Number(page) || 1); + const pageSize = Math.max(1, Number(limit) || 20); + + const where: any = status && status !== "all" ? { status } : {}; + + const result = await Escrow.findAndCountAll({ + where, + include: PARTY_INCLUDES, + order: [["disputeRaisedAt", "DESC"]], + limit: pageSize, + offset: (pageNumber - 1) * pageSize, + }); + + res.status(200).json({ + success: true, + data: result.rows, + pagination: { + total: result.count, + page: pageNumber, + limit: pageSize, + totalPages: Math.ceil(result.count / pageSize), + }, + }); + } catch (error) { + console.error("Get all escrow disputes error:", error); + res.status(500).json({ success: false, message: "Internal server error" }); + } +}; + +/** + * Full detail of a single escrow for admin review - no ownership check, since an + * admin isn't a party to the escrow they're reviewing. + * GET /api/v1/admin/escrow-disputes/:id + */ +const getDisputeById = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + + const escrow = await Escrow.findByPk(id, { include: PARTY_INCLUDES }); + if (!escrow) { + res.status(404).json({ success: false, message: "Escrow not found" }); + return; + } + + res.status(200).json({ success: true, data: escrow }); + } catch (error) { + console.error("Get escrow dispute detail error:", error); + res.status(500).json({ success: false, message: "Internal server error" }); + } +}; + +export default { + getAllDisputes, + getDisputeById, +}; diff --git a/src/controllers/batchTransferController.ts b/src/controllers/batchTransferController.ts new file mode 100644 index 0000000..f81626e --- /dev/null +++ b/src/controllers/batchTransferController.ts @@ -0,0 +1,621 @@ +import { Response } from "express"; +import * as bcrypt from "bcrypt"; +import database_models from "../database/config/db.config"; +import { AuthenticatedRequest } from "../types/requests"; +import { AuthRequest } from "../middleware/auth.unified.middleware"; +import { getAvailableBalance } from "../utils/walletBalance"; +import { resolveWalletWhere, validateFundsAvailability } from "../utils/transferValidation"; +import { notifyPaymentReceived, notifyBatchTransferCompleted } from "../utils/notificationHelpers"; + +const { + Wallet, + Transaction: TransactionModel, + Category, + WalletRestriction, + User, + Organization, + SubAction, + Action, + TransferBatch, +} = database_models as any; + +const MAX_BATCH_RECIPIENTS = 100; + +interface RecipientInput { + receiverUserId?: string; + receiverOrganizationId?: string; + receiverWalletId?: string; + amount: number | string; + description?: string; +} + +const buildBatchResponse = (batch: any, transactions: any[]) => { + const successResults = transactions.map((t: any) => ({ + status: "success", + transactionId: t.id, + receiverWalletId: t.receiverWalletId, + amount: parseFloat(t.amount.toString()), + })); + const failureResults = (batch.failures || []).map((f: any) => ({ ...f, status: "failed" })); + + return { + batchId: batch.id, + status: batch.status, + recipientCount: batch.recipientCount, + successCount: batch.successCount, + failureCount: batch.failureCount, + totalRequestedAmount: parseFloat(batch.totalRequestedAmount.toString()), + totalSentAmount: parseFloat(batch.totalSentAmount.toString()), + totalFailedAmount: parseFloat(batch.totalFailedAmount.toString()), + results: [...successResults, ...failureResults], + }; +}; + +/** + * Sends money to multiple recipients in one request. Validates the batch total up front + * (fail fast if it's unaffordable as a whole), then processes each recipient independently + * inside a single locked sender-wallet transaction: a recipient that fails validation is + * skipped with a reason, it never touches the sender's balance, and the rest of the batch + * still goes through. Mirrors transferMoney's PIN/ownership/restriction rules exactly, since + * this is the same money-movement operation just run for a list instead of one recipient. + */ +export const createBatchTransfer = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + const dbTransaction = await TransactionModel.sequelize?.transaction(); + + try { + const { + senderUserId, + senderOrganizationId, + senderSubActionId, + recipients, + description = "", + categoryId, + type = "transfer", + applyConstraints = false, + pin, + idempotencyKey, + } = req.body as { + senderUserId?: string; + senderOrganizationId?: string; + senderSubActionId?: string; + recipients: RecipientInput[]; + description?: string; + categoryId?: string; + type?: string; + applyConstraints?: boolean; + pin?: string; + idempotencyKey?: string; + }; + + const senderTargets = [senderUserId, senderOrganizationId, senderSubActionId].filter(Boolean).length; + if (senderTargets !== 1) { + await dbTransaction?.rollback(); + res.status(400).json({ + success: false, + message: "Exactly one sender (user, organization, or sub-action) is required", + }); + return; + } + + if (!Array.isArray(recipients) || recipients.length === 0) { + await dbTransaction?.rollback(); + res.status(400).json({ success: false, message: "At least one recipient is required" }); + return; + } + + if (recipients.length > MAX_BATCH_RECIPIENTS) { + await dbTransaction?.rollback(); + res.status(400).json({ + success: false, + message: `A batch can contain at most ${MAX_BATCH_RECIPIENTS} recipients`, + }); + return; + } + + for (const r of recipients) { + const recipientTargets = [r.receiverUserId, r.receiverOrganizationId, r.receiverWalletId].filter( + Boolean + ).length; + if (recipientTargets !== 1 || !r.amount || Number(r.amount) <= 0) { + await dbTransaction?.rollback(); + res.status(400).json({ + success: false, + message: "Each recipient needs exactly one receiver target and an amount greater than 0", + }); + return; + } + } + + const authenticatedUserId = req.user.id; + const authUser = (req as unknown as AuthRequest).user; + const requesterOrganizationId = + authUser?.accountType === "organization" ? authUser.id : authUser?.organizationId || null; + + if (senderUserId && senderUserId !== authenticatedUserId) { + await dbTransaction?.rollback(); + res.status(403).json({ success: false, message: "You can only send money from your own account" }); + return; + } + + if (senderOrganizationId) { + if (!requesterOrganizationId || senderOrganizationId !== requesterOrganizationId) { + await dbTransaction?.rollback(); + res.status(403).json({ + success: false, + message: "You are not authorized to send money from this organization", + }); + return; + } + } + + if (senderSubActionId) { + if (!requesterOrganizationId) { + await dbTransaction?.rollback(); + res.status(403).json({ + success: false, + message: "Organization context is required to send from a sub-action wallet", + }); + return; + } + const subActionRecord: any = await SubAction.findByPk(senderSubActionId, { + include: [{ model: Action, as: "action", attributes: ["id", "organizationId"] }], + }); + if (!subActionRecord) { + await dbTransaction?.rollback(); + res.status(404).json({ success: false, message: "Sender sub-action not found" }); + return; + } + const ownerOrganizationId = subActionRecord.action?.organizationId; + if (!ownerOrganizationId || ownerOrganizationId !== requesterOrganizationId) { + await dbTransaction?.rollback(); + res.status(403).json({ + success: false, + message: "You are not authorized to send money from this sub-action wallet", + }); + return; + } + } + + for (const r of recipients) { + if ( + (senderUserId && r.receiverUserId && senderUserId === r.receiverUserId) || + (senderOrganizationId && r.receiverOrganizationId && senderOrganizationId === r.receiverOrganizationId) + ) { + await dbTransaction?.rollback(); + res.status(400).json({ success: false, message: "Cannot transfer to yourself" }); + return; + } + } + + // PIN verification (mirrors transferMoney's flow exactly, since this is the same + // authorization gate for the same kind of money-moving action) + if (senderUserId) { + const authenticatedUser = await User.findByPk(authenticatedUserId); + if (!authenticatedUser) { + await dbTransaction?.rollback(); + res.status(404).json({ success: false, message: "Authenticated user not found" }); + return; + } + + if (!authenticatedUser.hasPinSet) { + await dbTransaction?.rollback(); + res.status(403).json({ + success: false, + message: "PIN not set up. Please set up your transaction PIN before making transfers.", + requiresPinSetup: true, + }); + return; + } + + if (!pin) { + await dbTransaction?.rollback(); + res.status(400).json({ success: false, message: "PIN is required for transactions" }); + return; + } + + if (!/^\d{4}$/.test(pin)) { + await dbTransaction?.rollback(); + res.status(400).json({ success: false, message: "PIN must be exactly 4 digits" }); + return; + } + + if (authenticatedUser.pinLockedUntil && authenticatedUser.pinLockedUntil > new Date()) { + const remainingTime = Math.ceil( + (authenticatedUser.pinLockedUntil.getTime() - Date.now()) / 60000 + ); + await dbTransaction?.rollback(); + res.status(429).json({ + success: false, + message: `PIN is temporarily locked. Try again in ${remainingTime} minutes.`, + lockedUntil: authenticatedUser.pinLockedUntil, + remainingMinutes: remainingTime, + }); + return; + } + + const isValidPin = await bcrypt.compare(pin, authenticatedUser.transactionPin!); + if (!isValidPin) { + const newAttempts = (authenticatedUser.pinAttempts || 0) + 1; + const maxAttempts = 3; + const lockoutMinutes = 15; + const updateData: any = { pinAttempts: newAttempts }; + + if (newAttempts >= maxAttempts) { + const lockedUntil = new Date(Date.now() + lockoutMinutes * 60000); + updateData.pinLockedUntil = lockedUntil; + await authenticatedUser.update(updateData); + + try { + const sendEmailFn = require("../helpers/email").default; + await sendEmailFn({ + to: authenticatedUser.email, + subject: "Account Locked - PIN Reset Required", + type: "account_blocked", + data: { + name: authenticatedUser.firstName, + lockoutMinutes: lockoutMinutes.toString(), + resetUrl: `${process.env.FRONTEND_URL || "http://localhost:3000"}/settings/security?tab=pin-reset`, + }, + }); + } catch (emailError) { + console.error("Failed to send account blocked email:", emailError); + } + + await dbTransaction?.rollback(); + res.status(429).json({ + success: false, + message: "Account locked due to too many failed PIN attempts. Please reset your PIN to regain access.", + attemptsRemaining: 0, + lockedUntil, + remainingMinutes: lockoutMinutes, + }); + return; + } + + await authenticatedUser.update(updateData); + await dbTransaction?.rollback(); + res.status(400).json({ + success: false, + message: `Invalid PIN. ${maxAttempts - newAttempts} attempts remaining.`, + attemptsRemaining: maxAttempts - newAttempts, + }); + return; + } + + await authenticatedUser.update({ pinAttempts: 0, pinLockedUntil: null }); + } + + // Idempotency: a repeated submission with the same key returns the original result + // instead of processing (and potentially double-paying) the batch again. + if (idempotencyKey) { + const existing = await TransferBatch.findOne({ + where: { createdByUserId: authenticatedUserId, idempotencyKey }, + }); + if (existing) { + await dbTransaction?.rollback(); + const existingTransactions = await TransactionModel.findAll({ where: { batchId: existing.id } }); + res.status(200).json({ + success: true, + message: "Batch already processed", + data: buildBatchResponse(existing, existingTransactions), + }); + return; + } + } + + const senderWhere = resolveWalletWhere({ + userId: senderUserId, + organizationId: senderOrganizationId, + subActionId: senderSubActionId, + }); + const senderWallet = await Wallet.findOne({ + where: senderWhere as any, + lock: dbTransaction?.LOCK.UPDATE, + transaction: dbTransaction, + }); + + if (!senderWallet) { + await dbTransaction?.rollback(); + res.status(404).json({ success: false, message: "Sender wallet not found or inactive" }); + return; + } + + // Fast fail: reject the whole batch up front if it's unaffordable in aggregate, + // rather than letting every recipient fail one-by-one. + const totalRequestedAmount = recipients.reduce((sum, r) => sum + parseFloat(r.amount.toString()), 0); + if (getAvailableBalance(senderWallet) < totalRequestedAmount) { + await dbTransaction?.rollback(); + res.status(400).json({ + success: false, + message: `Insufficient balance for this batch. Available: ${getAvailableBalance(senderWallet)}, Required: ${totalRequestedAmount}`, + }); + return; + } + + const batch = await TransferBatch.create( + { + createdByUserId: authenticatedUserId, + senderUserId: senderUserId || null, + senderOrganizationId: senderOrganizationId || null, + senderSubActionId: senderSubActionId || null, + currency: senderWallet.currency, + type, + description, + categoryId: categoryId || null, + applyConstraints, + idempotencyKey: idempotencyKey || null, + recipientCount: recipients.length, + totalRequestedAmount, + } as any, + { transaction: dbTransaction } + ); + + const results: any[] = []; + const failures: any[] = []; + let totalSent = 0; + let successCount = 0; + + for (const recipient of recipients) { + const recipientIdentity = { + receiverUserId: recipient.receiverUserId || null, + receiverOrganizationId: recipient.receiverOrganizationId || null, + receiverWalletId: recipient.receiverWalletId || null, + }; + const transferAmount = parseFloat(recipient.amount.toString()); + + const receiverWhere = resolveWalletWhere({ + userId: recipient.receiverUserId, + organizationId: recipient.receiverOrganizationId, + walletId: recipient.receiverWalletId, + }); + + const receiverWallet = await Wallet.findOne({ + where: receiverWhere as any, + lock: dbTransaction?.LOCK.UPDATE, + transaction: dbTransaction, + }); + + if (!receiverWallet) { + const reason = "Receiver wallet not found or inactive"; + failures.push({ ...recipientIdentity, amount: transferAmount, reason }); + results.push({ ...recipientIdentity, amount: transferAmount, status: "failed", reason }); + continue; + } + + if (receiverWallet.id === senderWallet.id) { + const reason = "Cannot transfer to the same wallet"; + failures.push({ ...recipientIdentity, amount: transferAmount, reason }); + results.push({ ...recipientIdentity, amount: transferAmount, status: "failed", reason }); + continue; + } + + // Re-read the sender's live balance/restrictions each iteration, since prior + // recipients in this same loop may have already consumed some of it. + await senderWallet.reload({ transaction: dbTransaction }); + const restrictions = await WalletRestriction.findAll({ + where: { walletId: senderWallet.id }, + include: [{ model: Category, as: "category", required: true }], + transaction: dbTransaction, + }); + + const receiverIsUser = !!receiverWallet.userId; + const validation = validateFundsAvailability({ + availableBalance: getAvailableBalance(senderWallet), + restrictions: restrictions as any, + transferAmount, + receiverIsUser, + categoryId, + }); + + if (!validation.ok) { + const reason = validation.message || "Insufficient balance"; + failures.push({ ...recipientIdentity, amount: transferAmount, reason }); + results.push({ ...recipientIdentity, amount: transferAmount, status: "failed", reason }); + continue; + } + + await senderWallet.decrement("balance", { by: transferAmount, transaction: dbTransaction }); + await receiverWallet.increment("balance", { by: transferAmount, transaction: dbTransaction }); + await senderWallet.reload({ transaction: dbTransaction }); + + const spendConstraintType = applyConstraints && categoryId ? "category" : "none"; + const constraintCategoryId = applyConstraints && categoryId ? categoryId : null; + const referenceId = `BATCH${Date.now()}${Math.floor(Math.random() * 1000)}`; + + const newTransaction = await TransactionModel.create( + { + referenceId, + senderWalletId: senderWallet.id, + receiverWalletId: receiverWallet.id, + amount: transferAmount, + fee: 0, + totalAmount: transferAmount, + currency: senderWallet.currency, + status: "completed", + type, + description: recipient.description || description, + categoryId: categoryId || undefined, + spendConstraintType, + constraintCategoryId, + hasAccount: true, + batchId: batch.id, + } as any, + { transaction: dbTransaction } + ); + + // Same restriction bookkeeping transferMoney does for a single send. + if (applyConstraints && categoryId) { + const existingRestriction = await WalletRestriction.findOne({ + where: { walletId: receiverWallet.id, categoryId }, + transaction: dbTransaction, + }); + if (existingRestriction) { + await existingRestriction.update( + { amount: parseFloat(existingRestriction.amount.toString()) + transferAmount }, + { transaction: dbTransaction } + ); + } else { + await WalletRestriction.create( + { walletId: receiverWallet.id, categoryId, amount: transferAmount }, + { transaction: dbTransaction } + ); + } + } else if (categoryId && !receiverIsUser) { + const matching = restrictions.find((r: any) => r.categoryId === categoryId); + if (matching) { + const restrictedAvailable = parseFloat((matching as any).amount.toString()); + const reduceBy = Math.min(restrictedAvailable, transferAmount); + const newAmount = restrictedAvailable - reduceBy; + if (newAmount <= 0) { + await (matching as any).destroy({ transaction: dbTransaction }); + } else { + await (matching as any).update({ amount: newAmount }, { transaction: dbTransaction }); + } + } + } + + totalSent += transferAmount; + successCount += 1; + results.push({ ...recipientIdentity, amount: transferAmount, status: "success", transactionId: newTransaction.id }); + } + + const failureCount = recipients.length - successCount; + const totalFailedAmount = failures.reduce((sum, f) => sum + f.amount, 0); + const status = successCount === 0 ? "failed" : failureCount > 0 ? "partial" : "completed"; + + await batch.update( + { + totalSentAmount: totalSent, + totalFailedAmount, + successCount, + failureCount, + status, + failures: failures.length > 0 ? failures : null, + }, + { transaction: dbTransaction } + ); + + await dbTransaction?.commit(); + + try { + let senderName = "A user"; + if (senderUserId) { + const sender = await User.findByPk(senderUserId); + senderName = sender ? `${sender.firstName} ${sender.lastName}` : senderName; + } else if (senderOrganizationId) { + const senderOrg = await Organization.findByPk(senderOrganizationId); + senderName = senderOrg ? senderOrg.name : senderName; + } + + if (senderUserId) { + await notifyBatchTransferCompleted( + req.app, + senderUserId, + batch.id, + successCount, + failureCount, + totalSent, + senderWallet.currency || "RWF" + ); + } + + for (const result of results) { + if (result.status === "success" && result.receiverUserId) { + await notifyPaymentReceived( + req.app, + result.receiverUserId, + result.transactionId, + result.amount, + senderWallet.currency || "RWF", + senderName + ); + } + } + } catch (notificationError) { + console.error("Batch transfer notification error:", notificationError); + } + + res.status(200).json({ + success: true, + message: `Batch transfer processed: ${successCount} succeeded, ${failureCount} failed`, + data: { + batchId: batch.id, + status, + recipientCount: recipients.length, + successCount, + failureCount, + totalRequestedAmount, + totalSentAmount: totalSent, + totalFailedAmount, + results, + }, + }); + } catch (error) { + if (dbTransaction) { + await dbTransaction.rollback(); + } + console.error("Batch transfer error:", error); + res.status(500).json({ success: false, message: "Internal server error during batch transfer" }); + } +}; + +export const getUserBatchTransfers = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const authenticatedUserId = req.user.id; + const page = parseInt((req.query.page as string) || "1"); + const limit = parseInt((req.query.limit as string) || "20"); + const offset = (page - 1) * limit; + + const { rows, count } = await TransferBatch.findAndCountAll({ + where: { createdByUserId: authenticatedUserId }, + order: [["createdAt", "DESC"]], + limit, + offset, + }); + + res.status(200).json({ + success: true, + data: rows.map((b: any) => ({ + id: b.id, + status: b.status, + recipientCount: b.recipientCount, + successCount: b.successCount, + failureCount: b.failureCount, + totalSentAmount: parseFloat(b.totalSentAmount.toString()), + currency: b.currency, + description: b.description, + createdAt: b.createdAt, + })), + pagination: { page, limit, total: count, pages: Math.ceil(count / limit) }, + }); + } catch (error) { + console.error("Get user batch transfers error:", error); + res.status(500).json({ success: false, message: "Internal server error" }); + } +}; + +export const getBatchTransferById = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const authenticatedUserId = req.user.id; + const { id } = req.params; + + const batch = await TransferBatch.findByPk(id); + if (!batch || batch.createdByUserId !== authenticatedUserId) { + res.status(404).json({ success: false, message: "Batch not found" }); + return; + } + + const transactions = await TransactionModel.findAll({ + where: { batchId: batch.id }, + order: [["createdAt", "ASC"]], + }); + + res.status(200).json({ success: true, data: buildBatchResponse(batch, transactions) }); + } catch (error) { + console.error("Get batch transfer by id error:", error); + res.status(500).json({ success: false, message: "Internal server error" }); + } +}; diff --git a/src/controllers/chatMoneyController.ts b/src/controllers/chatMoneyController.ts index c2e22fe..a13254f 100644 --- a/src/controllers/chatMoneyController.ts +++ b/src/controllers/chatMoneyController.ts @@ -6,6 +6,7 @@ import { Op } from "sequelize"; import { sequelizeConnection } from "../database/config/db.config"; import ChatService from "../services/chatService"; import { PDFGenerator } from "../utils/pdfGenerator"; +import { getAvailableBalance } from "../utils/walletBalance"; import * as fs from 'fs'; import * as path from 'path'; @@ -327,7 +328,7 @@ export const sendMoneyInChat = async ( console.log('Transfer details:', { transferAmount, fee, totalAmount, amount, isGroupFundraising }); - if (senderWallet.balance < totalAmount) { + if (getAvailableBalance(senderWallet) < totalAmount) { await dbTransaction.rollback(); res.status(400).json({ success: false, diff --git a/src/controllers/e2ee.controller.ts b/src/controllers/e2ee.controller.ts index 46e0608..4edd010 100644 --- a/src/controllers/e2ee.controller.ts +++ b/src/controllers/e2ee.controller.ts @@ -349,7 +349,8 @@ export const getSecureChatMessages = async ( messages: result.rows.map((message: any) => ({ id: message.id, chatId: message.chatId, - content: "", + content: message.isEncrypted ? "" : message.content, + isEncrypted: message.isEncrypted, encryptedEnvelope: message.encryptedEnvelope, messageType: message.messageType, replyToMessageId: message.replyToMessageId, diff --git a/src/controllers/escrowController.ts b/src/controllers/escrowController.ts new file mode 100644 index 0000000..205c5ae --- /dev/null +++ b/src/controllers/escrowController.ts @@ -0,0 +1,553 @@ +import { Response } from "express"; +import { Op, Transaction as DbTransaction } from "sequelize"; +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 { notifyPaymentReceived, notifyPaymentSent } from "../utils/notificationHelpers"; + +const { sequelize, Wallet, Escrow, User, ChatParticipant, ChatMessage } = database_models as any; + +const PARTY_INCLUDES = [ + { model: User, as: "payerUser", attributes: ["id", "firstName", "lastName"] }, + { model: User, as: "payeeUser", attributes: ["id", "firstName", "lastName"] }, +]; + +const MIN_AUTO_RELEASE_LEAD_MINUTES = 60; +const MAX_AUTO_RELEASE_HORIZON_DAYS = 90; +// Once the payee marks an escrow fulfilled, the payer can no longer cancel it - +// only release or dispute. This deadline protects the payee from a payer who then +// goes silent: if the payer does neither within this window, it auto-releases. +const FULFILLMENT_AUTO_RELEASE_DAYS = 3; + +class ControllerError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +const handleControllerError = (res: Response, error: unknown, context: string): void => { + if (error instanceof ControllerError) { + res.status(error.status).json({ success: false, message: error.message }); + return; + } + console.error(`${context} error:`, error); + res.status(500).json({ success: false, message: "Internal server error" }); +}; + +const verifyPin = async (userId: string, pin: string): Promise => { + const user = await User.findByPk(userId); + if (!user) throw new ControllerError(404, "Authenticated user not found"); + if (!user.hasPinSet) { + throw new ControllerError(403, "PIN not set up. Please set up your transaction PIN first."); + } + if (!pin) throw new ControllerError(400, "PIN is required"); + if (!/^\d{4}$/.test(pin)) throw new ControllerError(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 ControllerError(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 ControllerError(429, "Account locked due to too many failed PIN attempts. Please reset your PIN."); + } + await user.update({ pinAttempts: attempts }); + throw new ControllerError(400, `Invalid PIN. ${maxAttempts - attempts} attempts remaining.`); + } + + await user.update({ pinAttempts: 0, pinLockedUntil: null }); +}; + +const validateAutoReleaseAt = (value: unknown): Date => { + const date = new Date(value as string); + if (isNaN(date.getTime())) throw new ControllerError(400, "autoReleaseAt must be a valid date"); + + const now = new Date(); + const minAllowed = new Date(now.getTime() + MIN_AUTO_RELEASE_LEAD_MINUTES * 60000); + const maxAllowed = new Date(now.getTime() + MAX_AUTO_RELEASE_HORIZON_DAYS * 24 * 60 * 60000); + + if (date < minAllowed) { + throw new ControllerError(400, `autoReleaseAt must be at least ${MIN_AUTO_RELEASE_LEAD_MINUTES} minutes in the future`); + } + if (date > maxAllowed) { + throw new ControllerError(400, `autoReleaseAt cannot be more than ${MAX_AUTO_RELEASE_HORIZON_DAYS} days in the future`); + } + return date; +}; + +/** + * Opens an escrow: funds are reserved (heldBalance) against the payer's wallet + * immediately, but nothing reaches the payee until release. Fails outright if funds + * aren't available, same as scheduled transfers. + */ +const createEscrow = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { + payeeUserId, + amount, + description = "", + releaseMode = "manual", + autoReleaseAt, + pin, + chatId, + } = req.body; + + const authenticatedUserId = req.user.id; + + if (!payeeUserId || !amount || parseFloat(amount) <= 0) { + throw new ControllerError(400, "payeeUserId and an amount greater than 0 are required"); + } + if (payeeUserId === authenticatedUserId) { + throw new ControllerError(400, "Cannot open an escrow with yourself"); + } + if (!["manual", "auto_timeout"].includes(releaseMode)) { + throw new ControllerError(400, "releaseMode must be 'manual' or 'auto_timeout'"); + } + + let autoReleaseDate: Date | null = null; + if (releaseMode === "auto_timeout") { + autoReleaseDate = validateAutoReleaseAt(autoReleaseAt); + } + + if (chatId) { + const [payerParticipant, payeeParticipant] = await Promise.all([ + ChatParticipant.findOne({ where: { chatId, userId: authenticatedUserId } }), + ChatParticipant.findOne({ where: { chatId, userId: payeeUserId } }), + ]); + if (!payerParticipant) throw new ControllerError(403, "You are not a participant in this chat"); + if (!payeeParticipant) throw new ControllerError(400, "Payee is not a participant in this chat"); + } + + await verifyPin(authenticatedUserId, pin); + + const payeeWallet = await Wallet.findOne({ where: { userId: payeeUserId, isActive: true } }); + if (!payeeWallet) throw new ControllerError(404, "Payee wallet not found or inactive"); + + const escrowAmount = parseFloat(amount); + let created: any; + + await sequelize.transaction(async (t: DbTransaction) => { + const payerWallet = await Wallet.findOne({ + where: { userId: authenticatedUserId, isActive: true }, + lock: t.LOCK.UPDATE, + transaction: t, + }); + if (!payerWallet) throw new ControllerError(404, "Your wallet not found or inactive"); + + if (getAvailableBalance(payerWallet) < escrowAmount) { + throw new ControllerError( + 400, + `Insufficient balance. You have ${getAvailableBalance(payerWallet)} ${payerWallet.currency}, but need ${escrowAmount}` + ); + } + + await holdEscrowFunds(payerWallet, escrowAmount, t); + + created = await Escrow.create( + { + chatId: chatId || null, + payerWalletId: payerWallet.id, + payeeWalletId: payeeWallet.id, + payerUserId: authenticatedUserId, + payeeUserId, + amount: escrowAmount, + currency: payerWallet.currency || "RWF", + description, + status: "held", + releaseMode, + autoReleaseAt: autoReleaseDate, + fundedAt: new Date(), + } as any, + { transaction: t } + ); + }); + + let chatMessage: any = null; + if (chatId) { + chatMessage = await postEscrowChatMessage(req, chatId, authenticatedUserId, created, payeeUserId); + } + + res.status(201).json({ success: true, message: "Funds placed in escrow", data: created, chatMessage }); + } catch (error) { + handleControllerError(res, error, "Create escrow"); + } +}; + +/** + * Drops a chat message announcing the hold and broadcasts it, same as sendMoneyInChat does + * for a normal transfer - the escrow card in the thread is how both sides see/act on it. + */ +const postEscrowChatMessage = async ( + req: AuthenticatedRequest, + chatId: string, + senderId: string, + escrow: any, + payeeUserId: string +): Promise => { + const [payerUser, payeeUser] = await Promise.all([User.findByPk(senderId), User.findByPk(payeeUserId)]); + + const content = JSON.stringify({ + type: "escrow", + escrowId: escrow.id, + amount: parseFloat(escrow.amount.toString()), + currency: escrow.currency, + payerName: payerUser ? `${payerUser.firstName} ${payerUser.lastName}` : "Sender", + payeeName: payeeUser ? `${payeeUser.firstName} ${payeeUser.lastName}` : "Recipient", + description: escrow.description || "", + releaseMode: escrow.releaseMode, + autoReleaseAt: escrow.autoReleaseAt, + timestamp: new Date().toISOString(), + }); + + const message = await ChatMessage.create({ + chatId, + senderId, + content, + messageType: "escrow", + isEncrypted: false, + encryptionIv: "", + status: "sent", + }); + + const messageWithSender = await ChatMessage.findByPk(message.id, { + include: [{ model: User, as: "sender", attributes: ["id", "firstName", "lastName"] }], + }); + + const io = req.app.get("io"); + if (io) { + const participants = await ChatParticipant.findAll({ where: { chatId }, attributes: ["userId"] }); + const broadcastMessage = messageWithSender!.toJSON(); + for (const p of participants) { + io.to(`user_${p.userId}`).emit("new_message", broadcastMessage); + } + } + + return messageWithSender; +}; + +const listMyEscrows = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const authenticatedUserId = req.user.id; + const { status, role } = req.query as { status?: string; role?: "payer" | "payee" }; + + const where: any = {}; + if (role === "payer") { + where.payerUserId = authenticatedUserId; + } else if (role === "payee") { + where.payeeUserId = authenticatedUserId; + } else { + where[Op.or as any] = [{ payerUserId: authenticatedUserId }, { payeeUserId: authenticatedUserId }]; + } + if (status) where.status = status; + + const escrows = await Escrow.findAll({ where, include: PARTY_INCLUDES, order: [["createdAt", "DESC"]] }); + res.status(200).json({ success: true, data: escrows }); + } catch (error) { + handleControllerError(res, error, "List escrows"); + } +}; + +const getEscrowById = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const authenticatedUserId = req.user.id; + + const escrow = await Escrow.findByPk(id, { include: PARTY_INCLUDES }); + if (!escrow) throw new ControllerError(404, "Escrow not found"); + if (escrow.payerUserId !== authenticatedUserId && escrow.payeeUserId !== authenticatedUserId) { + throw new ControllerError(403, "Not authorized to view this escrow"); + } + + res.status(200).json({ success: true, data: escrow }); + } catch (error) { + handleControllerError(res, error, "Get escrow"); + } +}; + +/** Only the payer can release - they're the one confirming the condition was met. */ +const releaseEscrow = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const authenticatedUserId = req.user.id; + + let updated: any; + let releaseResult: { transactionId: string } | null = null; + + await sequelize.transaction(async (t: DbTransaction) => { + 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 release these funds"); + } + if (escrow.status !== "held") { + throw new ControllerError(400, `Escrow cannot be released from status '${escrow.status}'`); + } + + releaseResult = await releaseEscrowFunds(escrow, t); + await escrow.update( + { status: "released", releasedAt: new Date(), releaseTransactionId: releaseResult.transactionId }, + { transaction: t } + ); + updated = escrow; + }); + + if (releaseResult) { + const [payerUser, payeeUser] = await Promise.all([ + User.findByPk(updated.payerUserId), + User.findByPk(updated.payeeUserId), + ]); + const amount = parseFloat(updated.amount.toString()); + if (payeeUser && payerUser) { + await notifyPaymentReceived( + req.app, + updated.payeeUserId, + (releaseResult as { transactionId: string }).transactionId, + amount, + updated.currency, + `${payerUser.firstName} ${payerUser.lastName}` + ); + await notifyPaymentSent( + req.app, + updated.payerUserId, + (releaseResult as { transactionId: string }).transactionId, + amount, + updated.currency, + `${payeeUser.firstName} ${payeeUser.lastName}` + ); + } + } + + res.status(200).json({ success: true, message: "Escrow released", data: updated }); + } catch (error) { + handleControllerError(res, error, "Release escrow"); + } +}; + +/** + * Payee marks their end done. This strips the payer's unilateral cancel (they can + * now only release or dispute), and starts a grace-period auto-release deadline so + * the payee isn't left waiting forever on a payer who never acts. + */ +const fulfillEscrow = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const authenticatedUserId = req.user.id; + let updated: any; + + await sequelize.transaction(async (t: DbTransaction) => { + const escrow = await Escrow.findByPk(id, { lock: t.LOCK.UPDATE, transaction: t }); + if (!escrow) throw new ControllerError(404, "Escrow not found"); + if (escrow.payeeUserId !== authenticatedUserId) { + throw new ControllerError(403, "Only the payee can mark this escrow as fulfilled"); + } + if (escrow.status !== "held") { + throw new ControllerError(400, `Escrow cannot be fulfilled from status '${escrow.status}'`); + } + if (escrow.fulfilledAt) { + throw new ControllerError(400, "Escrow is already marked as fulfilled"); + } + + const now = new Date(); + const graceDeadline = new Date(now.getTime() + FULFILLMENT_AUTO_RELEASE_DAYS * 24 * 60 * 60 * 1000); + const existingDeadline = escrow.autoReleaseAt ? new Date(escrow.autoReleaseAt) : null; + const nextAutoReleaseAt = existingDeadline && existingDeadline < graceDeadline ? existingDeadline : graceDeadline; + + await escrow.update( + { + fulfilledAt: now, + releaseMode: "auto_timeout", + autoReleaseAt: nextAutoReleaseAt, + }, + { transaction: t } + ); + updated = escrow; + }); + + res.status(200).json({ success: true, message: "Escrow marked as fulfilled", data: updated }); + } catch (error) { + handleControllerError(res, error, "Fulfill escrow"); + } +}; + +/** + * 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. + */ +const refundEscrow = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const authenticatedUserId = req.user.id; + let updated: any; + + await sequelize.transaction(async (t: DbTransaction) => { + 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.status !== "held") { + throw new ControllerError(400, `Escrow cannot be refunded from status '${escrow.status}'`); + } + if (escrow.fulfilledAt) { + throw new ControllerError(400, "The payee has marked this escrow fulfilled - release the funds or raise a dispute instead"); + } + + await refundEscrowFunds(escrow, t); + await escrow.update({ status: "refunded", refundedAt: new Date() }, { transaction: t }); + updated = escrow; + }); + + res.status(200).json({ success: true, message: "Escrow refunded", data: updated }); + } catch (error) { + handleControllerError(res, error, "Refund escrow"); + } +}; + +/** Either party can flag a held escrow; this freezes it until an admin resolves it. */ +const raiseDispute = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const { reason } = req.body; + const authenticatedUserId = req.user.id; + + if (!reason) throw new ControllerError(400, "A dispute reason is required"); + + 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 dispute this escrow"); + } + if (escrow.status !== "held") { + throw new ControllerError(400, `Escrow cannot be disputed from status '${escrow.status}'`); + } + + await escrow.update({ + status: "disputed", + disputeRaisedBy: authenticatedUserId, + disputeReason: reason, + disputeRaisedAt: new Date(), + }); + + res.status(200).json({ success: true, message: "Dispute raised", data: escrow }); + } catch (error) { + handleControllerError(res, error, "Raise escrow dispute"); + } +}; + +/** + * Lets the party who did NOT raise the dispute put their side on record before an + * admin reviews it. One-shot (not a thread) - keeps this simple while still making + * sure a review isn't working off only the complainant's account. + */ +const respondToDispute = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const { response } = req.body; + const authenticatedUserId = req.user.id; + + if (!response) throw new ControllerError(400, "A response is required"); + + 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 respond to this dispute"); + } + if (escrow.status !== "disputed") { + throw new ControllerError(400, "This escrow is not currently disputed"); + } + if (escrow.disputeRaisedBy === authenticatedUserId) { + throw new ControllerError(400, "You raised this dispute - a response is for the other party"); + } + if (escrow.disputeResponse) { + throw new ControllerError(400, "A response has already been recorded for this dispute"); + } + + await escrow.update({ + disputeResponse: response, + disputeRespondedBy: authenticatedUserId, + disputeRespondedAt: new Date(), + }); + + res.status(200).json({ success: true, message: "Response recorded", data: escrow }); + } catch (error) { + handleControllerError(res, error, "Respond to escrow dispute"); + } +}; + +/** Admin-only (gated at the route level) - decides a disputed escrow one way or the other. */ +const resolveDispute = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const { action, note } = req.body as { action?: "release" | "refund"; note?: string }; + const adminId = req.user.id; + + if (action !== "release" && action !== "refund") { + throw new ControllerError(400, "action must be 'release' or 'refund'"); + } + + let updated: any; + let releaseResult: { transactionId: string } | null = null; + + await sequelize.transaction(async (t: DbTransaction) => { + const escrow = await Escrow.findByPk(id, { lock: t.LOCK.UPDATE, transaction: t }); + if (!escrow) throw new ControllerError(404, "Escrow not found"); + if (escrow.status !== "disputed") { + throw new ControllerError(400, "Only disputed escrows can be resolved"); + } + + if (action === "release") { + releaseResult = await releaseEscrowFunds(escrow, t); + await escrow.update( + { + status: "released", + releasedAt: new Date(), + releaseTransactionId: releaseResult.transactionId, + resolvedByAdminId: adminId, + resolutionNote: note || null, + resolvedAt: new Date(), + }, + { transaction: t } + ); + } else { + await refundEscrowFunds(escrow, t); + await escrow.update( + { + status: "refunded", + refundedAt: new Date(), + resolvedByAdminId: adminId, + resolutionNote: note || null, + resolvedAt: new Date(), + }, + { transaction: t } + ); + } + updated = escrow; + }); + + res.status(200).json({ success: true, message: `Dispute resolved: escrow ${action}d`, data: updated }); + } catch (error) { + handleControllerError(res, error, "Resolve escrow dispute"); + } +}; + +export default { + createEscrow, + listMyEscrows, + getEscrowById, + releaseEscrow, + fulfillEscrow, + refundEscrow, + raiseDispute, + respondToDispute, + resolveDispute, +}; diff --git a/src/controllers/groupContributionController.ts b/src/controllers/groupContributionController.ts index a2e71b9..2971ba7 100644 --- a/src/controllers/groupContributionController.ts +++ b/src/controllers/groupContributionController.ts @@ -8,6 +8,7 @@ import { GroupMemberRole, GroupMemberStatus } from "../types/group"; import { NotificationType } from "../utils/notificationConfig"; import { createAndSendNotification } from "../utils/notificationService"; import { redactAnonymousPayments } from "../utils/paymentPrivacy"; +import { getAvailableBalance } from "../utils/walletBalance"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -480,7 +481,7 @@ export const contribute = async ( return; } - const memberBalance = parseFloat(memberWallet.balance.toString()); + const memberBalance = getAvailableBalance(memberWallet); if (memberBalance < contributionAmount) { await dbTransaction.rollback(); res diff --git a/src/controllers/publicContributionController.ts b/src/controllers/publicContributionController.ts index e9cf6d3..3a78a0f 100644 --- a/src/controllers/publicContributionController.ts +++ b/src/controllers/publicContributionController.ts @@ -8,6 +8,7 @@ import { NotificationType } from "../utils/notificationConfig"; import { createAndSendNotification } from "../utils/notificationService"; import { GroupMemberRole, GroupMemberStatus, GroupPrivacyType, GroupExpirationType } from "../types/group"; import { redactAnonymousPayments } from "../utils/paymentPrivacy"; +import { getAvailableBalance } from "../utils/walletBalance"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -324,7 +325,7 @@ export const contribute = async ( return; } - const payerBalance = parseFloat(payerWallet.balance.toString()); + const payerBalance = getAvailableBalance(payerWallet); if (payerBalance < contributionAmount) { await dbTransaction.rollback(); res.status(400).json({ success: false, message: "Insufficient balance" }); diff --git a/src/controllers/scheduledTransferController.ts b/src/controllers/scheduledTransferController.ts new file mode 100644 index 0000000..357bc54 --- /dev/null +++ b/src/controllers/scheduledTransferController.ts @@ -0,0 +1,887 @@ +import { Response } from "express"; +import { Op } from "sequelize"; +import * as bcrypt from "bcrypt"; +import { v4 as uuidv4 } from "uuid"; +import database_models from "../database/config/db.config"; +import { AuthenticatedRequest } from "../types/requests"; +import { AuthRequest } from "../middleware/auth.unified.middleware"; +import { resolveWalletWhere } from "../utils/transferValidation"; +import { validateRecurrenceRule, computeNextRun, isRecurrenceExhausted } from "../utils/recurrence"; +import { + placeHoldForOccurrence, + releaseHoldForOccurrence, + ROLLING_HOLD_WINDOW_HOURS, +} from "../services/scheduledTransferExecutionService"; +import { + notifyScheduledTransferCreated, + notifyScheduledTransferCancelled, + notifyScheduledTransferPaused, + notifyScheduledTransferResumed, + notifyScheduledBatchCreated, +} from "../utils/notificationHelpers"; + +const { + sequelize, + Wallet, + ScheduledTransfer, + ScheduledTransferBatch, + Category, + User, + Organization, + Action, + SubAction, + Transaction: TransactionModel, +} = database_models as any; + +const PARTY_INCLUDES = [ + { model: User, as: "senderUser", attributes: ["id", "firstName", "lastName"] }, + { model: User, as: "receiverUser", attributes: ["id", "firstName", "lastName"] }, + { model: Organization, as: "senderOrganization", attributes: ["id", "name"] }, + { model: Organization, as: "receiverOrganization", attributes: ["id", "name"] }, +]; + +const MIN_LEAD_MINUTES = 5; +const MAX_HORIZON_DAYS = 365; +const MAX_ACTIVE_PER_USER = 50; +const MAX_BATCH_RECIPIENTS = 100; +const EDITABLE_STATUSES = ["scheduled", "held"]; +const ACTIVE_STATUSES = ["scheduled", "held", "executing", "paused"]; +const CANCELLABLE_STATUSES = ["scheduled", "held", "paused"]; + +class ControllerError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +const handleControllerError = (res: Response, error: unknown, context: string): void => { + if (error instanceof ControllerError) { + res.status(error.status).json({ success: false, message: error.message }); + return; + } + console.error(`${context} error:`, error); + res.status(500).json({ success: false, message: "Internal server error" }); +}; + +const verifyPin = async (userId: string, pin: string): Promise => { + const user = await User.findByPk(userId); + if (!user) throw new ControllerError(404, "Authenticated user not found"); + if (!user.hasPinSet) { + throw new ControllerError(403, "PIN not set up. Please set up your transaction PIN first."); + } + if (!pin) throw new ControllerError(400, "PIN is required"); + if (!/^\d{4}$/.test(pin)) throw new ControllerError(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 ControllerError(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 ControllerError(429, "Account locked due to too many failed PIN attempts. Please reset your PIN."); + } + await user.update({ pinAttempts: attempts }); + throw new ControllerError(400, `Invalid PIN. ${maxAttempts - attempts} attempts remaining.`); + } + + await user.update({ pinAttempts: 0, pinLockedUntil: null }); +}; + +const validateScheduledFor = (value: unknown): Date => { + const date = new Date(value as string); + if (isNaN(date.getTime())) throw new ControllerError(400, "scheduledFor must be a valid date"); + + const now = new Date(); + const minAllowed = new Date(now.getTime() + MIN_LEAD_MINUTES * 60000); + const maxAllowed = new Date(now.getTime() + MAX_HORIZON_DAYS * 24 * 60 * 60000); + + if (date < minAllowed) { + throw new ControllerError(400, `scheduledFor must be at least ${MIN_LEAD_MINUTES} minutes in the future`); + } + if (date > maxAllowed) { + throw new ControllerError(400, `scheduledFor cannot be more than ${MAX_HORIZON_DAYS} days in the future`); + } + return date; +}; + +/** + * 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 + * silently deferring an insufficient-funds failure to execution time. + */ +const createScheduledTransfer = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { + senderUserId, + senderOrganizationId, + senderSubActionId, + receiverUserId, + receiverOrganizationId, + receiverWalletId, + amount, + description = "", + categoryId, + type = "transfer", + applyConstraints = false, + pin, + scheduledFor, + timezone = "Africa/Kigali", + recurrence, + } = req.body; + + const senderTargets = [senderUserId, senderOrganizationId, senderSubActionId].filter(Boolean).length; + const receiverTargets = [receiverUserId, receiverOrganizationId, receiverWalletId].filter(Boolean).length; + + if (senderTargets !== 1 || receiverTargets !== 1 || !amount) { + throw new ControllerError( + 400, + "Exactly one sender (user, organization, or sub-action), one receiver (user, organization, or wallet), and amount are required" + ); + } + if (parseFloat(amount) <= 0) { + throw new ControllerError(400, "Amount must be greater than 0"); + } + if ( + (senderUserId && receiverUserId && senderUserId === receiverUserId) || + (senderOrganizationId && receiverOrganizationId && senderOrganizationId === receiverOrganizationId) + ) { + throw new ControllerError(400, "Cannot schedule a transfer to yourself"); + } + + const scheduledDate = validateScheduledFor(scheduledFor); + + if (recurrence) { + const ruleCheck = validateRecurrenceRule(recurrence); + if (!ruleCheck.valid) throw new ControllerError(400, ruleCheck.error!); + } + + const authenticatedUserId = req.user.id; + const authUser = (req as unknown as AuthRequest).user; + const requesterOrganizationId = + authUser?.accountType === "organization" ? authUser.id : authUser?.organizationId || null; + + if (senderUserId && senderUserId !== authenticatedUserId) { + throw new ControllerError(403, "You can only schedule transfers from your own account"); + } + if (senderOrganizationId) { + if (!requesterOrganizationId || senderOrganizationId !== requesterOrganizationId) { + throw new ControllerError(403, "You are not authorized to schedule transfers from this organization"); + } + } + if (senderSubActionId) { + if (!requesterOrganizationId) { + throw new ControllerError(400, "Organization context is required to schedule from a sub-action wallet"); + } + const subActionRecord: any = await SubAction.findByPk(senderSubActionId, { + include: [{ model: Action, as: "action", attributes: ["id", "organizationId"] }], + }); + if (!subActionRecord) throw new ControllerError(404, "Sender sub-action not found"); + const ownerOrganizationId = subActionRecord.action?.organizationId; + if (!ownerOrganizationId || ownerOrganizationId !== requesterOrganizationId) { + throw new ControllerError(403, "You are not authorized to schedule transfers from this sub-action wallet"); + } + } + + const activeCount = await ScheduledTransfer.count({ + where: { createdByUserId: authenticatedUserId, status: { [Op.in]: ACTIVE_STATUSES } }, + }); + if (activeCount >= MAX_ACTIVE_PER_USER) { + throw new ControllerError(400, `You can have at most ${MAX_ACTIVE_PER_USER} active scheduled transfers`); + } + + let pinVerifiedAt: Date | null = null; + if (senderUserId) { + await verifyPin(authenticatedUserId, pin); + pinVerifiedAt = new Date(); + } + + if (categoryId) { + const category = await Category.findByPk(categoryId); + if (!category) throw new ControllerError(400, "Invalid category ID"); + } + + const receiverWhere = resolveWalletWhere({ + userId: receiverUserId, + organizationId: receiverOrganizationId, + walletId: receiverWalletId, + }); + const receiverWallet = receiverWhere ? await Wallet.findOne({ where: receiverWhere }) : null; + if (!receiverWallet) throw new ControllerError(404, "Receiver wallet not found or inactive"); + + const id = uuidv4(); + const transferAmount = parseFloat(amount); + let created: any; + + await sequelize.transaction(async (t: any) => { + created = await ScheduledTransfer.create( + { + id, + createdByUserId: authenticatedUserId, + senderUserId: senderUserId || null, + senderOrganizationId: senderOrganizationId || null, + senderSubActionId: senderSubActionId || null, + receiverUserId: receiverUserId || null, + receiverOrganizationId: receiverOrganizationId || null, + receiverWalletId: receiverWalletId || null, + amount: transferAmount, + fee: 0, + currency: receiverWallet.currency || "RWF", + type, + description, + categoryId: categoryId || null, + applyConstraints, + scheduledFor: scheduledDate, + timezone, + recurrenceRule: recurrence || null, + occurrenceCount: 0, + status: "scheduled", + idempotencyKey: `${id}:0`, + pinVerifiedAt, + } as any, + { transaction: t } + ); + + const holdOutcome = await placeHoldForOccurrence(created, t); + if (!holdOutcome.ok) { + throw new ControllerError(400, holdOutcome.message || "Insufficient funds to schedule this transfer"); + } + + await created.update( + { status: "held", heldAmount: holdOutcome.heldAmount, heldAt: new Date() }, + { transaction: t } + ); + }); + + await notifyScheduledTransferCreated( + req.app, + authenticatedUserId, + created.id, + transferAmount, + created.currency, + scheduledDate, + !!recurrence + ); + + res.status(201).json({ success: true, message: "Transfer scheduled successfully", data: created }); + } catch (error) { + handleControllerError(res, error, "Create scheduled transfer"); + } +}; + +/** + * Schedules a transfer to multiple recipients at once - one PIN entry, one date/recurrence + * rule, applied to every recipient. Each recipient gets its own ScheduledTransfer row and its + * own independent hold attempt: a recipient that can't be scheduled (bad wallet, no funds left + * once earlier recipients in the batch have claimed their holds) is recorded as failed with a + * reason, and the rest of the batch is still scheduled - same partial-success rule as the + * immediate multi-recipient send. + */ +const createScheduledBatchTransfer = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { + senderUserId, + senderOrganizationId, + senderSubActionId, + recipients, + description = "", + categoryId, + type = "transfer", + applyConstraints = false, + pin, + scheduledFor, + timezone = "Africa/Kigali", + recurrence, + } = req.body as { + senderUserId?: string; + senderOrganizationId?: string; + senderSubActionId?: string; + recipients: Array<{ + receiverUserId?: string; + receiverOrganizationId?: string; + receiverWalletId?: string; + amount: number | string; + description?: string; + }>; + description?: string; + categoryId?: string; + type?: string; + applyConstraints?: boolean; + pin?: string; + scheduledFor: string; + timezone?: string; + recurrence?: any; + }; + + const senderTargets = [senderUserId, senderOrganizationId, senderSubActionId].filter(Boolean).length; + if (senderTargets !== 1) { + throw new ControllerError(400, "Exactly one sender (user, organization, or sub-action) is required"); + } + if (!Array.isArray(recipients) || recipients.length === 0) { + throw new ControllerError(400, "At least one recipient is required"); + } + if (recipients.length > MAX_BATCH_RECIPIENTS) { + throw new ControllerError(400, `A scheduled batch can contain at most ${MAX_BATCH_RECIPIENTS} recipients`); + } + for (const r of recipients) { + const targets = [r.receiverUserId, r.receiverOrganizationId, r.receiverWalletId].filter(Boolean).length; + if (targets !== 1 || !r.amount || parseFloat(r.amount.toString()) <= 0) { + throw new ControllerError(400, "Each recipient needs exactly one receiver target and an amount greater than 0"); + } + if ( + (senderUserId && r.receiverUserId && senderUserId === r.receiverUserId) || + (senderOrganizationId && r.receiverOrganizationId && senderOrganizationId === r.receiverOrganizationId) + ) { + throw new ControllerError(400, "Cannot schedule a transfer to yourself"); + } + } + + const scheduledDate = validateScheduledFor(scheduledFor); + if (recurrence) { + const ruleCheck = validateRecurrenceRule(recurrence); + if (!ruleCheck.valid) throw new ControllerError(400, ruleCheck.error!); + } + + const authenticatedUserId = req.user.id; + const authUser = (req as unknown as AuthRequest).user; + const requesterOrganizationId = + authUser?.accountType === "organization" ? authUser.id : authUser?.organizationId || null; + + if (senderUserId && senderUserId !== authenticatedUserId) { + throw new ControllerError(403, "You can only schedule transfers from your own account"); + } + if (senderOrganizationId) { + if (!requesterOrganizationId || senderOrganizationId !== requesterOrganizationId) { + throw new ControllerError(403, "You are not authorized to schedule transfers from this organization"); + } + } + if (senderSubActionId) { + if (!requesterOrganizationId) { + throw new ControllerError(400, "Organization context is required to schedule from a sub-action wallet"); + } + const subActionRecord: any = await SubAction.findByPk(senderSubActionId, { + include: [{ model: Action, as: "action", attributes: ["id", "organizationId"] }], + }); + if (!subActionRecord) throw new ControllerError(404, "Sender sub-action not found"); + const ownerOrganizationId = subActionRecord.action?.organizationId; + if (!ownerOrganizationId || ownerOrganizationId !== requesterOrganizationId) { + throw new ControllerError(403, "You are not authorized to schedule transfers from this sub-action wallet"); + } + } + + const activeCount = await ScheduledTransfer.count({ + where: { createdByUserId: authenticatedUserId, status: { [Op.in]: ACTIVE_STATUSES } }, + }); + if (activeCount + recipients.length > MAX_ACTIVE_PER_USER) { + throw new ControllerError( + 400, + `This batch would exceed your limit of ${MAX_ACTIVE_PER_USER} active scheduled transfers` + ); + } + + let pinVerifiedAt: Date | null = null; + if (senderUserId) { + await verifyPin(authenticatedUserId, pin as string); + pinVerifiedAt = new Date(); + } + + if (categoryId) { + const category = await Category.findByPk(categoryId); + if (!category) throw new ControllerError(400, "Invalid category ID"); + } + + const totalRequestedAmount = recipients.reduce((sum, r) => sum + parseFloat(r.amount.toString()), 0); + const batch = await ScheduledTransferBatch.create({ + createdByUserId: authenticatedUserId, + senderUserId: senderUserId || null, + senderOrganizationId: senderOrganizationId || null, + senderSubActionId: senderSubActionId || null, + type, + description, + categoryId: categoryId || null, + applyConstraints, + recipientCount: recipients.length, + totalRequestedAmount, + } as any); + + const results: any[] = []; + const failures: any[] = []; + let successCount = 0; + + for (const recipient of recipients) { + const recipientIdentity = { + receiverUserId: recipient.receiverUserId || null, + receiverOrganizationId: recipient.receiverOrganizationId || null, + receiverWalletId: recipient.receiverWalletId || null, + }; + const transferAmount = parseFloat(recipient.amount.toString()); + + try { + const receiverWhere = resolveWalletWhere({ + userId: recipient.receiverUserId, + organizationId: recipient.receiverOrganizationId, + walletId: recipient.receiverWalletId, + }); + const receiverWallet = receiverWhere ? await Wallet.findOne({ where: receiverWhere }) : null; + if (!receiverWallet) { + throw new Error("Receiver wallet not found or inactive"); + } + + const id = uuidv4(); + let created: any; + + await sequelize.transaction(async (t: any) => { + created = await ScheduledTransfer.create( + { + id, + createdByUserId: authenticatedUserId, + senderUserId: senderUserId || null, + senderOrganizationId: senderOrganizationId || null, + senderSubActionId: senderSubActionId || null, + receiverUserId: recipient.receiverUserId || null, + receiverOrganizationId: recipient.receiverOrganizationId || null, + receiverWalletId: recipient.receiverWalletId || null, + amount: transferAmount, + fee: 0, + currency: receiverWallet.currency || "RWF", + type, + description: recipient.description || description, + categoryId: categoryId || null, + applyConstraints, + scheduledFor: scheduledDate, + timezone, + recurrenceRule: recurrence || null, + occurrenceCount: 0, + status: "scheduled", + idempotencyKey: `${id}:0`, + pinVerifiedAt, + scheduledBatchId: batch.id, + } as any, + { transaction: t } + ); + + const holdOutcome = await placeHoldForOccurrence(created, t); + if (!holdOutcome.ok) { + throw new Error(holdOutcome.message || "Insufficient funds to schedule this transfer"); + } + + await created.update( + { status: "held", heldAmount: holdOutcome.heldAmount, heldAt: new Date() }, + { transaction: t } + ); + }); + + successCount += 1; + results.push({ + ...recipientIdentity, + amount: transferAmount, + status: "success", + scheduledTransferId: created.id, + }); + } catch (err: any) { + const reason = err?.message || "Failed to schedule this recipient"; + failures.push({ ...recipientIdentity, amount: transferAmount, reason }); + results.push({ ...recipientIdentity, amount: transferAmount, status: "failed", reason }); + } + } + + const failureCount = recipients.length - successCount; + const status = successCount === 0 ? "failed" : failureCount > 0 ? "partial" : "completed"; + + await batch.update({ + successCount, + failureCount, + status, + failures: failures.length > 0 ? failures : null, + }); + + if (senderUserId) { + await notifyScheduledBatchCreated( + req.app, + senderUserId, + batch.id, + successCount, + failureCount, + scheduledDate, + !!recurrence + ); + } + + res.status(201).json({ + success: true, + message: `Scheduled ${successCount} of ${recipients.length} transfers`, + data: { + batchId: batch.id, + status, + scheduledFor: scheduledDate, + recurrence: recurrence || null, + recipientCount: recipients.length, + successCount, + failureCount, + totalRequestedAmount, + results, + }, + }); + } catch (error) { + handleControllerError(res, error, "Create scheduled batch transfer"); + } +}; + +const getScheduledBatchById = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user.id; + + const batch = await ScheduledTransferBatch.findByPk(id); + if (!batch || batch.createdByUserId !== userId) { + throw new ControllerError(404, "Scheduled batch not found"); + } + + const scheduledTransfers = await ScheduledTransfer.findAll({ + where: { scheduledBatchId: batch.id }, + include: PARTY_INCLUDES, + order: [["createdAt", "ASC"]], + }); + + res.status(200).json({ + success: true, + data: { + ...batch.toJSON(), + scheduledTransfers, + failures: batch.failures || [], + }, + }); + } catch (error) { + handleControllerError(res, error, "Get scheduled batch"); + } +}; + +const listScheduledBatches = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const userId = req.user.id; + const { limit = "20", offset = "0" } = req.query as Record; + + const { rows, count } = await ScheduledTransferBatch.findAndCountAll({ + where: { createdByUserId: userId }, + order: [["createdAt", "DESC"]], + limit: Math.min(parseInt(limit) || 20, 100), + offset: parseInt(offset) || 0, + }); + + res.status(200).json({ success: true, data: rows, total: count }); + } catch (error) { + handleControllerError(res, error, "List scheduled batches"); + } +}; + +/** + * Cancels every still-cancellable child transfer in the group (releasing any holds). + * Transfers already executed, cancelled, or otherwise past cancellation are left untouched + * and reported separately, rather than failing the whole request. + */ +const cancelScheduledBatch = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user.id; + + const batch = await ScheduledTransferBatch.findByPk(id); + if (!batch || batch.createdByUserId !== userId) { + throw new ControllerError(404, "Scheduled batch not found"); + } + + const scheduledTransfers = await ScheduledTransfer.findAll({ where: { scheduledBatchId: batch.id } }); + + let cancelledCount = 0; + const skipped: any[] = []; + + for (const st of scheduledTransfers) { + if (!CANCELLABLE_STATUSES.includes(st.status)) { + skipped.push({ scheduledTransferId: st.id, status: st.status }); + continue; + } + await sequelize.transaction(async (t: any) => { + const locked = await ScheduledTransfer.findByPk(st.id, { lock: t.LOCK.UPDATE, transaction: t }); + if (!locked || !CANCELLABLE_STATUSES.includes(locked.status)) return; + if (locked.status === "held") { + await releaseHoldForOccurrence(locked, t); + } + await locked.update({ status: "cancelled", heldAmount: null, heldAt: null }, { transaction: t }); + }); + cancelledCount += 1; + } + + res.status(200).json({ + success: true, + message: `Cancelled ${cancelledCount} of ${scheduledTransfers.length} scheduled transfers in this batch`, + data: { batchId: batch.id, cancelledCount, skipped }, + }); + } catch (error) { + handleControllerError(res, error, "Cancel scheduled batch"); + } +}; + +const listScheduledTransfers = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const userId = req.user.id; + const { status, direction = "outgoing", limit = "20", offset = "0" } = req.query as Record; + + const where: any = {}; + if (direction === "incoming") { + where.receiverUserId = userId; + } else if (direction === "all") { + where[Op.or] = [{ createdByUserId: userId }, { receiverUserId: userId }]; + } else { + where.createdByUserId = userId; + } + if (status) { + where.status = { [Op.in]: status.split(",") }; + } + + const { rows, count } = await ScheduledTransfer.findAndCountAll({ + where, + include: PARTY_INCLUDES, + order: [["scheduledFor", "ASC"]], + limit: Math.min(parseInt(limit) || 20, 100), + offset: parseInt(offset) || 0, + }); + + res.status(200).json({ success: true, data: rows, total: count }); + } catch (error) { + handleControllerError(res, error, "List scheduled transfers"); + } +}; + +const getScheduledTransferById = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user.id; + + const st = await ScheduledTransfer.findByPk(id, { + include: [...PARTY_INCLUDES, { model: TransactionModel, as: "executedTransactions" }], + }); + if (!st) throw new ControllerError(404, "Scheduled transfer not found"); + if (st.createdByUserId !== userId && st.receiverUserId !== userId) { + throw new ControllerError(403, "Not authorized to view this scheduled transfer"); + } + + res.status(200).json({ success: true, data: st }); + } catch (error) { + handleControllerError(res, error, "Get scheduled transfer"); + } +}; + +const updateScheduledTransfer = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user.id; + const { amount, scheduledFor, description, categoryId, applyConstraints, recurrence, pin } = req.body; + + let updated: any; + + await sequelize.transaction(async (t: any) => { + const st = await ScheduledTransfer.findByPk(id, { lock: t.LOCK.UPDATE, transaction: t }); + if (!st) throw new ControllerError(404, "Scheduled transfer not found"); + if (st.createdByUserId !== userId) throw new ControllerError(403, "Not authorized to edit this scheduled transfer"); + if (!EDITABLE_STATUSES.includes(st.status)) { + throw new ControllerError(400, "Only scheduled or held transfers can be edited"); + } + + const newScheduledFor = scheduledFor !== undefined ? validateScheduledFor(scheduledFor) : st.scheduledFor; + + if (recurrence !== undefined && recurrence !== null) { + const ruleCheck = validateRecurrenceRule(recurrence); + if (!ruleCheck.valid) throw new ControllerError(400, ruleCheck.error!); + } + + const amountChanged = amount !== undefined && parseFloat(amount) !== parseFloat(st.amount.toString()); + + if (amountChanged) { + if (parseFloat(amount) <= 0) throw new ControllerError(400, "Amount must be greater than 0"); + if (st.senderUserId) { + await verifyPin(userId, pin); + } + + if (st.status === "held") { + await releaseHoldForOccurrence(st, t); + } + await st.update({ amount: parseFloat(amount), heldAmount: null, heldAt: null }, { transaction: t }); + + const holdOutcome = await placeHoldForOccurrence(st, t); + if (!holdOutcome.ok) { + throw new ControllerError(400, holdOutcome.message || "Insufficient funds for the updated amount"); + } + await st.update( + { status: "held", heldAmount: holdOutcome.heldAmount, heldAt: new Date(), pinVerifiedAt: new Date() }, + { transaction: t } + ); + } + + await st.update( + { + scheduledFor: newScheduledFor, + description: description !== undefined ? description : st.description, + categoryId: categoryId !== undefined ? categoryId : st.categoryId, + applyConstraints: applyConstraints !== undefined ? applyConstraints : st.applyConstraints, + recurrenceRule: recurrence !== undefined ? recurrence : st.recurrenceRule, + }, + { transaction: t } + ); + + updated = st; + }); + + res.status(200).json({ success: true, message: "Scheduled transfer updated", data: updated }); + } catch (error) { + handleControllerError(res, error, "Update scheduled transfer"); + } +}; + +const cancelScheduledTransfer = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user.id; + let cancelled: any; + + await sequelize.transaction(async (t: any) => { + const st = await ScheduledTransfer.findByPk(id, { lock: t.LOCK.UPDATE, transaction: t }); + if (!st) throw new ControllerError(404, "Scheduled transfer not found"); + if (st.createdByUserId !== userId) throw new ControllerError(403, "Not authorized to cancel this scheduled transfer"); + if (!["scheduled", "held", "paused"].includes(st.status)) { + throw new ControllerError(400, "This scheduled transfer can no longer be cancelled"); + } + if (st.status === "held") { + await releaseHoldForOccurrence(st, t); + } + await st.update({ status: "cancelled", heldAmount: null, heldAt: null }, { transaction: t }); + cancelled = st; + }); + + await notifyScheduledTransferCancelled(req.app, userId, cancelled.id, cancelled.amount, cancelled.currency); + res.status(200).json({ success: true, message: "Scheduled transfer cancelled", data: cancelled }); + } catch (error) { + handleControllerError(res, error, "Cancel scheduled transfer"); + } +}; + +const pauseScheduledTransfer = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user.id; + let paused: any; + + await sequelize.transaction(async (t: any) => { + const st = await ScheduledTransfer.findByPk(id, { lock: t.LOCK.UPDATE, transaction: t }); + if (!st) throw new ControllerError(404, "Scheduled transfer not found"); + if (st.createdByUserId !== userId) throw new ControllerError(403, "Not authorized to pause this scheduled transfer"); + if (!st.recurrenceRule) throw new ControllerError(400, "Only recurring transfers can be paused"); + if (!["scheduled", "held"].includes(st.status)) { + throw new ControllerError(400, "This scheduled transfer cannot be paused right now"); + } + if (st.status === "held") { + await releaseHoldForOccurrence(st, t); + } + await st.update({ status: "paused", heldAmount: null, heldAt: null }, { transaction: t }); + paused = st; + }); + + await notifyScheduledTransferPaused(req.app, userId, paused.id); + res.status(200).json({ success: true, message: "Scheduled transfer paused", data: paused }); + } catch (error) { + handleControllerError(res, error, "Pause scheduled transfer"); + } +}; + +const resumeScheduledTransfer = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user.id; + let resumed: any; + + await sequelize.transaction(async (t: any) => { + const st = await ScheduledTransfer.findByPk(id, { lock: t.LOCK.UPDATE, transaction: t }); + if (!st) throw new ControllerError(404, "Scheduled transfer not found"); + if (st.createdByUserId !== userId) throw new ControllerError(403, "Not authorized to resume this scheduled transfer"); + if (st.status !== "paused") throw new ControllerError(400, "Only paused transfers can be resumed"); + + const withinWindow = + st.scheduledFor.getTime() - Date.now() <= ROLLING_HOLD_WINDOW_HOURS * 60 * 60 * 1000; + const holdOutcome = withinWindow ? await placeHoldForOccurrence(st, t) : null; + + if (holdOutcome?.ok) { + await st.update( + { + status: "held", + heldAmount: holdOutcome.heldAmount, + heldAt: new Date(), + consecutiveFailureCount: 0, + lastFailureReason: null, + }, + { transaction: t } + ); + } else { + await st.update({ status: "scheduled", consecutiveFailureCount: 0, lastFailureReason: null }, { transaction: t }); + } + resumed = st; + }); + + await notifyScheduledTransferResumed(req.app, userId, resumed.id); + res.status(200).json({ success: true, message: "Scheduled transfer resumed", data: resumed }); + } catch (error) { + handleControllerError(res, error, "Resume scheduled transfer"); + } +}; + +const skipNextOccurrence = async (req: AuthenticatedRequest, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user.id; + let updated: any; + + await sequelize.transaction(async (t: any) => { + const st = await ScheduledTransfer.findByPk(id, { lock: t.LOCK.UPDATE, transaction: t }); + if (!st) throw new ControllerError(404, "Scheduled transfer not found"); + if (st.createdByUserId !== userId) throw new ControllerError(403, "Not authorized to modify this scheduled transfer"); + if (!st.recurrenceRule) throw new ControllerError(400, "Only recurring transfers support skipping an occurrence"); + if (!["scheduled", "held"].includes(st.status)) { + throw new ControllerError(400, "This occurrence cannot be skipped right now"); + } + + if (st.status === "held") { + await releaseHoldForOccurrence(st, t); + } + + const nextRun = computeNextRun(st.scheduledFor, st.timezone, st.recurrenceRule); + if (isRecurrenceExhausted(st.recurrenceRule, st.occurrenceCount, nextRun)) { + await st.update({ status: "completed", heldAmount: null, heldAt: null }, { transaction: t }); + } else { + await st.update({ status: "scheduled", scheduledFor: nextRun, heldAmount: null, heldAt: null }, { transaction: t }); + } + updated = st; + }); + + res.status(200).json({ success: true, message: "Occurrence skipped", data: updated }); + } catch (error) { + handleControllerError(res, error, "Skip scheduled transfer occurrence"); + } +}; + +export default { + createScheduledTransfer, + listScheduledTransfers, + getScheduledTransferById, + updateScheduledTransfer, + cancelScheduledTransfer, + pauseScheduledTransfer, + resumeScheduledTransfer, + skipNextOccurrence, + createScheduledBatchTransfer, + listScheduledBatches, + getScheduledBatchById, + cancelScheduledBatch, +}; diff --git a/src/controllers/transactionController.ts b/src/controllers/transactionController.ts index 276e40c..ebe3de9 100644 --- a/src/controllers/transactionController.ts +++ b/src/controllers/transactionController.ts @@ -14,6 +14,7 @@ import { notifyPaymentRequestAccepted, } from '../utils/notificationHelpers'; import { AuthRequest } from '../middleware/auth.unified.middleware'; +import { getAvailableBalance } from '../utils/walletBalance'; const { Wallet, @@ -399,8 +400,8 @@ const transferMoney = async ( const receiverIsUser = !!receiverWallet.userId; - // Check sufficient balance - if (senderWallet.balance < totalAmount) { + // Check sufficient balance (excluding funds already reserved by scheduled transfers) + if (getAvailableBalance(senderWallet) < totalAmount) { await transaction?.rollback(); res.status(400).json({ success: false, @@ -428,8 +429,8 @@ const transferMoney = async ( 0, ); - // Calculate available unrestricted amount - const totalBalance = parseFloat(senderWallet.balance.toString()); + // Calculate available unrestricted amount (balance minus funds held by scheduled transfers) + const totalBalance = getAvailableBalance(senderWallet); // Prevent negative unrestricted amount when restrictions exceed total balance const availableUnrestrictedAmount = Math.max( 0, @@ -868,6 +869,8 @@ const getWalletBalance = async (req: Request, res: Response): Promise => { data: { walletId: wallet.id, balance: parseFloat(wallet.balance.toString()), + heldBalance: parseFloat(wallet.heldBalance.toString()), + availableBalance: getAvailableBalance(wallet), currency: wallet.currency, isActive: wallet.isActive, }, @@ -3508,7 +3511,7 @@ const acceptPaymentRequest = async ( return; } - if (Number(payerWallet.balance) < totalAmount) { + if (getAvailableBalance(payerWallet) < totalAmount) { await dbTransaction.rollback(); res.status(400).json({ success: false, diff --git a/src/database/migrations/20260723000000-add-held-balance-to-wallets.js b/src/database/migrations/20260723000000-add-held-balance-to-wallets.js new file mode 100644 index 0000000..c621a3a --- /dev/null +++ b/src/database/migrations/20260723000000-add-held-balance-to-wallets.js @@ -0,0 +1,15 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("Wallets", "heldBalance", { + type: Sequelize.DECIMAL(15, 2), + defaultValue: 0, + allowNull: false, + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn("Wallets", "heldBalance"); + }, +}; diff --git a/src/database/migrations/20260723000001-create-scheduled-transfers.js b/src/database/migrations/20260723000001-create-scheduled-transfers.js new file mode 100644 index 0000000..a36dd8b --- /dev/null +++ b/src/database/migrations/20260723000001-create-scheduled-transfers.js @@ -0,0 +1,224 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_ScheduledTransfers_status') THEN + CREATE TYPE "enum_ScheduledTransfers_status" AS ENUM ('scheduled', 'held', 'executing', 'completed', 'failed', 'cancelled', 'paused'); + END IF; + END + $$; + `); + + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_ScheduledTransfers_type') THEN + CREATE TYPE "enum_ScheduledTransfers_type" AS ENUM ('transfer', 'payment', 'donation', 'vote', 'topup', 'withdrawal'); + END IF; + END + $$; + `); + + await queryInterface.createTable("ScheduledTransfers", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, + }, + createdByUserId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderUserId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderOrganizationId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Organizations", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderSubActionId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "SubActions", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + receiverUserId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + receiverOrganizationId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Organizations", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + receiverWalletId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Wallets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + amount: { + type: Sequelize.DECIMAL(15, 2), + allowNull: false, + }, + fee: { + type: Sequelize.DECIMAL(15, 2), + defaultValue: 0, + allowNull: false, + }, + currency: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: "RWF", + }, + type: { + type: Sequelize.ENUM( + "transfer", + "payment", + "donation", + "vote", + "topup", + "withdrawal" + ), + allowNull: false, + defaultValue: "transfer", + }, + description: { + type: Sequelize.STRING, + allowNull: true, + }, + categoryId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Categories", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + applyConstraints: { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + scheduledFor: { + type: Sequelize.DATE, + allowNull: false, + }, + timezone: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: "Africa/Kigali", + }, + recurrenceRule: { + type: Sequelize.JSONB, + allowNull: true, + comment: + "null for one-time transfers. Otherwise {frequency, interval, endDate?, maxOccurrences?}", + }, + occurrenceCount: { + type: Sequelize.INTEGER, + defaultValue: 0, + allowNull: false, + }, + status: { + type: Sequelize.ENUM( + "scheduled", + "held", + "executing", + "completed", + "failed", + "cancelled", + "paused" + ), + defaultValue: "scheduled", + allowNull: false, + }, + heldAmount: { + type: Sequelize.DECIMAL(15, 2), + allowNull: true, + }, + heldAt: { + type: Sequelize.DATE, + allowNull: true, + }, + idempotencyKey: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + lastExecutedTransactionId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Transactions", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + consecutiveFailureCount: { + type: Sequelize.INTEGER, + defaultValue: 0, + allowNull: false, + }, + lastFailureReason: { + type: Sequelize.STRING, + allowNull: true, + }, + pinVerifiedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + + await queryInterface.addIndex("ScheduledTransfers", ["createdByUserId"], { + name: "idx_scheduled_transfers_created_by", + }); + await queryInterface.addIndex("ScheduledTransfers", ["status", "scheduledFor"], { + name: "idx_scheduled_transfers_status_scheduled_for", + }); + await queryInterface.addIndex("ScheduledTransfers", ["senderUserId"], { + name: "idx_scheduled_transfers_sender_user_id", + }); + await queryInterface.addIndex("ScheduledTransfers", ["receiverUserId"], { + name: "idx_scheduled_transfers_receiver_user_id", + }); + }, + + down: async (queryInterface) => { + await queryInterface.dropTable("ScheduledTransfers"); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_ScheduledTransfers_status";' + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_ScheduledTransfers_type";' + ); + }, +}; diff --git a/src/database/migrations/20260723000002-add-scheduled-transfer-id-to-transactions.js b/src/database/migrations/20260723000002-add-scheduled-transfer-id-to-transactions.js new file mode 100644 index 0000000..b8ba4ae --- /dev/null +++ b/src/database/migrations/20260723000002-add-scheduled-transfer-id-to-transactions.js @@ -0,0 +1,25 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("Transactions", "scheduledTransferId", { + type: Sequelize.UUID, + allowNull: true, + references: { model: "ScheduledTransfers", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }); + + await queryInterface.addIndex("Transactions", ["scheduledTransferId"], { + name: "idx_transactions_scheduled_transfer_id", + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeIndex( + "Transactions", + "idx_transactions_scheduled_transfer_id" + ); + await queryInterface.removeColumn("Transactions", "scheduledTransferId"); + }, +}; diff --git a/src/database/migrations/20260723000003-create-transfer-batches.js b/src/database/migrations/20260723000003-create-transfer-batches.js new file mode 100644 index 0000000..102b256 --- /dev/null +++ b/src/database/migrations/20260723000003-create-transfer-batches.js @@ -0,0 +1,165 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_TransferBatches_status') THEN + CREATE TYPE "enum_TransferBatches_status" AS ENUM ('completed', 'partial', 'failed'); + END IF; + END + $$; + `); + + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_TransferBatches_type') THEN + CREATE TYPE "enum_TransferBatches_type" AS ENUM ('transfer', 'payment', 'donation', 'vote', 'topup', 'withdrawal'); + END IF; + END + $$; + `); + + await queryInterface.createTable("TransferBatches", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, + }, + createdByUserId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderUserId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderOrganizationId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Organizations", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderSubActionId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "SubActions", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + currency: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: "RWF", + }, + type: { + type: Sequelize.ENUM( + "transfer", + "payment", + "donation", + "vote", + "topup", + "withdrawal" + ), + allowNull: false, + defaultValue: "transfer", + }, + description: { + type: Sequelize.STRING, + allowNull: true, + }, + categoryId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Categories", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + applyConstraints: { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + idempotencyKey: { + type: Sequelize.STRING, + allowNull: true, + unique: true, + }, + recipientCount: { + type: Sequelize.INTEGER, + allowNull: false, + }, + totalRequestedAmount: { + type: Sequelize.DECIMAL(15, 2), + allowNull: false, + }, + totalSentAmount: { + type: Sequelize.DECIMAL(15, 2), + defaultValue: 0, + allowNull: false, + }, + totalFailedAmount: { + type: Sequelize.DECIMAL(15, 2), + defaultValue: 0, + allowNull: false, + }, + successCount: { + type: Sequelize.INTEGER, + defaultValue: 0, + allowNull: false, + }, + failureCount: { + type: Sequelize.INTEGER, + defaultValue: 0, + allowNull: false, + }, + status: { + type: Sequelize.ENUM("completed", "partial", "failed"), + allowNull: false, + defaultValue: "completed", + }, + failures: { + type: Sequelize.JSONB, + allowNull: true, + comment: "Array of {receiverUserId?, receiverOrganizationId?, receiverWalletId?, amount, reason} for recipients that were skipped", + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + + await queryInterface.addIndex("TransferBatches", ["createdByUserId"], { + name: "idx_transfer_batches_created_by", + }); + await queryInterface.addIndex("TransferBatches", ["status"], { + name: "idx_transfer_batches_status", + }); + }, + + down: async (queryInterface) => { + await queryInterface.dropTable("TransferBatches"); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_TransferBatches_status";' + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_TransferBatches_type";' + ); + }, +}; diff --git a/src/database/migrations/20260723000004-add-batch-id-to-transactions.js b/src/database/migrations/20260723000004-add-batch-id-to-transactions.js new file mode 100644 index 0000000..e23b819 --- /dev/null +++ b/src/database/migrations/20260723000004-add-batch-id-to-transactions.js @@ -0,0 +1,22 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("Transactions", "batchId", { + type: Sequelize.UUID, + allowNull: true, + references: { model: "TransferBatches", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }); + + await queryInterface.addIndex("Transactions", ["batchId"], { + name: "idx_transactions_batch_id", + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeIndex("Transactions", "idx_transactions_batch_id"); + await queryInterface.removeColumn("Transactions", "batchId"); + }, +}; diff --git a/src/database/migrations/20260727000000-create-scheduled-transfer-batches.js b/src/database/migrations/20260727000000-create-scheduled-transfer-batches.js new file mode 100644 index 0000000..cf55a07 --- /dev/null +++ b/src/database/migrations/20260727000000-create-scheduled-transfer-batches.js @@ -0,0 +1,160 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_ScheduledTransferBatches_status') THEN + CREATE TYPE "enum_ScheduledTransferBatches_status" AS ENUM ('completed', 'partial', 'failed'); + END IF; + END + $$; + `); + + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_ScheduledTransferBatches_type') THEN + CREATE TYPE "enum_ScheduledTransferBatches_type" AS ENUM ('transfer', 'payment', 'donation', 'vote', 'topup', 'withdrawal'); + END IF; + END + $$; + `); + + await queryInterface.createTable("ScheduledTransferBatches", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, + }, + createdByUserId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderUserId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderOrganizationId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Organizations", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderSubActionId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "SubActions", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + currency: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: "RWF", + }, + type: { + type: Sequelize.ENUM( + "transfer", + "payment", + "donation", + "vote", + "topup", + "withdrawal" + ), + allowNull: false, + defaultValue: "transfer", + }, + description: { + type: Sequelize.STRING, + allowNull: true, + }, + categoryId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Categories", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + applyConstraints: { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + recipientCount: { + type: Sequelize.INTEGER, + allowNull: false, + }, + totalRequestedAmount: { + type: Sequelize.DECIMAL(15, 2), + allowNull: false, + }, + successCount: { + type: Sequelize.INTEGER, + defaultValue: 0, + allowNull: false, + }, + failureCount: { + type: Sequelize.INTEGER, + defaultValue: 0, + allowNull: false, + }, + status: { + type: Sequelize.ENUM("completed", "partial", "failed"), + allowNull: false, + defaultValue: "completed", + }, + failures: { + type: Sequelize.JSONB, + allowNull: true, + comment: "Array of {receiverUserId?, receiverOrganizationId?, receiverWalletId?, amount, reason} for recipients that failed to be scheduled", + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + + await queryInterface.addIndex("ScheduledTransferBatches", ["createdByUserId"], { + name: "idx_scheduled_transfer_batches_created_by", + }); + + await queryInterface.addColumn("ScheduledTransfers", "scheduledBatchId", { + type: Sequelize.UUID, + allowNull: true, + references: { model: "ScheduledTransferBatches", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }); + await queryInterface.addIndex("ScheduledTransfers", ["scheduledBatchId"], { + name: "idx_scheduled_transfers_scheduled_batch_id", + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeIndex("ScheduledTransfers", "idx_scheduled_transfers_scheduled_batch_id"); + await queryInterface.removeColumn("ScheduledTransfers", "scheduledBatchId"); + await queryInterface.dropTable("ScheduledTransferBatches"); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_ScheduledTransferBatches_status";' + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_ScheduledTransferBatches_type";' + ); + }, +}; diff --git a/src/database/migrations/20260813000000-create-escrows.js b/src/database/migrations/20260813000000-create-escrows.js new file mode 100644 index 0000000..81a2c56 --- /dev/null +++ b/src/database/migrations/20260813000000-create-escrows.js @@ -0,0 +1,185 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_Escrows_status') THEN + CREATE TYPE "enum_Escrows_status" AS ENUM ('held', 'released', 'refunded', 'disputed', 'cancelled', 'expired'); + END IF; + END + $$; + `); + + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_Escrows_releaseMode') THEN + CREATE TYPE "enum_Escrows_releaseMode" AS ENUM ('manual', 'auto_timeout'); + END IF; + END + $$; + `); + + await queryInterface.createTable("Escrows", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, + }, + chatId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Chats", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + payerWalletId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Wallets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + payeeWalletId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Wallets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + payerUserId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + payeeUserId: { + 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", + }, + description: { + type: Sequelize.STRING, + allowNull: true, + }, + status: { + type: Sequelize.ENUM( + "held", + "released", + "refunded", + "disputed", + "cancelled", + "expired" + ), + defaultValue: "held", + allowNull: false, + }, + releaseMode: { + type: Sequelize.ENUM("manual", "auto_timeout"), + defaultValue: "manual", + allowNull: false, + }, + autoReleaseAt: { + type: Sequelize.DATE, + allowNull: true, + }, + fundedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + releasedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + refundedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + releaseTransactionId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Transactions", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + disputeRaisedBy: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + disputeReason: { + type: Sequelize.STRING, + allowNull: true, + }, + disputeRaisedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + resolvedByAdminId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + resolutionNote: { + type: Sequelize.STRING, + allowNull: true, + }, + resolvedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + + await queryInterface.addIndex("Escrows", ["payerWalletId"], { + name: "idx_escrows_payer_wallet_id", + }); + await queryInterface.addIndex("Escrows", ["payeeWalletId"], { + name: "idx_escrows_payee_wallet_id", + }); + await queryInterface.addIndex("Escrows", ["status", "releaseMode", "autoReleaseAt"], { + name: "idx_escrows_status_release_mode_auto_release_at", + }); + await queryInterface.addIndex("Escrows", ["chatId"], { + name: "idx_escrows_chat_id", + }); + }, + + down: async (queryInterface) => { + await queryInterface.dropTable("Escrows"); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_Escrows_status";' + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_Escrows_releaseMode";' + ); + }, +}; diff --git a/src/database/migrations/20260813000001-add-escrow-id-to-transactions.js b/src/database/migrations/20260813000001-add-escrow-id-to-transactions.js new file mode 100644 index 0000000..2355575 --- /dev/null +++ b/src/database/migrations/20260813000001-add-escrow-id-to-transactions.js @@ -0,0 +1,22 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("Transactions", "escrowId", { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Escrows", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }); + + await queryInterface.addIndex("Transactions", ["escrowId"], { + name: "idx_transactions_escrow_id", + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeIndex("Transactions", "idx_transactions_escrow_id"); + await queryInterface.removeColumn("Transactions", "escrowId"); + }, +}; diff --git a/src/database/migrations/20260813000002-add-escrow-to-chat-message-type-enum.js b/src/database/migrations/20260813000002-add-escrow-to-chat-message-type-enum.js new file mode 100644 index 0000000..e91fccd --- /dev/null +++ b/src/database/migrations/20260813000002-add-escrow-to-chat-message-type-enum.js @@ -0,0 +1,23 @@ +"use strict"; + +module.exports = { + up: async (queryInterface) => { + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'escrow' + AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_ChatMessages_messageType') + ) THEN + ALTER TYPE "enum_ChatMessages_messageType" ADD VALUE 'escrow'; + END IF; + END + $$; + `); + }, + + down: async () => { + // Postgres does not support removing a value from an enum type; no-op. + }, +}; diff --git a/src/database/migrations/20260813000003-add-fulfilled-at-to-escrows.js b/src/database/migrations/20260813000003-add-fulfilled-at-to-escrows.js new file mode 100644 index 0000000..82bf900 --- /dev/null +++ b/src/database/migrations/20260813000003-add-fulfilled-at-to-escrows.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("Escrows", "fulfilledAt", { + type: Sequelize.DATE, + allowNull: true, + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn("Escrows", "fulfilledAt"); + }, +}; diff --git a/src/database/migrations/20260813000004-add-dispute-response-to-escrows.js b/src/database/migrations/20260813000004-add-dispute-response-to-escrows.js new file mode 100644 index 0000000..8fd11ff --- /dev/null +++ b/src/database/migrations/20260813000004-add-dispute-response-to-escrows.js @@ -0,0 +1,27 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("Escrows", "disputeResponse", { + type: Sequelize.STRING, + allowNull: true, + }); + await queryInterface.addColumn("Escrows", "disputeRespondedBy", { + type: Sequelize.UUID, + allowNull: true, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }); + await queryInterface.addColumn("Escrows", "disputeRespondedAt", { + type: Sequelize.DATE, + allowNull: true, + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn("Escrows", "disputeRespondedAt"); + await queryInterface.removeColumn("Escrows", "disputeRespondedBy"); + await queryInterface.removeColumn("Escrows", "disputeResponse"); + }, +}; diff --git a/src/database/models/chatMessage.model.ts b/src/database/models/chatMessage.model.ts index 1500dae..37e781f 100644 --- a/src/database/models/chatMessage.model.ts +++ b/src/database/models/chatMessage.model.ts @@ -10,7 +10,7 @@ class ChatMessage extends Model< public chatId!: string; public senderId!: string; public content!: string; - public messageType!: "text" | "image" | "file" | "money" | "audio" | "video" | "document"; + public messageType!: "text" | "image" | "file" | "money" | "escrow" | "audio" | "video" | "document"; public replyToMessageId?: string; public transactionId?: string; public isEncrypted!: boolean; @@ -39,7 +39,7 @@ const ChatMessage_model = (sequelize: Sequelize) => { senderId: { type: DataTypes.UUID, allowNull: false }, content: { type: DataTypes.TEXT, allowNull: false }, messageType: { - type: DataTypes.ENUM("text", "image", "file", "money", "audio", "video", "document"), + type: DataTypes.ENUM("text", "image", "file", "money", "escrow", "audio", "video", "document"), allowNull: false, }, replyToMessageId: { type: DataTypes.UUID, allowNull: true }, diff --git a/src/database/models/escrow.model.ts b/src/database/models/escrow.model.ts new file mode 100644 index 0000000..a49ad85 --- /dev/null +++ b/src/database/models/escrow.model.ts @@ -0,0 +1,91 @@ +// escrow.model.ts +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { EscrowAttributes, EscrowCreationAttributes } from "../../types/model"; + +class Escrow extends Model { + public id!: string; + public chatId?: string | null; + public payerWalletId!: string; + public payeeWalletId!: string; + public payerUserId!: string; + public payeeUserId!: string; + public amount!: number; + public currency!: string; + public description?: string | null; + public status!: + | "held" + | "released" + | "refunded" + | "disputed" + | "cancelled" + | "expired"; + public releaseMode!: "manual" | "auto_timeout"; + public autoReleaseAt?: Date | null; + public fundedAt!: Date; + public fulfilledAt?: Date | null; + public releasedAt?: Date | null; + public refundedAt?: Date | null; + public releaseTransactionId?: string | null; + public disputeRaisedBy?: string | null; + public disputeReason?: string | null; + public disputeRaisedAt?: Date | null; + public disputeResponse?: string | null; + public disputeRespondedBy?: string | null; + public disputeRespondedAt?: Date | null; + public resolvedByAdminId?: string | null; + public resolutionNote?: string | null; + public resolvedAt?: Date | null; + public createdAt?: Date; + public updatedAt?: Date; +} + +const escrow_model = (sequelize: Sequelize) => { + Escrow.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + chatId: DataTypes.UUID, + payerWalletId: { type: DataTypes.UUID, allowNull: false }, + payeeWalletId: { type: DataTypes.UUID, allowNull: false }, + payerUserId: { type: DataTypes.UUID, allowNull: false }, + payeeUserId: { type: DataTypes.UUID, allowNull: false }, + amount: { type: DataTypes.DECIMAL(15, 2), allowNull: false }, + currency: { type: DataTypes.STRING, defaultValue: "RWF" }, + description: DataTypes.STRING, + status: { + type: DataTypes.ENUM( + "held", + "released", + "refunded", + "disputed", + "cancelled", + "expired" + ), + defaultValue: "held", + }, + releaseMode: { + type: DataTypes.ENUM("manual", "auto_timeout"), + defaultValue: "manual", + }, + autoReleaseAt: { type: DataTypes.DATE, allowNull: true }, + fundedAt: { type: DataTypes.DATE, allowNull: false }, + fulfilledAt: { type: DataTypes.DATE, allowNull: true }, + releasedAt: { type: DataTypes.DATE, allowNull: true }, + refundedAt: { type: DataTypes.DATE, allowNull: true }, + releaseTransactionId: { type: DataTypes.UUID, allowNull: true }, + disputeRaisedBy: { type: DataTypes.UUID, allowNull: true }, + disputeReason: { type: DataTypes.STRING, allowNull: true }, + disputeRaisedAt: { type: DataTypes.DATE, allowNull: true }, + disputeResponse: { type: DataTypes.STRING, allowNull: true }, + disputeRespondedBy: { type: DataTypes.UUID, allowNull: true }, + disputeRespondedAt: { type: DataTypes.DATE, allowNull: true }, + resolvedByAdminId: { type: DataTypes.UUID, allowNull: true }, + resolutionNote: { type: DataTypes.STRING, allowNull: true }, + resolvedAt: { type: DataTypes.DATE, allowNull: true }, + }, + { sequelize, tableName: "Escrows" } + ); + + return Escrow; +}; + +export default escrow_model; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 150c814..9151282 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -36,6 +36,10 @@ import actionPurchase_model from "./actionPurchase.model"; import qrObject_model from "./qrObject.model"; 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 transferBatch_model from "./transferBatch.model"; +import scheduledTransferBatch_model from "./scheduledTransferBatch.model"; import galleryItem_model from "./galleryItem.model"; import outsideMessage_model from "./outsideMessage.model"; import groupContribution_model from "./groupContribution.model"; @@ -96,6 +100,18 @@ const Models = (sequelize: Sequelize) => { // Payment Request model const PaymentRequest = paymentRequest_model(sequelize); + // Scheduled Transfer model + const ScheduledTransfer = scheduledTransfer_model(sequelize); + + // Escrow model + const Escrow = escrow_model(sequelize); + + // Transfer Batch model + const TransferBatch = transferBatch_model(sequelize); + + // Scheduled Transfer Batch model + const ScheduledTransferBatch = scheduledTransferBatch_model(sequelize); + // Group Contribution models const GroupContribution = groupContribution_model(sequelize); const GroupContributionPayment = groupContributionPayment_model(sequelize); @@ -322,6 +338,57 @@ const Models = (sequelize: Sequelize) => { Transaction.hasOne(PaymentRequest, { foreignKey: "transactionId", as: "paymentRequest" }); PaymentRequest.belongsTo(Transaction, { foreignKey: "transactionId", as: "transaction" }); + // Scheduled Transfers + User.hasMany(ScheduledTransfer, { foreignKey: "createdByUserId", as: "createdScheduledTransfers" }); + ScheduledTransfer.belongsTo(User, { foreignKey: "createdByUserId", as: "createdBy" }); + + User.hasMany(ScheduledTransfer, { foreignKey: "senderUserId", as: "sentScheduledTransfers" }); + ScheduledTransfer.belongsTo(User, { foreignKey: "senderUserId", as: "senderUser" }); + + User.hasMany(ScheduledTransfer, { foreignKey: "receiverUserId", as: "receivedScheduledTransfers" }); + ScheduledTransfer.belongsTo(User, { foreignKey: "receiverUserId", as: "receiverUser" }); + + Organization.hasMany(ScheduledTransfer, { foreignKey: "senderOrganizationId", as: "sentScheduledTransfers" }); + ScheduledTransfer.belongsTo(Organization, { foreignKey: "senderOrganizationId", as: "senderOrganization" }); + + Organization.hasMany(ScheduledTransfer, { foreignKey: "receiverOrganizationId", as: "receivedScheduledTransfers" }); + ScheduledTransfer.belongsTo(Organization, { foreignKey: "receiverOrganizationId", as: "receiverOrganization" }); + + ScheduledTransfer.hasMany(Transaction, { foreignKey: "scheduledTransferId", as: "executedTransactions" }); + Transaction.belongsTo(ScheduledTransfer, { foreignKey: "scheduledTransferId", as: "scheduledTransfer" }); + + Wallet.hasMany(Escrow, { foreignKey: "payerWalletId", as: "escrowsAsPayer" }); + Escrow.belongsTo(Wallet, { foreignKey: "payerWalletId", as: "payerWallet" }); + + Wallet.hasMany(Escrow, { foreignKey: "payeeWalletId", as: "escrowsAsPayee" }); + Escrow.belongsTo(Wallet, { foreignKey: "payeeWalletId", as: "payeeWallet" }); + + User.hasMany(Escrow, { foreignKey: "payerUserId", as: "escrowsAsPayer" }); + Escrow.belongsTo(User, { foreignKey: "payerUserId", as: "payerUser" }); + + User.hasMany(Escrow, { foreignKey: "payeeUserId", as: "escrowsAsPayee" }); + Escrow.belongsTo(User, { foreignKey: "payeeUserId", as: "payeeUser" }); + + Chat.hasMany(Escrow, { foreignKey: "chatId", as: "escrows" }); + Escrow.belongsTo(Chat, { foreignKey: "chatId", as: "chat" }); + + Escrow.hasMany(Transaction, { foreignKey: "escrowId", as: "transactions" }); + Transaction.belongsTo(Escrow, { foreignKey: "escrowId", as: "escrow" }); + + // Transfer Batches + User.hasMany(TransferBatch, { foreignKey: "createdByUserId", as: "createdTransferBatches" }); + TransferBatch.belongsTo(User, { foreignKey: "createdByUserId", as: "createdBy" }); + + TransferBatch.hasMany(Transaction, { foreignKey: "batchId", as: "transactions" }); + Transaction.belongsTo(TransferBatch, { foreignKey: "batchId", as: "batch" }); + + // Scheduled Transfer Batches + User.hasMany(ScheduledTransferBatch, { foreignKey: "createdByUserId", as: "createdScheduledTransferBatches" }); + ScheduledTransferBatch.belongsTo(User, { foreignKey: "createdByUserId", as: "createdBy" }); + + ScheduledTransferBatch.hasMany(ScheduledTransfer, { foreignKey: "scheduledBatchId", as: "scheduledTransfers" }); + ScheduledTransfer.belongsTo(ScheduledTransferBatch, { foreignKey: "scheduledBatchId", as: "batch" }); + // Groups User.hasMany(Group, { foreignKey: "ownerId", as: "ownedGroups" }); Group.belongsTo(User, { foreignKey: "ownerId", as: "owner" }); @@ -645,6 +712,10 @@ const Models = (sequelize: Sequelize) => { QRObject, AuditLog, PaymentRequest, + ScheduledTransfer, + Escrow, + TransferBatch, + ScheduledTransferBatch, OutsideMessage, GalleryItem, GroupContribution, diff --git a/src/database/models/scheduledTransfer.model.ts b/src/database/models/scheduledTransfer.model.ts new file mode 100644 index 0000000..d233be5 --- /dev/null +++ b/src/database/models/scheduledTransfer.model.ts @@ -0,0 +1,121 @@ +// scheduledTransfer.model.ts +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + ScheduledTransferAttributes, + ScheduledTransferCreationAttributes, +} from "../../types/model"; + +class ScheduledTransfer extends Model< + ScheduledTransferAttributes, + ScheduledTransferCreationAttributes +> { + public id!: string; + public createdByUserId!: string; + public senderUserId?: string | null; + public senderOrganizationId?: string | null; + public senderSubActionId?: string | null; + public receiverUserId?: string | null; + public receiverOrganizationId?: string | null; + public receiverWalletId?: string | null; + public amount!: number; + public fee!: number; + public currency!: string; + public type!: + | "transfer" + | "payment" + | "donation" + | "vote" + | "topup" + | "withdrawal"; + public description?: string | null; + public categoryId?: string | null; + public applyConstraints!: boolean; + public scheduledFor!: Date; + public timezone!: string; + public recurrenceRule!: { + frequency: "daily" | "weekly" | "monthly" | "yearly"; + interval: number; + endDate?: string; + maxOccurrences?: number; + } | null; + public occurrenceCount!: number; + public status!: + | "scheduled" + | "held" + | "executing" + | "completed" + | "failed" + | "cancelled" + | "paused"; + public heldAmount?: number | null; + public heldAt?: Date | null; + public idempotencyKey!: string; + public lastExecutedTransactionId?: string | null; + public consecutiveFailureCount!: number; + public lastFailureReason?: string | null; + public pinVerifiedAt?: Date | null; + public scheduledBatchId?: string | null; + public createdAt?: Date; + public updatedAt?: Date; +} + +const scheduledTransfer_model = (sequelize: Sequelize) => { + ScheduledTransfer.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + createdByUserId: { type: DataTypes.UUID, allowNull: false }, + senderUserId: DataTypes.UUID, + senderOrganizationId: DataTypes.UUID, + senderSubActionId: DataTypes.UUID, + receiverUserId: DataTypes.UUID, + receiverOrganizationId: DataTypes.UUID, + receiverWalletId: DataTypes.UUID, + amount: { type: DataTypes.DECIMAL(15, 2), allowNull: false }, + fee: { type: DataTypes.DECIMAL(15, 2), defaultValue: 0 }, + currency: { type: DataTypes.STRING, defaultValue: "RWF" }, + type: { + type: DataTypes.ENUM( + "transfer", + "payment", + "donation", + "vote", + "topup", + "withdrawal" + ), + defaultValue: "transfer", + }, + description: DataTypes.STRING, + categoryId: DataTypes.UUID, + applyConstraints: { type: DataTypes.BOOLEAN, defaultValue: false }, + scheduledFor: { type: DataTypes.DATE, allowNull: false }, + timezone: { type: DataTypes.STRING, defaultValue: "Africa/Kigali" }, + recurrenceRule: { type: DataTypes.JSONB, allowNull: true }, + occurrenceCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + status: { + type: DataTypes.ENUM( + "scheduled", + "held", + "executing", + "completed", + "failed", + "cancelled", + "paused" + ), + defaultValue: "scheduled", + }, + heldAmount: { type: DataTypes.DECIMAL(15, 2), allowNull: true }, + heldAt: { type: DataTypes.DATE, allowNull: true }, + idempotencyKey: { type: DataTypes.STRING, allowNull: false, unique: true }, + lastExecutedTransactionId: { type: DataTypes.UUID, allowNull: true }, + consecutiveFailureCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + lastFailureReason: DataTypes.STRING, + pinVerifiedAt: { type: DataTypes.DATE, allowNull: true }, + scheduledBatchId: { type: DataTypes.UUID, allowNull: true }, + }, + { sequelize, tableName: "ScheduledTransfers" } + ); + + return ScheduledTransfer; +}; + +export default scheduledTransfer_model; diff --git a/src/database/models/scheduledTransferBatch.model.ts b/src/database/models/scheduledTransferBatch.model.ts new file mode 100644 index 0000000..83993e1 --- /dev/null +++ b/src/database/models/scheduledTransferBatch.model.ts @@ -0,0 +1,77 @@ +// scheduledTransferBatch.model.ts +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + ScheduledTransferBatchAttributes, + ScheduledTransferBatchCreationAttributes, +} from "../../types/model"; + +class ScheduledTransferBatch extends Model< + ScheduledTransferBatchAttributes, + ScheduledTransferBatchCreationAttributes +> { + public id!: string; + public createdByUserId!: string; + public senderUserId?: string | null; + public senderOrganizationId?: string | null; + public senderSubActionId?: string | null; + public currency!: string; + public type!: + | "transfer" + | "payment" + | "donation" + | "vote" + | "topup" + | "withdrawal"; + public description?: string | null; + public categoryId?: string | null; + public applyConstraints!: boolean; + public recipientCount!: number; + public totalRequestedAmount!: number; + public successCount!: number; + public failureCount!: number; + public status!: "completed" | "partial" | "failed"; + public failures?: Array> | null; + public createdAt?: Date; + public updatedAt?: Date; +} + +const scheduledTransferBatch_model = (sequelize: Sequelize) => { + ScheduledTransferBatch.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + createdByUserId: { type: DataTypes.UUID, allowNull: false }, + senderUserId: DataTypes.UUID, + senderOrganizationId: DataTypes.UUID, + senderSubActionId: DataTypes.UUID, + currency: { type: DataTypes.STRING, defaultValue: "RWF" }, + type: { + type: DataTypes.ENUM( + "transfer", + "payment", + "donation", + "vote", + "topup", + "withdrawal" + ), + defaultValue: "transfer", + }, + description: DataTypes.STRING, + categoryId: DataTypes.UUID, + applyConstraints: { type: DataTypes.BOOLEAN, defaultValue: false }, + recipientCount: { type: DataTypes.INTEGER, allowNull: false }, + totalRequestedAmount: { type: DataTypes.DECIMAL(15, 2), allowNull: false }, + successCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + failureCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + status: { + type: DataTypes.ENUM("completed", "partial", "failed"), + defaultValue: "completed", + }, + failures: { type: DataTypes.JSONB, allowNull: true }, + }, + { sequelize, tableName: "ScheduledTransferBatches" } + ); + + return ScheduledTransferBatch; +}; + +export default scheduledTransferBatch_model; diff --git a/src/database/models/transaction.model.ts b/src/database/models/transaction.model.ts index 4c76f5f..06a85e3 100644 --- a/src/database/models/transaction.model.ts +++ b/src/database/models/transaction.model.ts @@ -22,6 +22,9 @@ class Transaction extends Model< | "vote" | "topup" | "withdrawal"; + public scheduledTransferId?: string | null; + public batchId?: string | null; + public escrowId?: string | null; } const Transaction_model = (sequelize: Sequelize) => { @@ -94,6 +97,30 @@ const Transaction_model = (sequelize: Sequelize) => { key: "id", }, }, + scheduledTransferId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: "ScheduledTransfers", + key: "id", + }, + }, + batchId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: "TransferBatches", + key: "id", + }, + }, + escrowId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: "Escrows", + key: "id", + }, + }, }, { sequelize, tableName: "Transactions" } ); diff --git a/src/database/models/transferBatch.model.ts b/src/database/models/transferBatch.model.ts new file mode 100644 index 0000000..3c13a33 --- /dev/null +++ b/src/database/models/transferBatch.model.ts @@ -0,0 +1,83 @@ +// transferBatch.model.ts +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + TransferBatchAttributes, + TransferBatchCreationAttributes, +} from "../../types/model"; + +class TransferBatch extends Model< + TransferBatchAttributes, + TransferBatchCreationAttributes +> { + public id!: string; + public createdByUserId!: string; + public senderUserId?: string | null; + public senderOrganizationId?: string | null; + public senderSubActionId?: string | null; + public currency!: string; + public type!: + | "transfer" + | "payment" + | "donation" + | "vote" + | "topup" + | "withdrawal"; + public description?: string | null; + public categoryId?: string | null; + public applyConstraints!: boolean; + public idempotencyKey?: string | null; + public recipientCount!: number; + public totalRequestedAmount!: number; + public totalSentAmount!: number; + public totalFailedAmount!: number; + public successCount!: number; + public failureCount!: number; + public status!: "completed" | "partial" | "failed"; + public failures?: Array> | null; + public createdAt?: Date; + public updatedAt?: Date; +} + +const transferBatch_model = (sequelize: Sequelize) => { + TransferBatch.init( + { + id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true }, + createdByUserId: { type: DataTypes.UUID, allowNull: false }, + senderUserId: DataTypes.UUID, + senderOrganizationId: DataTypes.UUID, + senderSubActionId: DataTypes.UUID, + currency: { type: DataTypes.STRING, defaultValue: "RWF" }, + type: { + type: DataTypes.ENUM( + "transfer", + "payment", + "donation", + "vote", + "topup", + "withdrawal" + ), + defaultValue: "transfer", + }, + description: DataTypes.STRING, + categoryId: DataTypes.UUID, + applyConstraints: { type: DataTypes.BOOLEAN, defaultValue: false }, + idempotencyKey: { type: DataTypes.STRING, allowNull: true, unique: true }, + recipientCount: { type: DataTypes.INTEGER, allowNull: false }, + totalRequestedAmount: { type: DataTypes.DECIMAL(15, 2), allowNull: false }, + totalSentAmount: { type: DataTypes.DECIMAL(15, 2), defaultValue: 0 }, + totalFailedAmount: { type: DataTypes.DECIMAL(15, 2), defaultValue: 0 }, + successCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + failureCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + status: { + type: DataTypes.ENUM("completed", "partial", "failed"), + defaultValue: "completed", + }, + failures: { type: DataTypes.JSONB, allowNull: true }, + }, + { sequelize, tableName: "TransferBatches" } + ); + + return TransferBatch; +}; + +export default transferBatch_model; diff --git a/src/database/models/wallet.model.ts b/src/database/models/wallet.model.ts index 7d50c1c..faa8b98 100644 --- a/src/database/models/wallet.model.ts +++ b/src/database/models/wallet.model.ts @@ -10,6 +10,7 @@ class Wallet extends Model { public subActionId?: string; public publicContributionId?: string; public balance!: number; + public heldBalance!: number; public currency!: string; public isActive!: boolean; } @@ -24,6 +25,9 @@ const Wallet_model = (sequelize: Sequelize) => { subActionId: DataTypes.UUID, publicContributionId: 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). + heldBalance: { type: DataTypes.DECIMAL(15, 2), defaultValue: 0 }, currency: { type: DataTypes.STRING, defaultValue: "RWF" }, isActive: { type: DataTypes.BOOLEAN, defaultValue: true }, }, diff --git a/src/index.ts b/src/index.ts index d32c6f5..753d235 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import * as http from "http"; import { Server as SocketIOServer } from "socket.io"; // Remove PORT import since we define it locally as SERVER_PORT import Models from "./database/models"; +import { initScheduledTransferJobs } from "./jobs/scheduledTransferJobs"; +import { initEscrowJobs } from "./jobs/escrowJobs"; const startServer = async () => { try { @@ -59,6 +61,12 @@ const startServer = async () => { const socketManager = new SocketManager.default(io, app); app.set("socketManager", socketManager); + // 7. Start scheduled-transfer background jobs (execution poller + rolling hold poller) + initScheduledTransferJobs(app); + + // 7b. Start escrow auto-release job + initEscrowJobs(app); + const SERVER_PORT = process.env.PORT || 5500; server.listen(SERVER_PORT, () => { console.log(`🚀 Server is running on port ${SERVER_PORT}`); diff --git a/src/jobs/escrowJobs.ts b/src/jobs/escrowJobs.ts new file mode 100644 index 0000000..0e9c0d5 --- /dev/null +++ b/src/jobs/escrowJobs.ts @@ -0,0 +1,78 @@ +import cron from "node-cron"; +import { Op, Transaction as DbTransaction } from "sequelize"; +import database_models from "../database/config/db.config"; +import { releaseEscrowFunds } from "../services/escrowService"; +import { notifyPaymentReceived, notifyPaymentSent } from "../utils/notificationHelpers"; +import { Application } from "express"; + +const { sequelize, Escrow, User } = database_models as any; + +const AUTO_RELEASE_BATCH_SIZE = 50; + +/** + * Auto-releases held escrows whose autoReleaseAt has passed with no dispute raised. + * Uses SELECT ... FOR UPDATE SKIP LOCKED so this stays correct if the backend ever + * runs as multiple instances, same as the scheduled-transfer execution poller. + */ +const runAutoReleasePoll = async (app: Application): Promise => { + try { + const dueEscrows = await Escrow.findAll({ + where: { + status: "held", + releaseMode: "auto_timeout", + autoReleaseAt: { [Op.lte]: new Date() }, + }, + limit: AUTO_RELEASE_BATCH_SIZE, + }); + + for (const dueEscrow of dueEscrows) { + await autoReleaseOne(app, dueEscrow.id); + } + } catch (error) { + console.error("[escrows] Auto-release poll failed:", error); + } +}; + +const autoReleaseOne = async (app: Application, escrowId: string): Promise => { + let released: any = null; + let transactionId: string | null = null; + + try { + await sequelize.transaction(async (t: DbTransaction) => { + const escrow = await Escrow.findByPk(escrowId, { lock: t.LOCK.UPDATE, skipLocked: true, transaction: t }); + if (!escrow || escrow.status !== "held") return; + + const result = await releaseEscrowFunds(escrow, t); + await escrow.update( + { status: "released", releasedAt: new Date(), releaseTransactionId: result.transactionId }, + { transaction: t } + ); + released = escrow; + transactionId = result.transactionId; + }); + } catch (error) { + console.error(`[escrows] Auto-release failed for escrow ${escrowId}:`, error); + return; + } + + if (!released || !transactionId) return; + + const [payerUser, payeeUser] = await Promise.all([ + User.findByPk(released.payerUserId), + User.findByPk(released.payeeUserId), + ]); + const amount = parseFloat(released.amount.toString()); + + if (payeeUser && payerUser) { + await notifyPaymentReceived(app, released.payeeUserId, transactionId, amount, released.currency, `${payerUser.firstName} ${payerUser.lastName}`); + await notifyPaymentSent(app, released.payerUserId, transactionId, amount, released.currency, `${payeeUser.firstName} ${payeeUser.lastName}`); + } +}; + +export const initEscrowJobs = (app: Application): void => { + cron.schedule("*/5 * * * *", () => { + void runAutoReleasePoll(app); + }); + + console.log("✅ Escrow jobs initialized (auto-release: every 5 minutes)"); +}; diff --git a/src/jobs/scheduledTransferJobs.ts b/src/jobs/scheduledTransferJobs.ts new file mode 100644 index 0000000..219fcae --- /dev/null +++ b/src/jobs/scheduledTransferJobs.ts @@ -0,0 +1,131 @@ +import cron from "node-cron"; +import { Application } from "express"; +import { Op } from "sequelize"; +import database_models from "../database/config/db.config"; +import { + executeOccurrence, + attemptHold, + finalizeFailedOccurrence, + ROLLING_HOLD_WINDOW_HOURS, + STALE_EXECUTING_MINUTES, +} from "../services/scheduledTransferExecutionService"; + +const { sequelize, ScheduledTransfer } = database_models as any; + +const EXECUTION_BATCH_SIZE = 50; +const HOLD_BATCH_SIZE = 50; + +/** + * Recovers rows a crashed process left stuck in 'executing'. Rows that already had + * funds reserved (heldAmount set) go back to 'held' for a retry; rows that were being + * processed as a missed, never-held occurrence go back to 'scheduled' instead - reclaiming + * them as 'held' would be wrong, since no funds were ever actually set aside for them. + */ +const reclaimStaleExecutingRows = async (): Promise => { + const staleBefore = new Date(Date.now() - STALE_EXECUTING_MINUTES * 60000); + + const [heldCount] = await ScheduledTransfer.update( + { status: "held" }, + { + where: { + status: "executing", + heldAmount: { [Op.ne]: null }, + updatedAt: { [Op.lt]: staleBefore }, + }, + } + ); + + const [scheduledCount] = await ScheduledTransfer.update( + { status: "scheduled" }, + { + where: { + status: "executing", + heldAmount: null, + updatedAt: { [Op.lt]: staleBefore }, + }, + } + ); + + if (heldCount > 0 || scheduledCount > 0) { + console.warn( + `[scheduled-transfers] Reclaimed ${heldCount} held + ${scheduledCount} missed row(s) stuck in 'executing'` + ); + } +}; + +const claimRows = async (status: "held" | "scheduled"): Promise => { + return sequelize.transaction(async (t: any) => { + const rows = await ScheduledTransfer.findAll({ + where: { status, scheduledFor: { [Op.lte]: new Date() } }, + limit: EXECUTION_BATCH_SIZE, + lock: t.LOCK.UPDATE, + skipLocked: true, + transaction: t, + }); + const ids = rows.map((r: any) => r.id); + if (ids.length > 0) { + await ScheduledTransfer.update( + { status: "executing" }, + { where: { id: { [Op.in]: ids } }, transaction: t } + ); + } + return ids; + }); +}; + +const runExecutionPoll = async (app: Application): Promise => { + try { + await reclaimStaleExecutingRows(); + + // Due occurrences that already have funds reserved - the common path. + const dueHeld = await claimRows("held"); + for (const id of dueHeld) { + await executeOccurrence(app, id); + } + + // Occurrences that reached their due date without ever successfully being held + // (the rolling-hold job kept failing right up to the deadline). + const missed = await claimRows("scheduled"); + for (const id of missed) { + await finalizeFailedOccurrence(app, id, "Scheduled time passed before funds could be reserved"); + } + } catch (error) { + console.error("[scheduled-transfers] Execution poll failed:", error); + } +}; + +const runRollingHoldPoll = async (app: Application): Promise => { + try { + const windowEnd = new Date(Date.now() + ROLLING_HOLD_WINDOW_HOURS * 60 * 60 * 1000); + const rows = await ScheduledTransfer.findAll({ + where: { + status: "scheduled", + scheduledFor: { [Op.gt]: new Date(), [Op.lte]: windowEnd }, + }, + limit: HOLD_BATCH_SIZE, + }); + for (const row of rows) { + await attemptHold(app, row.id); + } + } catch (error) { + console.error("[scheduled-transfers] Rolling hold poll failed:", error); + } +}; + +/** + * Starts the scheduled-transfer background jobs. Uses SELECT ... FOR UPDATE SKIP LOCKED + * to claim rows, so this stays correct even if the backend is ever run as multiple + * instances - two pollers hitting the same due row simply split the work instead of + * double-executing it. + */ +export const initScheduledTransferJobs = (app: Application): void => { + cron.schedule("* * * * *", () => { + void runExecutionPoll(app); + }); + + cron.schedule("0 * * * *", () => { + void runRollingHoldPoll(app); + }); + + console.log("✅ Scheduled transfer jobs initialized (execution: every minute, hold reservation: hourly)"); +}; diff --git a/src/routes/admin.escrowDisputes.routes.ts b/src/routes/admin.escrowDisputes.routes.ts new file mode 100644 index 0000000..039982b --- /dev/null +++ b/src/routes/admin.escrowDisputes.routes.ts @@ -0,0 +1,13 @@ +import express, { RequestHandler } from "express"; +import adminEscrowDisputesController from "../controllers/admin.escrowDisputes.controller"; +import { authenticate, adminOnly } from "../middleware/auth.unified.middleware"; + +const router = express.Router(); + +router.use(authenticate as RequestHandler); +router.use(adminOnly as RequestHandler); + +router.get("/", adminEscrowDisputesController.getAllDisputes as RequestHandler); +router.get("/:id", adminEscrowDisputesController.getDisputeById as RequestHandler); + +export default router; diff --git a/src/routes/admin.main.routes.ts b/src/routes/admin.main.routes.ts index ded9e47..b986b56 100644 --- a/src/routes/admin.main.routes.ts +++ b/src/routes/admin.main.routes.ts @@ -9,6 +9,7 @@ import adminWalletsRouter from "./admin.wallets.routes"; import adminNotificationsRouter from "./admin.notifications.routes"; import adminAnalyticsRouter from "./admin.analytics.routes"; import adminSupportRouter from "./admin.support.routes"; +import adminEscrowDisputesRouter from "./admin.escrowDisputes.routes"; const router = express.Router(); @@ -22,5 +23,6 @@ router.use("/wallets", adminWalletsRouter); router.use("/notifications", adminNotificationsRouter); router.use("/analytics", adminAnalyticsRouter); router.use("/support", adminSupportRouter); +router.use("/escrow-disputes", adminEscrowDisputesRouter); export default router; diff --git a/src/routes/escrow.routes.ts b/src/routes/escrow.routes.ts new file mode 100644 index 0000000..d2a9383 --- /dev/null +++ b/src/routes/escrow.routes.ts @@ -0,0 +1,19 @@ +import express, { RequestHandler } from "express"; +import escrowController from "../controllers/escrowController"; +import { authenticate, adminOnly } from "../middleware/auth.unified.middleware"; + +const router = express.Router(); + +router.use(authenticate as RequestHandler); + +router.post("/", escrowController.createEscrow as RequestHandler); +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/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); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index f6ba7c5..7403171 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -3,6 +3,8 @@ import orgRouter from "./organization.routes"; import userRouter from "./user.routes"; import resetRouter from "./reset.routes"; import transactionRouter from "./transaction.routes"; +import scheduledTransferRouter from "./scheduledTransfer.routes"; +import escrowRouter from "./escrow.routes"; import walletItemsRouter from "./walletItems.routes"; import analyticsRouter from "./analytics.routes"; import contactRouter from "./contact.routes"; @@ -53,6 +55,8 @@ router.use("/users", userRouter); router.use("/users/pin", pinRouter); router.use("/auth", resetRouter); router.use("/transactions", transactionRouter); // Includes unified endpoints +router.use("/scheduled-transfers", scheduledTransferRouter); +router.use("/escrows", escrowRouter); router.use("/wallets", walletItemsRouter); // Wallet summary + items ("items wallet") router.use("/analytics", analyticsRouter); router.use("/contacts", contactRouter); diff --git a/src/routes/scheduledTransfer.routes.ts b/src/routes/scheduledTransfer.routes.ts new file mode 100644 index 0000000..0b7c919 --- /dev/null +++ b/src/routes/scheduledTransfer.routes.ts @@ -0,0 +1,25 @@ +import express, { RequestHandler } from "express"; +import scheduledTransferController from "../controllers/scheduledTransferController"; +import { authenticate } from "../middleware/auth.unified.middleware"; + +const router = express.Router(); + +router.use(authenticate as RequestHandler); + +router.post("/", scheduledTransferController.createScheduledTransfer as RequestHandler); +router.get("/", scheduledTransferController.listScheduledTransfers as RequestHandler); + +// Scheduled batch transfers (multiple recipients, one schedule) - must come before /:id +router.post("/batch", scheduledTransferController.createScheduledBatchTransfer as RequestHandler); +router.get("/batches", scheduledTransferController.listScheduledBatches as RequestHandler); +router.get("/batch/:id", scheduledTransferController.getScheduledBatchById as RequestHandler); +router.post("/batch/:id/cancel", scheduledTransferController.cancelScheduledBatch as RequestHandler); + +router.get("/:id", scheduledTransferController.getScheduledTransferById as RequestHandler); +router.patch("/:id", scheduledTransferController.updateScheduledTransfer as RequestHandler); +router.post("/:id/cancel", scheduledTransferController.cancelScheduledTransfer as RequestHandler); +router.post("/:id/pause", scheduledTransferController.pauseScheduledTransfer as RequestHandler); +router.post("/:id/resume", scheduledTransferController.resumeScheduledTransfer as RequestHandler); +router.post("/:id/skip-next", scheduledTransferController.skipNextOccurrence as RequestHandler); + +export default router; diff --git a/src/routes/transaction.routes.ts b/src/routes/transaction.routes.ts index bdce940..23b18ab 100644 --- a/src/routes/transaction.routes.ts +++ b/src/routes/transaction.routes.ts @@ -2,6 +2,11 @@ import express, { RequestHandler } from "express"; import transactionController from "../controllers/transactionController"; import { downloadReceipt } from "../controllers/receiptController"; import { authenticate } from "../middleware/auth.unified.middleware"; +import { + createBatchTransfer, + getUserBatchTransfers, + getBatchTransferById, +} from "../controllers/batchTransferController"; const router = express.Router(); @@ -20,6 +25,24 @@ router.post( transactionController.transferMoney as express.RequestHandler, ); +// Send money to multiple recipients in one request +router.post( + "/batch-transfer", + createBatchTransfer as express.RequestHandler, +); + +// List the authenticated user's batch transfers +router.get( + "/batches", + getUserBatchTransfers as express.RequestHandler, +); + +// Get a single batch transfer with per-recipient results +router.get( + "/batch/:id", + getBatchTransferById as express.RequestHandler, +); + // Download transaction receipt router.get( "/receipt/:transactionId", diff --git a/src/services/e2eeMessage.service.ts b/src/services/e2eeMessage.service.ts index b3d2856..bc27963 100644 --- a/src/services/e2eeMessage.service.ts +++ b/src/services/e2eeMessage.service.ts @@ -638,7 +638,7 @@ export const getSecureDMMessagePage = async ( { model: models.ChatMessageRecipientPayload, as: "recipientPayloads", - required: true, + required: false, where: { recipientUserId: userId, recipientDeviceId: deviceId, @@ -650,6 +650,16 @@ export const getSecureDMMessagePage = async ( offset: (page - 1) * limit, }); + // Plain (non-E2EE) messages - money/escrow holds, etc. - never get a recipient + // payload row, so the left join above leaves them with none; keep those as-is. + // Encrypted messages with no payload for this device are ciphertext this device + // was never given the key for, so drop them rather than showing an undecryptable stub. + result.rows = result.rows.filter((message: any) => { + if (!message.isEncrypted) return true; + const payload = Array.isArray(message.recipientPayloads) ? message.recipientPayloads[0] : null; + return Boolean(payload); + }); + const justDeliveredIds = result.rows .filter((message: any) => { const payload = Array.isArray(message.recipientPayloads) @@ -742,6 +752,10 @@ export const getSecureDMMessagePage = async ( createdAt: message.createdAt, senderId: message.senderId, sender: message.sender, + isEncrypted: message.isEncrypted, + // Plain messages (money/escrow holds, etc.) carry their real content straight + // through - there's no per-device envelope to decrypt for these. + content: message.isEncrypted ? undefined : message.content, encryptedEnvelope: payload?.encryptedEnvelope || null, }; }), diff --git a/src/services/escrowService.ts b/src/services/escrowService.ts new file mode 100644 index 0000000..9d5f821 --- /dev/null +++ b/src/services/escrowService.ts @@ -0,0 +1,86 @@ +import { Transaction as DbTransaction } from "sequelize"; +import database_models from "../database/config/db.config"; + +const { Wallet, Transaction: TransactionModel } = database_models as any; + +/** + * Reserves `amount` against the payer wallet's heldBalance. Does not move any money - + * balance stays put, it's just no longer spendable (see utils/walletBalance.ts). + */ +export const holdEscrowFunds = async ( + payerWallet: any, + amount: number, + dbTransaction: DbTransaction +): Promise => { + const currentHeld = parseFloat(payerWallet.heldBalance.toString()); + await payerWallet.update({ heldBalance: currentHeld + amount }, { transaction: dbTransaction }); +}; + +/** + * Moves the held amount from payer to payee and writes the one ledger entry for the + * escrow's whole lifetime. Locks both wallets - caller must already be inside a + * sequelize managed transaction. + */ +export const releaseEscrowFunds = async ( + escrow: any, + dbTransaction: DbTransaction +): Promise<{ transactionId: string }> => { + const [payerWallet, payeeWallet] = await Promise.all([ + Wallet.findByPk(escrow.payerWalletId, { lock: dbTransaction.LOCK.UPDATE, transaction: dbTransaction }), + Wallet.findByPk(escrow.payeeWalletId, { lock: dbTransaction.LOCK.UPDATE, transaction: dbTransaction }), + ]); + if (!payerWallet) throw new Error("Escrow payer wallet not found"); + if (!payeeWallet) throw new Error("Escrow payee wallet not found"); + + const amount = parseFloat(escrow.amount.toString()); + + await payerWallet.update( + { + balance: parseFloat(payerWallet.balance.toString()) - amount, + heldBalance: Math.max(0, parseFloat(payerWallet.heldBalance.toString()) - amount), + }, + { transaction: dbTransaction } + ); + await payeeWallet.update( + { balance: parseFloat(payeeWallet.balance.toString()) + amount }, + { transaction: dbTransaction } + ); + + const releaseTransaction = await TransactionModel.create( + { + referenceId: `ESCROW-${escrow.id.substring(0, 8)}-${Date.now()}`, + senderWalletId: payerWallet.id, + receiverWalletId: payeeWallet.id, + amount, + fee: 0, + totalAmount: amount, + currency: escrow.currency, + status: "completed", + type: "transfer", + description: escrow.description || "Escrow release", + escrowId: escrow.id, + } as any, + { transaction: dbTransaction } + ); + + return { transactionId: releaseTransaction.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. + */ +export const refundEscrowFunds = async (escrow: any, dbTransaction: DbTransaction): Promise => { + const payerWallet = await Wallet.findByPk(escrow.payerWalletId, { + lock: dbTransaction.LOCK.UPDATE, + transaction: dbTransaction, + }); + if (!payerWallet) throw new Error("Escrow payer wallet not found"); + + const amount = parseFloat(escrow.amount.toString()); + const currentHeld = parseFloat(payerWallet.heldBalance.toString()); + await payerWallet.update( + { heldBalance: Math.max(0, currentHeld - amount) }, + { transaction: dbTransaction } + ); +}; diff --git a/src/services/scheduledTransferExecutionService.ts b/src/services/scheduledTransferExecutionService.ts new file mode 100644 index 0000000..9732446 --- /dev/null +++ b/src/services/scheduledTransferExecutionService.ts @@ -0,0 +1,499 @@ +import { Application } from "express"; +import { Transaction as DbTransaction } from "sequelize"; +import database_models from "../database/config/db.config"; +import { getAvailableBalance } from "../utils/walletBalance"; +import { resolveWalletWhere, validateFundsAvailability } from "../utils/transferValidation"; +import { computeNextRun, isRecurrenceExhausted } from "../utils/recurrence"; +import { + notifyScheduledTransferHeld, + notifyScheduledTransferHoldFailed, + notifyScheduledTransferExecuted, + notifyScheduledTransferFailed, + notifyScheduledSeriesAutoPaused, + notifyPaymentReceived, +} from "../utils/notificationHelpers"; + +const { + sequelize, + Wallet, + Transaction: TransactionModel, + Category, + WalletRestriction, + ScheduledTransfer, + User, + Organization, +} = database_models as any; + +export const MAX_CONSECUTIVE_FAILURES = 3; +export const ROLLING_HOLD_WINDOW_HOURS = 24; +export const STALE_EXECUTING_MINUTES = 5; + +const identityWhere = (st: any, side: "sender" | "receiver") => + side === "sender" + ? resolveWalletWhere({ + userId: st.senderUserId, + organizationId: st.senderOrganizationId, + subActionId: st.senderSubActionId, + }) + : resolveWalletWhere({ + userId: st.receiverUserId, + organizationId: st.receiverOrganizationId, + walletId: st.receiverWalletId, + }); + +const resolveName = async (identity: { + userId?: string | null; + organizationId?: string | null; +}): Promise => { + if (identity.userId) { + const user = await User.findByPk(identity.userId); + return user ? `${user.firstName} ${user.lastName}` : "A user"; + } + if (identity.organizationId) { + const org = await Organization.findByPk(identity.organizationId); + return org ? org.name : "An organization"; + } + return "A user"; +}; + +/** + * Locks the sender wallet, validates funds (respecting existing holds and category + * restrictions), and reserves amount+fee against heldBalance if there's room. + * Caller is responsible for persisting the resulting status on the ScheduledTransfer row. + */ +export const placeHoldForOccurrence = async ( + scheduledTransfer: any, + dbTransaction: DbTransaction +): Promise<{ ok: boolean; message?: string; heldAmount?: number }> => { + const senderWhere = identityWhere(scheduledTransfer, "sender"); + const receiverWhere = identityWhere(scheduledTransfer, "receiver"); + if (!senderWhere || !receiverWhere) { + return { ok: false, message: "Invalid sender or receiver on scheduled transfer" }; + } + + const [senderWallet, receiverWallet] = await Promise.all([ + Wallet.findOne({ where: senderWhere, lock: dbTransaction.LOCK.UPDATE, transaction: dbTransaction }), + Wallet.findOne({ where: receiverWhere, transaction: dbTransaction }), + ]); + + if (!senderWallet) return { ok: false, message: "Sender wallet not found or inactive" }; + if (!receiverWallet) return { ok: false, message: "Receiver wallet not found or inactive" }; + + const transferAmount = parseFloat(scheduledTransfer.amount.toString()); + const fee = parseFloat((scheduledTransfer.fee ?? 0).toString()); + const totalAmount = transferAmount + fee; + const receiverIsUser = !!receiverWallet.userId; + + const restrictions = await WalletRestriction.findAll({ + where: { walletId: senderWallet.id }, + include: [{ model: Category, as: "category", required: true }], + transaction: dbTransaction, + }); + + const result = validateFundsAvailability({ + availableBalance: getAvailableBalance(senderWallet), + restrictions, + transferAmount: totalAmount, + receiverIsUser, + categoryId: scheduledTransfer.categoryId, + }); + + if (!result.ok) { + return { ok: false, message: result.message }; + } + + await senderWallet.update( + { heldBalance: parseFloat(senderWallet.heldBalance.toString()) + totalAmount }, + { transaction: dbTransaction } + ); + + return { ok: true, heldAmount: totalAmount }; +}; + +export const releaseHoldForOccurrence = async (scheduledTransfer: any, dbTransaction: DbTransaction) => { + if (!scheduledTransfer.heldAmount) return; + + const senderWhere = identityWhere(scheduledTransfer, "sender"); + if (!senderWhere) return; + + const senderWallet = await Wallet.findOne({ + where: senderWhere, + lock: dbTransaction.LOCK.UPDATE, + transaction: dbTransaction, + }); + if (!senderWallet) return; + + const currentHeld = parseFloat(senderWallet.heldBalance.toString()); + const releaseAmount = parseFloat(scheduledTransfer.heldAmount.toString()); + await senderWallet.update( + { heldBalance: Math.max(0, currentHeld - releaseAmount) }, + { transaction: dbTransaction } + ); +}; + +/** + * Called by the rolling-hold job for a recurring series whose next occurrence is + * approaching but not yet due. Never changes scheduledFor - a failure here just means + * "try again next hour", up until the occurrence is actually due. + */ +export const attemptHold = async (app: Application, scheduledTransferId: string): Promise => { + const st = await ScheduledTransfer.findByPk(scheduledTransferId); + if (!st || st.status !== "scheduled") return; + + type HoldOutcome = { ok: boolean; message?: string; heldAmount?: number }; + + const outcome: HoldOutcome | null = await sequelize.transaction(async (t: DbTransaction) => { + const locked = await ScheduledTransfer.findByPk(scheduledTransferId, { + lock: t.LOCK.UPDATE, + transaction: t, + }); + if (!locked || locked.status !== "scheduled") return null; + + const holdOutcome: HoldOutcome = await placeHoldForOccurrence(locked, t); + + if (holdOutcome.ok) { + await locked.update( + { + status: "held", + heldAmount: holdOutcome.heldAmount, + heldAt: new Date(), + consecutiveFailureCount: 0, + lastFailureReason: null, + }, + { transaction: t } + ); + } else { + const consecutiveFailureCount = (locked.consecutiveFailureCount || 0) + 1; + const shouldPause = consecutiveFailureCount >= MAX_CONSECUTIVE_FAILURES; + await locked.update( + { + consecutiveFailureCount, + lastFailureReason: holdOutcome.message, + status: shouldPause ? "paused" : "scheduled", + }, + { transaction: t } + ); + } + + return holdOutcome; + }); + + if (!outcome) return; + + const fresh = await ScheduledTransfer.findByPk(scheduledTransferId); + if (!fresh) return; + + if (outcome.ok) { + await notifyScheduledTransferHeld(app, fresh.createdByUserId, fresh.id, fresh.amount, fresh.currency, fresh.scheduledFor); + } else if (fresh.status === "paused") { + await notifyScheduledSeriesAutoPaused(app, fresh.createdByUserId, fresh.id, fresh.lastFailureReason || "Repeated failures reserving funds"); + } else { + await notifyScheduledTransferHoldFailed(app, fresh.createdByUserId, fresh.id, fresh.amount, fresh.currency, outcome.message || "Insufficient funds"); + } +}; + +/** + * 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 + * (one-time / paused-out recurring) or rolls it forward to the next occurrence. + */ +export const finalizeFailedOccurrence = async ( + app: Application, + scheduledTransferId: string, + reason: string +): Promise => { + let resultStatus = ""; + + await sequelize.transaction(async (t: DbTransaction) => { + const st = await ScheduledTransfer.findByPk(scheduledTransferId, { + lock: t.LOCK.UPDATE, + transaction: t, + }); + if (!st || ["completed", "cancelled", "failed"].includes(st.status)) return; + + await releaseHoldForOccurrence(st, t); + + const consecutiveFailureCount = (st.consecutiveFailureCount || 0) + 1; + const isRecurring = !!st.recurrenceRule; + + if (!isRecurring) { + resultStatus = "failed"; + await st.update( + { status: "failed", consecutiveFailureCount, lastFailureReason: reason, heldAmount: null, heldAt: null }, + { transaction: t } + ); + return; + } + + if (consecutiveFailureCount >= MAX_CONSECUTIVE_FAILURES) { + resultStatus = "paused"; + await st.update( + { status: "paused", consecutiveFailureCount, lastFailureReason: reason, heldAmount: null, heldAt: null }, + { transaction: t } + ); + return; + } + + const nextRun = computeNextRun(st.scheduledFor, st.timezone, st.recurrenceRule); + if (isRecurrenceExhausted(st.recurrenceRule, st.occurrenceCount, nextRun)) { + resultStatus = "completed"; + await st.update( + { status: "completed", consecutiveFailureCount, lastFailureReason: reason, heldAmount: null, heldAt: null }, + { transaction: t } + ); + return; + } + + resultStatus = "scheduled"; + await st.update( + { + status: "scheduled", + scheduledFor: nextRun, + consecutiveFailureCount, + lastFailureReason: reason, + heldAmount: null, + heldAt: null, + }, + { transaction: t } + ); + }); + + const fresh = await ScheduledTransfer.findByPk(scheduledTransferId); + if (!fresh) return; + + if (resultStatus === "paused") { + await notifyScheduledSeriesAutoPaused(app, fresh.createdByUserId, fresh.id, reason); + } else { + await notifyScheduledTransferFailed(app, fresh.createdByUserId, fresh.id, fresh.amount, fresh.currency, reason); + } +}; + +/** + * Executes a due, already-held occurrence: moves the money, writes the ledger entry, + * and either closes the series out or rolls it forward. Idempotent via idempotencyKey - + * safe to call again for a row still sitting in 'executing' after a crash, since the + * ledger write and status advance happen in the same DB transaction. + */ +export const executeOccurrence = async (app: Application, scheduledTransferId: string): Promise => { + let outcome: "completed" | "rolled" | "failed" | "skipped" = "skipped"; + let executedTransactionId: string | null = null; + let executedAmount = 0; + let executedCurrency = "RWF"; + let receiverName = ""; + let senderName = ""; + let receiverUserId: string | null = null; + let failureReason: string | null = null; + + try { + await sequelize.transaction(async (t: DbTransaction) => { + const st = await ScheduledTransfer.findByPk(scheduledTransferId, { + lock: t.LOCK.UPDATE, + transaction: t, + }); + if (!st || st.status !== "executing") return; + + const senderWhere = identityWhere(st, "sender"); + const receiverWhere = identityWhere(st, "receiver"); + if (!senderWhere || !receiverWhere) { + failureReason = "Invalid sender or receiver on scheduled transfer"; + return; + } + + const [senderWallet, receiverWallet] = await Promise.all([ + Wallet.findOne({ where: senderWhere, lock: t.LOCK.UPDATE, transaction: t }), + Wallet.findOne({ where: receiverWhere, lock: t.LOCK.UPDATE, transaction: t }), + ]); + + if (!senderWallet) { + failureReason = "Sender wallet not found or inactive"; + return; + } + if (!receiverWallet) { + failureReason = "Receiver wallet not found or inactive"; + return; + } + + const heldAmount = parseFloat((st.heldAmount ?? 0).toString()); + const transferAmount = parseFloat(st.amount.toString()); + const fee = parseFloat((st.fee ?? 0).toString()); + + // Defensive: funds were reserved at hold time, so this should never trip in practice. + if (parseFloat(senderWallet.balance.toString()) < heldAmount) { + failureReason = "Reserved funds are no longer available in the sender wallet"; + return; + } + + const receiverIsUser = !!receiverWallet.userId; + + await senderWallet.update( + { + balance: parseFloat(senderWallet.balance.toString()) - heldAmount, + heldBalance: Math.max(0, parseFloat(senderWallet.heldBalance.toString()) - heldAmount), + }, + { transaction: t } + ); + await receiverWallet.update( + { balance: parseFloat(receiverWallet.balance.toString()) + transferAmount }, + { transaction: t } + ); + + const spendConstraintType = st.applyConstraints && st.categoryId ? "category" : "none"; + const constraintCategoryId = st.applyConstraints && st.categoryId ? st.categoryId : null; + + const newTransaction = await TransactionModel.create( + { + referenceId: st.idempotencyKey, + senderWalletId: senderWallet.id, + receiverWalletId: receiverWallet.id, + amount: transferAmount, + fee, + totalAmount: heldAmount, + currency: st.currency, + status: "completed", + type: st.type, + description: st.description, + categoryId: st.categoryId, + spendConstraintType, + constraintCategoryId, + hasAccount: true, + scheduledTransferId: st.id, + } as any, + { transaction: t } + ); + + if (st.applyConstraints && st.categoryId) { + const existingRestriction = await WalletRestriction.findOne({ + where: { walletId: receiverWallet.id, categoryId: st.categoryId }, + transaction: t, + }); + if (existingRestriction) { + await existingRestriction.update( + { amount: parseFloat(existingRestriction.amount.toString()) + transferAmount }, + { transaction: t } + ); + } else { + await WalletRestriction.create( + { walletId: receiverWallet.id, categoryId: st.categoryId, amount: transferAmount }, + { transaction: t } + ); + } + } else if (st.categoryId && !receiverIsUser) { + const restrictions = await WalletRestriction.findAll({ + where: { walletId: senderWallet.id }, + transaction: t, + }); + const matching = restrictions.find((r: any) => r.categoryId === st.categoryId); + if (matching) { + const restrictedAvailable = parseFloat(matching.amount.toString()); + const reduceBy = Math.min(restrictedAvailable, transferAmount); + const newAmount = restrictedAvailable - reduceBy; + if (newAmount <= 0) { + await matching.destroy({ transaction: t }); + } else { + await matching.update({ amount: newAmount }, { transaction: t }); + } + } + } + + const occurrenceCount = (st.occurrenceCount || 0) + 1; + executedTransactionId = newTransaction.id; + executedAmount = transferAmount; + executedCurrency = st.currency; + receiverUserId = st.receiverUserId; + + if (!st.recurrenceRule) { + outcome = "completed"; + await st.update( + { + status: "completed", + occurrenceCount, + lastExecutedTransactionId: newTransaction.id, + consecutiveFailureCount: 0, + heldAmount: null, + heldAt: null, + }, + { transaction: t } + ); + return; + } + + const nextRun = computeNextRun(st.scheduledFor, st.timezone, st.recurrenceRule); + if (isRecurrenceExhausted(st.recurrenceRule, occurrenceCount, nextRun)) { + outcome = "completed"; + await st.update( + { + status: "completed", + occurrenceCount, + lastExecutedTransactionId: newTransaction.id, + consecutiveFailureCount: 0, + heldAmount: null, + heldAt: null, + }, + { transaction: t } + ); + return; + } + + outcome = "rolled"; + const nextIdempotencyKey = `${st.id}:${occurrenceCount}`; + const withinWindow = nextRun.getTime() - Date.now() <= ROLLING_HOLD_WINDOW_HOURS * 60 * 60 * 1000; + + let nextStatus: "held" | "scheduled" = "scheduled"; + let nextHeldAmount: number | null = null; + let nextHeldAt: Date | null = null; + + if (withinWindow) { + const draft = { ...st.toJSON(), scheduledFor: nextRun }; + const holdResult = await placeHoldForOccurrence(draft, t); + if (holdResult.ok) { + nextStatus = "held"; + nextHeldAmount = holdResult.heldAmount ?? null; + nextHeldAt = new Date(); + } + } + + await st.update( + { + status: nextStatus, + occurrenceCount, + scheduledFor: nextRun, + idempotencyKey: nextIdempotencyKey, + lastExecutedTransactionId: newTransaction.id, + consecutiveFailureCount: 0, + heldAmount: nextHeldAmount, + heldAt: nextHeldAt, + }, + { transaction: t } + ); + }); + } catch (error) { + console.error(`Scheduled transfer execution error (${scheduledTransferId}):`, error); + failureReason = "Internal error while executing the transfer"; + } + + if (failureReason) { + await finalizeFailedOccurrence(app, scheduledTransferId, failureReason); + return; + } + + if (outcome === "skipped" || !executedTransactionId) return; + + const fresh = await ScheduledTransfer.findByPk(scheduledTransferId); + if (!fresh) return; + + senderName = await resolveName({ userId: fresh.senderUserId, organizationId: fresh.senderOrganizationId }); + receiverName = await resolveName({ userId: fresh.receiverUserId, organizationId: fresh.receiverOrganizationId }); + + await notifyScheduledTransferExecuted( + app, + fresh.createdByUserId, + executedTransactionId, + executedAmount, + executedCurrency, + receiverName + ); + + if (receiverUserId) { + await notifyPaymentReceived(app, receiverUserId, executedTransactionId, executedAmount, executedCurrency, senderName); + } +}; diff --git a/src/types/model.ts b/src/types/model.ts index f902da5..6d5753c 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -179,6 +179,7 @@ export interface WalletModelAttributes { id: string; userId: string; balance: number; + heldBalance: number; currency: string; isActive: boolean; createdAt?: Date; @@ -230,6 +231,7 @@ export interface ChatMessageAttributes { | "image" | "file" | "money" + | "escrow" | "audio" | "video" | "document"; @@ -647,6 +649,9 @@ export interface TransactionAttributes { actionPurchaseId?: string; actionId?: string; subActionId?: string; + scheduledTransferId?: string | null; + batchId?: string | null; + escrowId?: string | null; createdAt?: Date; updatedAt?: Date; } @@ -656,6 +661,228 @@ export type TransactionCreationAttributes = Omit< "id" | "createdAt" | "updatedAt" | "fee" >; +export interface ScheduledTransferRecurrenceRule { + frequency: "daily" | "weekly" | "monthly" | "yearly"; + interval: number; + endDate?: string; + maxOccurrences?: number; +} + +export interface ScheduledTransferAttributes { + id: string; + createdByUserId: string; + senderUserId?: string | null; + senderOrganizationId?: string | null; + senderSubActionId?: string | null; + receiverUserId?: string | null; + receiverOrganizationId?: string | null; + receiverWalletId?: string | null; + amount: number; + fee: number; + currency: string; + type: "transfer" | "payment" | "donation" | "vote" | "topup" | "withdrawal"; + description?: string | null; + categoryId?: string | null; + applyConstraints: boolean; + scheduledFor: Date; + timezone: string; + recurrenceRule: ScheduledTransferRecurrenceRule | null; + occurrenceCount: number; + status: + | "scheduled" + | "held" + | "executing" + | "completed" + | "failed" + | "cancelled" + | "paused"; + heldAmount?: number | null; + heldAt?: Date | null; + idempotencyKey: string; + lastExecutedTransactionId?: string | null; + consecutiveFailureCount: number; + lastFailureReason?: string | null; + pinVerifiedAt?: Date | null; + scheduledBatchId?: string | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type ScheduledTransferCreationAttributes = Optional< + ScheduledTransferAttributes, + | "id" + | "fee" + | "currency" + | "type" + | "description" + | "categoryId" + | "applyConstraints" + | "occurrenceCount" + | "status" + | "heldAmount" + | "heldAt" + | "lastExecutedTransactionId" + | "consecutiveFailureCount" + | "lastFailureReason" + | "pinVerifiedAt" + | "scheduledBatchId" + | "createdAt" + | "updatedAt" +>; + +export interface EscrowAttributes { + id: string; + chatId?: string | null; + payerWalletId: string; + payeeWalletId: string; + payerUserId: string; + payeeUserId: string; + amount: number; + currency: string; + description?: string | null; + status: "held" | "released" | "refunded" | "disputed" | "cancelled" | "expired"; + releaseMode: "manual" | "auto_timeout"; + autoReleaseAt?: Date | null; + fundedAt: Date; + fulfilledAt?: Date | null; + releasedAt?: Date | null; + refundedAt?: Date | null; + releaseTransactionId?: string | null; + disputeRaisedBy?: string | null; + disputeReason?: string | null; + disputeRaisedAt?: Date | null; + disputeResponse?: string | null; + disputeRespondedBy?: string | null; + disputeRespondedAt?: Date | null; + resolvedByAdminId?: string | null; + resolutionNote?: string | null; + resolvedAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type EscrowCreationAttributes = Optional< + EscrowAttributes, + | "id" + | "chatId" + | "currency" + | "description" + | "status" + | "releaseMode" + | "autoReleaseAt" + | "fulfilledAt" + | "releasedAt" + | "refundedAt" + | "releaseTransactionId" + | "disputeRaisedBy" + | "disputeReason" + | "disputeRaisedAt" + | "disputeResponse" + | "disputeRespondedBy" + | "disputeRespondedAt" + | "resolvedByAdminId" + | "resolutionNote" + | "resolvedAt" + | "createdAt" + | "updatedAt" +>; + +export interface ScheduledTransferBatchFailureEntry { + receiverUserId?: string | null; + receiverOrganizationId?: string | null; + receiverWalletId?: string | null; + amount: number; + reason: string; +} + +export interface ScheduledTransferBatchAttributes { + id: string; + createdByUserId: string; + senderUserId?: string | null; + senderOrganizationId?: string | null; + senderSubActionId?: string | null; + currency: string; + type: "transfer" | "payment" | "donation" | "vote" | "topup" | "withdrawal"; + description?: string | null; + categoryId?: string | null; + applyConstraints: boolean; + recipientCount: number; + totalRequestedAmount: number; + successCount: number; + failureCount: number; + status: "completed" | "partial" | "failed"; + failures?: ScheduledTransferBatchFailureEntry[] | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type ScheduledTransferBatchCreationAttributes = Optional< + ScheduledTransferBatchAttributes, + | "id" + | "currency" + | "type" + | "description" + | "categoryId" + | "applyConstraints" + | "successCount" + | "failureCount" + | "status" + | "failures" + | "createdAt" + | "updatedAt" +>; + +export interface TransferBatchFailureEntry { + receiverUserId?: string | null; + receiverOrganizationId?: string | null; + receiverWalletId?: string | null; + amount: number; + reason: string; +} + +export interface TransferBatchAttributes { + id: string; + createdByUserId: string; + senderUserId?: string | null; + senderOrganizationId?: string | null; + senderSubActionId?: string | null; + currency: string; + type: "transfer" | "payment" | "donation" | "vote" | "topup" | "withdrawal"; + description?: string | null; + categoryId?: string | null; + applyConstraints: boolean; + idempotencyKey?: string | null; + recipientCount: number; + totalRequestedAmount: number; + totalSentAmount: number; + totalFailedAmount: number; + successCount: number; + failureCount: number; + status: "completed" | "partial" | "failed"; + failures?: TransferBatchFailureEntry[] | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type TransferBatchCreationAttributes = Optional< + TransferBatchAttributes, + | "id" + | "currency" + | "type" + | "description" + | "categoryId" + | "applyConstraints" + | "idempotencyKey" + | "totalSentAmount" + | "totalFailedAmount" + | "successCount" + | "failureCount" + | "status" + | "failures" + | "createdAt" + | "updatedAt" +>; + export interface CategoryAttributes { id: string; name: string; @@ -712,6 +939,7 @@ export interface WalletAttributes { subActionId?: string; publicContributionId?: string; balance: number; + heldBalance: number; currency: string; isActive: boolean; createdAt?: Date; @@ -719,9 +947,10 @@ export interface WalletAttributes { } export type WalletCreationAttributes = Omit< WalletAttributes, - "id" | "currency" | "isActive" | "createdAt" | "updatedAt" + "id" | "currency" | "isActive" | "createdAt" | "updatedAt" | "heldBalance" > & { balance?: number; + heldBalance?: number; }; export interface WalletRestrictionAttributes { diff --git a/src/utils/notificationConfig.ts b/src/utils/notificationConfig.ts index c2a85d5..58cee9a 100644 --- a/src/utils/notificationConfig.ts +++ b/src/utils/notificationConfig.ts @@ -69,7 +69,21 @@ export enum NotificationType { PAYMENT_REQUEST_RECEIVED = "PAYMENT_REQUEST_RECEIVED", PAYMENT_REQUEST_DECLINED = "PAYMENT_REQUEST_DECLINED", PAYMENT_REQUEST_ACCEPTED = "PAYMENT_REQUEST_ACCEPTED", - + BATCH_TRANSFER_COMPLETED = "BATCH_TRANSFER_COMPLETED", + SCHEDULED_BATCH_CREATED = "SCHEDULED_BATCH_CREATED", + + // Scheduled transfer notifications + SCHEDULED_TRANSFER_CREATED = "SCHEDULED_TRANSFER_CREATED", + SCHEDULED_TRANSFER_HELD = "SCHEDULED_TRANSFER_HELD", + SCHEDULED_TRANSFER_HOLD_FAILED = "SCHEDULED_TRANSFER_HOLD_FAILED", + SCHEDULED_TRANSFER_UPCOMING = "SCHEDULED_TRANSFER_UPCOMING", + SCHEDULED_TRANSFER_EXECUTED = "SCHEDULED_TRANSFER_EXECUTED", + SCHEDULED_TRANSFER_FAILED = "SCHEDULED_TRANSFER_FAILED", + SCHEDULED_TRANSFER_CANCELLED = "SCHEDULED_TRANSFER_CANCELLED", + SCHEDULED_TRANSFER_PAUSED = "SCHEDULED_TRANSFER_PAUSED", + SCHEDULED_TRANSFER_RESUMED = "SCHEDULED_TRANSFER_RESUMED", + SCHEDULED_SERIES_AUTO_PAUSED = "SCHEDULED_SERIES_AUTO_PAUSED", + // Wallet-related notifications WALLET_CREATED = "WALLET_CREATED", WALLET_RESTRICTION_ADDED = "WALLET_RESTRICTION_ADDED", @@ -157,7 +171,17 @@ export interface NotificationPayload { currency?: string; transactionType?: string; fee?: number; - + + // Scheduled transfer data + scheduledTransferId?: string; + scheduledFor?: string; + + // Batch transfer data + batchId?: string; + successCount?: number; + failureCount?: number; + recipientCount?: number; + // Contact-related data contactId?: string; contactName?: string; @@ -461,7 +485,57 @@ const notificationConfig = { description: "Your payment request was accepted", priority: "high", }, - + [NotificationType.BATCH_TRANSFER_COMPLETED]: { + description: "A batch transfer to multiple people has finished processing", + priority: "high", + }, + [NotificationType.SCHEDULED_BATCH_CREATED]: { + description: "A batch of scheduled transfers to multiple people has been set up", + priority: "high", + }, + + // Scheduled transfer notifications + [NotificationType.SCHEDULED_TRANSFER_CREATED]: { + description: "A transfer has been scheduled", + priority: "normal", + }, + [NotificationType.SCHEDULED_TRANSFER_HELD]: { + description: "Funds have been reserved for an upcoming scheduled transfer", + priority: "low", + }, + [NotificationType.SCHEDULED_TRANSFER_HOLD_FAILED]: { + description: "Could not reserve funds for an upcoming scheduled transfer", + priority: "high", + }, + [NotificationType.SCHEDULED_TRANSFER_UPCOMING]: { + description: "A scheduled transfer is about to be sent", + priority: "high", + }, + [NotificationType.SCHEDULED_TRANSFER_EXECUTED]: { + description: "A scheduled transfer has been sent", + priority: "high", + }, + [NotificationType.SCHEDULED_TRANSFER_FAILED]: { + description: "A scheduled transfer could not be completed", + priority: "high", + }, + [NotificationType.SCHEDULED_TRANSFER_CANCELLED]: { + description: "A scheduled transfer was cancelled", + priority: "normal", + }, + [NotificationType.SCHEDULED_TRANSFER_PAUSED]: { + description: "A scheduled transfer series was paused", + priority: "normal", + }, + [NotificationType.SCHEDULED_TRANSFER_RESUMED]: { + description: "A scheduled transfer series was resumed", + priority: "normal", + }, + [NotificationType.SCHEDULED_SERIES_AUTO_PAUSED]: { + description: "A recurring transfer was automatically paused after repeated failures", + priority: "critical", + }, + // Wallet notifications [NotificationType.WALLET_CREATED]: { description: "Your wallet has been created", diff --git a/src/utils/notificationHelpers.ts b/src/utils/notificationHelpers.ts index 252df4d..0a3f5f7 100644 --- a/src/utils/notificationHelpers.ts +++ b/src/utils/notificationHelpers.ts @@ -2099,3 +2099,290 @@ export const notifyExternalAccountVerified = async ( }, }); }; + +/* ---------- Scheduled transfer notifications ---------- */ + +export const notifyScheduledTransferCreated = async ( + app: Application, + recipientId: string, + scheduledTransferId: string, + amount: number, + currency: string, + scheduledFor: Date, + isRecurring: boolean +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_TRANSFER_CREATED, + recipientId, + data: { + scheduledTransferId, + amount, + currency, + scheduledFor: scheduledFor.toISOString(), + title: isRecurring ? "Recurring Transfer Scheduled" : "Transfer Scheduled", + message: `${amount} ${currency} will be sent on ${scheduledFor.toLocaleString()}${isRecurring ? ", repeating" : ""}. Funds have been reserved.`, + url: `/scheduled-transfers/${scheduledTransferId}`, + }, + }); +}; + +export const notifyScheduledTransferHeld = async ( + app: Application, + recipientId: string, + scheduledTransferId: string, + amount: number, + currency: string, + scheduledFor: Date +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_TRANSFER_HELD, + recipientId, + data: { + scheduledTransferId, + amount, + currency, + scheduledFor: scheduledFor.toISOString(), + title: "Funds Reserved", + message: `${amount} ${currency} has been reserved for your transfer on ${scheduledFor.toLocaleString()}`, + url: `/scheduled-transfers/${scheduledTransferId}`, + }, + }); +}; + +export const notifyScheduledTransferHoldFailed = async ( + app: Application, + recipientId: string, + scheduledTransferId: string, + amount: number, + currency: string, + reason: string +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_TRANSFER_HOLD_FAILED, + recipientId, + data: { + scheduledTransferId, + amount, + currency, + reason, + title: "Couldn't Reserve Funds Yet", + message: `We couldn't reserve ${amount} ${currency} for your upcoming scheduled transfer: ${reason}. We'll keep trying.`, + url: `/scheduled-transfers/${scheduledTransferId}`, + }, + }); +}; + +export const notifyScheduledTransferUpcoming = async ( + app: Application, + recipientId: string, + scheduledTransferId: string, + amount: number, + currency: string, + scheduledFor: Date, + receiverName: string +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_TRANSFER_UPCOMING, + recipientId, + data: { + scheduledTransferId, + amount, + currency, + scheduledFor: scheduledFor.toISOString(), + userName: receiverName, + title: "Upcoming Scheduled Transfer", + message: `${amount} ${currency} will be sent to ${receiverName} on ${scheduledFor.toLocaleString()}`, + url: `/scheduled-transfers/${scheduledTransferId}`, + actions: [ + { + type: "cancel", + label: "Cancel", + url: `/scheduled-transfers/${scheduledTransferId}/cancel`, + }, + ], + }, + }); +}; + +export const notifyScheduledTransferExecuted = async ( + app: Application, + recipientId: string, + transactionId: string, + amount: number, + currency: string, + receiverName: string +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_TRANSFER_EXECUTED, + recipientId, + data: { + transactionId, + amount, + currency, + userName: receiverName, + title: "Scheduled Transfer Sent", + message: `Your scheduled transfer of ${amount} ${currency} to ${receiverName} has been sent`, + url: `/transactions/${transactionId}`, + }, + }); +}; + +export const notifyScheduledTransferFailed = async ( + app: Application, + recipientId: string, + scheduledTransferId: string, + amount: number, + currency: string, + reason: string +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_TRANSFER_FAILED, + recipientId, + data: { + scheduledTransferId, + amount, + currency, + reason, + title: "Scheduled Transfer Failed", + message: `Your scheduled transfer of ${amount} ${currency} could not be completed: ${reason}`, + url: `/scheduled-transfers/${scheduledTransferId}`, + }, + }); +}; + +export const notifyScheduledTransferCancelled = async ( + app: Application, + recipientId: string, + scheduledTransferId: string, + amount: number, + currency: string +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_TRANSFER_CANCELLED, + recipientId, + data: { + scheduledTransferId, + amount, + currency, + title: "Scheduled Transfer Cancelled", + message: `Your scheduled transfer of ${amount} ${currency} has been cancelled and reserved funds released`, + url: `/scheduled-transfers/${scheduledTransferId}`, + }, + }); +}; + +export const notifyScheduledTransferPaused = async ( + app: Application, + recipientId: string, + scheduledTransferId: string +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_TRANSFER_PAUSED, + recipientId, + data: { + scheduledTransferId, + title: "Scheduled Transfer Paused", + message: `Your recurring transfer has been paused. No further occurrences will be sent until you resume it.`, + url: `/scheduled-transfers/${scheduledTransferId}`, + }, + }); +}; + +export const notifyScheduledTransferResumed = async ( + app: Application, + recipientId: string, + scheduledTransferId: string +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_TRANSFER_RESUMED, + recipientId, + data: { + scheduledTransferId, + title: "Scheduled Transfer Resumed", + message: `Your recurring transfer has been resumed`, + url: `/scheduled-transfers/${scheduledTransferId}`, + }, + }); +}; + +export const notifyScheduledSeriesAutoPaused = async ( + app: Application, + recipientId: string, + scheduledTransferId: string, + reason: string +) => { + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_SERIES_AUTO_PAUSED, + recipientId, + data: { + scheduledTransferId, + reason, + title: "Recurring Transfer Paused", + message: `Your recurring transfer was automatically paused after repeated failures: ${reason}. Resume it once you've topped up.`, + url: `/scheduled-transfers/${scheduledTransferId}`, + }, + }); +}; + +export const notifyScheduledBatchCreated = async ( + app: Application, + recipientId: string, + batchId: string, + successCount: number, + failureCount: number, + scheduledFor: Date, + isRecurring: boolean +) => { + const recipientCount = successCount + failureCount; + const message = + failureCount > 0 + ? `Scheduled for ${successCount} of ${recipientCount} people on ${scheduledFor.toLocaleString()}${isRecurring ? ", repeating" : ""}. ${failureCount} could not be scheduled.` + : `Scheduled for ${successCount} ${successCount === 1 ? "person" : "people"} on ${scheduledFor.toLocaleString()}${isRecurring ? ", repeating" : ""}. Funds have been reserved.`; + + return createAndSendNotification(app, { + type: NotificationType.SCHEDULED_BATCH_CREATED, + recipientId, + data: { + batchId, + successCount, + failureCount, + recipientCount, + scheduledFor: scheduledFor.toISOString(), + title: failureCount > 0 ? "Scheduled Batch Partially Set Up" : "Scheduled Batch Created", + message, + url: `/scheduled-transfers/batches/${batchId}`, + }, + }); +}; + +export const notifyBatchTransferCompleted = async ( + app: Application, + recipientId: string, + batchId: string, + successCount: number, + failureCount: number, + totalSent: number, + currency: string +) => { + const recipientCount = successCount + failureCount; + const message = + failureCount > 0 + ? `Sent ${totalSent} ${currency} to ${successCount} of ${recipientCount} people. ${failureCount} failed.` + : `Sent ${totalSent} ${currency} to ${successCount} ${successCount === 1 ? "person" : "people"}`; + + return createAndSendNotification(app, { + type: NotificationType.BATCH_TRANSFER_COMPLETED, + recipientId, + data: { + batchId, + successCount, + failureCount, + recipientCount, + amount: totalSent, + currency, + title: failureCount > 0 ? "Batch Transfer Partially Completed" : "Batch Transfer Completed", + message, + url: `/transfer/batches/${batchId}`, + }, + }); +}; diff --git a/src/utils/recurrence.ts b/src/utils/recurrence.ts new file mode 100644 index 0000000..a6fd9ad --- /dev/null +++ b/src/utils/recurrence.ts @@ -0,0 +1,95 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import { ScheduledTransferRecurrenceRule } from "../types/model"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const SUPPORTED_FREQUENCIES = ["daily", "weekly", "monthly", "yearly"]; + +export const validateRecurrenceRule = ( + rule: any +): { valid: boolean; error?: string } => { + if (!rule) return { valid: true }; + + if (!SUPPORTED_FREQUENCIES.includes(rule.frequency)) { + return { + valid: false, + error: `Recurrence frequency must be one of: ${SUPPORTED_FREQUENCIES.join(", ")}`, + }; + } + + if (rule.interval !== undefined && (!Number.isInteger(rule.interval) || rule.interval < 1)) { + return { valid: false, error: "Recurrence interval must be a positive integer" }; + } + + if (rule.maxOccurrences !== undefined && (!Number.isInteger(rule.maxOccurrences) || rule.maxOccurrences < 1)) { + return { valid: false, error: "maxOccurrences must be a positive integer" }; + } + + if (rule.endDate !== undefined && isNaN(new Date(rule.endDate).getTime())) { + return { valid: false, error: "endDate must be a valid date" }; + } + + return { valid: true }; +}; + +/** + * Computes the next occurrence for a recurring transfer, in the series' own timezone, + * so DST shifts and month lengths don't drift the intended calendar date. + * Month/year overflow (e.g. Jan 31 -> Feb) is clamped to the last valid day of the target month, + * rather than rolling over into the following month. + */ +export const computeNextRun = ( + current: Date, + tz: string, + rule: ScheduledTransferRecurrenceRule +): Date => { + const interval = Math.max(1, rule.interval || 1); + const zoned = dayjs(current).tz(tz); + + switch (rule.frequency) { + case "daily": + return zoned.add(interval, "day").toDate(); + + case "weekly": + return zoned.add(interval * 7, "day").toDate(); + + case "monthly": { + const originalDay = zoned.date(); + // Move months while pinned to day 1 to avoid native month-overflow (e.g. Jan 31 + 1mo -> Mar 3), + // then clamp back onto the target month's real last day. + const target = zoned.date(1).add(interval, "month"); + const daysInTarget = target.daysInMonth(); + return target.date(Math.min(originalDay, daysInTarget)).toDate(); + } + + case "yearly": { + const originalDay = zoned.date(); + const target = zoned.date(1).add(interval, "year"); + const daysInTarget = target.daysInMonth(); + return target.date(Math.min(originalDay, daysInTarget)).toDate(); + } + + default: + throw new Error(`Unsupported recurrence frequency: ${rule.frequency}`); + } +}; + +/** + * Whether a recurring series should stop after the given occurrence, based on its own end rule. + */ +export const isRecurrenceExhausted = ( + rule: ScheduledTransferRecurrenceRule, + occurrenceCountAfterThisRun: number, + nextRun: Date +): boolean => { + if (rule.maxOccurrences && occurrenceCountAfterThisRun >= rule.maxOccurrences) { + return true; + } + if (rule.endDate && nextRun.getTime() > new Date(rule.endDate).getTime()) { + return true; + } + return false; +}; diff --git a/src/utils/transferValidation.ts b/src/utils/transferValidation.ts new file mode 100644 index 0000000..c5702b0 --- /dev/null +++ b/src/utils/transferValidation.ts @@ -0,0 +1,108 @@ +// Shared, side-effect-free helpers for validating a wallet-to-wallet transfer. +// Used by both the live transfer endpoint's fund-hold equivalent and scheduled transfers, +// so the "can this wallet afford this send" rule only lives in one place. + +export interface WalletRestrictionLike { + categoryId: string; + amount: number | string; + category?: { name?: string }; +} + +export interface FundsAvailabilityResult { + ok: boolean; + message?: string; + availableUnrestrictedAmount: number; + restrictedAvailable?: number; + allowedCategories?: { + categoryId: string; + categoryName?: string; + availableAmount: number; + }[]; +} + +export const validateFundsAvailability = (params: { + availableBalance: number; + restrictions: WalletRestrictionLike[]; + transferAmount: number; + receiverIsUser: boolean; + categoryId?: string | null; +}): FundsAvailabilityResult => { + const { availableBalance, restrictions, transferAmount, receiverIsUser, categoryId } = params; + + const totalRestrictedAmount = restrictions.reduce( + (sum, r) => sum + parseFloat(r.amount.toString()), + 0 + ); + const availableUnrestrictedAmount = Math.max(0, availableBalance - totalRestrictedAmount); + + const allowedCategories = () => + restrictions.map((r) => ({ + categoryId: r.categoryId, + categoryName: r.category?.name, + availableAmount: parseFloat(r.amount.toString()), + })); + + // Sending to an individual user can only draw on unrestricted funds. + if (receiverIsUser) { + if (availableUnrestrictedAmount < transferAmount) { + return { + ok: false, + message: `Insufficient unrestricted balance for transfer to a user. Available: ${availableUnrestrictedAmount}, Required: ${transferAmount}`, + availableUnrestrictedAmount, + }; + } + return { ok: true, availableUnrestrictedAmount }; + } + + if (categoryId) { + const matching = restrictions.find((r) => r.categoryId === categoryId); + if (matching) { + const restrictedAvailable = parseFloat(matching.amount.toString()); + if (restrictedAvailable < transferAmount) { + const remainderNeeded = transferAmount - restrictedAvailable; + if (availableUnrestrictedAmount < remainderNeeded) { + return { + ok: false, + message: `Insufficient funds. Restricted available: ${restrictedAvailable}, Unrestricted available: ${availableUnrestrictedAmount}, Required: ${transferAmount}`, + availableUnrestrictedAmount, + restrictedAvailable, + }; + } + } + return { ok: true, availableUnrestrictedAmount, restrictedAvailable }; + } + + if (availableUnrestrictedAmount < transferAmount) { + return { + ok: false, + message: `Insufficient unrestricted balance. Available: ${availableUnrestrictedAmount}, Required: ${transferAmount}`, + availableUnrestrictedAmount, + allowedCategories: allowedCategories(), + }; + } + return { ok: true, availableUnrestrictedAmount }; + } + + if (availableUnrestrictedAmount < transferAmount) { + return { + ok: false, + message: `Insufficient unrestricted balance. Available: ${availableUnrestrictedAmount}, Required: ${transferAmount}`, + availableUnrestrictedAmount, + allowedCategories: allowedCategories(), + }; + } + return { ok: true, availableUnrestrictedAmount }; +}; + +export const resolveWalletWhere = (identity: { + userId?: string | null; + organizationId?: string | null; + subActionId?: string | null; + walletId?: string | null; +}): Record | null => { + if (identity.userId) return { userId: identity.userId, isActive: true }; + if (identity.organizationId) return { organizationId: identity.organizationId, isActive: true }; + if (identity.subActionId) return { subActionId: identity.subActionId, isActive: true }; + if (identity.walletId) return { id: identity.walletId, isActive: true }; + return null; +}; diff --git a/src/utils/walletBalance.ts b/src/utils/walletBalance.ts new file mode 100644 index 0000000..674a23a --- /dev/null +++ b/src/utils/walletBalance.ts @@ -0,0 +1,13 @@ +// Spendable balance always excludes funds reserved by scheduled transfers. +// Never read wallet.balance directly for a "can this be spent" check. + +interface WalletBalanceLike { + balance: number | string; + heldBalance: number | string; +} + +export const getAvailableBalance = (wallet: WalletBalanceLike): number => { + const balance = parseFloat(wallet.balance.toString()); + const held = parseFloat((wallet.heldBalance ?? 0).toString()); + return Math.max(0, balance - held); +};