Commit cb12e735 by tdgiang

Add spec and implementation plan for Sổ Bán Lẻ auto-order feature

Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KPzWwuTEeX2vXGXvyGn4q
parent 798b419d
# Chức năng: Tạo đơn hàng tự động (tích hợp Sổ Bán Lẻ)
## 1. Mục tiêu
Khi admin tạo giao dịch thanh toán mới (trang `admin/transactions/new`), thay vì
dùng thẳng số tiền admin nhập, hệ thống tự động:
1. Tạo trước một đơn hàng bên hệ thống **Sổ Bán Lẻ** (sobanle.com, POS của
cửa hàng Hải Yến) — chọn ngẫu nhiên tổ hợp sản phẩm có sẵn trong kho sao
cho tổng tiền đơn hàng gần bằng số tiền admin nhập.
2. Dùng **tổng tiền thật** của đơn hàng vừa tạo (có thể thấp hơn số tiền
nhập tối đa 50.000đ) để tạo giao dịch thanh toán (payment-gate) như luồng
hiện tại.
3. Khi giao dịch thanh toán được xác nhận thành công (IPN từ provider), gọi
thêm API cập nhật trạng thái đơn hàng bên Sổ Bán Lẻ thành "đã thanh toán".
Nguồn: `docs/readme.md` (yêu cầu gốc) + phần brainstorm làm rõ trong hội thoại.
## 2. Luồng dữ liệu
```
Admin nhập tên/SĐT/địa chỉ/số tiền dự kiến (form admin/transactions/new)
→ POST admin/transactions (createTransaction, admin.server.controller.js)
1. SobanleClient.getToken()
- lấy JWT bằng API login, cache token (memory hoặc Redis sẵn có)
- tự login lại khi token hết hạn (theo `exp` trong JWT) hoặc gặp 401
2. SobanleClient.getProducts()
- lấy danh sách sản phẩm còn active trong kho
3. pickProductCombo(products, targetAmount)
- mỗi sản phẩm có thuế suất riêng (`tax.rate`, %); tổng dùng để so
khớp target là tổng ĐÃ GỒM thuế của từng dòng
(`line_total = qty * unit_price * (1 + tax.rate/100)`, làm tròn
về đơn vị đồng)
- chọn ngẫu nhiên tổ hợp sản phẩm, tổng (đã gồm thuế) nằm trong
[targetAmount - 50000, targetAmount] (không được vượt targetAmount)
- không tìm được tổ hợp phù hợp sau N lần thử → trả lỗi
4. SobanleClient.createSale(customerData, listProduct, sale_status: 2)
- customerData lấy từ tên/SĐT/địa chỉ admin vừa nhập; các field
không có dữ liệu tương ứng (tax_no, id_card_number,
passport_number, email) để trống/null
- warehouse_id / biller_id / account_id / currency_id: hằng số cố
định lấy từ config (một cửa hàng Hải Yến duy nhất)
- bất kỳ lỗi nào ở bước 1-4 → dừng lại, KHÔNG tạo AdminTransaction,
trả lỗi cho admin (không tạo giao dịch thanh toán mồ côi không
gắn với đơn hàng gốc)
5. Lấy orderTotal (tổng tiền thật) + sobanleOrderId từ kết quả bước 4
6. AdminTransaction.create({ ..., amount: orderTotal, sobanleOrderId })
- giữ nguyên logic tạo merTrxId/transCode/merchantToken hiện có,
chỉ đổi nguồn `amount` từ input người dùng sang orderTotal thật
7. Trả paymentUrl như luồng hiện tại
epayIPN (đã có sẵn, admin.server.controller.js dòng 144-198)
→ khi cập nhật status thành "success" (sau updateOne):
SobanleClient.changeSaleStatus(tx.sobanleOrderId, sale_status: 4)
- lỗi ở bước này: chỉ log, KHÔNG rollback giao dịch thanh toán (tiền
đã về tài khoản) — xem mục Rủi ro/Ngoài phạm vi
```
## 3. Thuật toán chọn sản phẩm (`pickProductCombo`)
- Mỗi sản phẩm trả về từ API có object `tax: { id, name, rate, is_active,
... }``rate` là % thuế (vd `5` = VAT 5%). Đơn giá sản phẩm được coi là
giá CHƯA thuế; dòng hàng tính:
`line_total = round(qty * unit_price * (1 + tax.rate / 100))`.
- Bỏ qua sản phẩm có `qty` (tồn kho) `<= 0`.
- Shuffle ngẫu nhiên danh sách sản phẩm.
- Duyệt qua danh sách đã shuffle, với mỗi sản phẩm chọn số lượng ngẫu nhiên
nhỏ, không vượt quá tồn kho (`qty` của sản phẩm), ví dụ
`min(1-3 ngẫu nhiên, product.qty)`, cộng dồn `line_total` (đã gồm thuế)
vào tổng nếu không vượt quá `targetAmount`.
- Dừng khi tổng (đã gồm thuế) đạt khoảng `[targetAmount - 50000,
targetAmount]`.
- Nếu duyệt hết danh sách mà chưa đạt khoảng cho phép → thử lại (shuffle lại
từ đầu), tối đa N lần (đề xuất N = 20).
- Hết N lần vẫn không đạt → trả lỗi `NO_PRODUCT_COMBO_MATCH`, dừng toàn bộ
luồng tạo giao dịch.
- `orderTotal` dùng để tạo giao dịch thanh toán (mục 2, bước 5) = tổng đã
gồm thuế của tổ hợp được chọn.
## 4. API Sổ Bán Lẻ sử dụng
Toàn bộ base URL: `https://haiyen.sobanle.com/api/jwt`. Token JWT lấy 1 lần
từ API login, dùng lại (cache) cho cả 3 API còn lại — không cần login riêng
cho từng API. Các response shape dưới đây đã verify thật (không phải suy
đoán).
| Việc | Method | Path | Request | Response (field dùng) |
|---|---|---|---|---|
| Đăng nhập lấy token | POST | `/login` | `{ login, password }` | `access_token` (top-level, không nằm trong `data`), `expires_in` (giây, TTL cache token — không cần decode JWT) |
| Lấy danh sách sản phẩm | GET | `/products?page=1&is_active=true&per_page=1000` | header `Authorization: Bearer <token>` | `data`: mảng sản phẩm thẳng (không lồng thêm cấp); field dùng: `id`, `price` (giá CHƯA thuế — verify: `price` = `original_price` cộng biên lợi nhuận, tách biệt thuế), `qty` (tồn kho — KHÔNG được chọn số lượng vượt quá field này), `is_active`, `tax: { id, name, rate }` |
| Tạo đơn hàng | POST | `/sales` | `{ warehouse_id, biller_id, account_id, currency_id, exchange_rate: "1", reference_no: null, is_internal_api: true, sale_status: 2, list_product: [{product_id, qty}], customer_data {...}, payment_receiver, payment_note, sale_note, staff_note }` | `data.id` (orderId), `data.grand_total` (orderTotal thật) |
| Cập nhật trạng thái đơn hàng | PATCH | `/sales/change-sale-status/{id}` | `{ sale_status: 4 }` (4 = đã thanh toán/hoàn tất) | `message` — gọi sau khi giao dịch thanh toán bên payment-gate xác nhận thành công |
Ghi chú bảo mật: các ví dụ curl trong `docs/readme.md` chứa token/cookie
sống thật — không copy trực tiếp vào code hay commit. Client mới phải tự
login lấy token qua API, không dùng token/cookie đã bị lộ trong tài liệu.
## 5. Thay đổi file/module
- **Mới** `app/libs/SobanleClient.js` — login/cache token, `getProducts`,
`createSale`, `changeSaleStatus`. Dùng `ApiRequest` sẵn có trong
`app/libs/ApiRequest.js` theo pattern các provider khác trong repo (không
tạo HTTP client mới).
- **Mới** `app/libs/productComboPicker.js` — thuật toán chọn tổ hợp sản
phẩm (mục 3), tách riêng khỏi phần gọi mạng để dễ test độc lập.
- **Sửa** `app/libs/ApiRequest.js` — bổ sung `getOtherUrlWithHeader`
`patchOtherUrlWithHeader` (thư viện hiện có chỉ hỗ trợ POST kèm header
tuỳ chỉnh qua `postOtherUrlWithHeader`, thiếu biến thể GET/PATCH cần cho
lấy sản phẩm có Bearer token và cập nhật trạng thái đơn hàng).
- **Sửa** `config/env/all.js` — thêm block:
```js
sobanle: {
base_url: "https://haiyen.sobanle.com/api/jwt",
username: process.env.SOBANLE_USERNAME,
password: process.env.SOBANLE_PASSWORD,
warehouse_id: "1",
biller_id: "1",
account_id: "1",
currency_id: "1",
}
```
- **Sửa** `app/models/AdminTransaction.js` — thêm field
`sobanleOrderId: { type: String, default: null }`.
- **Sửa** `app/controllers/admin.server.controller.js`:
- `createTransaction` — chèn bước gọi `SobanleClient` trước khi
`AdminTransaction.create`, đổi `amount` thành `orderTotal`.
- `epayIPN` — sau khi `updateOne` set `status: "success"` thành công, gọi
`SobanleClient.changeSaleStatus`.
## 6. Xử lý lỗi
| Tình huống | Xử lý |
|---|---|
| Login sổ bán lẻ lỗi / token không lấy được | Dừng, trả lỗi cho admin, không tạo giao dịch |
| API lấy sản phẩm lỗi | Dừng, trả lỗi cho admin |
| Không tìm được tổ hợp sản phẩm khớp sai số | Dừng, trả lỗi cho admin (`NO_PRODUCT_COMBO_MATCH`) |
| API tạo đơn hàng lỗi | Dừng, trả lỗi cho admin |
| API cập nhật trạng thái đơn hàng lỗi (sau khi đã thanh toán thành công) | Chỉ log lỗi, không rollback giao dịch — tiền đã về tài khoản |
## 7. Rủi ro / Ngoài phạm vi (chưa xử lý ở bước này)
- Nếu bước cập nhật trạng thái đơn hàng (mục 6, dòng cuối) thất bại, đơn
hàng bên Sổ Bán Lẻ có thể bị kẹt ở trạng thái "chờ xử lý" dù khách đã
thanh toán xong — cần cơ chế retry/queue trong bước triển khai sau, chưa
thiết kế ở tài liệu này.
- Danh sách sản phẩm (`getProducts`) gọi lại mỗi lần tạo giao dịch, chưa có
cache — cần đánh giá hiệu năng nếu tần suất tạo giao dịch cao.
- warehouse_id/biller_id/account_id/currency_id hiện cố định 1 cửa hàng duy
nhất; nếu sau này multi-tenant theo subdomain (`checkSubDomain` middleware
đã có trong app) cần thiết kế lại thành cấu hình theo tenant.
# Sổ Bán Lẻ Auto-Order 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:** When an admin creates a payment transaction in `admin/transactions/new`, automatically create a matching sales order in the external Sổ Bán Lẻ POS (sobanle.com) first, then create the payment-gate transaction using that order's real total — and mark the order paid via Sổ Bán Lẻ's API once the payment IPN confirms success.
**Architecture:** A new pure algorithm module (`productComboPicker.js`) randomly selects a product combo whose tax-inclusive total is within 50,000₫ of (and never exceeds) the admin's input amount. A new network client module (`SobanleClient.js`) handles login/token caching, product lookup, order creation, and status update against `https://haiyen.sobanle.com/api/jwt`, built on the existing `ApiRequest` lib (extended with two small header-aware helpers). `admin.server.controller.js`'s `createTransaction` and `epayIPN` are modified to call into this client at the two points identified in the spec. `AdminTransaction` gains a `sobanleOrderId` field linking the two systems.
**Tech Stack:** Node.js (ES5-style, matches existing codebase), Express 4, Mongoose 5.7, existing `request`-based `ApiRequest` lib. No new npm dependencies.
**Spec:** `docs/description.md`
## Global Constraints
- No real automated test framework exists in this repo — verification for every task is a throwaway `scratch/` script (gitignored, see `.gitignore` lines 14-15) run with plain `node`, following the exact pattern used in `docs/superpowers/plans/2026-08-29-admin-account-management.md`. No assert library — scripts `console.log` labeled boolean checks (`"X correct:", actual === expected`).
- Tolerance: order total must satisfy `targetAmount - 50000 <= total <= targetAmount` (never exceed the admin's input amount), and `total` must come from at least one selected product line (never 0).
- Product combo retry budget: `maxAttempts = 20` reshuffles before giving up and failing the whole `createTransaction` call with no DB row written.
- Sổ Bán Lẻ base URL: `https://haiyen.sobanle.com/api/jwt`. Verified request/response shapes (do not guess other fields):
- `POST /login` — body `{ login, password }` → response `{ access_token, expires_in, ... }` (token at top level, `expires_in` in seconds).
- `GET /products?page=1&is_active=true&per_page=1000` — response `{ data: [ { id, price, qty, is_active, tax: { rate }, ... } ], pagination: {...} }`. `price` is tax-EXCLUSIVE. `qty` is stock on hand — never select more than this per product.
- `POST /sales` — body `{ warehouse_id, biller_id, account_id, currency_id, exchange_rate: "1", reference_no: null, is_internal_api: true, sale_status: 2, list_product: [{product_id, qty}], customer_data: {...}, payment_receiver, payment_note, sale_note, staff_note }` → response `{ data: { id, grand_total, ... }, message }`.
- `PATCH /sales/change-sale-status/{id}` — body `{ sale_status: 4 }` → response `{ data: {...}, message }`. `4` = paid/completed. Called only after payment IPN confirms success; failure here is logged only, never rolls back the payment.
- `warehouse_id`, `biller_id`, `account_id`, `currency_id` are fixed config constants (`"1"` each) — single store, no multi-tenant handling in this plan.
- Real Sổ Bán Lẻ credentials (`SOBANLE_USERNAME`/`SOBANLE_PASSWORD`) go in local `.env` only — never write a real password into a committed file, a scratch script, or this plan. All scratch scripts in Tasks 1-6 test against a local fixture HTTP server (`require("http")`, no new dependency) — they never call the real `haiyen.sobanle.com`. Only the final manual verification task touches the real service.
- Internal module functions that need to be mockable from scratch scripts (any function `createAutoOrder`/`getProducts`/etc. calls internally) must be invoked via `exports.fn(...)` / `module.exports.fn(...)`, not a local variable reference — otherwise a scratch script monkey-patching `SobanleClient.someFn = ...` won't actually intercept the call.
- `AdminTransaction.amount` is set from the Sổ Bán Lẻ order's real `grand_total`, never directly from the admin's raw form input.
- Existing `POST /admin/transactions` JSON response contract (`{code, data: {merTrxId, paymentUrl}}`) is unchanged.
---
### Task 1: `ApiRequest.js` — header-aware GET/PATCH + status code passthrough
**Files:**
- Modify: `app/libs/ApiRequest.js`
- Test: `scratch/test-sobanle-apirequest-headers.js`
**Interfaces:**
- Produces: `ApiRequest.getOtherUrlWithHeader(apiName, param, headers, callback)` and `ApiRequest.patchOtherUrlWithHeader(apiName, param, headers, callback)` — both call `callback(err, body, statusCode)` (3rd arg is the HTTP status code, `undefined` on network-level `err`). `ApiRequest.postOtherUrlWithHeader` (existing function) is extended to also pass `statusCode` as a 3rd callback arg — existing callers (`core.server.controller.js:2041`) are unaffected since they only read the first 2 args.
- Consumed by: Task 5 (`SobanleClient.js``getProducts`, `createSale`, `changeSaleStatus`).
- [ ] **Step 1: Write the failing verification script**
```bash
mkdir -p scratch
cat > scratch/test-sobanle-apirequest-headers.js << 'EOF'
"use strict";
var http = require("http");
var ApiRequest = require("../app/libs/ApiRequest");
var server = http.createServer(function (req, res) {
var chunks = [];
req.on("data", function (c) { chunks.push(c); });
req.on("end", function () {
var body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
if (req.url.indexOf("/get-ok") === 0) {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ receivedAuth: req.headers["authorization"] || null, url: req.url }));
}
if (req.url.indexOf("/get-401") === 0) {
res.writeHead(401, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: "unauthorized" }));
}
if (req.url.indexOf("/patch-ok") === 0 && req.method === "PATCH") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ receivedAuth: req.headers["authorization"] || null, receivedBody: body }));
}
res.writeHead(404);
res.end();
});
});
server.listen(0, function () {
var port = server.address().port;
var base = "http://127.0.0.1:" + port;
var pending = 2;
ApiRequest.getOtherUrlWithHeader(base + "/get-ok?page=1&", {}, { Authorization: "Bearer tok123" }, function (err, body, statusCode) {
console.log("GET no error:", !err);
console.log("GET status 200:", statusCode === 200);
console.log("GET auth header sent:", body && body.receivedAuth === "Bearer tok123");
console.log("GET token not leaked into query string:", body && body.url.indexOf("token=") === -1);
done();
});
ApiRequest.getOtherUrlWithHeader(base + "/get-401", {}, { Authorization: "Bearer bad" }, function (err, body, statusCode) {
console.log("GET 401 status passed through:", statusCode === 401);
done();
});
function done() {
pending -= 1;
if (pending === 0) {
ApiRequest.patchOtherUrlWithHeader(base + "/patch-ok", { sale_status: 4 }, { Authorization: "Bearer tok123" }, function (err, body, statusCode) {
console.log("PATCH no error:", !err);
console.log("PATCH status 200:", statusCode === 200);
console.log("PATCH auth header sent:", body && body.receivedAuth === "Bearer tok123");
console.log("PATCH body sent correctly:", body && body.receivedBody && body.receivedBody.sale_status === 4);
server.close();
});
}
}
});
EOF
node scratch/test-sobanle-apirequest-headers.js
```
Expected: fails — `ApiRequest.getOtherUrlWithHeader is not a function`.
- [ ] **Step 2: Implement the additions in `app/libs/ApiRequest.js`**
Add at the end of the file (before the final blank line), and update `postOtherUrlWithHeader` in place:
```js
exports.postOtherUrlWithHeader = function (apiName, param, headers, callback) {
if (typeof (param) === 'function') {
callback = param;
param = {};
}
var options = {
method: 'POST',
uri: apiName,
headers: headers,
json: param
};
if (param && param.token) {
options['auth'] = {
'bearer': param.token
};
}
// request ato API Host
request(options, function (err, httpResponse, body) {
var statusCode = httpResponse ? httpResponse.statusCode : undefined;
if (err) {
callback(err, body, statusCode);
} else if (statusCode >= 200 && statusCode < 300) {
callback(err, body, statusCode);
} else {
callback(body, undefined, statusCode);
}
});
};
exports.getOtherUrlWithHeader = function (apiName, param, headers, callback) {
if (typeof (param) === 'function') {
callback = param;
param = {};
}
var url = apiName;
for (var k in param) {
url += k + '=' + param[k] + '&';
}
var options = {
method: 'GET',
uri: url,
headers: headers,
json: true
};
request(options, function (err, httpResponse, body) {
var statusCode = httpResponse ? httpResponse.statusCode : undefined;
if (err) {
callback(err, body, statusCode);
} else if (statusCode >= 200 && statusCode < 300) {
callback(err, body, statusCode);
} else {
callback(body, undefined, statusCode);
}
});
};
exports.patchOtherUrlWithHeader = function (apiName, param, headers, callback) {
if (typeof (param) === 'function') {
callback = param;
param = {};
}
var options = {
method: 'PATCH',
uri: apiName,
headers: headers,
json: param
};
request(options, function (err, httpResponse, body) {
var statusCode = httpResponse ? httpResponse.statusCode : undefined;
if (err) {
callback(err, body, statusCode);
} else if (statusCode >= 200 && statusCode < 300) {
callback(err, body, statusCode);
} else {
callback(body, undefined, statusCode);
}
});
};
```
Replace the existing `exports.postOtherUrlWithHeader` definition with the version above (adds `headers` passthrough it already had, plus statusCode).
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-sobanle-apirequest-headers.js
```
Expected: 9 lines, all `true`.
- [ ] **Step 4: Commit**
```bash
git add app/libs/ApiRequest.js
git commit -m "Add header-aware GET/PATCH helpers with status code passthrough to ApiRequest"
```
---
### Task 2: `productComboPicker.js` — tax-aware product combo algorithm
**Files:**
- Create: `app/libs/productComboPicker.js`
- Test: `scratch/test-product-combo-picker.js`
**Interfaces:**
- Produces: `pickProductCombo(products, targetAmount, options)``{ lines: [{product_id, qty}], total: Number }` or `null`. `options` (all optional): `{ tolerance = 50000, maxAttempts = 20, maxQtyPerLine = 3, random = Math.random }`. Also exports `computeLineTotal(product, qty)``Math.round(qty * product.price * (1 + rate/100))`.
- Consumed by: Task 6 (`SobanleClient.createAutoOrder`).
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-product-combo-picker.js << 'EOF'
"use strict";
var picker = require("../app/libs/productComboPicker");
var sampleProducts = [
{ id: 609, price: 14488, qty: 435, is_active: 1, tax: { rate: 5 } },
{ id: 610, price: 14488, qty: 181, is_active: 1, tax: { rate: 5 } },
{ id: 611, price: 28976, qty: 488, is_active: 1, tax: { rate: 5 } },
{ id: 612, price: 11591, qty: 482, is_active: 1, tax: { rate: 5 } },
{ id: 613, price: 14469, qty: 725, is_active: 1, tax: { rate: 5 } }
];
// --- computeLineTotal: tax math ---
var lt = picker.computeLineTotal({ price: 100000, tax: { rate: 5 } }, 2);
console.log("computeLineTotal applies tax correctly:", lt === Math.round(2 * 100000 * 1.05));
var ltNoTax = picker.computeLineTotal({ price: 100000, tax: null }, 1);
console.log("computeLineTotal defaults missing tax to 0%:", ltNoTax === 100000);
// --- deterministic RNG for reproducible combo test ---
function seededRandom(seed) {
var s = seed;
return function () {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
}
var target = 200000;
var combo = picker.pickProductCombo(sampleProducts, target, { random: seededRandom(42) });
console.log("combo found:", combo !== null);
if (combo) {
console.log("combo total <= target:", combo.total <= target);
console.log("combo total >= target - 50000:", combo.total >= target - 50000);
console.log("combo has at least 1 line:", combo.lines.length >= 1);
var qtyOk = combo.lines.every(function (line) {
var p = sampleProducts.filter(function (sp) { return sp.id === line.product_id; })[0];
return line.qty >= 1 && line.qty <= Math.min(3, p.qty);
});
console.log("every line qty within [1, min(3, stock)]:", qtyOk);
}
// --- invariant check over many real-random runs ---
var allWithinBounds = true;
for (var i = 0; i < 200; i++) {
var c = picker.pickProductCombo(sampleProducts, 150000, {});
if (c && (c.total > 150000 || c.total < 150000 - 50000)) {
allWithinBounds = false;
}
}
console.log("200 real-random runs all respect bounds:", allWithinBounds);
// --- stock ceiling never exceeded, even with a low-stock product ---
var lowStock = [{ id: 999, price: 1000, qty: 2, is_active: 1, tax: { rate: 0 } }];
var comboLowStock = picker.pickProductCombo(lowStock, 5000, { maxAttempts: 50 });
console.log("low-stock product never oversold:", comboLowStock === null || comboLowStock.lines[0].qty <= 2);
// --- empty product list fails cleanly ---
console.log("empty product list returns null:", picker.pickProductCombo([], 100000, {}) === null);
// --- target far below cheapest product fails cleanly, never returns a 0-total combo ---
var expensiveOnly = [{ id: 1, price: 500000, qty: 10, is_active: 1, tax: { rate: 5 } }];
console.log("target too small returns null (not a 0-total combo):", picker.pickProductCombo(expensiveOnly, 1000, { maxAttempts: 5 }) === null);
// --- products with 0 stock are skipped ---
var zeroStock = [{ id: 1, price: 1000, qty: 0, is_active: 1, tax: { rate: 0 } }];
console.log("0-stock product yields null combo:", picker.pickProductCombo(zeroStock, 5000, { maxAttempts: 5 }) === null);
EOF
node scratch/test-product-combo-picker.js
```
Expected: fails — `Cannot find module '../app/libs/productComboPicker'`.
- [ ] **Step 2: Implement `app/libs/productComboPicker.js`**
```js
"use strict";
var DEFAULT_TOLERANCE = 50000;
var DEFAULT_MAX_ATTEMPTS = 20;
var DEFAULT_MAX_QTY_PER_LINE = 3;
function shuffle(list, random) {
var arr = list.slice();
for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(random() * (i + 1));
var tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
return arr;
}
function computeLineTotal(product, qty) {
var rate = product.tax && typeof product.tax.rate === "number" ? product.tax.rate : 0;
return Math.round(qty * product.price * (1 + rate / 100));
}
function pickProductCombo(products, targetAmount, options) {
options = options || {};
var tolerance = typeof options.tolerance === "number" ? options.tolerance : DEFAULT_TOLERANCE;
var maxAttempts = options.maxAttempts || DEFAULT_MAX_ATTEMPTS;
var random = options.random || Math.random;
var maxQtyPerLine = options.maxQtyPerLine || DEFAULT_MAX_QTY_PER_LINE;
var candidates = products.filter(function (p) {
return p.qty > 0 && p.price > 0;
});
if (candidates.length === 0) {
return null;
}
var minAcceptable = Math.max(1, targetAmount - tolerance);
for (var attempt = 0; attempt < maxAttempts; attempt++) {
var shuffled = shuffle(candidates, random);
var total = 0;
var lines = [];
for (var i = 0; i < shuffled.length; i++) {
var product = shuffled[i];
var maxQty = Math.min(maxQtyPerLine, product.qty);
var qty = 1 + Math.floor(random() * maxQty);
var lineTotal = computeLineTotal(product, qty);
if (total + lineTotal > targetAmount) {
continue;
}
total += lineTotal;
lines.push({ product_id: product.id, qty: qty });
if (total >= minAcceptable) {
break;
}
}
if (total >= minAcceptable && total <= targetAmount) {
return { lines: lines, total: total };
}
}
return null;
}
module.exports = {
pickProductCombo: pickProductCombo,
computeLineTotal: computeLineTotal,
};
```
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-product-combo-picker.js
```
Expected: every line prints `true`.
- [ ] **Step 4: Commit**
```bash
git add app/libs/productComboPicker.js
git commit -m "Add tax-aware random product combo picker for Sổ Bán Lẻ auto-order"
```
---
### Task 3: `config/env/all.js` + `.env` — Sổ Bán Lẻ config block
**Files:**
- Modify: `config/env/all.js`
- Modify: `.env`
- Test: `scratch/test-sobanle-config.js`
**Interfaces:**
- Produces: `config.sobanle = { base_url, username, password, warehouse_id, biller_id, account_id, currency_id }`.
- Consumed by: Task 4, 5 (`SobanleClient.js`).
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-sobanle-config.js << 'EOF'
"use strict";
require("dotenv").config({ path: require("path").join(__dirname, "../.env") });
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
console.log("sobanle block exists:", !!config.sobanle);
console.log("base_url correct:", config.sobanle.base_url === "https://haiyen.sobanle.com/api/jwt");
console.log("warehouse_id is \"1\":", config.sobanle.warehouse_id === "1");
console.log("biller_id is \"1\":", config.sobanle.biller_id === "1");
console.log("account_id is \"1\":", config.sobanle.account_id === "1");
console.log("currency_id is \"1\":", config.sobanle.currency_id === "1");
console.log("username key wired to env:", "username" in config.sobanle);
console.log("password key wired to env:", "password" in config.sobanle);
EOF
node scratch/test-sobanle-config.js
```
Expected: fails — `config.sobanle` is `undefined`, first check prints `false` and the rest throw.
- [ ] **Step 2: Add the config block to `config/env/all.js`**
Add after the `alepay` block (currently ends around line 101, right before the closing pattern that leads into `admin`/`encrypt_key` near the end of the file):
```js
sobanle: {
base_url: "https://haiyen.sobanle.com/api/jwt",
username: process.env.SOBANLE_USERNAME,
password: process.env.SOBANLE_PASSWORD,
warehouse_id: "1",
biller_id: "1",
account_id: "1",
currency_id: "1",
},
```
- [ ] **Step 3: Add the new env keys to `.env`**
Append (values left blank — fill in the real Sổ Bán Lẻ login locally, never commit the real value):
```
SOBANLE_USERNAME=
SOBANLE_PASSWORD=
```
- [ ] **Step 4: Run the verification script again**
```bash
node scratch/test-sobanle-config.js
```
Expected: all 8 lines `true`.
- [ ] **Step 5: Commit**
```bash
git add config/env/all.js .env
git commit -m "Add Sổ Bán Lẻ config block"
```
Note: confirm `.env` is already git-ignored or already tracked-with-secrets per this repo's existing convention (it holds live provider secrets already, per `CLAUDE.md`) before committing — do not change its tracked status as part of this task.
---
### Task 4: `SobanleClient.js` — token cache, login, `getToken`
**Files:**
- Create: `app/libs/SobanleClient.js`
- Test: `scratch/test-sobanle-client-token.js`
**Interfaces:**
- Produces: `SobanleClient.login(callback)``(err, token)`, always performs a real login call and refreshes the cache. `SobanleClient.getToken(callback)``(err, token)`, returns the cached token if not expired, otherwise calls `exports.login`. `SobanleClient._resetTokenCacheForTest()` — test-only helper that clears the module-level cache between scratch-script scenarios.
- Consumed by: Task 5, 6, and (indirectly) Task 8, 9.
- Internal calls to `login`/`getToken` from other functions in this file (later tasks) MUST go through `exports.login(...)` / `exports.getToken(...)`, not a local reference, so tests can monkey-patch them.
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-sobanle-client-token.js << 'EOF'
"use strict";
var http = require("http");
global.__config_path = require("path").join(__dirname, "../config");
var config = require("../config/config");
var loginCallCount = 0;
var server = http.createServer(function (req, res) {
if (req.url === "/login") {
loginCallCount += 1;
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ access_token: "tok-" + loginCallCount, token_type: "bearer", expires_in: 2 }));
}
res.writeHead(404);
res.end();
});
server.listen(0, function () {
var port = server.address().port;
config.sobanle.base_url = "http://127.0.0.1:" + port;
config.sobanle.username = "haiyen";
config.sobanle.password = "haiyen";
var SobanleClient = require("../app/libs/SobanleClient");
SobanleClient.getToken(function (err1, token1) {
console.log("first getToken no error:", !err1);
console.log("first getToken returns token:", token1 === "tok-1");
console.log("first getToken triggered exactly 1 login:", loginCallCount === 1);
SobanleClient.getToken(function (err2, token2) {
console.log("second getToken (cached) no error:", !err2);
console.log("second getToken reuses cached token:", token2 === "tok-1");
console.log("second getToken did NOT call login again:", loginCallCount === 1);
setTimeout(function () {
SobanleClient.getToken(function (err3, token3) {
console.log("getToken after expiry no error:", !err3);
console.log("getToken after expiry re-logs in:", token3 === "tok-2");
console.log("getToken after expiry triggered a 2nd login:", loginCallCount === 2);
server.close();
});
}, 2100);
});
});
});
EOF
node scratch/test-sobanle-client-token.js
```
Expected: fails — `Cannot find module '../app/libs/SobanleClient'`.
- [ ] **Step 2: Implement `app/libs/SobanleClient.js` (token portion)**
```js
"use strict";
var config = require(__config_path + "/config");
var ApiRequest = require("./ApiRequest");
var tokenCache = { token: null, expiresAt: 0 };
function login(callback) {
var url = config.sobanle.base_url + "/login";
ApiRequest.postOtherUrl(url, { login: config.sobanle.username, password: config.sobanle.password }, function (err, body) {
if (err) {
return callback(err);
}
if (!body || !body.access_token) {
return callback(new Error("SOBANLE_LOGIN_FAILED"));
}
tokenCache.token = body.access_token;
// refresh 60s before real expiry to avoid racing a near-expiry token
tokenCache.expiresAt = Date.now() + (body.expires_in || 0) * 1000 - 60000;
callback(null, body.access_token);
});
}
exports.login = login;
function getToken(callback) {
if (tokenCache.token && Date.now() < tokenCache.expiresAt) {
return callback(null, tokenCache.token);
}
exports.login(callback);
}
exports.getToken = getToken;
exports._resetTokenCacheForTest = function () {
tokenCache.token = null;
tokenCache.expiresAt = 0;
};
```
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-sobanle-client-token.js
```
Expected: 9 lines, all `true`. (Takes ~2.1s due to the expiry wait.)
- [ ] **Step 4: Commit**
```bash
git add app/libs/SobanleClient.js
git commit -m "Add SobanleClient token cache and login"
```
---
### Task 5: `SobanleClient.js` — `getProducts`, `createSale`, `changeSaleStatus` with 401 retry
**Files:**
- Modify: `app/libs/SobanleClient.js`
- Test: `scratch/test-sobanle-client-api.js`
**Interfaces:**
- Produces: `SobanleClient.getProducts(callback)``(err, products)`. `SobanleClient.createSale(lines, customerData, callback)``(err, { orderId, orderTotal })`. `SobanleClient.changeSaleStatus(orderId, callback)``(err)`. All three transparently retry once (re-login, then retry the original call) on a `401` response.
- Consumes: `exports.getToken` from Task 4.
- Consumed by: Task 6 (`createAutoOrder`), Task 8, 9 (controller).
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-sobanle-client-api.js << 'EOF'
"use strict";
var http = require("http");
global.__config_path = require("path").join(__dirname, "../config");
var config = require("../config/config");
var loginCalls = 0;
var productsCalls = 0;
var failNextProductsAuth = false;
var server = http.createServer(function (req, res) {
var chunks = [];
req.on("data", function (c) { chunks.push(c); });
req.on("end", function () {
var body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
if (req.url === "/login") {
loginCalls += 1;
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ access_token: "tok-" + loginCalls, expires_in: 3600 }));
}
if (req.url.indexOf("/products") === 0) {
productsCalls += 1;
if (failNextProductsAuth && productsCalls === 1) {
res.writeHead(401, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: "unauthorized" }));
}
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({
data: [{ id: 609, price: 14488, qty: 435, is_active: 1, tax: { rate: 5 } }],
pagination: { total: 1 }
}));
}
if (req.url === "/sales" && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({
data: { id: 414, grand_total: 152124, reference_no: "Sobanle20260831-162017" },
message: " Sale created successfully",
receivedBody: body
}));
}
if (req.url === "/sales/change-sale-status/414" && req.method === "PATCH") {
console.log("changeSaleStatus sent correct body:", body && body.sale_status === 4);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ data: { id: 414, sale_status: 4 }, message: "ok" }));
}
res.writeHead(404);
res.end();
});
});
server.listen(0, function () {
var port = server.address().port;
config.sobanle.base_url = "http://127.0.0.1:" + port;
config.sobanle.username = "haiyen";
config.sobanle.password = "haiyen";
var SobanleClient = require("../app/libs/SobanleClient");
SobanleClient._resetTokenCacheForTest();
SobanleClient.getProducts(function (err, products) {
console.log("getProducts no error:", !err);
console.log("getProducts returns array:", Array.isArray(products) && products.length === 1);
console.log("getProducts product has tax rate:", products[0].tax.rate === 5);
var lines = [{ product_id: 609, qty: 10 }];
var customerData = { name: "Test KH", phone_number: "0900000000", address: "Somewhere" };
SobanleClient.createSale(lines, customerData, function (err2, order) {
console.log("createSale no error:", !err2);
console.log("createSale returns orderId:", order.orderId === 414);
console.log("createSale returns orderTotal:", order.orderTotal === 152124);
SobanleClient.changeSaleStatus(414, function (err3) {
console.log("changeSaleStatus no error:", !err3);
// --- 401-then-retry path ---
failNextProductsAuth = true;
productsCalls = 0;
SobanleClient._resetTokenCacheForTest();
SobanleClient.getToken(function () {
SobanleClient.getProducts(function (err4, products2) {
console.log("getProducts after 401 eventually succeeds:", !err4 && Array.isArray(products2));
console.log("getProducts after 401 retried exactly once (2 calls total):", productsCalls === 2);
server.close();
});
});
});
});
});
});
EOF
node scratch/test-sobanle-client-api.js
```
Expected: fails — `SobanleClient.getProducts is not a function`.
- [ ] **Step 2: Implement the rest of `app/libs/SobanleClient.js`**
Append to the file (after the Task 4 code, before nothing else — this is the full remainder of the module):
```js
function withAuthRetry(makeRequest, callback) {
exports.getToken(function (err, token) {
if (err) {
return callback(err);
}
makeRequest(token, function (err2, body, statusCode) {
if (statusCode === 401) {
tokenCache.token = null;
return exports.getToken(function (loginErr, freshToken) {
if (loginErr) {
return callback(loginErr);
}
makeRequest(freshToken, function (err3, body3) {
if (err3) {
return callback(err3);
}
callback(null, body3);
});
});
}
if (err2) {
return callback(err2);
}
callback(null, body);
});
});
}
function getProducts(callback) {
withAuthRetry(function (token, cb) {
var url = config.sobanle.base_url + "/products?page=1&is_active=true&per_page=1000&";
ApiRequest.getOtherUrlWithHeader(url, {}, { Authorization: "Bearer " + token }, cb);
}, function (err, body) {
if (err) {
return callback(err);
}
if (!body || !Array.isArray(body.data)) {
return callback(new Error("SOBANLE_PRODUCTS_INVALID"));
}
callback(null, body.data);
});
}
exports.getProducts = getProducts;
function createSale(lines, customerData, callback) {
withAuthRetry(function (token, cb) {
var url = config.sobanle.base_url + "/sales";
var payload = {
warehouse_id: config.sobanle.warehouse_id,
biller_id: config.sobanle.biller_id,
account_id: config.sobanle.account_id,
currency_id: config.sobanle.currency_id,
exchange_rate: "1",
reference_no: null,
is_internal_api: true,
sale_status: 2,
list_product: lines.map(function (line) {
return { product_id: String(line.product_id), qty: String(line.qty) };
}),
customer_data: customerData,
payment_receiver: "",
payment_note: "",
sale_note: "Tạo tự động từ payment-gate",
staff_note: "",
};
ApiRequest.postOtherUrlWithHeader(url, payload, { Authorization: "Bearer " + token }, cb);
}, function (err, body) {
if (err) {
return callback(err);
}
if (!body || !body.data || typeof body.data.id === "undefined" || typeof body.data.grand_total === "undefined") {
return callback(new Error("SOBANLE_CREATE_SALE_INVALID"));
}
callback(null, { orderId: body.data.id, orderTotal: body.data.grand_total });
});
}
exports.createSale = createSale;
function changeSaleStatus(orderId, callback) {
withAuthRetry(function (token, cb) {
var url = config.sobanle.base_url + "/sales/change-sale-status/" + orderId;
ApiRequest.patchOtherUrlWithHeader(url, { sale_status: 4 }, { Authorization: "Bearer " + token }, cb);
}, function (err) {
callback(err || null);
});
}
exports.changeSaleStatus = changeSaleStatus;
```
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-sobanle-client-api.js
```
Expected: 10 lines, all `true`.
- [ ] **Step 4: Commit**
```bash
git add app/libs/SobanleClient.js
git commit -m "Add SobanleClient getProducts/createSale/changeSaleStatus with 401 retry"
```
---
### Task 6: `SobanleClient.createAutoOrder` — orchestration
**Files:**
- Modify: `app/libs/SobanleClient.js`
- Test: `scratch/test-sobanle-client-auto-order.js`
**Interfaces:**
- Produces: `SobanleClient.createAutoOrder(customerData, targetAmount, callback)``(err, { orderId, orderTotal })`. Fetches products, picks a combo via `productComboPicker.pickProductCombo`, creates the sale. Fails with `NO_PRODUCT_COMBO_MATCH` without ever calling `createSale` if no combo fits.
- Consumes: `exports.getProducts`, `exports.createSale` from Task 5; `pickProductCombo` from Task 2.
- Consumed by: Task 8 (`admin.server.controller.js#createTransaction`).
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-sobanle-client-auto-order.js << 'EOF'
"use strict";
var http = require("http");
global.__config_path = require("path").join(__dirname, "../config");
var config = require("../config/config");
var salesCalls = 0;
var server = http.createServer(function (req, res) {
var chunks = [];
req.on("data", function (c) { chunks.push(c); });
req.on("end", function () {
var body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
if (req.url === "/login") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ access_token: "tok-1", expires_in: 3600 }));
}
if (req.url.indexOf("/products") === 0) {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({
data: [
{ id: 609, price: 14488, qty: 435, is_active: 1, tax: { rate: 5 } },
{ id: 611, price: 28976, qty: 488, is_active: 1, tax: { rate: 5 } }
]
}));
}
if (req.url === "/sales" && req.method === "POST") {
salesCalls += 1;
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ data: { id: 999, grand_total: 190000 }, receivedBody: body }));
}
res.writeHead(404);
res.end();
});
});
server.listen(0, function () {
var port = server.address().port;
config.sobanle.base_url = "http://127.0.0.1:" + port;
config.sobanle.username = "haiyen";
config.sobanle.password = "haiyen";
var SobanleClient = require("../app/libs/SobanleClient");
SobanleClient._resetTokenCacheForTest();
var customerData = { name: "KH Test", phone_number: "0900000000", address: "Somewhere" };
SobanleClient.createAutoOrder(customerData, 200000, function (err, order) {
console.log("createAutoOrder no error:", !err);
console.log("createAutoOrder returns orderId:", order.orderId === 999);
console.log("createAutoOrder returns orderTotal:", order.orderTotal === 190000);
console.log("createAutoOrder called /sales exactly once:", salesCalls === 1);
SobanleClient.createAutoOrder(customerData, 1, function (err2, order2) {
console.log("createAutoOrder with impossible target fails:", err2 && err2.message === "NO_PRODUCT_COMBO_MATCH");
console.log("createAutoOrder with impossible target never called /sales:", salesCalls === 1);
server.close();
});
});
});
EOF
node scratch/test-sobanle-client-auto-order.js
```
Expected: fails — `SobanleClient.createAutoOrder is not a function`.
- [ ] **Step 2: Implement `createAutoOrder` in `app/libs/SobanleClient.js`**
Add near the top of the file (with the other `require`s) and append the function at the end:
```js
var pickProductCombo = require("./productComboPicker").pickProductCombo;
```
```js
function createAutoOrder(customerData, targetAmount, callback) {
exports.getProducts(function (err, products) {
if (err) {
return callback(err);
}
var combo = pickProductCombo(products, targetAmount, {});
if (!combo) {
return callback(new Error("NO_PRODUCT_COMBO_MATCH"));
}
exports.createSale(combo.lines, customerData, callback);
});
}
exports.createAutoOrder = createAutoOrder;
```
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-sobanle-client-auto-order.js
```
Expected: 6 lines, all truthy (`true` or the error-message match).
- [ ] **Step 4: Commit**
```bash
git add app/libs/SobanleClient.js
git commit -m "Add SobanleClient.createAutoOrder orchestration"
```
---
### Task 7: `AdminTransaction` model — `sobanleOrderId` field
**Files:**
- Modify: `app/models/AdminTransaction.js`
- Test: `scratch/test-admin-transaction-sobanle-field.js`
**Interfaces:**
- Produces: `AdminTransaction` schema gains `sobanleOrderId: { type: String, default: null }`.
- Consumed by: Task 8, 9.
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-admin-transaction-sobanle-field.js << 'EOF'
"use strict";
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: "TEST_SOBANLE_" + Date.now(),
transCode: "TEST_RT",
customerName: "Test",
customerPhone: "0900000000",
customerAddress: "Somewhere",
amount: 190000,
merchantToken: "faketoken",
timeStamp: "20260101000000",
sobanleOrderId: "999",
},
function (err, tx) {
if (err) {
console.log("CREATE FAILED:", err.message);
process.exit(1);
}
console.log("sobanleOrderId persisted:", tx.sobanleOrderId === "999");
AdminTransaction.findById(tx._id, function (err2, reloaded) {
console.log("sobanleOrderId round-trips on reload:", reloaded.sobanleOrderId === "999");
AdminTransaction.deleteOne({ _id: tx._id }, function () {
mongoose.connection.close();
});
});
}
);
});
EOF
docker run -d --rm --name plan-mongo-sobanle -p 27017:27017 mongo:7 2>/dev/null || echo "mongo already running on 27017, reusing it"
sleep 2
node scratch/test-admin-transaction-sobanle-field.js
```
Expected: fails — `sobanleOrderId persisted: false` (field is silently dropped by Mongoose since it's not in the schema yet).
- [ ] **Step 2: Add the field to `app/models/AdminTransaction.js`**
```js
sobanleOrderId: { type: String, default: null },
```
Add it as a new line inside the schema object, next to `resultMsg`/`createdByUsername` (order doesn't matter, keep it near the other optional metadata fields).
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-admin-transaction-sobanle-field.js
docker stop plan-mongo-sobanle 2>/dev/null || true
```
Expected: both lines `true`.
- [ ] **Step 4: Commit**
```bash
git add app/models/AdminTransaction.js
git commit -m "Add sobanleOrderId field to AdminTransaction"
```
---
### Task 8: Wire `createTransaction` to `SobanleClient.createAutoOrder`
**Files:**
- Modify: `app/controllers/admin.server.controller.js`
- Test: `scratch/test-admin-create-transaction-sobanle.js`
**Interfaces:**
- Consumes: `SobanleClient.createAutoOrder(customerData, targetAmount, callback)` (Task 6), `AdminTransaction` (Task 7).
- Produces: `createTransaction` now calls Sổ Bán Lẻ first; `AdminTransaction.amount` = `order.orderTotal`; `AdminTransaction.sobanleOrderId` = `String(order.orderId)`. On Sổ Bán Lẻ failure: `502 {code:"99", data:"SOBANLE_ORDER_FAILED"}`, no DB row written. Response contract for the success path is unchanged (`{code:"00", data:{merTrxId, paymentUrl}}`).
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-admin-create-transaction-sobanle.js << 'EOF'
"use strict";
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");
var SobanleClient = require("../app/libs/SobanleClient");
var admin = require("../app/controllers/admin.server.controller");
function fakeRes() {
var res = {
statusCode: 200,
body: null,
status: function (code) { res.statusCode = code; return res; },
json: function (payload) { res.body = payload; return res; },
};
return res;
}
// --- success path ---
SobanleClient.createAutoOrder = function (customerData, targetAmount, cb) {
cb(null, { orderId: 999, orderTotal: 187500 });
};
var req1 = {
body: { customerName: "KH Test", customerPhone: "0900000000", customerAddress: "Somewhere", amount: "200000" },
session: { username: "admin" },
};
var res1 = fakeRes();
admin.createTransaction(req1, res1);
setTimeout(function () {
console.log("success: status 200:", res1.statusCode === 200);
console.log("success: code 00:", res1.body && res1.body.code === "00");
AdminTransaction.findOne({ merTrxId: res1.body.data.merTrxId }, function (err, tx) {
console.log("success: amount set from orderTotal (not raw input):", tx.amount === 187500);
console.log("success: sobanleOrderId stored:", tx.sobanleOrderId === "999");
AdminTransaction.deleteOne({ _id: tx._id }, function () {
// --- failure path ---
SobanleClient.createAutoOrder = function (customerData, targetAmount, cb) {
cb(new Error("NO_PRODUCT_COMBO_MATCH"));
};
AdminTransaction.countDocuments({}, function (err2, before) {
var req2 = {
body: { customerName: "KH Test2", customerPhone: "0900000001", customerAddress: "Somewhere2", amount: "5000" },
session: { username: "admin" },
};
var res2 = fakeRes();
admin.createTransaction(req2, res2);
setTimeout(function () {
console.log("failure: status 502:", res2.statusCode === 502);
console.log("failure: code 99:", res2.body && res2.body.code === "99");
AdminTransaction.countDocuments({}, function (err3, after) {
console.log("failure: no DB row written:", after === before);
mongoose.connection.close();
});
}, 100);
});
});
});
}, 100);
});
EOF
docker run -d --rm --name plan-mongo-sobanle -p 27017:27017 mongo:7 2>/dev/null || echo "mongo already running on 27017, reusing it"
sleep 2
node scratch/test-admin-create-transaction-sobanle.js
```
Expected: fails — `success: amount set from orderTotal (not raw input): false` (controller still uses the raw `amount` field and never calls `SobanleClient`).
- [ ] **Step 2: Modify `createTransaction` in `app/controllers/admin.server.controller.js`**
Add near the top requires:
```js
var SobanleClient = require("../libs/SobanleClient");
```
Replace the full `exports.createTransaction` function (currently lines 16-67) with:
```js
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 targetAmount = parseInt(rawAmount, 10);
if (!isFinite(targetAmount) || targetAmount <= 0 || String(targetAmount) !== String(rawAmount).trim()) {
return res.status(400).json({ code: "99", data: "INVALID_AMOUNT" });
}
var customerData = {
customer_group_id: 1,
customer_type: 2,
phone_number: customerPhone,
tax_no: null,
name: customerName,
address: customerAddress,
id_card_number: null,
passport_number: null,
email: null,
};
SobanleClient.createAutoOrder(customerData, targetAmount, function (sobanleErr, order) {
if (sobanleErr) {
console.error("createTransaction: SobanleClient error:", sobanleErr.message);
return res.status(502).json({ code: "99", data: "SOBANLE_ORDER_FAILED" });
}
var amount = order.orderTotal;
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,
sobanleOrderId: String(order.orderId),
merchantToken: merchantToken,
timeStamp: timeStamp,
createdByUsername: req.session.username,
},
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 },
});
}
);
});
};
```
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-admin-create-transaction-sobanle.js
docker stop plan-mongo-sobanle 2>/dev/null || true
```
Expected: 7 lines, all `true`.
- [ ] **Step 4: Commit**
```bash
git add app/controllers/admin.server.controller.js
git commit -m "Create Sổ Bán Lẻ order before payment transaction in createTransaction"
```
---
### Task 9: Wire `epayIPN` to `SobanleClient.changeSaleStatus`
**Files:**
- Modify: `app/controllers/admin.server.controller.js`
- Test: `scratch/test-admin-epay-ipn-sobanle.js`
**Interfaces:**
- Consumes: `SobanleClient.changeSaleStatus(orderId, callback)` (Task 5).
- Produces: after `epayIPN` marks a transaction `success`, it fire-and-forgets a call to `SobanleClient.changeSaleStatus(tx.sobanleOrderId, ...)`. A failure there is logged only — the IPN still responds `200 {code:"00", data:"Success"}` immediately, unaffected by the Sổ Bán Lẻ call's outcome or latency.
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-admin-epay-ipn-sobanle.js << 'EOF'
"use strict";
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
var epaySign = require("../app/libs/epaySign");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var AdminTransaction = require("../app/models/AdminTransaction");
var SobanleClient = require("../app/libs/SobanleClient");
var admin = require("../app/controllers/admin.server.controller");
function fakeRes() {
var res = {
statusCode: 200,
body: null,
status: function (code) { res.statusCode = code; return res; },
json: function (payload) { res.body = payload; return res; },
};
return res;
}
var timeStamp = "20260831120000";
var merTrxId = "TEST_IPN_" + Date.now();
AdminTransaction.create(
{
merTrxId: merTrxId,
transCode: "TEST_RT",
customerName: "KH Test",
customerPhone: "0900000000",
customerAddress: "Somewhere",
amount: 187500,
merchantToken: "placeholder",
timeStamp: timeStamp,
sobanleOrderId: "999",
},
function (err, tx) {
var changeSaleStatusCalledWith = null;
SobanleClient.changeSaleStatus = function (orderId, cb) {
changeSaleStatusCalledWith = orderId;
cb(new Error("simulated failure"));
};
var resultCd = "00_000";
var trxId = "TRX123";
var payToken = "PAYTOKEN";
var merchantToken = epaySign.signResponse(
resultCd, timeStamp, merTrxId, trxId, config.epay.merchant_id, tx.amount, config.epay.encode_key, payToken
);
var req = {
body: { resultCd: resultCd, merTrxId: merTrxId, trxId: trxId, payToken: payToken, merchantToken: merchantToken, resultMsg: "OK" },
};
var res = fakeRes();
admin.epayIPN(req, res);
setTimeout(function () {
console.log("IPN responds 200 despite SobanleClient failure:", res.statusCode === 200);
console.log("IPN response code 00:", res.body && res.body.code === "00");
console.log("changeSaleStatus called with correct orderId:", changeSaleStatusCalledWith === "999");
AdminTransaction.findById(tx._id, function (err2, reloaded) {
console.log("transaction status still updated to success:", reloaded.status === "success");
AdminTransaction.deleteOne({ _id: tx._id }, function () {
mongoose.connection.close();
});
});
}, 100);
}
);
});
EOF
docker run -d --rm --name plan-mongo-sobanle -p 27017:27017 mongo:7 2>/dev/null || echo "mongo already running on 27017, reusing it"
sleep 2
node scratch/test-admin-epay-ipn-sobanle.js
```
Expected: fails — `changeSaleStatus called with correct orderId: false` (`changeSaleStatusCalledWith` stays `null`, `epayIPN` never calls it yet).
- [ ] **Step 2: Modify `epayIPN` in `app/controllers/admin.server.controller.js`**
Inside the `AdminTransaction.updateOne(...)` callback (after the `updateErr` check, before `return res.status(200)...`), add:
```js
if (newStatus === "success" && tx.sobanleOrderId) {
SobanleClient.changeSaleStatus(tx.sobanleOrderId, function (sobanleErr) {
if (sobanleErr) {
console.error(
"epayIPN: SobanleClient.changeSaleStatus failed for orderId=" + tx.sobanleOrderId + ":",
sobanleErr.message
);
}
});
}
```
(`SobanleClient` is already required at the top of the file from Task 8.)
- [ ] **Step 3: Run the verification script again**
```bash
node scratch/test-admin-epay-ipn-sobanle.js
docker stop plan-mongo-sobanle 2>/dev/null || true
```
Expected: 4 lines, all `true`.
- [ ] **Step 4: Commit**
```bash
git add app/controllers/admin.server.controller.js
git commit -m "Update Sổ Bán Lẻ order status to paid after successful epay IPN"
```
---
### Task 10: Manual end-to-end verification (real Sổ Bán Lẻ)
This task has no automated test — it's the one point in this plan that touches the real `haiyen.sobanle.com` service, and needs a human watching.
- [ ] **Step 1: Fill in real credentials**
In `.env`, set `SOBANLE_USERNAME` and `SOBANLE_PASSWORD` to the real Sổ Bán Lẻ login. Do not commit this change if `.env` is meant to stay local — check `git status` first.
- [ ] **Step 2: Start the dev server**
```bash
npm start
```
Confirm the log shows `MongoDB connected` and no startup errors.
- [ ] **Step 3: Log in to admin and create a transaction**
Open `http://localhost:8091/admin/login`, log in, go to "Thêm mới giao dịch", fill in a customer name/phone/address and an amount (e.g. `200000`), submit.
- [ ] **Step 4: Verify the result**
- Success panel shows a `merTrxId` and payment link, same as the existing flow.
- Log in to the real Sổ Bán Lẻ admin (or call `GET /api/jwt/sales/{id}` with a fresh token) and confirm a new sale exists with `sale_status: 2`, `grand_total` between `(amount - 50000)` and `amount`, and product lines that add up to that total.
- In `admin/transactions`, confirm the listed amount matches the Sổ Bán Lẻ order's `grand_total`, not the amount you typed.
- [ ] **Step 5: Verify the status update on payment success**
Trigger a successful `epay` IPN for that transaction (via a real sandbox payment if available, or by POSTing a correctly-signed payload to `/admin/epay/ipn` using `epaySign.signResponse` with `resultCd: "00_000"`, matching the transaction's stored `timeStamp`/`merTrxId`/`amount`). Confirm in Sổ Bán Lẻ that the order's `sale_status` is now `4`.
- [ ] **Step 6: Clean up**
Revert the amount/customer fields used for this test if the team doesn't want throwaway test orders lingering in Sổ Bán Lẻ's transaction history. No commit needed for this task.
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