Commit 5bced2fa by tdgiang

Add implementation plan for admin transaction management

11 bite-sized tasks: Docker Compose mongo service, config, Mongoose
connection, shared MegaPay signing helper, model, Basic Auth middleware,
create/pay/return/ipn/history endpoints, final Docker integration test.
parent 41ebef95
# Admin Transaction Management Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a self-contained admin module (`/admin/*`) where staff create Epay payment links for customers (with name/phone/address/amount) and track their status in a new local MongoDB collection.
**Architecture:** New Mongoose model + new controller + new routes + new Swig views, all in new files. A shared signing helper (`app/libs/epaySign.js`) implements the MegaPay merchantToken formula once and is used by every admin endpoint. The module never imports from or modifies `core.server.controller.js` / `core.server.routes.js` — it reuses only `config.epay.*` (same real Epay merchant credentials) and calls MegaPay directly, bypassing the external DCVF ledger entirely.
**Tech Stack:** Express (existing), Mongoose 5.7 (existing dependency, not yet connected to a live DB), Swig templates (existing), Docker Compose (existing, gets a new `mongo` service), vanilla JS + `fetch` on the client (no new frontend framework).
**Spec:** `docs/superpowers/specs/2026-08-28-admin-transaction-management-design.md`
## Global Constraints
- **Do not modify** `app/controllers/core.server.controller.js`, `app/routes/core.server.routes.js`, or any existing `/epay/*` view — this module is fully separate from the just-stabilized customer-facing Epay flow (spec §2, §3.1 rationale).
- **No automated test framework exists in this repo** (`npm test` runs `grunt test`, which has no `test` task — it fails). Every task's "test" is a small standalone Node script or `curl` command you run by hand and read the output of, the same style already used throughout this project's Epay work. Scripts live under `scratch/` at the repo root (create it if missing) and are not meant to be committed — add `scratch/` to `.gitignore` in Task 1 if not already ignored.
- **Mongo and Redis are internal-only** in `docker-compose.yml` — no host port is published for either. The app reaches them by Docker service name (`mongo`, `redis`), not `localhost`, when running inside Compose.
- **For local iteration (Tasks 2–10), don't rebuild Docker every time.** Run a disposable standalone MongoDB on the host and run the Node app directly:
```bash
docker run -d --rm --name plan-mongo -p 27017:27017 mongo:7
```
Add to your local `.env` (gitignored, real file, not `.env.example`):
```
MONGO_URI=mongodb://localhost:27017/haiyen_admin_dev
ADMIN_USER=admin
ADMIN_PASSWORD=devpassword123
```
Run the app with `NODE_ENV=test node server.js` (port 8092, matches the existing local dev convention in this repo). Stop the standalone Mongo with `docker stop plan-mongo` when done with local iteration; Task 11 switches to the real `docker compose` stack.
- **Reuse `config.epay.merchant_id`, `config.epay.encode_key`, `config.epay.domain`, `config.epay.req_domain`** for all MegaPay interaction — do not introduce a second set of Epay credentials.
- **All user-facing copy is Vietnamese**, matching the rest of the app.
- Every Mongoose callback in this plan uses the Node-style `(err, result)` callback form (matching `mongoose@5.7`'s API and this codebase's existing callback style) — not promises/async-await, for consistency with the rest of the controller layer.
---
### Task 1: Docker Compose — add `mongo` service
**Files:**
- Modify: `docker-compose.yml`
- Modify: `.gitignore` (add `scratch/` if not already present)
**Interfaces:**
- Produces: a `mongo` service reachable inside the Compose network at `mongo:27017`, backed by the named volume `mongo-data`.
- [ ] **Step 1: Add the `mongo` service and volume**
Edit `docker-compose.yml` so it reads exactly:
```yaml
services:
app:
build: .
restart: unless-stopped
env_file: .env
environment:
NODE_ENV: production
REDIS_HOST: redis
REDIS_PORT: 6379
ports:
# Host port 7003 chosen because this VPS is shared with many other
# services; 3003 (and most of the 3000-3003/8080-8090/9000-9080 range)
# was already taken. Verify with `sudo lsof -i :7003` before reusing.
- "7003:3003"
depends_on:
- redis
- mongo
volumes:
# Named volume, not a host bind-mount: a bind-mounted host directory
# keeps the host's ownership (usually root), which the container's
# non-root "node" user can't write to -> EACCES crash loop. A named
# volume is seeded from the image (already chown'd to node:node).
- app-log:/app/log
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis-data:/data
mongo:
image: mongo:7
restart: unless-stopped
volumes:
- mongo-data:/data/db
volumes:
redis-data:
app-log:
mongo-data:
```
(Only the `mongo` service, the `mongo` line in `app.depends_on`, and the `mongo-data:` volume line are new — everything else must stay exactly as it already is in the file.)
- [ ] **Step 2: Add `scratch/` to `.gitignore` if missing**
```bash
grep -qxF 'scratch/' .gitignore || echo 'scratch/' >> .gitignore
mkdir -p scratch
```
- [ ] **Step 3: Verify Mongo starts and is reachable from the `app` service**
```bash
docker compose up -d mongo
sleep 3
docker compose run --rm app node -e "console.log('reachable check placeholder')"
docker compose exec mongo mongosh --quiet --eval "db.runCommand({ping:1})"
```
Expected: the `mongosh` command prints `{ ok: 1 }`. If `mongosh` isn't found, use `mongo --quiet --eval "db.runCommand({ping:1})"` instead (older client name, same image may ship either depending on tag).
- [ ] **Step 4: Commit**
```bash
git add docker-compose.yml .gitignore
git commit -m "Add mongo service to docker-compose.yml for admin transaction storage"
```
---
### Task 2: Config — Mongo + admin auth env vars
**Files:**
- Modify: `config/env/all.js`
- Modify: `.env.example`
- Modify: local `.env` (not committed — add the real values yourself per Global Constraints)
**Interfaces:**
- Produces: `config.mongoUri` (String), `config.admin.user` (String), `config.admin.password` (String) — read by every later task via `require(__config_path + "/config")`.
- [ ] **Step 1: Add the new config keys**
In `config/env/all.js`, add these two entries to the exported object (alongside the existing `epay: {...}` block — same nesting level, order doesn't matter):
```js
mongoUri: process.env.MONGO_URI || "mongodb://mongo:27017/haiyen_admin",
admin: {
user: process.env.ADMIN_USER,
password: process.env.ADMIN_PASSWORD,
},
```
- [ ] **Step 2: Add placeholders to `.env.example`**
Append to `.env.example`:
```
# Admin transaction management
MONGO_URI=
ADMIN_USER=
ADMIN_PASSWORD=
```
- [ ] **Step 3: Add real local values to your own `.env`**
```
MONGO_URI=mongodb://localhost:27017/haiyen_admin_dev
ADMIN_USER=admin
ADMIN_PASSWORD=devpassword123
```
- [ ] **Step 4: Verify the config loads correctly**
```bash
node -e '
require("dotenv").config();
global.__config_path = __dirname + "/config";
var config = require("./config/config");
console.log("mongoUri:", config.mongoUri);
console.log("admin:", config.admin);
'
```
Expected: prints `mongoUri: mongodb://localhost:27017/haiyen_admin_dev` and `admin: { user: 'admin', password: 'devpassword123' }`.
- [ ] **Step 5: Commit**
```bash
git add config/env/all.js .env.example
git commit -m "Add MONGO_URI and admin Basic Auth config"
```
(`.env` is gitignored — nothing to commit there.)
---
### Task 3: Mongoose connection
**Files:**
- Modify: `server.js`
**Interfaces:**
- Consumes: `config.mongoUri` (Task 2).
- Produces: a live Mongoose connection; later tasks' `mongoose.model(...)` calls only work once this runs.
- [ ] **Step 1: Add the connect call**
`server.js` already does `mongoose = require('mongoose')` but never calls `.connect()`. Add the connection right after `var config = require('./config/config');` and before `global.__config_path = ...`:
```js
mongoose.connect(config.mongoUri, {
useNewUrlParser: true,
useUnifiedTopology: true
});
mongoose.connection.on('error', function (err) {
console.error('MongoDB connection error:', err.message);
});
mongoose.connection.once('open', function () {
console.log('MongoDB connected');
});
```
- [ ] **Step 2: Verify it connects (standalone Mongo from Global Constraints must be running)**
```bash
docker run -d --rm --name plan-mongo -p 27017:27017 mongo:7
sleep 2
NODE_ENV=test node server.js &
sleep 2
```
Expected console output includes `MongoDB connected`. Then stop it:
```bash
kill %1
```
- [ ] **Step 3: Commit**
```bash
git add server.js
git commit -m "Connect Mongoose to MongoDB on startup"
```
---
### Task 4: Shared MegaPay signing helper
**Files:**
- Create: `app/libs/epaySign.js`
- Test: `scratch/test-epaySign.js`
**Interfaces:**
- Produces:
- `signRequest(timeStamp, merTrxId, merId, amount, encodeKey, payToken)` → String (sha256 hex)
- `signResponse(resultCd, timeStamp, merTrxId, trxId, merId, amount, encodeKey, payToken)` → String (sha256 hex)
- Both consumed by Tasks 7 and 9.
- [ ] **Step 1: Write the failing verification script**
```bash
mkdir -p scratch
cat > scratch/test-epaySign.js << 'EOF'
var sha256 = require("../node_modules/sha256");
var epaySign = require("../app/libs/epaySign");
// Fixture values (not real secrets) — formula per MGP_Merchant_Interface doc 5.1 / 5.3.
var timeStamp = "20260101120000";
var merTrxId = "HY_TEST_123";
var trxId = "MEGAPAY_TRX_456";
var merId = "TESTMER01";
var amount = "50000";
var encodeKey = "fixture-encode-key";
var payToken = "";
var expectedRequest = sha256(timeStamp + merTrxId + merId + amount + encodeKey);
var gotRequest = epaySign.signRequest(timeStamp, merTrxId, merId, amount, encodeKey, payToken);
console.log("signRequest match:", gotRequest === expectedRequest, gotRequest);
var expectedResponse = sha256(
"00_000" + timeStamp + merTrxId + trxId + merId + amount + "" + encodeKey
);
var gotResponse = epaySign.signResponse(
"00_000", timeStamp, merTrxId, trxId, merId, amount, encodeKey, payToken
);
console.log("signResponse match:", gotResponse === expectedResponse, gotResponse);
var withToken = "abc123token";
var expectedResponseTok = sha256(
"00_000" + timeStamp + merTrxId + trxId + merId + amount + withToken + encodeKey
);
var gotResponseTok = epaySign.signResponse(
"00_000", timeStamp, merTrxId, trxId, merId, amount, encodeKey, withToken
);
console.log("signResponse with payToken match:", gotResponseTok === expectedResponseTok, gotResponseTok);
EOF
node scratch/test-epaySign.js
```
Expected: fails with `Cannot find module '../app/libs/epaySign'` — the file doesn't exist yet.
- [ ] **Step 2: Implement `app/libs/epaySign.js`**
```js
"use strict";
var sha256 = require("sha256");
function signRequest(timeStamp, merTrxId, merId, amount, encodeKey, payToken) {
var base = timeStamp + merTrxId + merId + amount;
return payToken
? sha256(base + payToken + encodeKey)
: sha256(base + encodeKey);
}
function signResponse(resultCd, timeStamp, merTrxId, trxId, merId, amount, encodeKey, payToken) {
return sha256(
resultCd + timeStamp + merTrxId + trxId + merId + amount + (payToken || "") + encodeKey
);
}
module.exports = {
signRequest: signRequest,
signResponse: signResponse,
};
```
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-epaySign.js
```
Expected: all three lines print `true`.
- [ ] **Step 4: Commit**
```bash
git add app/libs/epaySign.js
git commit -m "Add shared MegaPay signing helper for the admin module"
```
---
### Task 5: `AdminTransaction` Mongoose model
**Files:**
- Create: `app/models/AdminTransaction.js`
- Test: `scratch/test-admin-transaction-model.js`
**Interfaces:**
- Produces: `mongoose.model("AdminTransaction")` with fields `merTrxId` (String, unique), `transCode` (String), `customerName` (String), `customerPhone` (String), `customerAddress` (String), `amount` (Number), `payType` (String, default `"DC"`), `status` (String enum `pending|success|failed`, default `"pending"`), `merchantToken` (String), `timeStamp` (String), `resultMsg` (String, default `""`), `paidAt` (Date, default `null`), plus Mongoose-managed `createdAt`/`updatedAt`.
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-admin-transaction-model.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var AdminTransaction = require("../app/models/AdminTransaction");
AdminTransaction.create(
{
merTrxId: "HY_MODELTEST_" + Date.now(),
transCode: "HY_RT_MODELTEST",
customerName: "Nguyen Van Test",
customerPhone: "0900000000",
customerAddress: "123 Test St",
amount: 25000,
merchantToken: "fixture-token",
timeStamp: "20260101120000",
},
function (err, doc) {
if (err) {
console.log("CREATE FAILED:", err.message);
process.exit(1);
}
console.log("created ok, status:", doc.status, "payType:", doc.payType);
console.log("has createdAt:", !!doc.createdAt);
mongoose.connection.close();
}
);
});
EOF
node scratch/test-admin-transaction-model.js
```
Expected: fails with `Cannot find module '../app/models/AdminTransaction'`.
- [ ] **Step 2: Implement `app/models/AdminTransaction.js`**
```js
"use strict";
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var AdminTransactionSchema = new Schema(
{
merTrxId: { type: String, required: true, unique: true, index: true },
transCode: { type: String, required: true },
customerName: { type: String, required: true },
customerPhone: { type: String, required: true },
customerAddress: { type: String, required: true },
amount: { type: Number, required: true },
payType: { type: String, default: "DC" },
status: {
type: String,
enum: ["pending", "success", "failed"],
default: "pending",
},
merchantToken: { type: String, required: true },
timeStamp: { type: String, required: true },
resultMsg: { type: String, default: "" },
paidAt: { type: Date, default: null },
},
{ timestamps: true }
);
module.exports = mongoose.model("AdminTransaction", AdminTransactionSchema);
```
- [ ] **Step 3: Run the verification script again (standalone Mongo must still be running)**
```bash
node scratch/test-admin-transaction-model.js
```
Expected: `created ok, status: pending payType: DC` and `has createdAt: true`.
- [ ] **Step 4: Commit**
```bash
git add app/models/AdminTransaction.js
git commit -m "Add AdminTransaction Mongoose model"
```
---
### Task 6: Basic Auth middleware
**Files:**
- Create: `app/middlewares/basicAuth.js`
- Test: `scratch/test-basic-auth.js`
**Interfaces:**
- Produces: `module.exports` = an Express middleware function `(req, res, next)` that calls `next()` when `Authorization: Basic <base64(user:password)>` matches `config.admin.user`/`config.admin.password`, otherwise responds `401` with a `WWW-Authenticate` header.
- Consumed by Task 10 (route wiring) on the protected admin routes only.
- [ ] **Step 1: Write the failing verification script**
This spins up a tiny standalone Express app using the middleware, so it's testable without booting the whole project.
```bash
cat > scratch/test-basic-auth.js << 'EOF'
process.env.ADMIN_USER = "admin";
process.env.ADMIN_PASSWORD = "secret123";
global.__config_path = __dirname + "/../config";
process.env.NODE_ENV = "test";
require("dotenv").config();
var config = require("../config/config");
// Force the test creds regardless of what's in the developer's own .env.
config.admin = { user: "admin", password: "secret123" };
var express = require("express");
var basicAuth = require("../app/middlewares/basicAuth");
var app = express();
app.get("/protected", basicAuth, function (req, res) {
res.send("ok");
});
var server = app.listen(8099, function () {
var http = require("http");
function request(authHeader, cb) {
var options = { host: "localhost", port: 8099, path: "/protected", headers: {} };
if (authHeader) options.headers.Authorization = authHeader;
http.get(options, function (res) {
cb(res.statusCode);
});
}
request(null, function (status) {
console.log("no auth -> status", status, status === 401 ? "PASS" : "FAIL");
request("Basic " + Buffer.from("admin:wrong").toString("base64"), function (status) {
console.log("wrong password -> status", status, status === 401 ? "PASS" : "FAIL");
request("Basic " + Buffer.from("admin:secret123").toString("base64"), function (status) {
console.log("correct creds -> status", status, status === 200 ? "PASS" : "FAIL");
server.close();
});
});
});
});
EOF
node scratch/test-basic-auth.js
```
Expected: fails with `Cannot find module '../app/middlewares/basicAuth'`.
- [ ] **Step 2: Implement `app/middlewares/basicAuth.js`**
```js
"use strict";
module.exports = function basicAuth(req, res, next) {
var config = require(__config_path + "/config");
var header = req.headers.authorization || "";
var token = header.indexOf("Basic ") === 0 ? header.slice(6) : "";
var decoded = Buffer.from(token, "base64").toString("utf8");
var parts = decoded.split(":");
var user = parts[0];
var password = parts[1];
if (user === config.admin.user && password === config.admin.password) {
return next();
}
res.set("WWW-Authenticate", 'Basic realm="Admin"');
return res.status(401).send("Authentication required.");
};
```
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-basic-auth.js
```
Expected: all three lines print `PASS`.
- [ ] **Step 4: Commit**
```bash
git add app/middlewares/basicAuth.js
git commit -m "Add Basic Auth middleware for the admin module"
```
---
### Task 7: Create-transaction endpoint + form page
**Files:**
- Create: `app/controllers/admin.server.controller.js` (only `newTransactionForm` and `createTransaction` in this task — more exports added in Tasks 8–10)
- Create: `app/views/admin/transactions-new.server.view.html`
- Test: `scratch/test-create-transaction.js`
**Interfaces:**
- Consumes: `AdminTransaction` (Task 5), `epaySign.signRequest` (Task 4), `config.epay.*` / `config.epay.req_domain` (existing).
- Produces: `exports.newTransactionForm(req, res)`, `exports.createTransaction(req, res)` — the latter responds `{ code: "00", data: { merTrxId, paymentUrl } }` on success, `{ code: "99", data: <reason> }` otherwise. Consumed by Task 10 (routes).
This task requires the app itself to be running (it's an HTTP endpoint), so the verification step boots the real app in `NODE_ENV=test` rather than a standalone script — but routes aren't wired until Task 10, so for this task's verification you call the controller function directly, not over HTTP.
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-create-transaction.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var admin = require("../app/controllers/admin.server.controller");
var epaySign = require("../app/libs/epaySign");
var req = {
body: {
customerName: "Nguyen Van A",
customerPhone: "0912345678",
customerAddress: "123 Le Loi",
amount: "50000",
},
};
var res = {
status: function (code) { this._status = code; return this; },
json: function (body) {
console.log("HTTP status:", this._status || 200);
console.log("response:", JSON.stringify(body));
if (body.code === "00") {
var AdminTransaction = require("../app/models/AdminTransaction");
AdminTransaction.findOne({ merTrxId: body.data.merTrxId }, function (err, tx) {
var expected = epaySign.signRequest(
tx.timeStamp, tx.merTrxId, config.epay.merchant_id, "50000", config.epay.encode_key
);
console.log("merchantToken matches formula:", tx.merchantToken === expected);
console.log("paymentUrl contains merTrxId:", body.data.paymentUrl.indexOf(body.data.merTrxId) !== -1);
mongoose.connection.close();
});
} else {
mongoose.connection.close();
}
},
};
admin.createTransaction(req, res);
});
EOF
node scratch/test-create-transaction.js
```
Expected: fails with `Cannot find module '../app/controllers/admin.server.controller'`.
- [ ] **Step 2: Implement `app/controllers/admin.server.controller.js`**
```js
"use strict";
var moment = require("moment");
var uuidv4 = require("uuid").v4;
var config = require(__config_path + "/config");
var AdminTransaction = require("../models/AdminTransaction");
var epaySign = require("../libs/epaySign");
exports.newTransactionForm = function (req, res) {
res.render("admin/transactions-new", {});
};
exports.createTransaction = function (req, res) {
var customerName = req.body.customerName;
var customerPhone = req.body.customerPhone;
var customerAddress = req.body.customerAddress;
var amount = req.body.amount;
if (!customerName || !customerPhone || !customerAddress || !amount) {
return res.status(400).json({ code: "99", data: "MISSING_FIELDS" });
}
var timeStamp = moment().format("YYYYMMDDHHmmss");
var uniqueSuffix = uuidv4().split("-")[0];
var merTrxId = "HY_" + timeStamp + "_" + uniqueSuffix;
var transCode = "HY_RT_" + timeStamp + "_" + uniqueSuffix;
var merchantToken = epaySign.signRequest(
timeStamp,
merTrxId,
config.epay.merchant_id,
amount,
config.epay.encode_key
);
AdminTransaction.create(
{
merTrxId: merTrxId,
transCode: transCode,
customerName: customerName,
customerPhone: customerPhone,
customerAddress: customerAddress,
amount: parseInt(amount),
merchantToken: merchantToken,
timeStamp: timeStamp,
},
function (err, tx) {
if (err) {
return res.status(500).json({ code: "99", data: "DB_ERROR" });
}
var paymentUrl = config.epay.req_domain + "/admin/pay/" + tx.merTrxId;
return res.status(200).json({
code: "00",
data: { merTrxId: tx.merTrxId, paymentUrl: paymentUrl },
});
}
);
};
```
- [ ] **Step 3: Implement `app/views/admin/transactions-new.server.view.html`**
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Thêm mới giao dịch</title>
<style>
body { font-family: Arial, sans-serif; max-width: 480px; margin: 40px auto; }
label { display: block; margin-top: 12px; font-weight: bold; }
input { width: 100%; padding: 8px; margin-top: 4px; box-sizing: border-box; }
button { margin-top: 20px; padding: 10px 20px; background: #2d6cdf; color: #fff; border: none; cursor: pointer; }
#result { margin-top: 20px; padding: 12px; background: #f0f0f0; word-break: break-all; display: none; }
</style>
</head>
<body>
<h2>Thêm mới giao dịch</h2>
<p><a href="/admin/transactions">Xem lịch sử giao dịch</a></p>
<form id="txForm">
<label>Tên khách hàng</label>
<input type="text" name="customerName" required>
<label>Số điện thoại</label>
<input type="text" name="customerPhone" required>
<label>Địa chỉ</label>
<input type="text" name="customerAddress" required>
<label>Số tiền thanh toán (VNĐ)</label>
<input type="number" name="amount" required min="1000">
<button type="submit">Tạo giao dịch</button>
</form>
<div id="result"></div>
<script>
document.getElementById('txForm').addEventListener('submit', function (e) {
e.preventDefault();
var form = e.target;
var data = {
customerName: form.customerName.value,
customerPhone: form.customerPhone.value,
customerAddress: form.customerAddress.value,
amount: form.amount.value
};
fetch('/admin/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(function (r) { return r.json(); })
.then(function (res) {
var el = document.getElementById('result');
el.style.display = 'block';
if (res.code === '00') {
el.innerHTML = 'Link thanh toán: <a href="' + res.data.paymentUrl + '" target="_blank">' + res.data.paymentUrl + '</a>';
} else {
el.innerHTML = 'Lỗi: ' + res.data;
}
});
});
</script>
</body>
</html>
```
- [ ] **Step 4: Run the verification script again**
```bash
node scratch/test-create-transaction.js
```
Expected: `HTTP status: 200`, `merchantToken matches formula: true`, `paymentUrl contains merTrxId: true`.
- [ ] **Step 5: Commit**
```bash
git add app/controllers/admin.server.controller.js app/views/admin/transactions-new.server.view.html
git commit -m "Add admin create-transaction endpoint and form page"
```
---
### Task 8: Payment page (`GET /admin/pay/:merTrxId`)
**Files:**
- Modify: `app/controllers/admin.server.controller.js` (add `exports.payPage`)
- Create: `app/views/admin/pay.server.view.html`
- Test: `scratch/test-pay-page.js`
**Interfaces:**
- Consumes: `AdminTransaction` (Task 5), `config.epay.domain` / `config.epay.merchant_id` / `config.epay.req_domain` (existing).
- Produces: `exports.payPage(req, res)` — renders `admin/pay` with `{ notFound: true }` when no matching `merTrxId`, or `{ notFound: false, tx, domain, merId, reqDomain, callBackUrl, notiUrl }` when found. Consumed by Task 10 (routes).
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-pay-page.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var admin = require("../app/controllers/admin.server.controller");
var AdminTransaction = require("../app/models/AdminTransaction");
function fakeRes(label) {
return {
status: function (code) { this._status = code; return this; },
render: function (view, locals) {
console.log(label, "-> view:", view, "status:", this._status || 200, "notFound:", locals.notFound);
if (label === "found case") {
console.log(" has merId:", !!locals.merId, "has callBackUrl:", !!locals.callBackUrl);
}
},
};
}
admin.payPage({ params: { merTrxId: "DOES_NOT_EXIST" } }, fakeRes("missing case"));
AdminTransaction.create(
{
merTrxId: "HY_PAYPAGETEST_" + Date.now(),
transCode: "HY_RT_PAYPAGETEST",
customerName: "Test User",
customerPhone: "0900000000",
customerAddress: "Addr",
amount: 30000,
merchantToken: "fixture",
timeStamp: "20260101120000",
},
function (err, tx) {
admin.payPage({ params: { merTrxId: tx.merTrxId } }, fakeRes("found case"));
setTimeout(function () { mongoose.connection.close(); }, 500);
}
);
});
EOF
node scratch/test-pay-page.js
```
Expected: fails — `admin.payPage is not a function`.
- [ ] **Step 2: Add `exports.payPage` to `app/controllers/admin.server.controller.js`**
Append to the file (after `exports.createTransaction`):
```js
exports.payPage = function (req, res) {
AdminTransaction.findOne({ merTrxId: req.params.merTrxId }, function (err, tx) {
if (err || !tx) {
return res.status(404).render("admin/pay", { notFound: true });
}
res.render("admin/pay", {
notFound: false,
tx: tx,
domain: config.epay.domain,
merId: config.epay.merchant_id,
reqDomain: config.epay.req_domain,
callBackUrl: config.epay.req_domain + "/admin/epay/return",
notiUrl: config.epay.req_domain + "/admin/epay/ipn",
});
});
};
```
- [ ] **Step 3: Implement `app/views/admin/pay.server.view.html`**
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Thanh toán</title>
{% if not notFound %}
<link rel="stylesheet" href="{{domain}}/pg_was/css/payment/layer/paymentClient.css">
{% endif %}
</head>
<body>
{% if notFound %}
<h2>Không tìm thấy giao dịch</h2>
<p>Đường dẫn thanh toán không hợp lệ hoặc đã hết hạn.</p>
{% else %}
<h2>Thanh toán {{tx.amount}} VNĐ</h2>
<p>Khách hàng: {{tx.customerName}}</p>
<form id="megapayForm" name="megapayForm" method="POST">
<input type="hidden" name="merId" value="{{merId}}">
<input type="hidden" name="currency" value="VND">
<input type="hidden" name="amount" value="{{tx.amount}}">
<input type="hidden" name="invoiceNo" value="{{tx.transCode}}">
<input type="hidden" name="merTrxId" value="{{tx.merTrxId}}">
<input type="hidden" name="goodsNm" value="Thanh toan Hai Yen">
<input type="hidden" name="payType" value="{{tx.payType}}">
<input type="hidden" name="description" value="Thanh toan don hang">
<input type="hidden" name="callBackUrl" value="{{callBackUrl}}">
<input type="hidden" name="notiUrl" value="{{notiUrl}}">
<input type="hidden" name="reqDomain" value="{{reqDomain}}">
<input type="hidden" name="merchantToken" value="{{tx.merchantToken}}">
<input type="hidden" name="timeStamp" value="{{tx.timeStamp}}">
<input type="hidden" name="userLanguage" value="VN">
<input type="hidden" name="windowColor" value="#ef5459">
<input type="hidden" name="windowType" value="">
</form>
<button onclick="openPayment(1, '{{domain}}')">Thanh toán</button>
<script src="/js/jquery.min.js"></script>
<script src="{{domain}}/pg_was/js/payment/layer/paymentClient.js"></script>
{% endif %}
</body>
</html>
```
- [ ] **Step 4: Run the verification script again**
```bash
node scratch/test-pay-page.js
```
Expected:
```
missing case -> view: admin/pay status: 404 notFound: true
found case -> view: admin/pay status: 200 notFound: false
has merId: true has callBackUrl: true
```
- [ ] **Step 5: Commit**
```bash
git add app/controllers/admin.server.controller.js app/views/admin/pay.server.view.html
git commit -m "Add admin payment page (GET /admin/pay/:merTrxId)"
```
---
### Task 9: Return + IPN handlers
**Files:**
- Modify: `app/controllers/admin.server.controller.js` (add `exports.epayReturn`, `exports.epayIPN`)
- Create: `app/views/admin/pay-result.server.view.html`
- Test: `scratch/test-admin-return-ipn.js`
**Interfaces:**
- Consumes: `AdminTransaction` (Task 5), `epaySign.signResponse` (Task 4).
- Produces: `exports.epayReturn(req, res)` (renders `admin/pay-result`, read-only — never writes to the DB), `exports.epayIPN(req, res)` (JSON response, writes `status`/`resultMsg`/`paidAt`, idempotent). Consumed by Task 10 (routes).
- [ ] **Step 1: Write the failing verification script**
This exercises 5 cases: valid success, tampered signature, unknown `merTrxId`, a failed `resultCd`, and calling IPN twice (idempotency).
```bash
cat > scratch/test-admin-return-ipn.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var admin = require("../app/controllers/admin.server.controller");
var epaySign = require("../app/libs/epaySign");
var AdminTransaction = require("../app/models/AdminTransaction");
function fakeRes(label, done) {
return {
status: function (code) { this._status = code; return this; },
render: function (view, locals) {
console.log(label, "render -> status:", this._status || 200, "success:", locals.success);
if (done) done();
},
json: function (body) {
console.log(label, "json -> status:", this._status || 200, "body:", JSON.stringify(body));
if (done) done();
},
};
}
var timeStamp = "20260101120000";
var merTrxId = "HY_RETURNTEST_" + Date.now();
var trxId = "MEGAPAY_TRX_" + Date.now();
var amount = 40000;
AdminTransaction.create(
{
merTrxId: merTrxId,
transCode: "HY_RT_RETURNTEST",
customerName: "Test User",
customerPhone: "0900000000",
customerAddress: "Addr",
amount: amount,
merchantToken: "unused-here",
timeStamp: timeStamp,
},
function (err, tx) {
var validToken = epaySign.signResponse(
"00_000", timeStamp, merTrxId, trxId, config.epay.merchant_id, amount, config.epay.encode_key, ""
);
// Case 1: valid return (read-only, must not touch DB status)
admin.epayReturn(
{ query: { resultCd: "00_000", merTrxId: merTrxId, trxId: trxId, amount: amount, merchantToken: validToken } },
fakeRes("return/valid", function () {
// Case 2: tampered signature via IPN
admin.epayIPN(
{ body: { resultCd: "00_000", merTrxId: merTrxId, trxId: trxId, amount: amount, merchantToken: "TAMPERED" } },
fakeRes("ipn/tampered", function () {
// Case 3: unknown merTrxId
admin.epayIPN(
{ body: { resultCd: "00_000", merTrxId: "DOES_NOT_EXIST", trxId: trxId, amount: amount, merchantToken: "x" } },
fakeRes("ipn/unknown-trx", function () {
// Case 4: valid IPN -> should mark success
admin.epayIPN(
{ body: { resultCd: "00_000", merTrxId: merTrxId, trxId: trxId, amount: amount, merchantToken: validToken, resultMsg: "SUCCESS" } },
fakeRes("ipn/valid #1", function () {
// Case 5: same valid IPN again -> idempotent, still success, no error
admin.epayIPN(
{ body: { resultCd: "00_000", merTrxId: merTrxId, trxId: trxId, amount: amount, merchantToken: validToken, resultMsg: "SUCCESS" } },
fakeRes("ipn/valid #2 (retry)", function () {
AdminTransaction.findOne({ merTrxId: merTrxId }, function (err, finalTx) {
console.log("final status:", finalTx.status, "paidAt set:", !!finalTx.paidAt);
mongoose.connection.close();
});
})
);
})
);
})
);
})
);
})
);
}
);
});
EOF
node scratch/test-admin-return-ipn.js
```
Expected: fails — `admin.epayReturn is not a function`.
- [ ] **Step 2: Add `exports.epayReturn` and `exports.epayIPN`**
Append to `app/controllers/admin.server.controller.js` (after `exports.payPage`):
```js
exports.epayReturn = function (req, res) {
var q = req.query;
var resultCd = q.resultCd;
var merTrxId = q.merTrxId;
if (resultCd !== "00_000") {
return res.render("admin/pay-result", {
success: false,
message: q.resultMsg || "Giao dịch không thành công",
});
}
AdminTransaction.findOne({ merTrxId: merTrxId }, function (err, tx) {
if (err || !tx) {
return res.render("admin/pay-result", {
success: false,
message: "Không tìm thấy giao dịch",
});
}
var expected = epaySign.signResponse(
resultCd,
tx.timeStamp,
tx.merTrxId,
q.trxId,
config.epay.merchant_id,
tx.amount,
config.epay.encode_key,
q.payToken
);
if (expected !== q.merchantToken) {
return res.render("admin/pay-result", {
success: false,
message: "Chữ ký không đúng",
});
}
return res.render("admin/pay-result", {
success: true,
message: "Thanh toán thành công",
});
});
};
exports.epayIPN = function (req, res) {
var b = req.body;
var resultCd = b.resultCd;
var merTrxId = b.merTrxId;
AdminTransaction.findOne({ merTrxId: merTrxId }, function (err, tx) {
if (err || !tx) {
return res.status(400).json({ code: "99", data: "TRX_NOT_FOUND" });
}
var expected = epaySign.signResponse(
resultCd,
tx.timeStamp,
tx.merTrxId,
b.trxId,
config.epay.merchant_id,
tx.amount,
config.epay.encode_key,
b.payToken
);
if (expected !== b.merchantToken) {
return res.status(400).json({ code: "99", data: "INVALID_SIGNATURE" });
}
var newStatus = resultCd === "00_000" ? "success" : "failed";
AdminTransaction.updateOne(
{ merTrxId: merTrxId },
{
status: newStatus,
resultMsg: b.resultMsg || "",
paidAt: newStatus === "success" ? new Date() : tx.paidAt,
},
function (updateErr) {
if (updateErr) {
return res.status(500).json({ code: "99", data: "DB_ERROR" });
}
return res.status(200).json({ code: "00", data: "Success" });
}
);
});
};
```
- [ ] **Step 3: Implement `app/views/admin/pay-result.server.view.html`**
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Kết quả thanh toán</title>
<style>
body { font-family: Arial, sans-serif; max-width: 480px; margin: 60px auto; text-align: center; }
</style>
</head>
<body>
{% if success %}
<h2 style="color:#276b45">Thanh toán thành công</h2>
{% else %}
<h2 style="color:#a3352a">Thanh toán thất bại</h2>
<p>{{message}}</p>
{% endif %}
</body>
</html>
```
- [ ] **Step 4: Run the verification script again**
```bash
node scratch/test-admin-return-ipn.js
```
Expected:
```
return/valid render -> status: 200 success: true
ipn/tampered json -> status: 400 body: {"code":"99","data":"INVALID_SIGNATURE"}
ipn/unknown-trx json -> status: 400 body: {"code":"99","data":"TRX_NOT_FOUND"}
ipn/valid #1 json -> status: 200 body: {"code":"00","data":"Success"}
ipn/valid #2 (retry) json -> status: 200 body: {"code":"00","data":"Success"}
final status: success paidAt set: true
```
- [ ] **Step 5: Commit**
```bash
git add app/controllers/admin.server.controller.js app/views/admin/pay-result.server.view.html
git commit -m "Add admin return/IPN handlers with signature verification"
```
---
### Task 10: History list page + route wiring
**Files:**
- Modify: `app/controllers/admin.server.controller.js` (add `exports.listTransactions`)
- Create: `app/views/admin/transactions-list.server.view.html`
- Create: `app/routes/admin.server.routes.js`
- Test: `scratch/test-admin-routes.js`
**Interfaces:**
- Consumes: everything from Tasks 4–9.
- Produces: the full route table from spec §3.3, wired via the existing glob-based route loading in `config/express.js` (`config.getGlobbedFiles('./app/routes/**/*.js')`) — no manual registration needed elsewhere.
- [ ] **Step 1: Add `exports.listTransactions`**
Append to `app/controllers/admin.server.controller.js`:
```js
exports.listTransactions = function (req, res) {
var page = parseInt(req.query.page) || 1;
var perPage = 20;
AdminTransaction.find({})
.sort({ createdAt: -1 })
.skip((page - 1) * perPage)
.limit(perPage)
.exec(function (err, list) {
if (err) {
return res.status(500).send("Lỗi cơ sở dữ liệu");
}
var transactions = list.map(function (tx) {
return {
merTrxId: tx.merTrxId,
customerName: tx.customerName,
customerPhone: tx.customerPhone,
amount: tx.amount,
status: tx.status,
resultMsg: tx.resultMsg,
createdAt: moment(tx.createdAt).format("DD/MM/YYYY HH:mm"),
paymentUrl: config.epay.req_domain + "/admin/pay/" + tx.merTrxId,
};
});
res.render("admin/transactions-list", {
transactions: transactions,
page: page,
});
});
};
```
- [ ] **Step 2: Implement `app/views/admin/transactions-list.server.view.html`**
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Lịch sử giao dịch</title>
<style>
body { font-family: Arial, sans-serif; max-width: 900px; margin: 40px auto; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; font-size: 14px; }
th { background: #f0f0f0; }
.status-pending { color: #b8860b; }
.status-success { color: #276b45; }
.status-failed { color: #a3352a; }
</style>
</head>
<body>
<h2>Lịch sử giao dịch</h2>
<p><a href="/admin/transactions/new">+ Thêm mới giao dịch</a></p>
<table>
<thead>
<tr>
<th>Thời gian</th>
<th>Khách hàng</th>
<th>SĐT</th>
<th>Số tiền</th>
<th>Trạng thái</th>
<th>Link thanh toán</th>
</tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr>
<td>{{tx.createdAt}}</td>
<td>{{tx.customerName}}</td>
<td>{{tx.customerPhone}}</td>
<td>{{tx.amount}}</td>
<td class="status-{{tx.status}}">{{tx.status}}</td>
<td><a href="{{tx.paymentUrl}}" target="_blank">Link</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
```
- [ ] **Step 3: Implement `app/routes/admin.server.routes.js`**
```js
"use strict";
module.exports = function (app) {
var admin = require("../../app/controllers/admin.server.controller");
var basicAuth = require("../middlewares/basicAuth");
app.route("/admin/transactions")
.get(basicAuth, admin.listTransactions)
.post(basicAuth, admin.createTransaction);
app.route("/admin/transactions/new").get(basicAuth, admin.newTransactionForm);
app.route("/admin/pay/:merTrxId").get(admin.payPage);
app.route("/admin/epay/return").get(admin.epayReturn);
app.route("/admin/epay/ipn").post(admin.epayIPN);
};
```
- [ ] **Step 4: Boot the real app and verify over HTTP**
Standalone Mongo from Global Constraints must be running. In one shell:
```bash
NODE_ENV=test node server.js &
sleep 2
```
In another shell, run each check (values match the `ADMIN_USER=admin` / `ADMIN_PASSWORD=devpassword123` from the Global Constraints `.env` — adjust if you used different ones):
```bash
echo "no auth -> expect 401:"
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8092/admin/transactions
echo "wrong auth -> expect 401:"
curl -sS -o /dev/null -w "%{http_code}\n" -u admin:wrongpass http://localhost:8092/admin/transactions
echo "correct auth -> expect 200:"
curl -sS -o /dev/null -w "%{http_code}\n" -u admin:devpassword123 http://localhost:8092/admin/transactions
echo "new-transaction form, correct auth -> expect 200:"
curl -sS -o /dev/null -w "%{http_code}\n" -u admin:devpassword123 http://localhost:8092/admin/transactions/new
echo "create transaction, correct auth -> expect 200 + JSON with paymentUrl:"
curl -sS -u admin:devpassword123 -X POST http://localhost:8092/admin/transactions \
-H "Content-Type: application/json" \
-d '{"customerName":"Nguyen Van A","customerPhone":"0912345678","customerAddress":"123 Le Loi","amount":"30000"}'
```
Copy the `merTrxId` from that last JSON response, then:
```bash
echo "pay page, NO auth needed -> expect 200:"
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8092/admin/pay/<merTrxId>
```
Expected: status codes `401`, `401`, `200`, `200`, then a `200` JSON body with `"code":"00"` and a `data.paymentUrl`, then `200` for the pay page. Stop the app afterward: `kill %1`.
- [ ] **Step 5: Commit**
```bash
git add app/controllers/admin.server.controller.js app/views/admin/transactions-list.server.view.html app/routes/admin.server.routes.js
git commit -m "Add admin transaction history page and wire up all admin routes"
```
---
### Task 11: Full Docker Compose integration test
**Files:** none (verification-only task)
**Interfaces:** none new — exercises everything from Tasks 1–10 together in the real deployment shape.
- [ ] **Step 1: Stop the standalone dev Mongo from earlier tasks**
```bash
docker stop plan-mongo 2>/dev/null || true
```
- [ ] **Step 2: Add real values to the local `.env` used by Docker Compose**
Confirm `.env` (the real, gitignored one at the repo root — the same file `docker-compose.yml`'s `env_file: .env` reads) has:
```
MONGO_URI=mongodb://mongo:27017/haiyen_admin
ADMIN_USER=admin
ADMIN_PASSWORD=<pick a real password, not devpassword123>
```
- [ ] **Step 3: Build and start the full stack**
```bash
docker compose up -d --build
docker compose ps
```
Expected: `app`, `redis`, and `mongo` all show `Up` (`app` should reach `healthy` within ~30s).
- [ ] **Step 4: Verify Mongo connected inside the container**
```bash
docker compose logs app | grep -i "mongodb connected"
```
Expected: the line is present, no `MongoDB connection error` lines.
- [ ] **Step 5: End-to-end smoke test through the real container**
```bash
echo "no auth -> expect 401:"
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:7003/admin/transactions
echo "correct auth -> expect 200:"
curl -sS -o /dev/null -w "%{http_code}\n" -u admin:<your real password> http://localhost:7003/admin/transactions
echo "create transaction:"
curl -sS -u admin:<your real password> -X POST http://localhost:7003/admin/transactions \
-H "Content-Type: application/json" \
-d '{"customerName":"Nguyen Van A","customerPhone":"0912345678","customerAddress":"123 Le Loi","amount":"30000"}'
```
Expected: same status codes as Task 10's verification, now proven through the actual Docker image (matching how this app is deployed) rather than a bare `node server.js` process.
- [ ] **Step 6: Clean up scratch scripts not meant to be committed**
Everything under `scratch/` is gitignored (Task 1) — no cleanup commit needed, but you may `rm -rf scratch/*` locally if you want a clean working tree.
- [ ] **Step 7: Final commit (if anything changed)**
```bash
git status --short
```
If nothing is staged, there's nothing to commit — Task 11 is verification-only. If you touched `.env.example` or any tracked file while working through this task, commit that now with a message describing what changed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment