Commit cbc219c9 by tdgiang

Merge branch 'feature/admin-transaction-management' into dev

# Conflicts:
#	.gitignore
parents 73804b8d 557e8965
...@@ -42,3 +42,8 @@ ALEPAY_CHECKSUM_KEY= ...@@ -42,3 +42,8 @@ ALEPAY_CHECKSUM_KEY=
# Appota # Appota
APPOTA_API_KEY= APPOTA_API_KEY=
APPOTA_SECRET_KEY= APPOTA_SECRET_KEY=
# Admin transaction management
MONGO_URI=
ADMIN_USER=
ADMIN_PASSWORD=
...@@ -12,3 +12,4 @@ app/tests/coverage/ ...@@ -12,3 +12,4 @@ app/tests/coverage/
*.sublime-project *.sublime-project
dist/ dist/
.worktrees/ .worktrees/
scratch/
"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 rawAmount = req.body.amount;
if (!customerName || !customerPhone || !customerAddress || !rawAmount) {
return res.status(400).json({ code: "99", data: "MISSING_FIELDS" });
}
var amount = parseInt(rawAmount, 10);
if (!isFinite(amount) || amount <= 0 || String(amount) !== String(rawAmount).trim()) {
return res.status(400).json({ code: "99", data: "INVALID_AMOUNT" });
}
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: amount,
merchantToken: merchantToken,
timeStamp: timeStamp,
},
function (err, tx) {
if (err) {
console.error("createTransaction: DB error:", err.message);
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 },
});
}
);
};
exports.payPage = function (req, res) {
AdminTransaction.findOne({ merTrxId: req.params.merTrxId }, function (err, tx) {
if (err) {
console.error("payPage: DB error for merTrxId=" + req.params.merTrxId + ":", err.message);
return res.status(503).render("admin/pay", { notFound: true });
}
if (!tx) {
return res.status(404).render("admin/pay", { notFound: true });
}
res.render("admin/pay", {
notFound: false,
alreadyPaid: tx.status !== "pending",
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",
});
});
};
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) {
console.error("epayReturn: DB error for merTrxId=" + merTrxId + ":", err.message);
return res.status(503).render("admin/pay-result", {
success: false,
message: "Hệ thống đang bận, vui lòng thử lại sau",
});
}
if (!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) {
console.error("epayReturn: signature mismatch for merTrxId=" + merTrxId);
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) {
console.error("epayIPN: DB error for merTrxId=" + merTrxId + ":", err.message);
return res.status(503).json({ code: "99", data: "DB_UNAVAILABLE" });
}
if (!tx) {
console.error("epayIPN: TRX_NOT_FOUND for merTrxId=" + merTrxId);
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) {
console.error("epayIPN: INVALID_SIGNATURE for merTrxId=" + merTrxId + " resultCd=" + resultCd);
return res.status(400).json({ code: "99", data: "INVALID_SIGNATURE" });
}
if (tx.status !== "pending") {
// Already terminal - acknowledge without re-processing. Never let a later IPN
// (retry, or a duplicate payment attempt) downgrade an already-settled result.
return res.status(200).json({ code: "00", data: "Success" });
}
var newStatus = resultCd === "00_000" ? "success" : "failed";
AdminTransaction.updateOne(
{ merTrxId: merTrxId, status: "pending" },
{
status: newStatus,
resultMsg: b.resultMsg || "",
paidAt: newStatus === "success" ? new Date() : tx.paidAt,
},
function (updateErr) {
if (updateErr) {
console.error("epayIPN: DB error updating merTrxId=" + merTrxId + ":", updateErr.message);
return res.status(500).json({ code: "99", data: "DB_ERROR" });
}
return res.status(200).json({ code: "00", data: "Success" });
}
);
});
};
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) {
console.error("listTransactions: DB error:", err.message);
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,
prevPage: page - 1,
nextPage: page + 1,
hasPrev: page > 1,
hasNext: transactions.length === perPage,
});
});
};
"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,
};
"use strict";
var crypto = require("crypto");
function safeEqual(a, b) {
var bufA = Buffer.from(String(a));
var bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
module.exports = function basicAuth(req, res, next) {
var config = require(__config_path + "/config");
var expectedUser = config.admin && config.admin.user;
var expectedPassword = config.admin && config.admin.password;
if (!expectedUser || !expectedPassword) {
console.error("basicAuth: ADMIN_USER/ADMIN_PASSWORD not configured - denying all admin access");
return res.status(500).send("Admin auth not configured.");
}
var header = req.headers.authorization || "";
var token = header.slice(0, 6).toLowerCase() === "basic " ? header.slice(6) : "";
var decoded = Buffer.from(token, "base64").toString("utf8");
var sepIndex = decoded.indexOf(":");
var user = sepIndex === -1 ? decoded : decoded.slice(0, sepIndex);
var password = sepIndex === -1 ? "" : decoded.slice(sepIndex + 1);
if (safeEqual(user, expectedUser) && safeEqual(password, expectedPassword)) {
return next();
}
res.set("WWW-Authenticate", 'Basic realm="Admin"');
return res.status(401).send("Authentication required.");
};
"use strict";
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var AdminTransactionSchema = new Schema(
{
merTrxId: { type: String, required: true, unique: 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);
"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);
};
<!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>
<!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 %}
{% if alreadyPaid %}
<h2>Giao dịch này đã được xử lý</h2>
<p>Trạng thái: {{tx.status}}</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 %}
{% endif %}
</body>
</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: 1000px; 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; }
.pager { margin-top: 16px; }
.pager a { margin-right: 12px; }
</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>Mã giao dịch</th>
<th>Khách hàng</th>
<th>SĐT</th>
<th>Số tiền</th>
<th>Trạng thái</th>
<th>Ghi chú</th>
<th>Link thanh toán</th>
</tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr>
<td>{{tx.createdAt}}</td>
<td>{{tx.merTrxId}}</td>
<td>{{tx.customerName}}</td>
<td>{{tx.customerPhone}}</td>
<td>{{tx.amount}}</td>
<td class="status-{{tx.status}}">{{tx.status}}</td>
<td>{{tx.resultMsg}}</td>
<td><a href="{{tx.paymentUrl}}" target="_blank">Link</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="pager">
{% if hasPrev %}<a href="/admin/transactions?page={{prevPage}}">&laquo; Trang trước</a>{% endif %}
Trang {{page}}
{% if hasNext %}<a href="/admin/transactions?page={{nextPage}}">Trang sau &raquo;</a>{% endif %}
</p>
</body>
</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>
...@@ -108,5 +108,10 @@ module.exports = { ...@@ -108,5 +108,10 @@ module.exports = {
cancel_url: "https://pg.cbotvietnam.com/appota/cancel", cancel_url: "https://pg.cbotvietnam.com/appota/cancel",
ipn_url: "https://pg.cbotvietnam.com/appota/ipn", ipn_url: "https://pg.cbotvietnam.com/appota/ipn",
}, },
mongoUri: process.env.MONGO_URI || "mongodb://mongo:27017/haiyen_admin",
admin: {
user: process.env.ADMIN_USER,
password: process.env.ADMIN_PASSWORD,
},
encrypt_key: process.env.ENCRYPT_KEY, encrypt_key: process.env.ENCRYPT_KEY,
}; };
...@@ -14,6 +14,7 @@ services: ...@@ -14,6 +14,7 @@ services:
- "7003:3003" - "7003:3003"
depends_on: depends_on:
- redis - redis
- mongo
volumes: volumes:
# Named volume, not a host bind-mount: a bind-mounted host directory # Named volume, not a host bind-mount: a bind-mounted host directory
# keeps the host's ownership (usually root), which the container's # keeps the host's ownership (usually root), which the container's
...@@ -27,6 +28,13 @@ services: ...@@ -27,6 +28,13 @@ services:
volumes: volumes:
- redis-data:/data - redis-data:/data
mongo:
image: mongo:7
restart: unless-stopped
volumes:
- mongo-data:/data/db
volumes: volumes:
redis-data: redis-data:
app-log: app-log:
mongo-data:
...@@ -12,6 +12,21 @@ var init = require('./config/init')(), ...@@ -12,6 +12,21 @@ var init = require('./config/init')(),
// Avoids DEPTH_ZERO_SELF_SIGNED_CERT error for self-signed certs // Avoids DEPTH_ZERO_SELF_SIGNED_CERT error for self-signed certs
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
mongoose.set('bufferCommands', false);
mongoose.connect(config.mongoUri, {
useNewUrlParser: true,
useUnifiedTopology: true,
serverSelectionTimeoutMS: 5000
}).catch(function (err) {
console.error('MongoDB initial connection failed (admin module degraded):', err.message);
});
mongoose.connection.on('error', function (err) {
console.error('MongoDB connection error:', err.message);
});
mongoose.connection.once('open', function () {
console.log('MongoDB connected');
});
/** /**
* Main application entry file. * Main application entry file.
* Please note that the order of loading is important. * Please note that the order of loading is important.
......
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