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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import payoutRoutes from "./src/routes/payoutRoutes.js";
import uploadRoutes from "./src/routes/uploadRoutes.js";
import jobsRoutes from "./src/routes/jobsRoutes.js";
import wellKnownRoutes from "./src/routes/wellKnownRoutes.js";
import auditRoutes from "./src/routes/admin/auditRoutes.js";

handleUncaughtException();
validateEnv();
Expand Down Expand Up @@ -188,6 +189,7 @@ app.use("/api/stellar/donation", stellarDonationRoutes);
app.use("/api/payouts", payoutRoutes);
app.use("/api/uploads", uploadRoutes);
app.use("/admin/jobs", jobsRoutes);
app.use("/api/admin/audit", auditRoutes);

// ======================
// ERROR HANDLING
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"dev": "nodemon server.js",
"seed": "node src/scripts/seedDatabase.js",
"test-redis": "node test-redis.js",
"payouts:audit": "node src/scripts/auditPayouts.js"
"payouts:audit": "node src/scripts/auditPayouts.js",
"worker:ingest": "node src/workers/runIngestionWorker.js"
},
"keywords": [],
"author": "zeemscript",
Expand Down
17 changes: 17 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ const server = app.listen(PORT, () => {

startJobs().catch((err) => logger.error(err, "Background job startup failed"));

// Start payment ingestion worker if enabled
let stopIngestionWorker;
if (process.env.INGESTION_WORKER_ENABLED === "true") {
import("./src/workers/paymentIngestionWorker.js").then(
({ startIngestionWorker, stopIngestionWorker: stopFn }) => {
stopIngestionWorker = stopFn;
startIngestionWorker().catch((err) =>
logger.error(err, "Ingestion worker startup failed")
);
}
);
}

// Graceful shutdown
const gracefulShutdown = async (signal) => {
logger.info(`${signal} received. Starting graceful shutdown...`);
Expand All @@ -31,6 +44,10 @@ const gracefulShutdown = async (signal) => {

await stopJobs();

if (stopIngestionWorker) {
await stopIngestionWorker();
}

// Close Redis connection
await closeRedis();

Expand Down
2 changes: 2 additions & 0 deletions src/config/validateEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const optionalEnvVars = [
"ORG_TWITTER",
"ORG_GITHUB",
"SIGNING_KEY",
"INGESTION_WORKER_ENABLED",
"INGESTION_POLL_INTERVAL_MS",
];

export const validateEnv = () => {
Expand Down
80 changes: 80 additions & 0 deletions src/controllers/authController.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import sendMail from "../../services/emails/sendMail.js";
import { generatedOtp } from "../routes/emailRoutes.js";
import logger from "../config/logger.js";
import { enqueue } from "../jobs/queue.js";
import { recordAudit } from "../services/audit/auditService.js";
import { AUDIT_ACTIONS } from "../models/AuditLog.js";
import { generateOtp, hashOtp, verifyOtp } from "../utils/otp.js";

import { catchAsync, APIError } from "../middlewares/errorHandler.js";
Expand Down Expand Up @@ -111,6 +113,15 @@ export const registerUser = catchAsync(async (req, res, next) => {
const existing = await User.findOne({ email });
if (existing) {
logger.warn(`❌ Registration failed - Email already exists: ${email}`);
recordAudit({
action: AUDIT_ACTIONS.AUTH_REGISTER_FAILURE,
actor: null,
req,
targetType: "User",
targetId: email,
status: "failure",
metadata: { email, reason: "email_already_exists" },
});
return next(new APIError("Email already exists", 400));
}

Expand All @@ -137,6 +148,16 @@ export const registerUser = catchAsync(async (req, res, next) => {

logger.info(`✅ User registered successfully: ${email} (ID: ${user._id})`);

recordAudit({
action: AUDIT_ACTIONS.AUTH_REGISTER_SUCCESS,
actor: user._id,
req,
targetType: "User",
targetId: user._id.toString(),
status: "success",
metadata: { email, assignedRole, name },
});

// Generate session and tokens
const { accessToken, refreshToken } = await createSessionAndTokens(user, req, res);

Expand Down Expand Up @@ -169,13 +190,31 @@ export const loginUser = catchAsync(async (req, res, next) => {
const user = await User.findOne({ email }).select("+password");
if (!user) {
logger.warn(`❌ Login failed - User not found: ${email}`);
recordAudit({
action: AUDIT_ACTIONS.AUTH_LOGIN_FAILURE,
actor: null,
req,
targetType: "User",
targetId: email,
status: "failure",
metadata: { email, reason: "user_not_found" },
});
return next(new APIError("Invalid credentials", 401));
}

// Verify password
const isPasswordCorrect = await bcrypt.compare(password, user.password);
if (!isPasswordCorrect) {
logger.warn(`❌ Login failed - Incorrect password: ${email}`);
recordAudit({
action: AUDIT_ACTIONS.AUTH_LOGIN_FAILURE,
actor: user._id,
req,
targetType: "User",
targetId: user._id.toString(),
status: "failure",
metadata: { email, reason: "invalid_password" },
});
return next(new APIError("Invalid credentials", 401));
}

Expand All @@ -188,6 +227,16 @@ export const loginUser = catchAsync(async (req, res, next) => {

logger.info(`✅ Login successful: ${email} (ID: ${user._id})`);

recordAudit({
action: AUDIT_ACTIONS.AUTH_LOGIN_SUCCESS,
actor: user._id,
req,
targetType: "User",
targetId: user._id.toString(),
status: "success",
metadata: { email, role: user.role },
});

res.status(200).json({
success: true,
message: "Login successful",
Expand Down Expand Up @@ -251,6 +300,16 @@ export const requestPasswordReset = async (req, res) => {
await user.save();
}

recordAudit({
action: AUDIT_ACTIONS.AUTH_PASSWORD_RESET_REQUEST,
actor: user._id,
req,
targetType: "User",
targetId: user._id.toString(),
status: "success",
metadata: { email },
});

res.status(200).json({
success: true,
message:
Expand Down Expand Up @@ -303,6 +362,17 @@ export const resetPassword = async (req, res) => {
await user.save();

logger.info("✅ Password reset successful for:", email);

recordAudit({
action: AUDIT_ACTIONS.AUTH_PASSWORD_RESET_COMPLETE,
actor: user._id,
req,
targetType: "User",
targetId: user._id.toString(),
status: "success",
metadata: { email },
});

res.status(200).json({
success: true,
message:
Expand Down Expand Up @@ -484,6 +554,16 @@ export const logoutUser = catchAsync(async (req, res, next) => {
);
}

recordAudit({
action: AUDIT_ACTIONS.AUTH_LOGOUT,
actor: req.user?._id ?? null,
req,
targetType: "User",
targetId: req.user?._id?.toString() ?? null,
status: "success",
metadata: null,
});

const isProd = process.env.NODE_ENV === "production";
res.clearCookie("refreshToken", {
httpOnly: true,
Expand Down
102 changes: 75 additions & 27 deletions src/controllers/stellar/paymentController.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { getAssetConfig, isAssetSupported, getSupportedCodes } from "../../config/assets.js";
import * as StellarSdk from "@stellar/stellar-sdk";
import { recordSaleEarnings } from "../../services/payoutService.js";
import { grantItemAccess } from "../../services/stellar/reconciliationService.js";
import { enqueue } from "../../jobs/queue.js";
import logger from "../../config/logger.js";
import {
Expand All @@ -31,6 +32,8 @@ import {
paymentsConfirmed,
paymentsFailed,
} from "../../config/metrics.js";
import { recordAudit } from "../../services/audit/auditService.js";
import { AUDIT_ACTIONS } from "../../models/AuditLog.js";

/**
* Resolve the item, its creator, and the settlement destination wallet for a
Expand Down Expand Up @@ -519,6 +522,23 @@ export const initializePayment = async (req, res) => {
`Payment initialized: ${transaction._id} for ${itemType} ${itemId}`
);

recordAudit({
action: AUDIT_ACTIONS.PAYMENT_INITIALIZE,
actor: buyerId,
req,
targetType: "Transaction",
targetId: transaction._id.toString(),
status: "success",
metadata: {
transactionId: transaction._id.toString(),
itemType,
itemId,
itemTitle: item.title,
amount: item.price.toString(),
settlementMode,
},
});

res.status(200).json({
success: true,
transactionId: transaction._id,
Expand Down Expand Up @@ -683,6 +703,20 @@ export const submitPayment = async (req, res) => {
`Transaction ${transactionId} verification failed: ${verification.reason}`
);

recordAudit({
action: AUDIT_ACTIONS.PAYMENT_SUBMIT_FAILED,
actor: buyerId,
req,
targetType: "Transaction",
targetId: transactionId,
status: "failure",
metadata: {
transactionId,
stellarTxHash: result.hash,
failureReason: `On-chain verification failed: ${verification.reason}`,
},
});

Comment on lines +706 to +719

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Missing audit coverage: Stellar submission errors never reach the audit log.

PAYMENT_SUBMIT_FAILED is correctly recorded here for on-chain verification failures, but the sibling catch (stellarError) branch a few lines above (around line 577-592) — which also marks the transaction "failed" with a failureReason and returns a 400 — has no recordAudit call at all. Since Stellar submission errors (bad signature, insufficient funds, network rejection, etc.) are a distinct and common payment failure mode, this leaves a real gap in the financial audit trail that issue #66 asks for.

🛡️ Proposed fix: audit the Stellar submission failure branch too
     } catch (stellarError) {
       // Handle Stellar submission errors
       transaction.status = "failed";
       transaction.failureReason = stellarError.message;
       await transaction.save({ session });
       await session.commitTransaction();
       paymentsFailed.inc({ type: "purchase", reason: "stellar_error" });

       logger.error(`Transaction ${transactionId} failed:`, stellarError);

+      recordAudit({
+        action:     AUDIT_ACTIONS.PAYMENT_SUBMIT_FAILED,
+        actor:      buyerId,
+        req,
+        targetType: "Transaction",
+        targetId:   transactionId,
+        status:     "failure",
+        metadata:   {
+          transactionId,
+          failureReason: stellarError.message,
+        },
+      });
+
       return res.status(400).json({
         success: false,
         message: "Transaction failed on Stellar network",
         error: stellarError.message,
       });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` around lines 654 - 667, Update
the sibling catch (stellarError) branch in the payment submission flow to call
recordAudit with PAYMENT_SUBMIT_FAILED before returning the 400 response.
Populate the audit entry with the failed transaction context, including buyerId,
req, transactionId, status "failure", and a failureReason derived from the
Stellar submission error, matching the existing verification-failure audit
coverage.

return res.status(400).json({
success: false,
message: "Payment could not be verified on the Stellar network",
Expand All @@ -699,33 +733,13 @@ export const submitPayment = async (req, res) => {

await recordSaleEarnings(transaction, { session });

const buyer = await User.findById(buyerId).session(session);

if (transaction.itemType === "book") {
buyer.purchasedBooks.push({
bookId: transaction.itemId,
purchaseDate: new Date(),
});
if (buyer.stat) {
buyer.stat.booksRead = (buyer.stat.booksRead || 0) + 1;
}
} else {
buyer.purchasedCourses.push({
courseId: transaction.itemId,
purchaseDate: new Date(),
});
if (buyer.stat) {
buyer.stat.coursesEnrolled = (buyer.stat.coursesEnrolled || 0) + 1;
}

await Course.findByIdAndUpdate(
transaction.itemId,
{ $addToSet: { enrolledUsers: buyerId } },
{ session }
);
}

await buyer.save({ session });
// Grant access to the purchased item (shared with ingestion worker)
await grantItemAccess({
buyerId,
itemType: transaction.itemType,
itemId: transaction.itemId,
session,
});
try {
await enqueue(
"generateReceipt",
Expand All @@ -752,6 +766,24 @@ export const submitPayment = async (req, res) => {
`Payment successful: ${transactionId}, Stellar TX: ${result.hash}`
);

recordAudit({
action: AUDIT_ACTIONS.PAYMENT_SUBMIT_CONFIRMED,
actor: buyerId,
req,
targetType: "Transaction",
targetId: transactionId,
status: "success",
metadata: {
transactionId,
stellarTxHash: result.hash,
stellarLedger: result.ledger,
amount: transaction.amount,
itemType: transaction.itemType,
itemId: transaction.itemId.toString(),
settlementMode: transaction.settlement,
},
});

res.status(200).json({
success: true,
message: "Payment successful!",
Expand Down Expand Up @@ -912,6 +944,22 @@ export const cancelTransaction = async (req, res) => {

logger.info(`Transaction ${transactionId} cancelled by user ${userId}`);

recordAudit({
action: AUDIT_ACTIONS.PAYMENT_CANCEL,
actor: userId,
req,
targetType: "Transaction",
targetId: transactionId,
status: "success",
metadata: {
transactionId,
itemType: transaction.itemType,
itemId: transaction.itemId?.toString(),
amount: transaction.amount,
failureReason: "Cancelled by user",
},
});

res.status(200).json({
success: true,
message: "Transaction cancelled",
Expand Down
18 changes: 18 additions & 0 deletions src/controllers/stellar/reconciliationController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getReconciliationStatus } from "../../services/stellar/reconciliationService.js";
import logger from "../../config/logger.js";

export const reconciliationStatus = async (req, res) => {
try {
const status = await getReconciliationStatus();
res.status(200).json({
success: true,
...status,
});
} catch (error) {
logger.error("Get reconciliation status error:", error);
res.status(500).json({
success: false,
message: "Failed to fetch reconciliation status",
});
}
};
Loading
Loading