Files
AriWeb-Suite/modules/webmaker/lib/prepaid.js

155 lines
12 KiB
JavaScript

'use strict';
/* ============================================================================
* 선불 충전(선불금) 상품 · 주문 (v1.6.40, 요구 1)
*
* 비용은 지금처럼 선불 적립금(캐시, lib/credits.js)으로 쓴다. 이 모듈은 그 캐시를 "사용자가 스스로 사는" 길을 만든다.
* - 상품(package): 관리자 콘솔 › 회원·청구 › 선불 충전 상품 에서 만든다. 금액(원)과 추가 인센티브(정률 % 또는 정액 원)를 정하며,
* 적립되는 캐시 = 결제 금액 + 인센티브 (creditOf). 비활성(active=false) 상품은 사용자에게 보이지 않지만 지난 주문 기록은 그대로 남는다.
* - 주문(order): 사용자가 캐시·충전 화면에서 상품을 골라 "결제 진행" 을 누르면 pending 주문이 생긴다.
* **PG(결제 대행)가 아직 연동되지 않아 실제 결제는 일어나지 않는다** — 주문은 "결제 대기" 로 남고, 관리자가 입금(결제) 확인 뒤
* markPaid 로 적립하면 credits.apply(add, 적립 금액) 가 원장에 기록된다(ref: { orderId } 로 어느 주문의 적립인지 남긴다).
* PG 가 붙으면 결제 콜백에서 markPaid 를 부르는 구조로 그대로 이어 쓸 수 있다.
*
* 저장: data/prepaid.json = { packages: [...], orders: [...] } — jsonstore 로 원자적 쓰기 + .bak 보관(깨지면 .bak 으로 읽는다).
* package = { id, name, amount, bonusType: 'percent'|'fixed', bonusValue, active, sort, note, createdAt, updatedAt }
* order = { id, userId, packageId, name, amount, bonus, credit, status: 'pending'|'paid'|'cancelled', at, paidAt, cancelledAt, by, memo }
* ========================================================================== */
const path = require('path');
const crypto = require('crypto');
const js = require('./jsonstore');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'prepaid.json');
/** 사용자 한 명이 동시에 열어 둘 수 있는 결제 대기 주문 수 — 버튼을 연달아 눌러 주문이 쌓이는 것을 막는다 */
const MAX_PENDING_PER_USER = 5;
/** 상품 금액 범위(원): 너무 작은 값·입력 실수로 생긴 거대 금액을 막는다 */
const AMOUNT_MIN = 1000, AMOUNT_MAX = 100000000;
const BONUS_TYPES = ['percent', 'fixed'];
const STATUS = ['pending', 'paid', 'cancelled'];
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
function load() { const d = js.readJson(FILE, null); return d && typeof d === 'object' ? { packages: Array.isArray(d.packages) ? d.packages : [], orders: Array.isArray(d.orders) ? d.orders : [] } : { packages: [], orders: [] }; }
function save(d) { js.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true }); }
const id = () => crypto.randomBytes(6).toString('hex');
const now = () => new Date().toISOString();
/**
* 상품의 인센티브 금액(원). percent 는 결제 금액의 비율(소수점은 버림 — 원 단위), fixed 는 그대로.
* @param {object} p 상품 { amount, bonusType, bonusValue }
*/
function bonusOf(p) {
const amt = Math.round(Number(p.amount) || 0); const v = Number(p.bonusValue) || 0;
if (!(v > 0)) return 0;
return p.bonusType === 'percent' ? Math.floor(amt * v / 100) : Math.round(v);
}
/** 적립되는 캐시(원) = 결제 금액 + 인센티브 */
function creditOf(p) { return Math.round(Number(p.amount) || 0) + bonusOf(p); }
/** 화면용 상품 (적립 금액·인센티브 금액 계산값 포함) */
function pub(p) { return { ...p, bonus: bonusOf(p), credit: creditOf(p) }; }
/**
* 상품 목록. 기본은 사용자 화면용(활성 상품만, sort → amount 순). all=true 면 관리자용 전체.
*/
function listPackages({ all = false } = {}) {
const d = load();
return d.packages.filter((p) => all || p.active !== false).sort((a, b) => (a.sort || 0) - (b.sort || 0) || a.amount - b.amount).map(pub);
}
function getPackage(pid) { const p = load().packages.find((x) => x.id === pid); return p ? pub(p) : null; }
/**
* 상품 만들기·고치기 (관리자). input.id 가 있으면 수정, 없으면 새 상품.
* 검증: 이름 1~40자, 금액 AMOUNT_MIN~AMOUNT_MAX 의 정수(원), 인센티브 종류 percent|fixed, 값 0 이상(정률은 500% 까지, 정액은 금액의 10배까지).
* @param {object} input { id?, name, amount, bonusType, bonusValue, active, sort, note }
* @param {string} by 처리한 관리자 ID (기록용)
*/
function savePackage(input, by) {
const d = load(); const cur = input.id ? d.packages.find((p) => p.id === input.id) : null;
if (input.id && !cur) throw httpError('상품이 없습니다', 404);
const name = String(input.name == null ? (cur ? cur.name : '') : input.name).trim();
if (!name || name.length > 40) throw httpError('상품 이름은 1~40자로 입력하세요');
const amount = input.amount == null ? (cur ? cur.amount : NaN) : Math.round(Number(input.amount));
if (!Number.isFinite(amount) || amount < AMOUNT_MIN || amount > AMOUNT_MAX) throw httpError(`금액은 ${AMOUNT_MIN.toLocaleString('ko-KR')}~${AMOUNT_MAX.toLocaleString('ko-KR')}원 사이 정수로 입력하세요`);
const bonusType = input.bonusType == null ? (cur ? cur.bonusType : 'percent') : String(input.bonusType);
if (!BONUS_TYPES.includes(bonusType)) throw httpError('인센티브 종류는 percent(정률) 또는 fixed(정액)이어야 합니다');
const bonusValue = input.bonusValue == null || input.bonusValue === '' ? (cur ? cur.bonusValue : 0) : Number(input.bonusValue);
if (!Number.isFinite(bonusValue) || bonusValue < 0) throw httpError('인센티브는 0 이상 숫자로 입력하세요');
if (bonusType === 'percent' && bonusValue > 500) throw httpError('정률 인센티브는 500% 까지입니다');
if (bonusType === 'fixed' && bonusValue > amount * 10) throw httpError('정액 인센티브가 결제 금액의 10배를 넘습니다 — 입력을 확인하세요');
const next = {
id: cur ? cur.id : id(), name, amount, bonusType, bonusValue: bonusType === 'percent' ? Math.round(bonusValue * 100) / 100 : Math.round(bonusValue),
active: input.active == null ? (cur ? cur.active !== false : true) : !!(input.active === true || input.active === 'true'),
sort: input.sort == null || input.sort === '' ? (cur ? cur.sort || 0 : d.packages.length) : Math.round(Number(input.sort) || 0),
note: String(input.note == null ? (cur ? cur.note || '' : '') : input.note).trim().slice(0, 200),
createdAt: cur ? cur.createdAt : now(), updatedAt: now(), updatedBy: by || null,
};
if (cur) Object.assign(cur, next); else d.packages.push(next);
save(d); return pub(next);
}
/**
* 상품 삭제 (관리자). 주문 기록이 그 상품을 가리키면 지우지 않고 비활성으로 돌린다 — 지난 주문의 상품 정보가 사라지지 않게.
* @returns {{removed: boolean, deactivated: boolean, package: object}}
*/
function removePackage(pid) {
const d = load(); const p = d.packages.find((x) => x.id === pid); if (!p) throw httpError('상품이 없습니다', 404);
const referenced = d.orders.some((o) => o.packageId === pid);
if (referenced) { p.active = false; p.updatedAt = now(); save(d); return { removed: false, deactivated: true, package: pub(p) }; }
d.packages = d.packages.filter((x) => x.id !== pid); save(d); return { removed: true, deactivated: false, package: pub(p) };
}
/**
* 주문 만들기 (사용자). 활성 상품만, 결제 대기 주문은 MAX_PENDING_PER_USER 개까지.
* 주문에는 그 시점의 상품 이름·금액·인센티브를 복사해 둔다 — 나중에 상품을 고쳐도 주문 금액은 바뀌지 않는다.
*/
function createOrder(userId, packageId) {
if (!userId) throw httpError('로그인이 필요합니다', 401);
const d = load(); const p = d.packages.find((x) => x.id === packageId && x.active !== false);
if (!p) throw httpError('선택한 상품이 없거나 판매가 끝났습니다', 404);
const open = d.orders.filter((o) => o.userId === userId && o.status === 'pending');
if (open.length >= MAX_PENDING_PER_USER) throw httpError(`결제 대기 주문이 ${MAX_PENDING_PER_USER}건 있습니다. 먼저 기존 주문을 취소하거나 결제를 마쳐 주세요`, 409);
const o = { id: id(), userId, packageId: p.id, name: p.name, amount: p.amount, bonusType: p.bonusType, bonusValue: p.bonusValue, bonus: bonusOf(p), credit: creditOf(p), status: 'pending', at: now(), paidAt: null, cancelledAt: null, by: null, memo: '' };
d.orders.push(o); if (d.orders.length > 20000) d.orders.splice(0, d.orders.length - 20000); save(d); return { ...o };
}
/** 내 주문 (최신순) */
function myOrders(userId, limit = 50) { return load().orders.filter((o) => o.userId === userId).slice(-limit).reverse(); }
/** 관리자용 주문 목록 (최신순). filter.status 로 상태를 고를 수 있다 */
function listOrders({ status, limit = 200 } = {}) { return load().orders.filter((o) => !status || o.status === status).slice(-limit).reverse(); }
function getOrder(oid) { return load().orders.find((o) => o.id === oid) || null; }
/**
* 입금(결제) 확인 → 적립 (관리자, 또는 나중에 PG 콜백). 결제 대기 주문만 처리하며 캐시 원장에 add 로 기록한다.
* 같은 주문을 두 번 적립하지 못하게 상태를 먼저 바꾼 뒤 원장에 쓴다(원장 기록이 실패하면 상태를 되돌린다).
* @param {string} oid 주문 ID
* @param {string} by 처리자 (관리자 ID 또는 'pg:<provider>')
* @param {string} [memo] 메모(입금자명·승인번호 등. 비밀값은 넣지 않는다)
*/
function markPaid(oid, by, memo) {
const d = load(); const o = d.orders.find((x) => x.id === oid); if (!o) throw httpError('주문이 없습니다', 404);
if (o.status !== 'pending') throw httpError(`이미 ${o.status === 'paid' ? '적립된' : '취소된'} 주문입니다`, 409);
o.status = 'paid'; o.paidAt = now(); o.by = by || null; o.memo = String(memo || '').slice(0, 200); save(d);
try {
const credits = require('./credits');
const r = credits.apply({ userId: o.userId, type: 'add', amount: o.credit, memo: `선불 충전 ${o.name} — 결제 ${o.amount.toLocaleString('ko-KR')}원${o.bonus ? ` + 인센티브 ${o.bonus.toLocaleString('ko-KR')}원` : ''}`, by: by || 'system', ref: { orderId: o.id } });
return { order: { ...o }, balance: r.balance, entry: r.entry };
} catch (e) {
const d2 = load(); const o2 = d2.orders.find((x) => x.id === oid); if (o2) { o2.status = 'pending'; o2.paidAt = null; o2.by = null; save(d2); }
throw e;
}
}
/**
* 주문 취소. 사용자는 자기 결제 대기 주문만(userId 를 넘긴다), 관리자는 어떤 결제 대기 주문이든(userId 생략).
* 이미 적립된(paid) 주문은 취소할 수 없다 — 캐시 조정은 관리자 콘솔의 캐시 조정으로 한다.
*/
function cancelOrder(oid, by, { userId, reason } = {}) {
const d = load(); const o = d.orders.find((x) => x.id === oid); if (!o || (userId && o.userId !== userId)) throw httpError('주문이 없습니다', 404);
if (o.status === 'paid') throw httpError('이미 적립된 주문은 취소할 수 없습니다. 캐시 조정은 관리자에게 요청하세요', 409);
if (o.status === 'cancelled') return { ...o };
o.status = 'cancelled'; o.cancelledAt = now(); o.by = by || null; o.memo = String(reason || '').slice(0, 200); save(d); return { ...o };
}
/** 관리자 요약: 상품 수·결제 대기 건수·금액 */
function summary() {
const d = load(); const pending = d.orders.filter((o) => o.status === 'pending');
return { packages: d.packages.length, activePackages: d.packages.filter((p) => p.active !== false).length, pendingOrders: pending.length, pendingAmount: pending.reduce((a, o) => a + o.amount, 0), paidOrders: d.orders.filter((o) => o.status === 'paid').length };
}
module.exports = { FILE, MAX_PENDING_PER_USER, AMOUNT_MIN, AMOUNT_MAX, BONUS_TYPES, STATUS, bonusOf, creditOf, listPackages, getPackage, savePackage, removePackage, createOrder, myOrders, listOrders, getOrder, markPaid, cancelOrder, summary };