/* 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 = `
이번 달 예상
${money(d.monthEstimate)}
다음 청구일 ${d.nextBillingAt ? aw.fmt(d.nextBillingAt) : '없음'}
결제 대기
${money(d.awaitingTotal)}
${d.awaitingOrders.length}건
크레딧 잔액
${money(d.creditBalance)}
`;
h += `활성 상품과 옵션
${d.subscriptions.length ? d.subscriptions.map((s) => `
- 상품
- ${esc(s.product.name)} v${s.product.version} ${s.status === 'active' ? '●' : '!'} ${esc(s.label)}
- 항목
- ${s.items.map((i) => esc(i.name) + '×' + i.qty).join(', ')}
- 청구
- ${money(s.total)} / ${s.billing_cycle === 'monthly' ? '월' : '년'} · 다음 ${s.next_billing_at ? aw.fmt(s.next_billing_at) : '-'}
${s.payment_due_at ? `- 갱신 입금 기한
- ${aw.fmt(s.payment_due_at)} · 미납 제한 예정 ${aw.fmt(s.restrict_at)}
` : ''}${s.cancel_at ? `- 해지 적용일
- ${aw.fmt(s.cancel_at)}
` : ''}
${s.status === 'cancel_scheduled' ? '
' : '
'}
상품 변경·축소`).join('') : '
활성 상품이 없습니다.
상품 보기'}
`;
if (d.hold) h += `상품 축소 보류
새 한도 안에 들어오지 않는 항목이 있어 축소가 보류되었습니다. 아래 항목을 정리한 뒤 "다시 검증" 을 누르세요.
${d.hold.shortages.map((s) => `- ${esc(s.label)}: ${fmt(s.reduceBy, s.unit)} 줄이기 (새 한도 ${fmt(s.newLimit, s.unit)}, 배정+예약 ${fmt(s.inUse, s.unit)}, 사용 ${fmt(s.used, s.unit)})
${s.items.map((i) => `- ${esc(i.subjectType)} ${esc(i.subjectId)}: 배정 ${fmt(i.amount, s.unit)}
`).join('')}
`).join('')}
마지막 검증 ${aw.fmt(d.hold.last_checked_at)}
`;
h += `활성화된 권리
| 자원 | 구매 | 배정 | 사용 |
${d.entitlements.filter((e) => e.purchased).map((e) => `| ${esc(e.label)} | ${fmt(e.purchased, e.unit)} | ${fmt(e.allocated, e.unit)} | ${fmt(e.used, e.unit)} |
`).join('') || '| 없음 |
'}
`;
h += `주문
${d.orders.length ? `
| 번호 | 종류 | 금액 | 상태 | 일시 |
${d.orders.map((o) => `| ${esc(o.order_no)} | ${esc({ new: '신규', renewal: '갱신', downsize: '축소' }[o.kind] || o.kind)} | ${money(o.total)} | ${esc(o.statusLabel)} | ${aw.fmt(o.created_at)} |
`).join('')}
` : '
주문이 없습니다.
'}
`;
h += `환불·크레딧
${d.credits.length ? `
| 종류 | 금액 | 사유 | 적용일 |
${d.credits.map((c) => `| ${esc({ refund: '환불', credit: '크레딧', adjust: '조정' }[c.kind])} | ${money(c.amount)} | ${esc(c.reason)} | ${aw.fmt(c.applies_at)} |
`).join('')}
` : '
없음
'}
`;
h += `AI Wallet
제작(AI) 기능 전용이며 호스팅 디스크·트래픽·메일 사용량과 분리되어 있습니다.
${d.wallet && d.wallet.balance !== undefined ? `
${esc(d.wallet.unit ? d.wallet.balanceText || d.wallet.balance : money(d.wallet.balance))}
${(d.wallet.ledger || []).slice(0, 10).map((l) => `
${aw.fmt(l.at)} ${esc(l.type)} ${esc(String(l.amount))} ${esc(l.memo || '')}${l.refunded ? ' (실패 환급)' : ''}
`).join('')}
충전·이력` : `
${esc(d.wallet?.message || '')}
`}
`;
h += `초과 사용 허용
자동 초과 사용과 자동 추가 과금은 기본으로 꺼져 있습니다. 허용하려면 항목별 최대 금액 또는 최대 사용량과 기간을 정해야 하며, 철회하면 즉시 100% 제한이 다시 적용됩니다.
${d.overage.filter((o) => !o.revoked_at).map((o) => `
${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)}
`).join('') || '
허용한 항목 없음
'}
`;
h += `도메인 등록
${d.domainRegistration.enabled ? '
등록기관을 통해 도메인을 검색·등록할 수 있습니다.
' : `
${esc(d.domainRegistration.note)}
`}
`;
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();
})();