Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
238 lines
35 KiB
JavaScript
238 lines
35 KiB
JavaScript
/**
|
||
* 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 };
|