A comprehensive backend solution built in JavaScript (Node.js, Express, and MongoDB/Mongoose) for managing user payouts for affiliate sales. This repository fulfills the requirements of the SDE Intern Assignment, covering Low-Level Design (LLD), database schemas, class relationships, API development, business rules enforcement, edge cases, and failed payout recovery.
- Advance Payout Job: Automates a 10% advance payout of earnings for eligible
pendingsales. Ensures that each sale is only processed once for advance payout, even if the job runs multiple times. - Admin Reconciliation: Reconciles pending sales to
approvedorrejectedstatus and updates the user's withdrawable balance atomically.- Approved: Remaining payout (
earning - advancePaid) is credited to the user's withdrawable balance. - Rejected: The advance payout received by the user (
-advancePaid) is adjusted (debited) from the user's withdrawable balance.
- Approved: Remaining payout (
- Withdrawal Restrictions: Limits users to a maximum of one withdrawal request every 24 hours.
- Failed Payout Recovery (Question 2): Reclaims failed, cancelled, or rejected payout transactions.
- Credits the failed amount back into the user's withdrawable balance.
- Resets the 24-hour limit constraint, allowing the user to request a withdrawal again.
- Robust Concurrency Control: Uses MongoDB/Mongoose atomic operations (
findOneAndUpdate) and database transactions (session.startTransaction()) to guarantee state consistency and prevent double-withdrawal or double-reconciliation race conditions.
- Core: Node.js
- Framework: Express.js
- Database: MongoDB (Object modeling using Mongoose)
- Testing: Jest & Supertest
Clone this repository to your local system and install the dependencies:
npm installCreate a .env file in the root directory (one is already prepared for you) with the following environment variables:
PORT=3000
MONGODB_URI="Add your MongoDB_URL"(The MONGODB_URI points to the user-supplied MongoDB Atlas cluster and automatically creates the target databases payout_db for development and payout_db_test for automated tests).
Start the local server with hot-reloading:
npm run devThe server will listen at http://localhost:3000.
To execute the test suite (covering Question 1, Question 2, withdrawal restrictions, and recovery):
npm run testThe complete low-level design document, including architectural design, ER diagrams, sequence diagrams, and class mappings using Mermaid, is located in lld.md.
- POST
/api/sales - Request Body:
{ "userId": "john_doe", "brand": "brand_1", "earning": 40 } - Response (
201 Created):{ "_id": "64b1f41d9ef19e4a3b8e8f81", "userId": "john_doe", "brand": "brand_1", "earning": 40, "status": "pending", "advancePaid": 0, "advanceStatus": "none", "reconciled": false, "createdAt": "2026-07-17T16:04:40.000Z", "updatedAt": "2026-07-17T16:04:40.000Z" }
- GET
/api/sales - Response (
200 OK): Array of sales documents.
- POST
/api/sales/advance-job - Request Body (optional):
{ "forceSuccess": true }(setfalseto simulate payout transfer failures). - Description: Runs the job to calculate and transfer 10% advance payout for all
pendingsales that haveadvanceStatusas'none'. - Response (
200 OK):{ "message": "Advance payout job execution completed", "result": { "totalEligible": 3, "successCount": 3, "failedCount": 0, "payouts": [...] } }
- POST
/api/sales/reconcile/:saleId - Request Body:
(Status must be either
{ "status": "approved" }"approved"or"rejected") - Response (
200 OK):{ "message": "Sale 64b1f41d9ef19e4a3b8e8f81 reconciled to approved status successfully", "sale": { "_id": "64b1f41d9ef19e4a3b8e8f81", "userId": "john_doe", "brand": "brand_1", "status": "approved", "advancePaid": 4, "reconciled": true }, "adjustment": 36, "newBalance": 36 }
- POST
/api/sales/seed - Description: Resets database collections and inserts the 3 pending sales for user
john_doematching the example in the PDF. - Response (
201 Created)
- GET
/api/users/:userId/balance - Response (
200 OK):{ "userId": "john_doe", "withdrawableBalance": 68, "lastWithdrawalAt": "2026-07-17T16:05:00.000Z" }
- POST
/api/users/:userId/withdraw - Request Body:
{ "amount": 50 } - Response (
201 Created):{ "message": "Withdrawal initiated successfully", "payout": { "_id": "64b1f41d9ef19e4a3b8e8f85", "userId": "john_doe", "amount": 50, "type": "withdrawal", "status": "pending", "createdAt": "2026-07-17T16:05:00.000Z" } }
- GET
/api/payouts - Query Parameters (optional):
userId=john_doe,type=withdrawalortype=advance - Response (
200 OK): Array of payouts.
- POST
/api/payouts/:payoutId/status - Request Body:
(Can be
{ "status": "failed" }"completed","failed","cancelled", or"rejected") - Description: Simulates feedback from payment processors. Updating a pending withdrawal payout status to
'failed','cancelled', or'rejected'triggers the recovery logic, refunding the amount back to the user's wallet balance and clearing the 24h block. - Response (
200 OK):{ "message": "Payout status updated to failed successfully", "payout": { "_id": "64b1f41d9ef19e4a3b8e8f85", "status": "failed" } }
- Floating-point Inaccuracy: Double arithmetic in JS (e.g.
40 * 0.1) can lead to floats like4.000000000000004. All monetary math is rounded to 2 decimal places usingMath.round(val * 100) / 100before db writes. - Double Payout Prevention: Sale
advanceStatususes an atomicfindOneAndUpdatelock transition:none->pending->paid/failed. Two worker instances running the advance payout job concurrently cannot double-pay any sale. - Double Reconciliation: If an administrator attempts to reconcile a sale that has already been reconciled, the request is rejected with a
400 Bad Requestandalready reconcilederror. - Transaction Rollbacks: If balance deduction succeeds but writing the payout record fails (or vice versa), the database transaction aborts and rolls back all operations, ensuring zero mismatch between ledger entries and wallet balance.
- Withdrawal Unlock on Failure: The 24-hour limit check queries only
pendingorcompletedwithdrawals. When a withdrawal status changes tofailed,cancelled, orrejected, the withdrawal amount is credited back to the user's balance and they can withdraw again immediately.