Files
AriWeb-Suite/portal/commerce/orders.mjs

238 lines
35 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* portal/commerce/orders.mjs — 주문·수동 결제 확인·승인·권리 활성화·구독·갱신·축소·해지·환불/크레딧·초과 사용(§6.2~6.5, §27.1, §35).
*
* 흐름(0.1.1 기본 = 수동 결제 확인)
* 1) 고객이 상품(+옵션)·적용 대상·결제 방식(manual_transfer 만)·약관 동의를 보내면 주문(awaiting_payment)이 생긴다. 주문 번호·금액·통화·세금·입금 기한·입금 안내를 보인다.
* 2) 운영자가 실제 입금을 확인(markPaid)하고 승인(approve)하거나 거절(reject)한다. 승인 때만 권리(lib/quota entitlement)가 생긴다.
* 3) 같은 승인을 두 번 눌러도 권리가 중복 생기지 않는다: 승인 트랜잭션 안에서 status 를 검사하고 부여한 권리 id 를 orders.entitlement_ids_json 에 남긴다.
* 4) 기한이 지나면 expired. 거절·만료 사유와 재주문·문의 안내를 화면에 보인다.
* 멱등: 고객이 보낸 idempotency_key 가 같으면 기존 주문을 돌려준다(새로 고침·연타 §36).
* 구독(subscriptions): 기본 상품 주문이 승인되면 활성 구독이 되고 next_billing_at 이 정해진다. 갱신은 renewal_notice_days 전에 갱신 주문(kind='renewal')을 만들어 알리고,
* 입금 확인 전까지 'awaiting_renewal_payment' 로 보인다. 미납 제한 예정 시각(restrict_at)은 정책의 restriction_after_unpaid_days 로 계산해 고객·운영자에게 똑같이 보인다. 데이터는 지우지 않는다.
* 축소(§6.4): 새 상품 한도 안에 현재 사용량·배정이 들어오면 즉시 축소 주문(kind='downsize', 0원이면 바로 승인 대기)이, 아니면 downsize_holds 에 보류되고 줄여야 할 항목을 구체적으로 보인다.
* 해지: cancel_at(=다음 청구일)에 권리를 종료한다. 사용 중 권리를 경고 없이 즉시 회수하지 않는다.
*/
import { newId } from '../../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
import { HttpError } from '../../lib/http.mjs';
import * as quota from '../../lib/quota.mjs';
import { notify } from '../../lib/notify.mjs';
import { audit } from '../../lib/audit.mjs';
import { getProductVersion, getSetting, publicProduct, rowToProduct } from './catalog.mjs';
export const ORDER_STATUS = { AWAITING_PAYMENT: 'awaiting_payment', AWAITING_APPROVAL: 'awaiting_approval', APPROVED: 'approved', REJECTED: 'rejected', EXPIRED: 'expired', CANCELLED: 'cancelled' };
export const ORDER_LABEL = { awaiting_payment: '결제 대기', awaiting_approval: '승인 대기', approved: '승인 완료', rejected: '거절됨', expired: '기한 만료', cancelled: '취소됨' };
export const SUB_LABEL = { active: '사용 중', awaiting_renewal_payment: '갱신 결제 대기', restricted: '미납 제한', cancel_scheduled: '해지 예약', cancelled: '해지됨', downsize_scheduled: '축소 예약' };
const DAY = 86400000;
export const rowToOrder = (r) => (r ? { ...r, items: parseJson(r.items_json, []), payment_instructions: parseJson(r.payment_instructions_json, null), entitlement_ids: parseJson(r.entitlement_ids_json, []), terms: parseJson(r.terms_json, null) } : null);
export const rowToSub = (r) => (r ? { ...r, items: parseJson(r.items_json, []), entitlement_ids: parseJson(r.entitlement_ids_json, []) } : null);
/** 주문 번호 AW-YYYYMMDD-NNNN (그날 순번) */
function orderNo(store) { const d = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const n = store.get("SELECT COUNT(*) AS n FROM orders WHERE order_no LIKE ?", `AW-${d}-%`).n + 1; return `AW-${d}-${String(n).padStart(4, '0')}`; }
/**
* 주문 항목 계산. input.items = [{ optionId?, qty, targetType?, targetId? }] + 기본 상품 포함 여부(includeBase).
* 각 항목에 단가·세금·합계·부여 자원(grants)을 붙인다. 옵션 수량 상한(max_qty)·per(site 면 대상 필요)을 검사한다.
*/
export function priceItems(pv, { includeBase = true, items = [] } = {}) {
const out = []; const addTax = (amt) => Math.round(amt * pv.tax_rate);
if (includeBase) out.push({ type: 'product', id: pv.product_id, name: pv.name, qty: 1, unit_price: pv.price, amount: pv.price, tax: addTax(pv.price), target_type: 'group', target_id: null, grants: pv.limits });
for (const it of items) {
const o = pv.options.find((x) => x.id === it.optionId); if (!o) throw new HttpError(400, 'bad_option', `알 수 없는 옵션: ${it.optionId}`);
const qty = Math.max(1, Math.min(Number(it.qty) || 1, o.max_qty || 99));
if (o.per === 'site' && !it.targetId) throw new HttpError(400, 'target_required', `${o.name} 은(는) 적용할 사이트를 골라야 합니다`);
const amount = o.price * qty; const grants = Object.fromEntries(Object.entries(o.grants || {}).map(([k, v]) => [k, v * qty]));
out.push({ type: 'option', id: o.id, name: o.name, qty, unit_price: o.price, amount, tax: addTax(amount), target_type: o.per === 'site' ? 'site' : 'group', target_id: it.targetId || null, grants, policy: o.policy || null });
}
const amount = out.reduce((s, x) => s + x.amount, 0); const tax = out.reduce((s, x) => s + x.tax, 0);
return { items: out, amount, tax, total: amount + tax, currency: pv.currency };
}
/** 입금 안내(settings.payment_instructions, 운영자 관리). 비밀값 없음. */
export function paymentInstructions(store) { return getSetting(store, 'payment_instructions', { method: 'manual_transfer', bank: '', account: '', holder: '', note: '', deadline_days: 7 }); }
/**
* 주문 만들기. 이메일 미확인 사용자는 유료 상품을 살 수 없다(§3.1). idempotencyKey 가 같으면 기존 주문.
* @returns {{order, created}}
*/
export function createOrder(store, { groupId, userId, userVerified, productVersionId, includeBase = true, items = [], paymentMethod = 'manual_transfer', acceptTerms, idempotencyKey = null, kind = 'new', subscriptionId = null }) {
const pv = getProductVersion(store, productVersionId); if (!pv || !pv.published) throw new HttpError(404, 'product_not_found', '판매 중인 상품이 아닙니다');
if (paymentMethod !== 'manual_transfer') throw new HttpError(400, 'payment_method_unsupported', '버전 0.1.1 은 수동 결제 확인(계좌 이체)만 지원합니다');
if (!acceptTerms) throw new HttpError(400, 'terms_required', '약관과 제한 사항에 동의해야 주문할 수 있습니다');
const priced = priceItems(pv, { includeBase, items });
if (priced.total > 0 && !userVerified) throw new HttpError(403, 'email_unverified', '이메일 확인이 끝나야 유료 상품을 주문할 수 있습니다', { action: 'verify_email' });
if (!priced.items.length) throw new HttpError(400, 'empty_order', '주문 항목이 없습니다');
return store.tx(() => {
if (idempotencyKey) { const ex = store.get('SELECT * FROM orders WHERE idempotency_key=?', idempotencyKey); if (ex) return { order: rowToOrder(ex), created: false }; }
// 같은 그룹에 결제 대기 중인 같은 상품 주문이 있으면 그것을 돌려준다(새로 고침 뒤 다른 키로 다시 눌러도 중복 생성 안 함)
if (kind === 'new' && includeBase) { const dup = store.get('SELECT * FROM orders WHERE group_id=? AND product_version_id=? AND kind=? AND status IN (?,?)', groupId, pv.id, 'new', 'awaiting_payment', 'awaiting_approval'); if (dup) return { order: rowToOrder(dup), created: false }; }
const pi = paymentInstructions(store); const now = nowIso(); const dueDays = Number(pv.policy?.payment_deadline_days || pi.deadline_days || 7);
const id = newId('o_');
store.run('INSERT INTO orders(id, group_id, order_no, kind, product_version_id, items_json, target_type, target_id, amount, tax, total, currency, status, payment_method, payment_instructions_json, due_at, idempotency_key, created_by, created_at, updated_at, terms_json, subscription_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, groupId, orderNo(store), kind, pv.id, toJson(priced.items), 'group', groupId, priced.amount, priced.tax, priced.total, priced.currency, priced.total === 0 ? ORDER_STATUS.AWAITING_APPROVAL : ORDER_STATUS.AWAITING_PAYMENT, paymentMethod, toJson(pi), new Date(Date.now() + dueDays * DAY).toISOString(), idempotencyKey, userId, now, now, toJson({ acceptedAt: now, productVersion: pv.id, terms: 'v0.1.1' }), subscriptionId);
const order = rowToOrder(store.get('SELECT * FROM orders WHERE id=?', id));
audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'order', subjectId: id, action: 'order.create', detail: { orderNo: order.order_no, total: order.total, kind } });
notify(store, { groupId, category: 'billing', severity: 'info', title: `주문 ${order.order_no} 접수 — ${order.status === 'awaiting_payment' ? '입금 확인 대기' : '승인 대기'}`, body: `금액 ${order.total.toLocaleString('ko-KR')} ${order.currency}(세금 포함) · 기한 ${order.due_at.slice(0, 10)}`, link: `/orders/${id}`, subjectType: 'order', subjectId: id, incidentKey: `order:${id}:status` });
return { order, created: true };
});
}
export function getOrder(store, id, groupId = null) { const r = store.get(`SELECT * FROM orders WHERE id=? ${groupId ? 'AND group_id=?' : ''}`, ...(groupId ? [id, groupId] : [id])); return rowToOrder(r); }
export function listOrders(store, { groupId, status, limit = 100 } = {}) { const w = []; const p = []; if (groupId) { w.push('group_id=?'); p.push(groupId); } if (status) { w.push(Array.isArray(status) ? `status IN (${status.map(() => '?').join(',')})` : 'status=?'); p.push(...[].concat(status)); } return store.all(`SELECT * FROM orders ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY created_at DESC LIMIT ?`, ...p, limit).map(rowToOrder); }
/** 고객 취소: 결제·승인 전에만 */
export function cancelOrder(store, id, groupId, userId) { const o = getOrder(store, id, groupId); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); if (!['awaiting_payment', 'awaiting_approval'].includes(o.status)) throw new HttpError(409, 'order_not_cancellable', `${ORDER_LABEL[o.status]} 상태의 주문은 취소할 수 없습니다`); store.run('UPDATE orders SET status=?, cancelled_at=?, updated_at=? WHERE id=?', 'cancelled', nowIso(), nowIso(), id); audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'order', subjectId: id, action: 'order.cancel' }); return getOrder(store, id); }
/** 운영자: 입금 확인(승인 전 단계). 입금 참조(입금자명·일시)는 비밀 아님. */
export function markPaid(store, id, { by, ref = '' }) { const o = getOrder(store, id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); if (o.status !== 'awaiting_payment') throw new HttpError(409, 'order_not_awaiting_payment', `${ORDER_LABEL[o.status]} 상태에서는 입금 확인을 할 수 없습니다`); store.run('UPDATE orders SET status=?, paid_at=?, paid_ref=?, updated_at=? WHERE id=?', 'awaiting_approval', nowIso(), String(ref).slice(0, 120), nowIso(), id); audit(store, { actorType: 'operator', actorId: by, groupId: o.group_id, subjectType: 'order', subjectId: id, action: 'payment.confirm', detail: { ref } }); notify(store, { groupId: o.group_id, category: 'billing', severity: 'info', title: `주문 ${o.order_no} 입금 확인 — 승인 대기`, link: `/orders/${id}`, subjectType: 'order', subjectId: id, incidentKey: `order:${id}:status` }); return getOrder(store, id); }
/**
* 운영자 승인 → 권리 활성화(§35). 멱등: 이미 approved 면 그대로 돌려준다(권리 재부여 없음).
* 기본 상품 주문이면 구독을 만들고(또는 갱신/축소 주문이면 구독을 갱신하고) 다음 청구일을 정한다.
*/
export function approveOrder(store, id, { by }) {
return store.tx(() => {
const o = getOrder(store, id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다');
if (o.status === 'approved') return { order: o, granted: [], already: true };
if (!['awaiting_approval', 'awaiting_payment'].includes(o.status)) throw new HttpError(409, 'order_not_approvable', `${ORDER_LABEL[o.status]} 상태의 주문은 승인할 수 없습니다`);
if (o.status === 'awaiting_payment' && o.total > 0) throw new HttpError(409, 'payment_not_confirmed', '입금 확인을 먼저 기록하세요');
const pv = getProductVersion(store, o.product_version_id); const now = nowIso();
let sub = o.subscription_id ? rowToSub(store.get('SELECT * FROM subscriptions WHERE id=?', o.subscription_id)) : null;
const granted = [];
if (o.kind === 'renewal' && sub) {
// 갱신: 권리는 그대로, 다음 청구일만 한 주기 뒤로
store.run('UPDATE subscriptions SET status=?, next_billing_at=?, renewal_order_id=NULL, renewal_notified_at=NULL, payment_due_at=NULL, restrict_at=NULL, updated_at=? WHERE id=?', 'active', addCycle(sub.next_billing_at || now, sub.billing_cycle), now, sub.id);
} else if (o.kind === 'downsize' && sub) {
// 축소: 옛 권리를 끝내고 새 상품 한도로 다시 부여(승인 시점에 다시 검증 — 그 사이 사용량이 늘었으면 거절)
const check = evaluateDownsize(store, o.group_id, pv, o.items);
if (check.shortages.length) throw new HttpError(409, 'downsize_blocked', '승인 시점 재검증에서 새 한도를 넘는 사용량이 있습니다', { shortages: check.shortages });
for (const eid of sub.entitlement_ids) quota.endEntitlement(store, eid, now);
for (const it of o.items) for (const [res, amt] of Object.entries(it.grants || {})) if (amt > 0) granted.push(quota.grantEntitlement(store, { groupId: o.group_id, resource: res, amount: amt, sourceId: o.id, productVersionId: pv.id, note: it.name }));
store.run('UPDATE subscriptions SET product_version_id=?, items_json=?, amount=?, tax=?, total=?, entitlement_ids_json=?, status=?, updated_at=? WHERE id=?', pv.id, toJson(o.items), o.amount, o.tax, o.total, toJson(granted), 'active', now, sub.id);
store.run("UPDATE downsize_holds SET status='applied', order_id=?, updated_at=? WHERE group_id=? AND status='ordered' AND order_id=?", o.id, now, o.group_id, o.id);
} else {
for (const it of o.items) for (const [res, amt] of Object.entries(it.grants || {})) if (amt > 0) granted.push(quota.grantEntitlement(store, { groupId: o.group_id, resource: res, amount: amt, sourceId: o.id, productVersionId: pv.id, note: it.target_type === 'site' && it.target_id ? `${it.name} (사이트 ${it.target_id})` : it.name }));
if (o.items.some((i) => i.type === 'product')) {
const sid = newId('sub_');
store.run('INSERT INTO subscriptions(id, group_id, product_version_id, order_id, items_json, billing_cycle, amount, tax, total, currency, status, started_at, next_billing_at, entitlement_ids_json, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', sid, o.group_id, pv.id, o.id, toJson(o.items), pv.billing_cycle, o.amount, o.tax, o.total, o.currency, 'active', now, addCycle(now, pv.billing_cycle), toJson(granted), now, now);
store.run('UPDATE orders SET subscription_id=? WHERE id=?', sid, o.id); sub = rowToSub(store.get('SELECT * FROM subscriptions WHERE id=?', sid));
} else if (sub) {
// 기존 구독의 추가 옵션: 구독 권리 목록에 합치고 금액을 더한다(다음 청구에 반영)
store.run('UPDATE subscriptions SET items_json=?, amount=amount+?, tax=tax+?, total=total+?, entitlement_ids_json=?, updated_at=? WHERE id=?', toJson([...sub.items, ...o.items]), o.amount, o.tax, o.total, toJson([...sub.entitlement_ids, ...granted]), now, sub.id);
}
}
store.run('UPDATE orders SET status=?, approved_at=?, approved_by=?, entitlement_ids_json=?, updated_at=? WHERE id=?', 'approved', now, by, toJson(granted), now, id);
audit(store, { actorType: 'operator', actorId: by, groupId: o.group_id, subjectType: 'order', subjectId: id, action: 'order.approve', detail: { orderNo: o.order_no, granted: granted.length, kind: o.kind } });
notify(store, { groupId: o.group_id, category: 'billing', severity: 'info', mandatory: true, title: `주문 ${o.order_no} 승인 — 권리가 활성화되었습니다`, body: o.items.map((i) => `${i.name}×${i.qty}`).join(', '), link: `/orders/${id}`, subjectType: 'order', subjectId: id, incidentKey: `order:${id}:status` });
return { order: getOrder(store, id), granted, already: false };
});
}
export function rejectOrder(store, id, { by, reason }) { const o = getOrder(store, id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); if (!['awaiting_payment', 'awaiting_approval'].includes(o.status)) throw new HttpError(409, 'order_not_rejectable', `${ORDER_LABEL[o.status]} 상태의 주문은 거절할 수 없습니다`); if (!reason) throw new HttpError(400, 'reason_required', '거절 사유를 적으세요'); store.run('UPDATE orders SET status=?, rejected_at=?, rejected_reason=?, updated_at=? WHERE id=?', 'rejected', nowIso(), String(reason).slice(0, 500), nowIso(), id); audit(store, { actorType: 'operator', actorId: by, groupId: o.group_id, subjectType: 'order', subjectId: id, action: 'order.reject', detail: { reason } }); notify(store, { groupId: o.group_id, category: 'billing', severity: 'warning', mandatory: true, title: `주문 ${o.order_no} 거절`, body: `사유: ${reason}. 다시 주문하거나 문의하세요`, link: `/orders/${id}`, subjectType: 'order', subjectId: id, incidentKey: `order:${id}:status` }); return getOrder(store, id); }
/** 기한 만료 처리(주기 작업). 만료된 주문 목록 반환 */
export function expireOrders(store, at = nowIso()) {
const rows = store.all("SELECT * FROM orders WHERE status='awaiting_payment' AND due_at IS NOT NULL AND due_at<?", at).map(rowToOrder);
for (const o of rows) { store.run('UPDATE orders SET status=?, expired_at=?, updated_at=? WHERE id=?', 'expired', at, at, o.id); notify(store, { groupId: o.group_id, category: 'billing', severity: 'warning', title: `주문 ${o.order_no} 입금 기한 만료`, body: '입금이 확인되지 않아 주문이 만료되었습니다. 다시 주문하거나 문의하세요', link: `/orders/${o.id}`, subjectType: 'order', subjectId: o.id, incidentKey: `order:${o.id}:status` }); audit(store, { actorType: 'system', automatic: true, groupId: o.group_id, subjectType: 'order', subjectId: o.id, action: 'order.expire' }); }
return rows;
}
/** 운영자: 환불·크레딧 기록(원장). 서비스 영향 시점은 applies_at. */
export function addCredit(store, { groupId, kind, amount, orderId = null, reason, by, appliesAt = null }) { if (!['refund', 'credit', 'adjust'].includes(kind)) throw new HttpError(400, 'bad_kind', 'kind 는 refund|credit|adjust'); if (!(Number(amount) !== 0)) throw new HttpError(400, 'bad_amount', '금액을 입력하세요'); if (!reason) throw new HttpError(400, 'reason_required', '사유를 적으세요'); const id = newId('cr_'); store.run('INSERT INTO credits(id, group_id, kind, amount, order_id, reason, created_by, created_at, applies_at) VALUES(?,?,?,?,?,?,?,?,?)', id, groupId, kind, Math.round(amount), orderId, String(reason).slice(0, 500), by, nowIso(), appliesAt || nowIso()); audit(store, { actorType: 'operator', actorId: by, groupId, subjectType: 'credit', subjectId: id, action: kind === 'refund' ? 'refund.record' : 'credit.record', detail: { amount, orderId, reason } }); notify(store, { groupId, category: 'billing', severity: 'info', mandatory: kind === 'refund', title: kind === 'refund' ? `환불 ${Math.round(amount).toLocaleString('ko-KR')}원 처리` : `크레딧 ${Math.round(amount).toLocaleString('ko-KR')}원 적립`, body: reason, link: '/billing' }); return store.get('SELECT * FROM credits WHERE id=?', id); }
export function listCredits(store, groupId) { return store.all('SELECT * FROM credits WHERE group_id=? ORDER BY created_at DESC', groupId); }
/** 청구 주기 더하기 */
export function addCycle(iso, cycle) { const d = new Date(iso); if (cycle === 'yearly') d.setUTCFullYear(d.getUTCFullYear() + 1); else d.setUTCMonth(d.getUTCMonth() + 1); return d.toISOString(); }
export function listSubscriptions(store, groupId) { return store.all('SELECT * FROM subscriptions WHERE group_id=? ORDER BY created_at DESC', groupId).map(rowToSub); }
export function activeSubscription(store, groupId) { return rowToSub(store.get("SELECT * FROM subscriptions WHERE group_id=? AND status NOT IN ('cancelled') ORDER BY created_at DESC LIMIT 1", groupId)); }
/** 그룹에 적용 중인 상품정책(경고 구간 등). 구독이 없으면 null */
export function appliedProduct(store, groupId) { const s = activeSubscription(store, groupId); return s ? getProductVersion(store, s.product_version_id) : null; }
/**
* 갱신 처리(주기 작업). notice_days 전에 갱신 주문을 만들고 알린다. 청구일이 지나도 미납이면 awaiting_renewal_payment, 기한이 더 지나면 restricted(제한 예정 시각은 미리 알림).
* 반환: { created:[subId], restricted:[subId] }
*/
export function processRenewals(store, at = nowIso()) {
const created = []; const restricted = []; const now = Date.parse(at);
for (const s of store.all("SELECT * FROM subscriptions WHERE status IN ('active','awaiting_renewal_payment') AND next_billing_at IS NOT NULL").map(rowToSub)) {
const pv = getProductVersion(store, s.product_version_id); if (!pv) continue;
const noticeDays = Number(pv.policy?.renewal_notice_days || 7); const deadlineDays = Number(pv.policy?.payment_deadline_days || 7); const restrictDays = Number(pv.policy?.restriction_after_unpaid_days || 14);
const billing = Date.parse(s.next_billing_at);
if (!s.renewal_order_id && now >= billing - noticeDays * DAY && s.status === 'active' && !s.cancel_at) {
const id = newId('o_'); const pi = paymentInstructions(store); const due = new Date(billing + deadlineDays * DAY).toISOString(); const restrictAt = new Date(billing + restrictDays * DAY).toISOString();
store.run('INSERT INTO orders(id, group_id, order_no, kind, product_version_id, items_json, target_type, target_id, amount, tax, total, currency, status, payment_method, payment_instructions_json, due_at, created_by, created_at, updated_at, subscription_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', id, s.group_id, orderNo(store), 'renewal', pv.id, s.items_json, 'group', s.group_id, s.amount, s.tax, s.total, s.currency, 'awaiting_payment', 'manual_transfer', toJson(pi), due, 'system', at, at, s.id);
store.run('UPDATE subscriptions SET renewal_order_id=?, renewal_notified_at=?, payment_due_at=?, restrict_at=?, status=?, updated_at=? WHERE id=?', id, at, due, restrictAt, 'awaiting_renewal_payment', at, s.id);
notify(store, { groupId: s.group_id, category: 'billing', severity: 'warning', mandatory: true, title: `갱신 결제 안내 — ${s.next_billing_at.slice(0, 10)} 청구 예정 ${s.total.toLocaleString('ko-KR')} ${s.currency}`, body: `입금 기한 ${due.slice(0, 10)} · 미납 시 ${restrictAt.slice(0, 10)} 부터 신규 배포·설정 변경이 제한됩니다(데이터는 삭제하지 않습니다)`, link: `/orders/${id}`, subjectType: 'subscription', subjectId: s.id, incidentKey: `sub:${s.id}:renewal` });
created.push(s.id);
} else if (s.status === 'awaiting_renewal_payment' && s.restrict_at && now >= Date.parse(s.restrict_at)) {
store.run('UPDATE subscriptions SET status=?, updated_at=? WHERE id=?', 'restricted', at, s.id); restricted.push(s.id);
notify(store, { groupId: s.group_id, category: 'billing', severity: 'critical', mandatory: true, title: '미납으로 신규 변경이 제한되었습니다', body: `${pv.policy?.restriction_effect || '신규 배포·설정 변경 제한'}. 입금이 확인되면 즉시 해제됩니다`, link: `/orders/${s.renewal_order_id}`, subjectType: 'subscription', subjectId: s.id, incidentKey: `sub:${s.id}:restricted` });
}
}
// 해지 예약 도래: 권리 종료
for (const s of store.all("SELECT * FROM subscriptions WHERE status='cancel_scheduled' AND cancel_at<=?", at).map(rowToSub)) { for (const eid of s.entitlement_ids) quota.endEntitlement(store, eid, at); store.run('UPDATE subscriptions SET status=?, cancelled_at=?, updated_at=? WHERE id=?', 'cancelled', at, at, s.id); notify(store, { groupId: s.group_id, category: 'billing', severity: 'warning', mandatory: true, title: '상품 해지가 적용되었습니다', body: '권리가 종료되었습니다. 데이터는 보존 기간 동안 조회·내보내기가 가능합니다', link: '/billing', subjectType: 'subscription', subjectId: s.id }); audit(store, { actorType: 'system', automatic: true, groupId: s.group_id, subjectType: 'subscription', subjectId: s.id, action: 'cancel.apply' }); }
return { created, restricted };
}
/**
* 축소 가능성 평가(§6.4). 새 상품 한도(+옵션 grants) 와 현재 배정·예약·실제 사용을 자원별로 비교한다.
* shortages: [{ resource, label, newLimit, inUse(배정+예약), used(계측), reduceBy, items:[{subjectType, subjectId, amount}] }]
*/
export function evaluateDownsize(store, groupId, pv, items = []) {
const newLimits = { ...pv.limits }; for (const it of items) if (it.type === 'option') for (const [r, a] of Object.entries(it.grants || {})) newLimits[r] = (newLimits[r] || 0) + a;
const shortages = []; const resources = [];
for (const r of Object.keys(quota.RESOURCES)) {
const f = quota.figures(store, groupId, r); const limit = newLimits[r] || 0; const inUse = f.allocated + f.reserved; const need = Math.max(inUse, f.used);
resources.push({ resource: r, label: f.label, unit: f.unit, current: f.purchased, newLimit: limit, allocated: f.allocated, reserved: f.reserved, used: f.used });
if (need > limit) shortages.push({ resource: r, label: f.label, unit: f.unit, newLimit: limit, inUse, used: f.used, reduceBy: need - limit, items: quota.listAllocations(store, groupId, r).map((a) => ({ subjectType: a.subject_type, subjectId: a.subject_id, amount: a.amount, used: a.used || 0 })) });
}
return { newLimits, resources, shortages };
}
/**
* 축소 요청: 범위 안이면 축소 주문(승인 대기; 차액은 0 — 차기 청구부터 새 금액)을 만들고, 아니면 보류(hold)를 만든다.
* @returns {{ fits:boolean, order?, hold?, shortages }}
*/
export function requestDownsize(store, { groupId, userId, userVerified, productVersionId, items = [], acceptTerms }) {
const sub = activeSubscription(store, groupId); if (!sub) throw new HttpError(409, 'no_subscription', '활성 상품이 없습니다');
const pv = getProductVersion(store, productVersionId); if (!pv || !pv.published) throw new HttpError(404, 'product_not_found', '판매 중인 상품이 아닙니다');
const priced = priceItems(pv, { includeBase: true, items }); const ev = evaluateDownsize(store, groupId, pv, priced.items);
const now = nowIso();
if (ev.shortages.length) {
const open = store.get("SELECT * FROM downsize_holds WHERE group_id=? AND status='holding'", groupId);
const id = open ? open.id : newId('dh_');
if (open) store.run('UPDATE downsize_holds SET target_product_version_id=?, items_json=?, shortages_json=?, last_checked_at=?, updated_at=? WHERE id=?', pv.id, toJson(priced.items), toJson(ev.shortages), now, now, id);
else store.run('INSERT INTO downsize_holds(id, group_id, subscription_id, target_product_version_id, items_json, status, shortages_json, last_checked_at, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, groupId, sub.id, pv.id, toJson(priced.items), 'holding', toJson(ev.shortages), now, userId, now, now);
notify(store, { groupId, category: 'billing', severity: 'warning', title: '상품 축소 보류 — 정리가 필요한 항목이 있습니다', body: ev.shortages.map((s) => `${s.label} ${s.reduceBy} 줄이기`).join(', '), link: '/billing#downsize', subjectType: 'downsize_hold', subjectId: id, incidentKey: `downsize:${groupId}` });
return { fits: false, hold: rowToHold(store.get('SELECT * FROM downsize_holds WHERE id=?', id)), shortages: ev.shortages };
}
const { order } = createOrder(store, { groupId, userId, userVerified, productVersionId, items, acceptTerms, kind: 'downsize', subscriptionId: sub.id, idempotencyKey: `downsize:${groupId}:${pv.id}:${sub.id}` });
store.run("UPDATE downsize_holds SET status='ordered', order_id=?, updated_at=? WHERE group_id=? AND status='holding'", order.id, now, groupId);
return { fits: true, order, shortages: [] };
}
export const rowToHold = (r) => (r ? { ...r, items: parseJson(r.items_json, []), shortages: parseJson(r.shortages_json, []) } : null);
export function openHold(store, groupId) { return rowToHold(store.get("SELECT * FROM downsize_holds WHERE group_id=? AND status='holding'", groupId)); }
/** 보류 재검증: 정리가 끝났으면 축소 주문으로 전환 */
export function revalidateHold(store, { groupId, userId, userVerified }) { const h = openHold(store, groupId); if (!h) throw new HttpError(404, 'no_hold', '보류 중인 축소 요청이 없습니다'); return requestDownsize(store, { groupId, userId, userVerified, productVersionId: h.target_product_version_id, items: h.items.filter((i) => i.type === 'option').map((i) => ({ optionId: i.id, qty: i.qty, targetId: i.target_id })), acceptTerms: true }); }
export function cancelHold(store, groupId) { return store.run("UPDATE downsize_holds SET status='cancelled', updated_at=? WHERE group_id=? AND status='holding'", nowIso(), groupId).changes; }
/** 해지 예약/철회(§6.4·§23). 다음 청구일에 권리 종료. */
export function scheduleCancel(store, { groupId, userId }) { const s = activeSubscription(store, groupId); if (!s) throw new HttpError(409, 'no_subscription', '활성 상품이 없습니다'); if (s.status === 'cancel_scheduled') return s; const at = s.next_billing_at || nowIso(); store.run('UPDATE subscriptions SET status=?, cancel_at=?, cancel_requested_at=?, updated_at=? WHERE id=?', 'cancel_scheduled', at, nowIso(), nowIso(), s.id); audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'subscription', subjectId: s.id, action: 'cancel.schedule', detail: { cancelAt: at } }); notify(store, { groupId, category: 'billing', severity: 'warning', mandatory: true, title: `상품 해지 예약 — ${at.slice(0, 10)} 적용`, body: '적용일까지 서비스와 권리는 유지됩니다. 적용 후 데이터는 보존 기간 동안 읽기·내보내기만 가능합니다', link: '/billing', subjectType: 'subscription', subjectId: s.id, incidentKey: `sub:${s.id}:cancel` }); return activeSubscription(store, groupId); }
export function undoCancel(store, { groupId, userId }) { const s = activeSubscription(store, groupId); if (!s || s.status !== 'cancel_scheduled') throw new HttpError(409, 'not_scheduled', '해지 예약이 없습니다'); store.run('UPDATE subscriptions SET status=?, cancel_at=NULL, cancel_requested_at=NULL, updated_at=? WHERE id=?', 'active', nowIso(), s.id); audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'subscription', subjectId: s.id, action: 'cancel.undo' }); return activeSubscription(store, groupId); }
/** 운영자: 수동 결제 실패/재처리 등 메모 */
export function noteOrder(store, id, { by, note }) { store.run('UPDATE orders SET note=?, updated_at=? WHERE id=?', String(note || '').slice(0, 1000), nowIso(), id); audit(store, { actorType: 'operator', actorId: by, subjectType: 'order', subjectId: id, action: 'order.note', detail: { note } }); return getOrder(store, id); }
/** 초과 사용 허용(§6.5): 항목별 최대 금액 또는 최대 사용량·기간 필수. 철회하면 그 즉시 100% 제한이 다시 적용된다. */
export function optInOverage(store, { groupId, userId, resource, capAmount = null, capUnits = null, periodStart, periodEnd }) {
if (!quota.RESOURCES[resource]) throw new HttpError(400, 'unknown_resource', '알 수 없는 자원'); if (!(capAmount > 0) && !(capUnits > 0)) throw new HttpError(400, 'cap_required', '최대 금액 또는 최대 사용량을 정해야 합니다'); if (!periodStart || !periodEnd || Date.parse(periodEnd) <= Date.parse(periodStart)) throw new HttpError(400, 'period_required', '적용 기간을 정해야 합니다');
const pv = appliedProduct(store, groupId); const unit = pv?.policy?.overage?.unit_prices?.[resource] || null; if (!unit) throw new HttpError(409, 'overage_not_offered', '이 자원은 초과 사용 상품이 없습니다');
store.run('UPDATE overage_optins SET revoked_at=?, revoked_by=? WHERE group_id=? AND resource=? AND revoked_at IS NULL', nowIso(), userId, groupId, resource);
const id = newId('ov_'); store.run('INSERT INTO overage_optins(id, group_id, resource, cap_amount, cap_units, unit_price_json, period_start, period_end, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?,?)', id, groupId, resource, capAmount, capUnits, toJson(unit), periodStart, periodEnd, userId, nowIso());
audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'overage', subjectId: id, action: 'overage.optin', detail: { resource, capAmount, capUnits, periodStart, periodEnd } });
return store.get('SELECT * FROM overage_optins WHERE id=?', id);
}
export function revokeOverage(store, { groupId, userId, resource }) { const n = store.run('UPDATE overage_optins SET revoked_at=?, revoked_by=? WHERE group_id=? AND resource=? AND revoked_at IS NULL', nowIso(), userId, groupId, resource).changes; if (n) audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'overage', subjectId: resource, action: 'overage.revoke' }); return n; }
export function activeOverage(store, groupId, resource, at = nowIso()) { return store.get('SELECT * FROM overage_optins WHERE group_id=? AND resource=? AND revoked_at IS NULL AND period_start<=? AND period_end>? ORDER BY created_at DESC LIMIT 1', groupId, resource, at, at) || null; }
export function listOverage(store, groupId) { return store.all('SELECT * FROM overage_optins WHERE group_id=? ORDER BY created_at DESC', groupId); }
/** 도메인 등록 기능 제공 여부(settings.domain_registration, 운영자가 등록기관 연결 후 켠다) */
export function domainRegistration(store) { const v = getSetting(store, 'domain_registration', { enabled: false }); return { enabled: !!v.enabled, registrar: v.registrar || null, note: v.note || '도메인 등록 기능이 연결되어 있지 않습니다. 외부 등록기관에서 등록한 뒤 도메인 화면에서 연결하세요' }; }
/** 이번 달 예상 비용·결제 대기 금액(§6.3) */
export function billingSummary(store, groupId) {
const subs = listSubscriptions(store, groupId); const act = subs.filter((s) => !['cancelled'].includes(s.status));
const awaiting = listOrders(store, { groupId, status: ['awaiting_payment', 'awaiting_approval'] });
const credits = listCredits(store, groupId); const creditBalance = credits.reduce((s, c) => s + (c.kind === 'refund' ? 0 : c.amount), 0);
return { subscriptions: act.map((s) => ({ ...s, label: SUB_LABEL[s.status] || s.status, product: publicProduct(getProductVersion(store, s.product_version_id)) })), nextBillingAt: act.map((s) => s.next_billing_at).filter(Boolean).sort()[0] || null, monthEstimate: act.reduce((s, x) => s + (x.billing_cycle === 'monthly' ? x.total : Math.round(x.total / 12)), 0), awaitingTotal: awaiting.reduce((s, o) => s + o.total, 0), awaitingOrders: awaiting, creditBalance, credits, currency: 'KRW' };
}
export { getProductVersion, rowToProduct };