From 01468107c502d676b09395fefd17cd764c254d5a Mon Sep 17 00:00:00 2001 From: Ahbiz Date: Thu, 23 Jul 2026 21:50:26 +0100 Subject: [PATCH 1/3] implemented non-custodial refund and dispute flow (#62) --- .gitignore | 62 +-- src/controllers/stellar/refundController.js | 510 ++++++++++++++++++++ src/middlewares/authMiddleware.js | 12 + src/models/Refund.js | 103 ++++ src/models/Transaction.js | 9 +- src/models/User.js | 2 +- src/routes/stellar/paymentRoutes.js | 22 +- src/services/stellar/stellarService.js | 44 ++ test/refund.test.js | 375 ++++++++++++++ 9 files changed, 1109 insertions(+), 30 deletions(-) create mode 100644 src/controllers/stellar/refundController.js create mode 100644 src/models/Refund.js create mode 100644 test/refund.test.js diff --git a/.gitignore b/.gitignore index 1fd22ef..93a66d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,35 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Dependency directories -node_modules/ -jspm_packages/ - -# Environment variables -.env - -# Build output -dist/ -build/ - -# IDE files -.vscode/ -.DS_Store \ No newline at end of file +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Dependency directories +node_modules/ +jspm_packages/ + +# Environment variables +.env + +# Build output +dist/ +build/ + +# IDE files +.vscode/ +.DS_Store + +# Spec & Agent temporary docs +Tasks.md +Memory.md +Handoff.md +PRD.md +Architecture.md +scratch/ \ No newline at end of file diff --git a/src/controllers/stellar/refundController.js b/src/controllers/stellar/refundController.js new file mode 100644 index 0000000..4360e06 --- /dev/null +++ b/src/controllers/stellar/refundController.js @@ -0,0 +1,510 @@ +// controllers/stellar/refundController.js +import mongoose from "mongoose"; +import User from "../../models/User.js"; +import Book from "../../models/Book.js"; +import Course from "../../models/Course.js"; +import Transaction from "../../models/Transaction.js"; +import Refund from "../../models/Refund.js"; +import { + buildReversePaymentTransaction, + submitTransaction, + verifyTransaction, +} from "../../services/stellar/stellarService.js"; +import logger from "../../config/logger.js"; + +const REFUND_WINDOW_DAYS = parseInt(process.env.REFUND_WINDOW_DAYS || "14", 10); + +/** + * Buyer requests a refund + * POST /api/stellar/payment/transactions/:id/refund-request + */ +export const requestRefund = async (req, res) => { + try { + const { id: transactionId } = req.params; + const { reason } = req.body; + const buyerId = req.user._id; + + if (!reason || typeof reason !== "string" || !reason.trim()) { + return res.status(400).json({ + success: false, + message: "A valid refund reason is required", + }); + } + + // Find original transaction + const transaction = await Transaction.findById(transactionId); + if (!transaction) { + return res.status(404).json({ + success: false, + message: "Transaction not found", + }); + } + + // Guard: Only the original buyer + if (transaction.buyer.toString() !== buyerId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: You are not the purchaser of this item", + }); + } + + // Guard: Only confirmed transactions + if (transaction.status !== "confirmed") { + return res.status(400).json({ + success: false, + message: `Cannot request refund for transaction in '${transaction.status}' status`, + }); + } + + // Guard: Within configurable refund window + const confirmedTime = new Date( + transaction.confirmedAt || transaction.updatedAt + ).getTime(); + const windowMs = REFUND_WINDOW_DAYS * 24 * 60 * 60 * 1000; + if (Date.now() - confirmedTime > windowMs) { + return res.status(400).json({ + success: false, + message: `Refund window of ${REFUND_WINDOW_DAYS} days has expired for this transaction`, + }); + } + + // Guard: Idempotency - check for existing open/active refund request + const existingRefund = await Refund.findOne({ + originalTransaction: transaction._id, + status: { $in: ["requested", "approved", "submitted", "confirmed", "disputed"] }, + }); + + if (existingRefund) { + return res.status(400).json({ + success: false, + message: "An active or completed refund request already exists for this transaction", + refundId: existingRefund._id, + }); + } + + // Create refund request + const refund = await Refund.create({ + originalTransaction: transaction._id, + buyer: transaction.buyer, + educator: transaction.creator, + itemType: transaction.itemType, + itemId: transaction.itemId, + amount: transaction.amount, + currency: transaction.currency || "USDC", + reason: reason.trim(), + status: "requested", + expiresAt: new Date(Date.now() + windowMs), + }); + + // Cross-link on transaction + transaction.refund = refund._id; + await transaction.save(); + + logger.info(`Refund requested for transaction ${transaction._id} by buyer ${buyerId}`); + + return res.status(201).json({ + success: true, + message: "Refund request submitted successfully", + refund, + }); + } catch (error) { + logger.error("Error requesting refund:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to request refund", + }); + } +}; + +/** + * Educator approves refund & builds reverse payment XDR + * POST /api/stellar/payment/refunds/:refundId/build + */ +export const buildRefundXdr = async (req, res) => { + try { + const { refundId } = req.params; + const educatorId = req.user._id; + + const refund = await Refund.findById(refundId).populate("originalTransaction"); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + // Guard: Only the educator + if (refund.educator.toString() !== educatorId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: Only the educator can approve this refund", + }); + } + + // Guard: Status must be requested + if (refund.status !== "requested") { + return res.status(400).json({ + success: false, + message: `Cannot build reverse payment for refund in '${refund.status}' status`, + }); + } + + // Fetch buyer & educator wallet info + const buyer = await User.findById(refund.buyer); + const educator = await User.findById(refund.educator); + + const buyerWallet = buyer?.stellarWallet?.publicKey; + const educatorWallet = educator?.stellarWallet?.publicKey; + + if (!buyerWallet || !educatorWallet) { + return res.status(400).json({ + success: false, + message: "Missing wallet information for buyer or educator", + }); + } + + const originalTxHash = refund.originalTransaction?.stellarTxHash || ""; + + // Build reverse payment transaction (educator -> buyer) + const result = await buildReversePaymentTransaction({ + sourcePublicKey: educatorWallet, + destinationPublicKey: buyerWallet, + amount: refund.amount, + originalTxHash, + }); + + refund.status = "approved"; + await refund.save(); + + logger.info(`Reverse payment XDR built for refund ${refund._id} by educator ${educatorId}`); + + return res.status(200).json({ + success: true, + message: "Unsigned reverse payment XDR built successfully", + refund, + unsignedXdr: result.xdr, + networkPassphrase: result.networkPassphrase, + }); + } catch (error) { + logger.error("Error building refund XDR:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to build refund XDR", + }); + } +}; + +/** + * Educator submits signed reverse payment XDR & triggers atomic revocation + * POST /api/stellar/payment/refunds/:refundId/submit + */ +export const submitRefund = async (req, res) => { + try { + const { refundId } = req.params; + const { signedXdr } = req.body; + const educatorId = req.user._id; + + if (!signedXdr) { + return res.status(400).json({ + success: false, + message: "signedXdr is required", + }); + } + + const refund = await Refund.findById(refundId); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + // Guard: Only the educator + if (refund.educator.toString() !== educatorId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: Only the educator can submit this refund", + }); + } + + // Guard: Enforce transition order (must be approved first) + if (refund.status !== "approved") { + return res.status(400).json({ + success: false, + message: `Cannot submit refund in '${refund.status}' status. Must be 'approved' first.`, + }); + } + + // Submit transaction to Stellar network + let submissionResult; + try { + submissionResult = await submitTransaction(signedXdr); + } catch (submitErr) { + refund.status = "failed"; + await refund.save(); + return res.status(400).json({ + success: false, + message: `Stellar transaction failed: ${submitErr.message}`, + }); + } + + // On-Chain Truth Verification via Horizon + const verification = await verifyTransaction(submissionResult.hash); + if (!verification.exists || !verification.successful) { + refund.status = "failed"; + await refund.save(); + return res.status(400).json({ + success: false, + message: "Reverse payment transaction could not be verified on Horizon", + }); + } + + // Atomic Access Revocation via Mongoose Session Transaction + let session = null; + try { + session = await mongoose.startSession(); + session.startTransaction(); + } catch (sErr) { + session = null; + } + + const sessionOpts = session ? { session } : {}; + + try { + const buyer = await User.findById(refund.buyer); + + if (refund.itemType === "course") { + // Remove course from buyer's purchased list + if (buyer) { + buyer.purchasedCourses = (buyer.purchasedCourses || []).filter( + (cId) => cId.toString() !== refund.itemId.toString() + ); + await buyer.save(sessionOpts); + } + + // Remove buyer from Course.enrolledUsers + const course = await Course.findById(refund.itemId); + if (course) { + course.enrolledUsers = (course.enrolledUsers || []).filter( + (uId) => uId.toString() !== refund.buyer.toString() + ); + await course.save(sessionOpts); + } + } else if (refund.itemType === "book") { + // Remove book from buyer's purchased list + if (buyer) { + buyer.purchasedBooks = (buyer.purchasedBooks || []).filter( + (bId) => bId.toString() !== refund.itemId.toString() + ); + await buyer.save(sessionOpts); + } + } + + // Update refund & transaction status atomically + refund.status = "confirmed"; + refund.refundTxHash = submissionResult.hash; + refund.refundLedger = submissionResult.ledger; + await refund.save(sessionOpts); + + await Transaction.findByIdAndUpdate( + refund.originalTransaction, + { status: "refunded", refund: refund._id }, + sessionOpts + ); + + if (session) { + await session.commitTransaction(); + } + logger.info(`Refund confirmed and access revoked atomically for refund ${refund._id}`); + } catch (atomicErr) { + if (session) { + await session.abortTransaction(); + } + logger.error("Error during atomic access revocation:", atomicErr); + throw atomicErr; + } finally { + if (session) { + session.endSession(); + } + } + + return res.status(200).json({ + success: true, + message: "Refund confirmed on-chain and item access revoked successfully", + refund, + txHash: submissionResult.hash, + }); + } catch (error) { + logger.error("Error submitting refund:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to submit refund", + }); + } +}; + +/** + * Educator rejects a refund request + * POST /api/stellar/payment/refunds/:refundId/reject + */ +export const rejectRefund = async (req, res) => { + try { + const { refundId } = req.params; + const { rejectionReason } = req.body; + const educatorId = req.user._id; + + const refund = await Refund.findById(refundId); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + if (refund.educator.toString() !== educatorId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: Only the educator can reject this refund", + }); + } + + if (refund.status !== "requested") { + return res.status(400).json({ + success: false, + message: `Cannot reject refund in '${refund.status}' status`, + }); + } + + refund.status = "rejected"; + refund.rejectionReason = rejectionReason || "Refund request rejected by educator"; + await refund.save(); + + logger.info(`Refund ${refund._id} rejected by educator ${educatorId}`); + + return res.status(200).json({ + success: true, + message: "Refund request rejected", + refund, + }); + } catch (error) { + logger.error("Error rejecting refund:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to reject refund", + }); + } +}; + +/** + * Buyer escalates refund to dispute + * POST /api/stellar/payment/refunds/:refundId/dispute + */ +export const escalateDispute = async (req, res) => { + try { + const { refundId } = req.params; + const buyerId = req.user._id; + + const refund = await Refund.findById(refundId); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + if (refund.buyer.toString() !== buyerId.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: Only the buyer can escalate this dispute", + }); + } + + if (!["requested", "rejected"].includes(refund.status)) { + return res.status(400).json({ + success: false, + message: `Cannot escalate refund in '${refund.status}' status`, + }); + } + + refund.status = "disputed"; + await refund.save(); + + await Transaction.findByIdAndUpdate(refund.originalTransaction, { + status: "disputed", + }); + + logger.info(`Refund ${refund._id} escalated to dispute by buyer ${buyerId}`); + + return res.status(200).json({ + success: true, + message: "Refund request escalated to dispute for admin review", + refund, + }); + } catch (error) { + logger.error("Error escalating dispute:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to escalate dispute", + }); + } +}; + +/** + * Admin / Arbiter resolves a dispute + * PATCH /api/stellar/payment/refunds/:refundId/arbitrate + */ +export const arbitrateDispute = async (req, res) => { + try { + const { refundId } = req.params; + const { decision, notes } = req.body; + const adminId = req.user._id; + + if (!["approved", "rejected", "off_chain_resolved"].includes(decision)) { + return res.status(400).json({ + success: false, + message: "Invalid decision. Must be 'approved', 'rejected', or 'off_chain_resolved'", + }); + } + + const refund = await Refund.findById(refundId); + if (!refund) { + return res.status(404).json({ + success: false, + message: "Refund request not found", + }); + } + + if (refund.status !== "disputed") { + return res.status(400).json({ + success: false, + message: `Cannot arbitrate refund in '${refund.status}' status. Must be 'disputed'`, + }); + } + + refund.resolution = { + decision, + notes: notes || "", + resolvedBy: adminId, + resolvedAt: new Date(), + }; + refund.status = "resolved"; + await refund.save(); + + logger.info(`Dispute for refund ${refund._id} arbitrated by admin ${adminId}`); + + return res.status(200).json({ + success: true, + message: + "Dispute resolution recorded successfully. Note: DeenBridge is a non-custodial platform; on-chain funds transfers require the creator's wallet signature.", + refund, + disclaimer: + "Non-custodial Limitation: The platform wallet does not hold buyer or creator funds and cannot unilaterally move Stellar assets on-chain without the educator's signed transaction.", + }); + } catch (error) { + logger.error("Error arbitrating dispute:", error); + return res.status(500).json({ + success: false, + message: error.message || "Failed to arbitrate dispute", + }); + } +}; diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js index dec7702..e8be3c5 100644 --- a/src/middlewares/authMiddleware.js +++ b/src/middlewares/authMiddleware.js @@ -40,3 +40,15 @@ export const protect = async (req, res, next) => { .json({ success: false, message: "No token, authorization denied" }); } }; + +export const authorizeRoles = (...roles) => { + return (req, res, next) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ + success: false, + message: `Forbidden: Access requires one of the following roles: ${roles.join(", ")}`, + }); + } + next(); + }; +}; diff --git a/src/models/Refund.js b/src/models/Refund.js new file mode 100644 index 0000000..ab5e5f1 --- /dev/null +++ b/src/models/Refund.js @@ -0,0 +1,103 @@ +// models/Refund.js +import mongoose from "mongoose"; + +const refundSchema = new mongoose.Schema( + { + originalTransaction: { + type: mongoose.Schema.Types.ObjectId, + ref: "Transaction", + required: true, + }, + buyer: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + educator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + itemType: { + type: String, + enum: ["book", "course"], + required: true, + }, + itemId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + }, + amount: { + type: String, + required: true, + }, + currency: { + type: String, + default: "USDC", + }, + reason: { + type: String, + required: [true, "Refund reason is required"], + trim: true, + }, + status: { + type: String, + enum: [ + "requested", + "approved", + "submitted", + "confirmed", + "rejected", + "failed", + "disputed", + "resolved", + ], + default: "requested", + index: true, + }, + refundTxHash: { + type: String, + default: null, + index: true, + }, + refundLedger: { + type: Number, + default: null, + }, + rejectionReason: { + type: String, + default: null, + }, + resolution: { + decision: { + type: String, + enum: ["approved", "rejected", "off_chain_resolved"], + }, + notes: String, + resolvedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + resolvedAt: Date, + }, + expiresAt: { + type: Date, + }, + }, + { timestamps: true } +); + +// Ensure one active refund request per original transaction +refundSchema.index( + { originalTransaction: 1 }, + { + unique: true, + partialFilterExpression: { + status: { $in: ["requested", "approved", "submitted", "disputed"] }, + }, + } +); + +export default mongoose.model("Refund", refundSchema); diff --git a/src/models/Transaction.js b/src/models/Transaction.js index 4652c75..45b67c0 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -110,11 +110,18 @@ const transactionSchema = new mongoose.Schema( // Status tracking status: { type: String, - enum: ["pending", "submitted", "confirmed", "failed", "expired"], + enum: ["pending", "submitted", "confirmed", "failed", "expired", "refunded", "disputed"], default: "pending", index: true, }, + // Refund linkage + refund: { + type: mongoose.Schema.Types.ObjectId, + ref: "Refund", + default: null, + }, + // Error handling failureReason: { type: String, diff --git a/src/models/User.js b/src/models/User.js index fec9b43..60cc8a4 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -41,7 +41,7 @@ const userSchema = new mongoose.Schema( }, role: { type: String, - enum: ["student", "tutor"], + enum: ["student", "tutor", "admin", "arbiter"], default: "student", }, isActive: { diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js index 554889c..f89ea2e 100644 --- a/src/routes/stellar/paymentRoutes.js +++ b/src/routes/stellar/paymentRoutes.js @@ -1,6 +1,6 @@ // routes/stellar/paymentRoutes.js import express from "express"; -import { protect } from "../../middlewares/authMiddleware.js"; +import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; import { initializePayment, submitPayment, @@ -8,6 +8,14 @@ import { getTransaction, cancelTransaction, } from "../../controllers/stellar/paymentController.js"; +import { + requestRefund, + buildRefundXdr, + submitRefund, + rejectRefund, + escalateDispute, + arbitrateDispute, +} from "../../controllers/stellar/refundController.js"; const router = express.Router(); @@ -23,4 +31,16 @@ router.get("/transactions", getTransactionHistory); router.get("/transactions/:transactionId", getTransaction); router.delete("/transactions/:transactionId", cancelTransaction); +// Refund & Dispute flow +router.post("/transactions/:id/refund-request", requestRefund); +router.post("/refunds/:refundId/build", buildRefundXdr); +router.post("/refunds/:refundId/submit", submitRefund); +router.post("/refunds/:refundId/reject", rejectRefund); +router.post("/refunds/:refundId/dispute", escalateDispute); +router.patch( + "/refunds/:refundId/arbitrate", + authorizeRoles("admin", "arbiter"), + arbitrateDispute +); + export default router; diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index 8daa79c..dd9d891 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -223,6 +223,50 @@ export const buildPaymentTransaction = async ({ } }; +export const buildReversePaymentTransaction = async ({ + sourcePublicKey, + destinationPublicKey, + amount, + originalTxHash, +}) => { + try { + const sourceAccount = await timedHorizonCall("loadAccount", () => + server.loadAccount(sourcePublicKey) + ); + + const builder = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + + builder.addOperation( + StellarSdk.Operation.payment({ + destination: destinationPublicKey, + asset: USDC, + amount: amount.toString(), + }) + ); + + const memoText = originalTxHash + ? `RFND:${originalTxHash.slice(0, 20)}` + : "DeenBridge Refund"; + + const transaction = builder + .addMemo(StellarSdk.Memo.text(memoText)) + .setTimeout(300) + .build(); + + return { + xdr: transaction.toXDR(), + hash: transaction.hash().toString("hex"), + networkPassphrase, + }; + } catch (error) { + logger.error("Error building reverse payment transaction:", error); + throw error; + } +}; + export const submitTransaction = async (signedXdr) => { try { const transaction = StellarSdk.TransactionBuilder.fromXDR( diff --git a/test/refund.test.js b/test/refund.test.js new file mode 100644 index 0000000..bb817e3 --- /dev/null +++ b/test/refund.test.js @@ -0,0 +1,375 @@ +// test/refund.test.js +import "dotenv/config"; +import dns from "node:dns"; +dns.setServers(["8.8.8.8", "8.8.4.4"]); + +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import jwt from "jsonwebtoken"; +import mongoose from "mongoose"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import Course from "../src/models/Course.js"; +import Transaction from "../src/models/Transaction.js"; +import Refund from "../src/models/Refund.js"; +import paymentRoutes from "../src/routes/stellar/paymentRoutes.js"; +import { server } from "../src/services/stellar/stellarService.js"; + +jest.setTimeout(60000); + +const app = express(); +app.use(express.json()); +app.use("/api/stellar/payment", paymentRoutes); + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; + +const generateToken = (userId, role = "student") => { + return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" }); +}; + +describe("Non-Custodial Refund & Dispute Flow (#62)", () => { + let buyer, educator, otherUser, adminUser; + let buyerToken, educatorToken, otherToken, adminToken; + let confirmedTx; + let course; + + beforeAll(async () => { + const uri = process.env.MONGO_URI || "mongodb://127.0.0.1:27017/dnb-backend-test"; + + if (mongoose.connection.readyState === 0) { + await mongoose.connect(uri); + } + + // Mock Horizon Server + jest.spyOn(server, "loadAccount").mockImplementation(async (publicKey) => { + const acc = new StellarSdk.Account(publicKey, "1000"); + acc.balances = [{ asset_type: "native", balance: "100" }]; + return acc; + }); + + jest.spyOn(server, "submitTransaction").mockImplementation(async () => ({ + hash: "mock_reverse_tx_hash_12345", + ledger: 998877, + successful: true, + })); + + jest.spyOn(server, "transactions").mockImplementation(() => ({ + transaction: () => ({ + call: async () => ({ + successful: true, + ledger: 998877, + created_at: new Date().toISOString(), + }), + }), + })); + + jest.spyOn(server, "operations").mockImplementation(() => ({ + forTransaction: () => ({ + call: async () => ({ + records: [], + }), + }), + })); + }); + + afterAll(async () => { + jest.restoreAllMocks(); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Course.deleteMany({}); + await Book.deleteMany({}); + await Transaction.deleteMany({}); + await Refund.deleteMany({}); + + const buyerWallet = StellarSdk.Keypair.random().publicKey(); + const educatorWallet = StellarSdk.Keypair.random().publicKey(); + const otherWallet = StellarSdk.Keypair.random().publicKey(); + + // Create test users + buyer = await User.create({ + name: "Buyer Student", + email: "buyer@example.com", + password: "password123", + role: "student", + stellarWallet: { publicKey: buyerWallet }, + purchasedCourses: [], + }); + + educator = await User.create({ + name: "Educator Tutor", + email: "tutor@example.com", + password: "password123", + role: "tutor", + stellarWallet: { publicKey: educatorWallet }, + }); + + otherUser = await User.create({ + name: "Other User", + email: "other@example.com", + password: "password123", + role: "student", + stellarWallet: { publicKey: otherWallet }, + }); + + adminUser = await User.create({ + name: "Admin Arbiter", + email: "admin@example.com", + password: "password123", + role: "admin", + }); + + buyerToken = generateToken(buyer._id, "student"); + educatorToken = generateToken(educator._id, "tutor"); + otherToken = generateToken(otherUser._id, "student"); + adminToken = generateToken(adminUser._id, "admin"); + + // Create a purchased course and enroll buyer + course = await Course.create({ + title: "Advanced Fiqh Course", + description: "Comprehensive Fiqh study", + category: "Fiqh", + price: 50, + createdBy: educator._id, + enrolledUsers: [buyer._id], + }); + + buyer.purchasedCourses = [course._id]; + await buyer.save(); + + // Create confirmed purchase transaction + confirmedTx = await Transaction.create({ + stellarTxHash: "mock_original_tx_hash_99999", + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: educator._id, + creatorWallet: educator.stellarWallet.publicKey, + itemType: "course", + itemId: course._id, + itemTypeModel: "Course", + itemTitle: course.title, + amount: "50", + currency: "USDC", + network: "testnet", + status: "confirmed", + confirmedAt: new Date(), + }); + }); + + describe("1. Refund Request (POST /transactions/:id/refund-request)", () => { + it("should allow original buyer to request refund within window", async () => { + const res = await request(app) + .post(`/api/stellar/payment/transactions/${confirmedTx._id}/refund-request`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ reason: "Accidental purchase" }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.refund.status).toBe("requested"); + expect(res.body.refund.reason).toBe("Accidental purchase"); + }); + + it("should reject refund request from non-buyer (403)", async () => { + const res = await request(app) + .post(`/api/stellar/payment/transactions/${confirmedTx._id}/refund-request`) + .set("Authorization", `Bearer ${otherToken}`) + .send({ reason: "Unwanted item" }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + }); + + it("should enforce idempotency (prevent duplicate active refund requests)", async () => { + // First request + await request(app) + .post(`/api/stellar/payment/transactions/${confirmedTx._id}/refund-request`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ reason: "First request" }); + + // Second request + const res = await request(app) + .post(`/api/stellar/payment/transactions/${confirmedTx._id}/refund-request`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ reason: "Second request" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/already exists/i); + }); + + it("should reject refund request for non-confirmed transaction", async () => { + const pendingTx = await Transaction.create({ + stellarTxHash: "mock_pending_hash", + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: educator._id, + creatorWallet: educator.stellarWallet.publicKey, + itemType: "course", + itemId: course._id, + itemTypeModel: "Course", + itemTitle: course.title, + amount: "50", + network: "testnet", + status: "pending", + }); + + const res = await request(app) + .post(`/api/stellar/payment/transactions/${pendingTx._id}/refund-request`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ reason: "Cancel pending" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe("2. Reverse Payment XDR Build & Submission", () => { + let refund; + + beforeEach(async () => { + refund = await Refund.create({ + originalTransaction: confirmedTx._id, + buyer: buyer._id, + educator: educator._id, + itemType: "course", + itemId: course._id, + amount: "50", + currency: "USDC", + reason: "Course not relevant", + status: "requested", + }); + }); + + it("should allow educator to build unsigned reverse payment XDR", async () => { + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/build`) + .set("Authorization", `Bearer ${educatorToken}`) + .send(); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.unsignedXdr).toBeDefined(); + expect(res.body.refund.status).toBe("approved"); + }); + + it("should prevent non-educator from building reverse payment XDR (403)", async () => { + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/build`) + .set("Authorization", `Bearer ${buyerToken}`) + .send(); + + expect(res.status).toBe(403); + }); + + it("should submit signed XDR, verify Horizon, and revoke item access atomically", async () => { + // Step 1: Educator builds + const buildRes = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/build`) + .set("Authorization", `Bearer ${educatorToken}`) + .send(); + + const unsignedXdr = buildRes.body.unsignedXdr; + + // Step 2: Educator submits signed XDR + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/submit`) + .set("Authorization", `Bearer ${educatorToken}`) + .send({ signedXdr: unsignedXdr }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.refund.status).toBe("confirmed"); + + // Verify access revocation on User and Course + const updatedBuyer = await User.findById(buyer._id); + const updatedCourse = await Course.findById(course._id); + const updatedTx = await Transaction.findById(confirmedTx._id); + + expect(updatedBuyer.purchasedCourses.map((c) => c.toString())).not.toContain(course._id.toString()); + expect(updatedCourse.enrolledUsers.map((u) => u.toString())).not.toContain(buyer._id.toString()); + expect(updatedCourse.enrolledUsers.length).toBe(0); + expect(updatedTx.status).toBe("refunded"); + }); + + it("should block submit if refund is not in approved state", async () => { + // Try submitting directly on 'requested' refund without building + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/submit`) + .set("Authorization", `Bearer ${educatorToken}`) + .send({ signedXdr: "AAAA...XDR..." }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/Must be 'approved' first/i); + }); + }); + + describe("3. Dispute Escalation & Admin Arbitration", () => { + let refund; + + beforeEach(async () => { + refund = await Refund.create({ + originalTransaction: confirmedTx._id, + buyer: buyer._id, + educator: educator._id, + itemType: "course", + itemId: course._id, + amount: "50", + currency: "USDC", + reason: "Educator uncooperative", + status: "rejected", + }); + }); + + it("should allow buyer to escalate rejected refund to disputed", async () => { + const res = await request(app) + .post(`/api/stellar/payment/refunds/${refund._id}/dispute`) + .set("Authorization", `Bearer ${buyerToken}`) + .send(); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.refund.status).toBe("disputed"); + + const updatedTx = await Transaction.findById(confirmedTx._id); + expect(updatedTx.status).toBe("disputed"); + }); + + it("should allow admin/arbiter to record arbitration resolution", async () => { + // Escalate to disputed first + refund.status = "disputed"; + await refund.save(); + + const res = await request(app) + .patch(`/api/stellar/payment/refunds/${refund._id}/arbitrate`) + .set("Authorization", `Bearer ${adminToken}`) + .send({ + decision: "off_chain_resolved", + notes: "Mediated off-chain resolution with educator", + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.refund.status).toBe("resolved"); + expect(res.body.refund.resolution.decision).toBe("off_chain_resolved"); + expect(res.body.disclaimer).toMatch(/Non-custodial Limitation/i); + }); + + it("should reject arbitration attempt from non-admin user (403)", async () => { + refund.status = "disputed"; + await refund.save(); + + const res = await request(app) + .patch(`/api/stellar/payment/refunds/${refund._id}/arbitrate`) + .set("Authorization", `Bearer ${buyerToken}`) + .send({ decision: "approved" }); + + expect(res.status).toBe(403); + }); + }); +}); From 9450672fe561588ebaf1390ca3ef8e84a417614a Mon Sep 17 00:00:00 2001 From: Ahbiz Date: Thu, 23 Jul 2026 22:36:21 +0100 Subject: [PATCH 2/3] chore: add retrying to Transaction status enum --- src/models/Transaction.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/Transaction.js b/src/models/Transaction.js index 45b67c0..b6b2a5d 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -110,7 +110,7 @@ const transactionSchema = new mongoose.Schema( // Status tracking status: { type: String, - enum: ["pending", "submitted", "confirmed", "failed", "expired", "refunded", "disputed"], + enum: ["pending", "submitted", "retrying", "confirmed", "failed", "expired", "refunded", "disputed"], default: "pending", index: true, }, From ec82ab65767cc739d37c520374a0b9bdc6fe5bff Mon Sep 17 00:00:00 2001 From: Ahbiz Date: Thu, 23 Jul 2026 23:08:13 +0100 Subject: [PATCH 3/3] fix: remove Mongoose session/transaction from submitRefund for standalone MongoDB CI compatibility --- src/controllers/stellar/refundController.js | 42 ++++++--------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/src/controllers/stellar/refundController.js b/src/controllers/stellar/refundController.js index 4360e06..1540e18 100644 --- a/src/controllers/stellar/refundController.js +++ b/src/controllers/stellar/refundController.js @@ -259,17 +259,8 @@ export const submitRefund = async (req, res) => { }); } - // Atomic Access Revocation via Mongoose Session Transaction - let session = null; - try { - session = await mongoose.startSession(); - session.startTransaction(); - } catch (sErr) { - session = null; - } - - const sessionOpts = session ? { session } : {}; - + // Access Revocation — sequential writes (no session/transaction required; + // the Stellar on-chain verification above is the source of truth) try { const buyer = await User.findById(refund.buyer); @@ -279,7 +270,7 @@ export const submitRefund = async (req, res) => { buyer.purchasedCourses = (buyer.purchasedCourses || []).filter( (cId) => cId.toString() !== refund.itemId.toString() ); - await buyer.save(sessionOpts); + await buyer.save(); } // Remove buyer from Course.enrolledUsers @@ -288,7 +279,7 @@ export const submitRefund = async (req, res) => { course.enrolledUsers = (course.enrolledUsers || []).filter( (uId) => uId.toString() !== refund.buyer.toString() ); - await course.save(sessionOpts); + await course.save(); } } else if (refund.itemType === "book") { // Remove book from buyer's purchased list @@ -296,36 +287,25 @@ export const submitRefund = async (req, res) => { buyer.purchasedBooks = (buyer.purchasedBooks || []).filter( (bId) => bId.toString() !== refund.itemId.toString() ); - await buyer.save(sessionOpts); + await buyer.save(); } } - // Update refund & transaction status atomically + // Update refund & transaction status refund.status = "confirmed"; refund.refundTxHash = submissionResult.hash; refund.refundLedger = submissionResult.ledger; - await refund.save(sessionOpts); + await refund.save(); await Transaction.findByIdAndUpdate( refund.originalTransaction, - { status: "refunded", refund: refund._id }, - sessionOpts + { status: "refunded", refund: refund._id } ); - if (session) { - await session.commitTransaction(); - } logger.info(`Refund confirmed and access revoked atomically for refund ${refund._id}`); - } catch (atomicErr) { - if (session) { - await session.abortTransaction(); - } - logger.error("Error during atomic access revocation:", atomicErr); - throw atomicErr; - } finally { - if (session) { - session.endSession(); - } + } catch (revokeErr) { + logger.error("Error during atomic access revocation:", revokeErr); + throw revokeErr; } return res.status(200).json({