상품·주문·수동 결제 승인·권리·청구·축소·해지·AI Wallet 표시·사용량 배정 화면·경고 엔진 (§6·§13·§27)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:26:35 +00:00
co-authored by Claude Fable 5.1
parent c90ac27596
commit c397452acd
21 changed files with 1147 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
/* portal/commerce/public/billing.js — 결제 화면(§6.3~6.5): 활성 상품·다음 청구일·예상 금액·결제 대기·주문 목록·권리·환불/크레딧·축소 보류·해지·AI Wallet·초과 사용·도메인 등록 안내. */
(() => {
const el = document.getElementById('billing'); const esc = aw.esc; const money = (n) => Number(n).toLocaleString('ko-KR') + '원'; const GB = 1024 ** 3;
const fmt = (n, unit) => (unit.startsWith('bytes') ? (n / GB).toFixed(1) + ' GB' : Number(n).toLocaleString('ko-KR'));
async function load() {
try { const d = await aw.api('GET', '/api/commerce/billing');
let h = `<div class="cards"><div class="card"><h2>이번 달 예상</h2><p class="stat">${money(d.monthEstimate)}</p><p class="sub">다음 청구일 ${d.nextBillingAt ? aw.fmt(d.nextBillingAt) : '없음'}</p></div><div class="card"><h2>결제 대기</h2><p class="stat">${money(d.awaitingTotal)}</p><p class="sub">${d.awaitingOrders.length}건</p></div><div class="card"><h2>크레딧 잔액</h2><p class="stat">${money(d.creditBalance)}</p></div></div>`;
h += `<div class="card"><h2>활성 상품과 옵션</h2>${d.subscriptions.length ? d.subscriptions.map((s) => `<dl class="kv"><dt>상품</dt><dd>${esc(s.product.name)} v${s.product.version} <span class="badge ${s.status === 'active' ? 'ok' : 'warning'}">${s.status === 'active' ? '●' : '!'} ${esc(s.label)}</span></dd><dt>항목</dt><dd>${s.items.map((i) => esc(i.name) + '×' + i.qty).join(', ')}</dd><dt>청구</dt><dd>${money(s.total)} / ${s.billing_cycle === 'monthly' ? '월' : '년'} · 다음 ${s.next_billing_at ? aw.fmt(s.next_billing_at) : '-'}</dd>${s.payment_due_at ? `<dt>갱신 입금 기한</dt><dd>${aw.fmt(s.payment_due_at)} · 미납 제한 예정 ${aw.fmt(s.restrict_at)}</dd>` : ''}${s.cancel_at ? `<dt>해지 적용일</dt><dd>${aw.fmt(s.cancel_at)}</dd>` : ''}</dl><div class="actions">${s.status === 'cancel_scheduled' ? '<button class="btn" data-act="undo-cancel">해지 철회</button>' : '<button class="btn danger" data-act="cancel">상품 해지</button>'}<a class="btn" href="/products">상품 변경·축소</a></div>`).join('') : '<p class="muted">활성 상품이 없습니다.</p><a class="btn primary" href="/products">상품 보기</a>'}</div>`;
if (d.hold) h += `<div class="card" id="downsize"><h2>상품 축소 보류</h2><p>새 한도 안에 들어오지 않는 항목이 있어 축소가 보류되었습니다. 아래 항목을 정리한 뒤 "다시 검증" 을 누르세요.</p><ul>${d.hold.shortages.map((s) => `<li><b>${esc(s.label)}</b>: ${fmt(s.reduceBy, s.unit)} 줄이기 (새 한도 ${fmt(s.newLimit, s.unit)}, 배정+예약 ${fmt(s.inUse, s.unit)}, 사용 ${fmt(s.used, s.unit)})<ul>${s.items.map((i) => `<li>${esc(i.subjectType)} ${esc(i.subjectId)}: 배정 ${fmt(i.amount, s.unit)}</li>`).join('')}</ul></li>`).join('')}</ul><p class="sub">마지막 검증 ${aw.fmt(d.hold.last_checked_at)}</p><div class="actions"><button class="btn primary" data-act="revalidate">다시 검증</button><button class="btn" data-act="cancel-hold">축소 요청 취소</button></div></div>`;
h += `<div class="card"><h2>활성화된 권리</h2><table class="r"><thead><tr><th>자원</th><th>구매</th><th>배정</th><th>사용</th></tr></thead><tbody>${d.entitlements.filter((e) => e.purchased).map((e) => `<tr><td data-l="자원">${esc(e.label)}</td><td data-l="구매">${fmt(e.purchased, e.unit)}</td><td data-l="배정">${fmt(e.allocated, e.unit)}</td><td data-l="사용">${fmt(e.used, e.unit)}</td></tr>`).join('') || '<tr><td colspan="4" class="muted">없음</td></tr>'}</tbody></table></div>`;
h += `<div class="card"><h2>주문</h2>${d.orders.length ? `<table class="r"><thead><tr><th>번호</th><th>종류</th><th>금액</th><th>상태</th><th>일시</th></tr></thead><tbody>${d.orders.map((o) => `<tr><td data-l="번호"><a href="/orders/${esc(o.id)}">${esc(o.order_no)}</a></td><td data-l="종류">${esc({ new: '신규', renewal: '갱신', downsize: '축소' }[o.kind] || o.kind)}</td><td data-l="금액">${money(o.total)}</td><td data-l="상태">${esc(o.statusLabel)}</td><td data-l="일시">${aw.fmt(o.created_at)}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">주문이 없습니다.</p>'}</div>`;
h += `<div class="card"><h2>환불·크레딧</h2>${d.credits.length ? `<table class="r"><thead><tr><th>종류</th><th>금액</th><th>사유</th><th>적용일</th></tr></thead><tbody>${d.credits.map((c) => `<tr><td data-l="종류">${esc({ refund: '환불', credit: '크레딧', adjust: '조정' }[c.kind])}</td><td data-l="금액">${money(c.amount)}</td><td data-l="사유">${esc(c.reason)}</td><td data-l="적용일">${aw.fmt(c.applies_at)}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">없음</p>'}</div>`;
h += `<div class="card"><h2>AI Wallet</h2><p class="sub">제작(AI) 기능 전용이며 호스팅 디스크·트래픽·메일 사용량과 분리되어 있습니다.</p>${d.wallet && d.wallet.balance !== undefined ? `<p class="stat">${esc(d.wallet.unit ? d.wallet.balanceText || d.wallet.balance : money(d.wallet.balance))}</p>${(d.wallet.ledger || []).slice(0, 10).map((l) => `<p class="sub">${aw.fmt(l.at)} ${esc(l.type)} ${esc(String(l.amount))} ${esc(l.memo || '')}${l.refunded ? ' (실패 환급)' : ''}</p>`).join('')}<a class="btn" href="${esc(d.wallet.chargeUrl || '/sites')}">충전·이력</a>` : `<p class="muted">${esc(d.wallet?.message || '')}</p>`}</div>`;
h += `<div class="card"><h2>초과 사용 허용</h2><p class="sub">자동 초과 사용과 자동 추가 과금은 기본으로 꺼져 있습니다. 허용하려면 항목별 최대 금액 또는 최대 사용량과 기간을 정해야 하며, 철회하면 즉시 100% 제한이 다시 적용됩니다.</p>${d.overage.filter((o) => !o.revoked_at).map((o) => `<p>${esc(o.resource)}: 상한 ${o.cap_amount ? money(o.cap_amount) : o.cap_units + ' 단위'} · ${o.period_start.slice(0, 10)}~${o.period_end.slice(0, 10)} <button class="btn" data-act="revoke-overage" data-res="${esc(o.resource)}">철회</button></p>`).join('') || '<p class="muted">허용한 항목 없음</p>'}<form id="ov" class="f"><label>자원</label><select name="resource"><option value="web_out">월간 웹 아웃바운드</option><option value="mail_msgs">월간 메일 통수</option></select><label>최대 금액(원)</label><input name="capAmount" type="number" min="0"><label>기간</label><input name="periodStart" type="date" required> ~ <input name="periodEnd" type="date" required><div class="actions"><button class="btn">초과 사용 허용</button></div></form></div>`;
h += `<div class="card"><h2>도메인 등록</h2>${d.domainRegistration.enabled ? '<p>등록기관을 통해 도메인을 검색·등록할 수 있습니다.</p>' : `<p class="muted">${esc(d.domainRegistration.note)}</p>`}</div>`;
el.innerHTML = h;
el.querySelectorAll('[data-act]').forEach((b) => { b.onclick = async () => { const a = b.dataset.act; try {
if (a === 'cancel') { const p = await aw.api('GET', '/api/commerce/cancel/preview'); if (!(await aw.confirmDanger({ title: '상품 해지', target: '현재 그룹의 활성 상품', impacts: [`적용일: ${p.effectiveAt ? aw.fmt(p.effectiveAt) : '즉시'}`, ...p.effects, `보존: 사이트 ${p.retention?.site}일 · 메일 ${p.retention?.mail}일 · 소스 ${p.retention?.source}일`], typeToConfirm: '해지', confirmLabel: '해지 예약' }))) return; await aw.api('POST', '/api/commerce/cancel'); }
if (a === 'undo-cancel') await aw.api('POST', '/api/commerce/cancel/undo');
if (a === 'revalidate') { const r = await aw.api('POST', '/api/commerce/downsize/revalidate'); aw.toast(r.fits ? '정리가 확인되어 축소 주문이 만들어졌습니다' : '아직 정리할 항목이 남아 있습니다'); }
if (a === 'cancel-hold') await aw.api('POST', '/api/commerce/downsize/cancel');
if (a === 'revoke-overage') { if (!(await aw.confirmDanger({ title: '초과 사용 철회', target: b.dataset.res, impacts: ['철회 즉시 100% 제한이 다시 적용됩니다'] }))) return; await aw.api('POST', '/api/commerce/overage', { revoke: true, resource: b.dataset.res }); }
load(); } catch (e) { aw.toast(e.message); } }; });
el.querySelector('#ov').onsubmit = async (ev) => { ev.preventDefault(); const f = new FormData(ev.target); try { await aw.api('POST', '/api/commerce/overage', { resource: f.get('resource'), capAmount: Number(f.get('capAmount')) || null, periodStart: new Date(f.get('periodStart')).toISOString(), periodEnd: new Date(f.get('periodEnd') + 'T23:59:59').toISOString() }); load(); } catch (e) { aw.toast(e.message); } };
} catch (e) { aw.state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: load }); }
}
load();
})();