상품·주문·수동 결제 승인·권리·청구·축소·해지·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
+86
View File
@@ -0,0 +1,86 @@
{
"_comment": "Ariweb Suite 0.1.1 출시 검증용 버전 고정 상품 Catalog. 운영 상품정책은 운영자가 화면에서 관리하며 이 파일은 검증 환경 시드일 뿐이다. 금액·한도는 시험용 값이고 비밀값은 없다.",
"catalog_version": "release-0.1.1",
"payment_instructions": {
"method": "manual_transfer",
"bank": "검증용 은행(예시)",
"account": "000-0000-0000-00 (검증 환경 예시 계좌)",
"holder": "Ariweb 검증 환경",
"note": "입금자명에 주문 번호를 적어 주세요. 운영자가 입금을 확인한 뒤 승인하면 권리가 활성화됩니다.",
"deadline_days": 7
},
"products": [
{
"product_id": "basic", "version": 1, "name": "베이직", "audience": "개인·소규모 사업자", "kind": "base",
"description": "사이트 1개와 대표 메일을 운영하는 기본 상품",
"billing_cycle": "monthly", "price": 19000, "currency": "KRW", "tax_rate": 0.1,
"limits": { "sites": 1, "web_disk": 5368709120, "web_out": 53687091200, "mail_storage": 5368709120, "mail_msgs": 3000, "mailboxes": 5 },
"options": [
{ "id": "standalone_mail", "name": "독립 메일 서비스", "price": 9000, "grants": { "mail_services": 1, "mail_storage": 5368709120, "mail_msgs": 3000, "mailboxes": 5 }, "per": "group", "max_qty": 3 },
{ "id": "source", "name": "Source(Gitea) 옵션", "price": 5000, "grants": { "source": 1 }, "per": "site", "max_qty": 1 },
{ "id": "backup", "name": "관리형 사이트 백업 옵션", "price": 7000, "grants": { "backup": 1 }, "per": "site", "max_qty": 1,
"policy": { "schedule": "daily", "schedule_time_kst": "03:30", "retention": { "daily": 7, "weekly": 4, "monthly": 3 }, "storage_bytes": 21474836480, "first_backup_within_hours": 24, "remote_copy_verification": "sha256 + 격리 환경 읽기 확인", "restore_cost": 0, "restore_approval": "manual", "expired_backup_retention_days": 7, "failure_alert_after": 3, "extra_storage_option": "extra_backup_storage" } },
{ "id": "extra_disk", "name": "웹 디스크 추가 5GB", "price": 3000, "grants": { "web_disk": 5368709120 }, "per": "group", "max_qty": 10 },
{ "id": "extra_out", "name": "월간 웹 아웃바운드 추가 50GB", "price": 3000, "grants": { "web_out": 53687091200 }, "per": "group", "max_qty": 10 },
{ "id": "extra_mailboxes", "name": "메일함 5개 추가", "price": 2000, "grants": { "mailboxes": 5 }, "per": "group", "max_qty": 10 },
{ "id": "extra_backup_storage", "name": "백업 저장 공간 추가 20GB", "price": 2000, "grants": {}, "per": "site", "max_qty": 5 }
],
"includes": ["관리형 배포(정적·승인된 동적)", "사이트 대표 메일 도메인 1개", "도메인 연결·HTTPS 인증서", "접속·사용량 통계", "알림 센터"],
"excludes": ["외부 배포 서버 운영·백업·SLA", "메일 원문 백업", "도메인 등록(외부 등록기관 이용)", "자동 결제"],
"policy": {
"approval": "manual",
"payment_deadline_days": 7,
"renewal_notice_days": 7,
"restriction_after_unpaid_days": 14,
"restriction_effect": "신규 배포·설정 변경·새 메일함 생성 제한(기존 서비스·조회·삭제·내보내기 유지)",
"retention_days": { "site": 30, "mail": 30, "source": 30, "backup_after_expiry": 7 },
"external_deploy_fee": 3000,
"relocation": { "customer_requested_fee": 10000, "platform_reason_fee": 0, "target_downtime_minutes": 15, "revert_window_hours": 72, "old_location_retention_hours": 72 },
"overage": { "default": false, "unit_prices": { "web_out": { "per_bytes": 10737418240, "price": 1000 }, "mail_msgs": { "per_units": 1000, "price": 1000 } } },
"lifecycle": { "pause_keeps_site_count": true, "cancel_effective": "next_billing_date", "readonly_retention_days": 30, "delete_after_days": 30 },
"alias_counting": { "value": null, "status": "deferred", "note": "별칭·전달 전용 주소의 계산 방식은 후속 상품정책에서 정한다. 확정 전에는 무제한으로 제공하지 않으며 메일함 수에 포함해 계산한다." },
"large_attachment": { "value": null, "status": "deferred", "note": "대용량 첨부의 허용 크기·다운로드 횟수·보관 기한은 후속 상품정책에서 정한다. 시스템 안전 상한이 우선한다." },
"ai_wallet_pricing": { "value": null, "status": "deferred", "note": "AI 제작 기능의 크레딧 단가는 Webmaker 관리자 설정(등급별)을 따르며 상품정책에 고정하지 않는다." }
},
"warn_levels": [70, 80, 90, 95], "effective_from": "2026-10-01T00:00:00.000Z", "published": true
},
{
"product_id": "team", "version": 1, "name": "팀", "audience": "여러 사이트를 함께 운영하는 팀·회사", "kind": "base",
"description": "사이트 3개와 넉넉한 메일·트래픽",
"billing_cycle": "monthly", "price": 49000, "currency": "KRW", "tax_rate": 0.1,
"limits": { "sites": 3, "web_disk": 21474836480, "web_out": 214748364800, "mail_storage": 21474836480, "mail_msgs": 10000, "mailboxes": 20 },
"options": [
{ "id": "standalone_mail", "name": "독립 메일 서비스", "price": 9000, "grants": { "mail_services": 1, "mail_storage": 5368709120, "mail_msgs": 3000, "mailboxes": 5 }, "per": "group", "max_qty": 5 },
{ "id": "source", "name": "Source(Gitea) 옵션", "price": 5000, "grants": { "source": 1 }, "per": "site", "max_qty": 1 },
{ "id": "backup", "name": "관리형 사이트 백업 옵션", "price": 7000, "grants": { "backup": 1 }, "per": "site", "max_qty": 1,
"policy": { "schedule": "daily", "schedule_time_kst": "03:30", "retention": { "daily": 7, "weekly": 4, "monthly": 3 }, "storage_bytes": 53687091200, "first_backup_within_hours": 24, "remote_copy_verification": "sha256 + 격리 환경 읽기 확인", "restore_cost": 0, "restore_approval": "manual", "expired_backup_retention_days": 7, "failure_alert_after": 3, "extra_storage_option": "extra_backup_storage" } },
{ "id": "extra_disk", "name": "웹 디스크 추가 5GB", "price": 3000, "grants": { "web_disk": 5368709120 }, "per": "group", "max_qty": 20 },
{ "id": "extra_out", "name": "월간 웹 아웃바운드 추가 50GB", "price": 3000, "grants": { "web_out": 53687091200 }, "per": "group", "max_qty": 20 },
{ "id": "extra_mailboxes", "name": "메일함 5개 추가", "price": 2000, "grants": { "mailboxes": 5 }, "per": "group", "max_qty": 20 },
{ "id": "extra_backup_storage", "name": "백업 저장 공간 추가 20GB", "price": 2000, "grants": {}, "per": "site", "max_qty": 5 }
],
"includes": ["관리형 배포(정적·승인된 동적)", "사이트마다 대표 메일 도메인 1개", "도메인 연결·HTTPS 인증서", "접속·사용량 통계", "사이트별 구성원 권한"],
"excludes": ["외부 배포 서버 운영·백업·SLA", "메일 원문 백업", "도메인 등록(외부 등록기관 이용)", "자동 결제"],
"policy": {
"approval": "manual",
"payment_deadline_days": 7,
"renewal_notice_days": 7,
"restriction_after_unpaid_days": 14,
"restriction_effect": "신규 배포·설정 변경·새 메일함 생성 제한(기존 서비스·조회·삭제·내보내기 유지)",
"retention_days": { "site": 30, "mail": 30, "source": 30, "backup_after_expiry": 7 },
"external_deploy_fee": 3000,
"relocation": { "customer_requested_fee": 10000, "platform_reason_fee": 0, "target_downtime_minutes": 15, "revert_window_hours": 72, "old_location_retention_hours": 72 },
"overage": { "default": false, "unit_prices": { "web_out": { "per_bytes": 10737418240, "price": 1000 }, "mail_msgs": { "per_units": 1000, "price": 1000 } } },
"lifecycle": { "pause_keeps_site_count": true, "cancel_effective": "next_billing_date", "readonly_retention_days": 30, "delete_after_days": 30 },
"alias_counting": { "value": null, "status": "deferred", "note": "별칭·전달 전용 주소의 계산 방식은 후속 상품정책에서 정한다. 확정 전에는 무제한으로 제공하지 않으며 메일함 수에 포함해 계산한다." },
"large_attachment": { "value": null, "status": "deferred", "note": "대용량 첨부의 허용 크기·다운로드 횟수·보관 기한은 후속 상품정책에서 정한다. 시스템 안전 상한이 우선한다." },
"ai_wallet_pricing": { "value": null, "status": "deferred", "note": "AI 제작 기능의 크레딧 단가는 Webmaker 관리자 설정(등급별)을 따르며 상품정책에 고정하지 않는다." }
},
"warn_levels": [70, 80, 90, 95], "effective_from": "2026-10-01T00:00:00.000Z", "published": true
}
],
"settings": {
"domain_registration": { "enabled": false, "note": "버전 0.1.1 검증 환경에는 등록기관이 연결되어 있지 않습니다. 도메인은 외부 등록기관에서 등록한 뒤 연결하세요." },
"usage.stale_minutes": 30
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"commerce": {
"orderStatus": { "awaiting_payment": "결제 대기", "awaiting_approval": "승인 대기", "approved": "승인 완료", "rejected": "거절됨", "expired": "기한 만료", "cancelled": "취소됨" },
"subStatus": { "active": "사용 중", "awaiting_renewal_payment": "갱신 결제 대기", "restricted": "미납 제한", "cancel_scheduled": "해지 예약", "cancelled": "해지됨" },
"manualOnly": "버전 0.1.1 은 수동 결제 확인(계좌 이체)만 지원합니다. 자동 결제수단은 아직 제공하지 않습니다.",
"deferred": "미확정 정책",
"domainExternal": "도메인 등록 기능이 연결되어 있지 않습니다. 외부 등록기관에서 등록한 뒤 도메인 화면에서 연결하세요.",
"walletSeparate": "AI Wallet 은 제작(AI) 기능에만 쓰이며 호스팅 디스크·트래픽·메일 사용량과 별도입니다.",
"overageOff": "자동 초과 사용과 자동 추가 과금은 기본으로 꺼져 있습니다."
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"usage": {
"allocState": { "available": "사용 가능", "reserved": "예약됨", "in_use": "사용 중", "returning": "반환 중", "returned": "반환됨" },
"warn": { "70": "사전 안내", "80": "경고", "90": "긴급 경고", "95": "최종 경고", "100": "제한 적용" },
"stale": "사용량 정보가 오래되어 배정을 늘릴 수 없습니다. 계측이 갱신된 뒤 다시 시도하세요.",
"belowUsage": "현재 사용량보다 작게 줄일 수 없습니다.",
"monthReset": "월간 항목은 다음 KST 월 시작에 초기화됩니다."
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* portal/commerce/api.mjs — 상품·주문·결제·구독·축소·해지·AI Wallet·초과 사용·도메인 등록 안내 화면과 API(§6·§27.1), 운영자 내부 API.
*
* 고객 화면 /products /order/new /orders/:id /billing /billing/invoice/:orderId(인쇄용)
* 고객 API /api/commerce/products · /orders · /orders/:id · /orders/:id/cancel · /billing · /downsize/preview|request|revalidate|cancel · /cancel|/cancel/undo · /overage · /wallet · /domain-registration
* 운영자 API /internal/ops/commerce/orders(목록: 승인 대기 우선, 대상·금액·권리 변화·현재 사용량) · /orders/:id · /orders/:id/paid|approve|reject|note · /credits · /products(버전 목록·새 버전·공개) · /settings(결제 안내·도메인 등록)
* 결제 방식은 수동 결제 확인(계좌 이체)뿐이며 자동 결제수단은 화면에 보이지 않는다. 가격·한도는 모두 product_versions 에서 온다.
*/
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { HttpError, esc } from '../../lib/http.mjs';
import * as quota from '../../lib/quota.mjs';
import { requireUser, requireGroup, requireRole, requireBilling } from '../auth/guard.mjs';
import { requireOps } from './opsauth.mjs';
import * as cat from './catalog.mjs';
import * as ord from './orders.mjs';
import { audit } from '../../lib/audit.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const money = (n, c = 'KRW') => `${Math.round(n).toLocaleString('ko-KR')} ${c === 'KRW' ? '원' : c}`;
/** 주문 화면용 JSON(라벨·안내·다음 단계 포함) */
function orderView(store, o) {
const pv = cat.getProductVersion(store, o.product_version_id);
const next = { awaiting_payment: `안내된 계좌로 ${money(o.total, o.currency)} 을 입금하세요. 운영자가 입금을 확인한 뒤 승인하면 권리가 활성화됩니다.`, awaiting_approval: '입금이 확인되었습니다. 운영자 승인을 기다리는 중입니다.', approved: '권리가 활성화되었습니다. 사이트·메일 화면에서 바로 이용할 수 있습니다.', rejected: `거절 사유: ${o.rejected_reason || ''}. 다시 주문하거나 문의하세요.`, expired: '입금 기한이 지나 주문이 만료되었습니다. 다시 주문하거나 문의하세요.', cancelled: '취소된 주문입니다.' }[o.status];
return { ...o, statusLabel: ord.ORDER_LABEL[o.status] || o.status, product: pv ? { id: pv.id, name: pv.name, version: pv.version, billingCycle: pv.billing_cycle } : null, nextStep: next, canCancel: ['awaiting_payment', 'awaiting_approval'].includes(o.status), lastCancellableStep: '운영자 승인 전까지', remaining: o.status === 'rejected' || o.status === 'expired' || o.status === 'cancelled' ? '권리 변화 없음 — 기존 서비스와 데이터는 그대로입니다' : null, policyNotes: pv ? { paymentDeadlineDays: pv.policy.payment_deadline_days, restrictionAfterUnpaidDays: pv.policy.restriction_after_unpaid_days, restrictionEffect: pv.policy.restriction_effect } : null };
}
export async function register(app, deps) {
const { store, page } = deps;
app.static('/ui/commerce', join(HERE, 'public'));
// 검증 환경 시드: 상품이 하나도 없고 자동 시드가 허용되면 넣는다(운영에서는 운영자가 화면에서 관리)
if ((deps.autoSeedCatalog ?? process.env.ARIWEB_AUTOSEED_CATALOG === '1') && !store.get('SELECT id FROM product_versions LIMIT 1')) { const r = cat.seedCatalog(store); deps.log?.(`[commerce] catalog 시드: 추가 ${r.inserted}${r.problems.length ? ' 문제 ' + r.problems.join('; ') : ''}`); }
const shell = (ctx, o) => page({ user: ctx.user, group: ctx.group, groups: ctx.groups || [ctx.group].filter(Boolean), csrf: ctx.session?.csrf, unread: ctx.unread, nav: 'billing', ...o });
// ---------- 화면 ----------
app.get('/products', (ctx) => ctx.html(shell(ctx, { title: '상품', crumbs: [{ label: '상품 및 결제', href: '/billing' }, { label: '상품' }], body: `<div id="products">${deps.statePanel('loading')}</div>`, scripts: ['/ui/commerce/products.js'] })));
app.get('/order/new', (ctx) => { requireGroup(ctx); return ctx.html(shell(ctx, { title: '주문', crumbs: [{ label: '상품', href: '/products' }, { label: '주문' }], body: `<div id="order-new" data-product="${esc(ctx.query.product || '')}" data-option="${esc(ctx.query.option || '')}" data-target="${esc(ctx.query.target || '')}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/commerce/order.js'] })); });
app.get('/orders/:id', (ctx) => { requireGroup(ctx); const o = ord.getOrder(store, ctx.params.id, ctx.group.id); if (!o) return ctx.html(shell(ctx, { title: '주문', body: deps.statePanel('empty', '주문을 찾을 수 없습니다', { href: '/billing' }) }), 404); return ctx.html(shell(ctx, { title: `주문 ${o.order_no}`, crumbs: [{ label: '상품 및 결제', href: '/billing' }, { label: o.order_no }], body: `<div id="order" data-id="${esc(o.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/commerce/order-detail.js'] })); });
app.get('/billing', (ctx) => { requireGroup(ctx); return ctx.html(shell(ctx, { title: '상품 및 결제', crumbs: [{ label: '상품 및 결제' }], actions: [{ label: '상품 보기', href: '/products' }], body: `<div id="billing">${deps.statePanel('loading')}</div>`, scripts: ['/ui/commerce/billing.js'] })); });
app.get('/billing/invoice/:orderId', (ctx) => { requireGroup(ctx); const o = ord.getOrder(store, ctx.params.orderId, ctx.group.id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); const pi = o.payment_instructions || {}; const kind = o.status === 'approved' ? '영수증' : '청구서';
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>${kind} ${esc(o.order_no)}</title><style>body{font-family:sans-serif;max-width:720px;margin:24px auto;padding:0 16px}table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid #ddd;padding:8px;text-align:left}.r{text-align:right}@media print{button{display:none}}</style></head><body><h1>${kind}</h1><p>주문 번호 ${esc(o.order_no)} · ${esc(ctx.group.name)} · 발행 ${esc(o.created_at.slice(0, 10))}${o.approved_at ? ` · 승인 ${esc(o.approved_at.slice(0, 10))}` : ''}</p><table><thead><tr><th>항목</th><th>수량</th><th>적용 대상</th><th class="r">단가</th><th class="r">세금</th><th class="r">합계</th></tr></thead><tbody>${o.items.map((i) => `<tr><td>${esc(i.name)}</td><td>${i.qty}</td><td>${esc(i.target_type === 'site' ? '사이트 ' + (i.target_id || '') : '그룹')}</td><td class="r">${money(i.unit_price, o.currency)}</td><td class="r">${money(i.tax, o.currency)}</td><td class="r">${money(i.amount + i.tax, o.currency)}</td></tr>`).join('')}</tbody><tfoot><tr><th colspan="5" class="r">합계(세금 포함)</th><th class="r">${money(o.total, o.currency)}</th></tr></tfoot></table>${o.status !== 'approved' ? `<h2>입금 안내</h2><p>${esc(pi.bank || '')} ${esc(pi.account || '')} (예금주 ${esc(pi.holder || '')})<br>기한 ${esc((o.due_at || '').slice(0, 10))} · ${esc(pi.note || '')}</p>` : ''}<p>상태: ${esc(ord.ORDER_LABEL[o.status] || o.status)}</p><button onclick="print()">인쇄</button></body></html>`); });
// ---------- 고객 API ----------
app.get('/api/commerce/products', (ctx) => ({ products: cat.publishedProducts(store).map(cat.publicProduct), paymentMethods: [{ id: 'manual_transfer', label: '계좌 이체(수동 결제 확인)' }], domainRegistration: ord.domainRegistration(store), sites: ctx.group ? store.all('SELECT id, name, lifecycle, deploy_mode FROM sites WHERE group_id=? AND lifecycle<>\'deleted\' ORDER BY name', ctx.group.id) : [] }));
app.post('/api/commerce/orders', async (ctx) => { requireBilling(ctx); const b = await ctx.body(); const r = ord.createOrder(store, { groupId: ctx.group.id, userId: ctx.user.id, userVerified: !!ctx.user.email_verified_at, productVersionId: b.productVersionId, includeBase: b.includeBase !== false, items: b.items || [], paymentMethod: b.paymentMethod || 'manual_transfer', acceptTerms: !!b.acceptTerms, idempotencyKey: b.idempotencyKey ? `${ctx.group.id}:${String(b.idempotencyKey).slice(0, 80)}` : null }); return { ok: true, created: r.created, order: orderView(store, r.order) }; });
app.post('/api/commerce/quote', async (ctx) => { requireGroup(ctx); const b = await ctx.body(); const pv = cat.getProductVersion(store, b.productVersionId); if (!pv) throw new HttpError(404, 'product_not_found', '상품이 없습니다'); return { quote: ord.priceItems(pv, { includeBase: b.includeBase !== false, items: b.items || [] }), paymentInstructions: ord.paymentInstructions(store), terms: { version: 'v0.1.1', notes: [...pv.excludes, '자동 결제는 지원하지 않습니다', `입금 기한 ${pv.policy.payment_deadline_days || 7}일`, `미납 ${pv.policy.restriction_after_unpaid_days || 14}일 후 신규 변경 제한(데이터 삭제 없음)`] } }; });
app.get('/api/commerce/orders', (ctx) => { requireGroup(ctx); return { orders: ord.listOrders(store, { groupId: ctx.group.id }).map((o) => orderView(store, o)) }; });
app.get('/api/commerce/orders/:id', (ctx) => { requireGroup(ctx); const o = ord.getOrder(store, ctx.params.id, ctx.group.id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); return { order: orderView(store, o) }; });
app.post('/api/commerce/orders/:id/cancel', (ctx) => { requireBilling(ctx); return { ok: true, order: orderView(store, ord.cancelOrder(store, ctx.params.id, ctx.group.id, ctx.user.id)) }; });
app.get('/api/commerce/billing', async (ctx) => { requireGroup(ctx); const s = ord.billingSummary(store, ctx.group.id); let wallet = null; try { wallet = deps.webmaker?.credits ? await deps.webmaker.credits(ctx.user.id, ctx.group.id) : null; } catch (e) { wallet = { error: 'unavailable', message: 'AI Wallet 정보를 지금 확인할 수 없습니다(정보 확인 지연)' }; }
return { ...s, orders: ord.listOrders(store, { groupId: ctx.group.id }).map((o) => orderView(store, o)), hold: ord.openHold(store, ctx.group.id), overage: ord.listOverage(store, ctx.group.id), wallet: wallet || { available: false, message: 'AI Wallet 은 Webmaker 제작 화면에서 확인·충전합니다(호스팅 사용량과 별도)' }, domainRegistration: ord.domainRegistration(store), entitlements: quota.summary(store, ctx.group.id) }; });
app.post('/api/commerce/downsize/preview', async (ctx) => { requireBilling(ctx); const b = await ctx.body(); const pv = cat.getProductVersion(store, b.productVersionId); if (!pv) throw new HttpError(404, 'product_not_found', '상품이 없습니다'); const priced = ord.priceItems(pv, { includeBase: true, items: b.items || [] }); return { ...ord.evaluateDownsize(store, ctx.group.id, pv, priced.items), quote: priced }; });
app.post('/api/commerce/downsize/request', async (ctx) => { requireBilling(ctx); const b = await ctx.body(); const r = ord.requestDownsize(store, { groupId: ctx.group.id, userId: ctx.user.id, userVerified: !!ctx.user.email_verified_at, productVersionId: b.productVersionId, items: b.items || [], acceptTerms: !!b.acceptTerms }); return { ok: true, fits: r.fits, shortages: r.shortages, order: r.order ? orderView(store, r.order) : null, hold: r.hold || null }; });
app.post('/api/commerce/downsize/revalidate', (ctx) => { requireBilling(ctx); const r = ord.revalidateHold(store, { groupId: ctx.group.id, userId: ctx.user.id, userVerified: !!ctx.user.email_verified_at }); return { ok: true, fits: r.fits, shortages: r.shortages, order: r.order ? orderView(store, r.order) : null, hold: r.hold || null }; });
app.post('/api/commerce/downsize/cancel', (ctx) => { requireBilling(ctx); return { ok: true, cancelled: ord.cancelHold(store, ctx.group.id) }; });
app.post('/api/commerce/cancel', (ctx) => { requireBilling(ctx); return { ok: true, subscription: ord.scheduleCancel(store, { groupId: ctx.group.id, userId: ctx.user.id }) }; });
app.post('/api/commerce/cancel/undo', (ctx) => { requireBilling(ctx); return { ok: true, subscription: ord.undoCancel(store, { groupId: ctx.group.id, userId: ctx.user.id }) }; });
app.get('/api/commerce/cancel/preview', (ctx) => { requireGroup(ctx); const s = ord.activeSubscription(store, ctx.group.id); const pv = s ? cat.getProductVersion(store, s.product_version_id) : null; return { subscription: s, effectiveAt: s?.next_billing_at || null, retention: pv?.policy?.retention_days || null, lifecycle: pv?.policy?.lifecycle || null, usage: quota.summary(store, ctx.group.id), effects: ['적용일까지 서비스·권리 유지', '적용 후 공개 서비스 중지와 권리 종료', '보존 기간 동안 읽기·내보내기 제공', '보존 종료 후 삭제(복구 불가)'] }; });
app.post('/api/commerce/overage', async (ctx) => { requireBilling(ctx); const b = await ctx.body(); if (b.revoke) return { ok: true, revoked: ord.revokeOverage(store, { groupId: ctx.group.id, userId: ctx.user.id, resource: b.resource }), effect: '철회 즉시 100% 제한이 다시 적용됩니다' }; return { ok: true, optin: ord.optInOverage(store, { groupId: ctx.group.id, userId: ctx.user.id, resource: b.resource, capAmount: b.capAmount, capUnits: b.capUnits, periodStart: b.periodStart, periodEnd: b.periodEnd }) }; });
app.get('/api/commerce/domain-registration', (ctx) => ord.domainRegistration(store));
// ---------- 운영자 내부 API ----------
const opsOrder = (o) => { const g = store.get('SELECT id, name, kind, contact_email FROM groups WHERE id=?', o.group_id); const pv = cat.getProductVersion(store, o.product_version_id); return { ...orderView(store, o), group: g, product: pv ? { id: pv.id, name: pv.name, version: pv.version } : null, entitlementDelta: o.items.flatMap((i) => Object.entries(i.grants || {}).map(([r, a]) => ({ resource: r, label: quota.RESOURCES[r]?.label, amount: a, target: i.target_type === 'site' ? i.target_id : 'group' }))), currentUsage: quota.summary(store, o.group_id).filter((f) => f.purchased || f.used) }; };
app.get('/internal/ops/commerce/orders', async (ctx) => { await requireOps(ctx, deps); const status = ctx.query.status ? ctx.query.status.split(',') : null; const list = ord.listOrders(store, { status, limit: 500 }); const rank = { awaiting_approval: 0, awaiting_payment: 1 }; list.sort((a, b) => (rank[a.status] ?? 9) - (rank[b.status] ?? 9) || b.created_at.localeCompare(a.created_at)); return { orders: list.map(opsOrder) }; });
app.get('/internal/ops/commerce/orders/:id', async (ctx) => { await requireOps(ctx, deps); const o = ord.getOrder(store, ctx.params.id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); return { order: opsOrder(o) }; });
app.post('/internal/ops/commerce/orders/:id/paid', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); return { ok: true, order: opsOrder(ord.markPaid(store, ctx.params.id, { by: actor, ref: b.ref || '' })) }; });
app.post('/internal/ops/commerce/orders/:id/approve', async (ctx) => { const { actor } = await requireOps(ctx, deps); const r = ord.approveOrder(store, ctx.params.id, { by: actor }); return { ok: true, already: r.already, granted: r.granted.length, order: opsOrder(r.order) }; });
app.post('/internal/ops/commerce/orders/:id/reject', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); return { ok: true, order: opsOrder(ord.rejectOrder(store, ctx.params.id, { by: actor, reason: b.reason })) }; });
app.post('/internal/ops/commerce/orders/:id/note', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); return { ok: true, order: opsOrder(ord.noteOrder(store, ctx.params.id, { by: actor, note: b.note })) }; });
app.post('/internal/ops/commerce/credits', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); return { ok: true, credit: ord.addCredit(store, { groupId: b.groupId, kind: b.kind, amount: b.amount, orderId: b.orderId, reason: b.reason, by: actor, appliesAt: b.appliesAt }) }; });
app.get('/internal/ops/commerce/groups/:groupId', async (ctx) => { await requireOps(ctx, deps); const gid = ctx.params.groupId; return { group: store.get('SELECT id, name, kind, status FROM groups WHERE id=?', gid), billing: ord.billingSummary(store, gid), orders: ord.listOrders(store, { groupId: gid }).map((o) => orderView(store, o)), hold: ord.openHold(store, gid), overage: ord.listOverage(store, gid), entitlements: quota.summary(store, gid) }; });
app.post('/internal/ops/commerce/subscriptions/:id/cancel-hold', async (ctx) => { const { actor } = await requireOps(ctx, deps); const s = store.get('SELECT * FROM subscriptions WHERE id=?', ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '구독이 없습니다'); const b = await ctx.body(); store.run('UPDATE subscriptions SET status=?, updated_at=? WHERE id=?', b.release ? 'active' : 'cancel_scheduled', new Date().toISOString(), s.id); audit(store, { actorType: 'operator', actorId: actor, groupId: s.group_id, subjectType: 'subscription', subjectId: s.id, action: b.release ? 'cancel.release_hold' : 'cancel.hold' }); return { ok: true }; });
app.post('/internal/ops/commerce/renewals/run', async (ctx) => { await requireOps(ctx, deps); return { ok: true, ...ord.processRenewals(store), expired: ord.expireOrders(store).length }; });
app.get('/internal/ops/commerce/products', async (ctx) => { await requireOps(ctx, deps); return { products: cat.allProductVersions(store).map((p) => ({ ...p, deferred: cat.deferredPolicies(p), subscribers: store.get('SELECT COUNT(*) AS n FROM subscriptions WHERE product_version_id=? AND status<>\'cancelled\'', p.id).n })) }; });
app.post('/internal/ops/commerce/products', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); if (!b.productId) throw new HttpError(400, 'product_id_required', 'productId 가 필요합니다'); return { ok: true, product: cat.createProductVersion(store, { productId: b.productId, base: b.baseVersionId || null, patch: b.patch || {}, by: actor }) }; });
app.post('/internal/ops/commerce/products/:id/publish', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); const p = cat.publishProductVersion(store, ctx.params.id, { published: b.published !== false, effectiveTo: b.effectiveTo }); audit(store, { actorType: 'operator', actorId: actor, subjectType: 'product_version', subjectId: p.id, action: b.published === false ? 'product.unpublish' : 'product.publish' }); return { ok: true, product: p }; });
app.post('/internal/ops/commerce/catalog/seed', async (ctx) => { await requireOps(ctx, deps); return { ok: true, ...cat.seedCatalog(store, cat.loadCatalogFile(), 'ops') }; });
app.get('/internal/ops/commerce/settings', async (ctx) => { await requireOps(ctx, deps); return { paymentInstructions: ord.paymentInstructions(store), domainRegistration: ord.domainRegistration(store), staleMinutes: cat.getSetting(store, 'usage.stale_minutes', 30) }; });
app.post('/internal/ops/commerce/settings', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); for (const k of ['payment_instructions', 'domain_registration', 'usage.stale_minutes']) if (b[k] !== undefined) cat.setSetting(store, k, b[k], actor); audit(store, { actorType: 'operator', actorId: actor, subjectType: 'settings', subjectId: 'commerce', action: 'settings.update', detail: Object.keys(b) }); return { ok: true }; });
// 주기 작업(스케줄러가 있으면): 주문 만료·갱신·해지 적용
if (deps.scheduler?.every) deps.scheduler.every('commerce.maintain', 10 * 60 * 1000, () => { ord.expireOrders(store); ord.processRenewals(store); });
deps.commerce = { expireOrders: () => ord.expireOrders(store), processRenewals: () => ord.processRenewals(store), appliedProduct: (gid) => ord.appliedProduct(store, gid), activeSubscription: (gid) => ord.activeSubscription(store, gid), seedCatalog: () => cat.seedCatalog(store) };
}
+111
View File
@@ -0,0 +1,111 @@
/**
* portal/commerce/catalog.mjs — 상품정책(ProductVersion) 조회·관리·시드(§6.1·§27.2).
*
* 원칙
* - 가격·한도는 코드에 고정하지 않고 product_versions 표에서 읽는다. 고객 화면은 "공개(published)·지금 유효(effective_from ≤ now < effective_to)" 인 버전만 본다.
* - 이미 판매된 조건은 바꾸지 않는다: 운영자가 상품을 고치면 같은 product_id 의 다음 version 행이 생기고, 기존 구독·권리는 옛 버전 id 를 계속 참조한다.
* - 미확정 정책은 policy_json 의 { value:null, status:'deferred', note } 로 표현하고 화면은 숫자 대신 "미확정 정책" 과 note 를 보인다(확정된 약속처럼 보이지 않게).
* 시드: catalog/release-0.1.1.json(출시 검증 환경용). seedCatalog() 는 같은 (product_id, version) 이 있으면 건드리지 않는다.
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { newId } from '../../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
import { RESOURCES } from '../../lib/quota.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const CATALOG_FILE = join(ROOT, 'catalog', 'release-0.1.1.json');
/** 시드 파일에서 비어 있으면 안 되는 상품 항목(출시 검증 조건) */
export const REQUIRED_PRODUCT_FIELDS = ['product_id', 'version', 'name', 'audience', 'billing_cycle', 'price', 'currency', 'tax_rate', 'limits', 'options', 'includes', 'excludes', 'policy', 'warn_levels', 'effective_from'];
export const REQUIRED_LIMITS = ['sites', 'web_disk', 'web_out', 'mail_storage', 'mail_msgs', 'mailboxes'];
export const REQUIRED_OPTIONS = ['standalone_mail', 'source', 'backup'];
export const REQUIRED_POLICY = ['approval', 'payment_deadline_days', 'renewal_notice_days', 'restriction_after_unpaid_days', 'retention_days', 'lifecycle', 'relocation'];
export const REQUIRED_BACKUP_POLICY = ['schedule', 'schedule_time_kst', 'retention', 'storage_bytes', 'first_backup_within_hours', 'remote_copy_verification', 'restore_cost', 'expired_backup_retention_days'];
/** 시드 파일 검증. 문제 목록(비어 있으면 통과). */
export function validateCatalog(doc) {
const problems = [];
const empty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0) || (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0);
if (!doc || !Array.isArray(doc.products) || !doc.products.length) return ['products 가 비어 있습니다'];
if (!doc.payment_instructions || ['bank', 'account', 'holder', 'deadline_days'].some((k) => empty(doc.payment_instructions[k]))) problems.push('payment_instructions(은행·계좌·예금주·기한) 가 비어 있습니다');
for (const p of doc.products) {
const tag = `${p.product_id || '?'}@${p.version || '?'}`;
for (const f of REQUIRED_PRODUCT_FIELDS) if (empty(p[f])) problems.push(`${tag}: ${f} 비어 있음`);
for (const r of REQUIRED_LIMITS) if (!(Number(p.limits?.[r]) > 0)) problems.push(`${tag}: limits.${r} 가 0 이거나 없음`);
for (const o of REQUIRED_OPTIONS) if (!(p.options || []).some((x) => x.id === o)) problems.push(`${tag}: 옵션 ${o} 없음`);
for (const o of p.options || []) { if (empty(o.id) || empty(o.name) || !(Number(o.price) >= 0) || !o.grants) problems.push(`${tag}: 옵션 ${o.id || '?'} 의 id/name/price/grants 확인`); for (const r of Object.keys(o.grants || {})) if (!RESOURCES[r]) problems.push(`${tag}: 옵션 ${o.id} 의 자원 ${r} 알 수 없음`); }
const bk = (p.options || []).find((x) => x.id === 'backup'); if (bk) for (const k of REQUIRED_BACKUP_POLICY) if (empty(bk.policy?.[k])) problems.push(`${tag}: 백업 옵션 policy.${k} 비어 있음`);
for (const k of REQUIRED_POLICY) if (empty(p.policy?.[k])) problems.push(`${tag}: policy.${k} 비어 있음`);
if (!Array.isArray(p.warn_levels) || p.warn_levels.length < 1 || p.warn_levels.some((n) => !(n > 0 && n < 100))) problems.push(`${tag}: warn_levels 는 0~100 사이 값 목록이어야 함`);
if (!['monthly', 'yearly'].includes(p.billing_cycle)) problems.push(`${tag}: billing_cycle 은 monthly|yearly`);
if (p.policy?.approval && p.policy.approval !== 'manual') problems.push(`${tag}: 0.1.1 은 수동 승인(manual)만 지원`);
}
return problems;
}
export function loadCatalogFile(file = CATALOG_FILE) { return JSON.parse(readFileSync(file, 'utf8')); }
/**
* 시드 적용. 반환 { inserted, skipped, problems }. problems 가 있으면 아무것도 넣지 않는다.
* settings 항목(결제 안내·도메인 등록 가능 여부·사용량 지연 기준)도 없을 때만 넣는다.
*/
export function seedCatalog(store, doc = loadCatalogFile(), by = 'seed') {
const problems = validateCatalog(doc); if (problems.length) return { inserted: 0, skipped: 0, problems };
let inserted = 0; let skipped = 0; const now = nowIso();
store.tx(() => {
for (const p of doc.products) {
if (store.get('SELECT id FROM product_versions WHERE product_id=? AND version=?', p.product_id, p.version)) { skipped++; continue; }
store.run('INSERT INTO product_versions(id, product_id, version, name, description, audience, kind, billing_cycle, price, currency, tax_rate, limits_json, options_json, includes_json, excludes_json, policy_json, warn_levels_json, approval, effective_from, effective_to, published, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
newId('pv_'), p.product_id, p.version, p.name, p.description || null, p.audience, p.kind || 'base', p.billing_cycle, Math.round(p.price), p.currency, Number(p.tax_rate), toJson(p.limits), toJson(p.options || []), toJson(p.includes || []), toJson(p.excludes || []), toJson(p.policy || {}), toJson(p.warn_levels), p.policy?.approval || 'manual', p.effective_from, p.effective_to || null, p.published ? 1 : 0, by, now);
inserted++;
}
if (!store.get('SELECT key FROM settings WHERE key=?', 'payment_instructions')) store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?)', 'payment_instructions', toJson(doc.payment_instructions), now, by);
for (const [k, v] of Object.entries(doc.settings || {})) if (!store.get('SELECT key FROM settings WHERE key=?', k)) store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?)', k, toJson(v), now, by);
});
return { inserted, skipped, problems: [] };
}
/** settings 읽기/쓰기(운영자 관리 값. 비밀값 금지는 호출자가 보장) */
export function getSetting(store, key, fallback = null) { const r = store.get('SELECT value_json FROM settings WHERE key=?', key); return r ? parseJson(r.value_json, fallback) : fallback; }
export function setSetting(store, key, value, by = 'ops') { if (/key|secret|token|pass/i.test(key)) throw new Error('비밀값은 settings 에 둘 수 없습니다'); store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=excluded.updated_at, updated_by=excluded.updated_by', key, toJson(value), nowIso(), by); }
/** 행 → 객체(JSON 열 해석) */
export function rowToProduct(r) { if (!r) return null; return { ...r, limits: parseJson(r.limits_json, {}), options: parseJson(r.options_json, []), includes: parseJson(r.includes_json, []), excludes: parseJson(r.excludes_json, []), policy: parseJson(r.policy_json, {}), warn_levels: parseJson(r.warn_levels_json, [70, 80, 90, 95]), published: !!r.published }; }
export function getProductVersion(store, id) { return rowToProduct(store.get('SELECT * FROM product_versions WHERE id=?', id)); }
/** 고객용: 공개·유효한 최신 버전(product_id 마다 하나) */
export function publishedProducts(store, at = nowIso()) {
const rows = store.all('SELECT * FROM product_versions WHERE published=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>?) ORDER BY product_id, version DESC', at, at);
const seen = new Set(); return rows.filter((r) => (seen.has(r.product_id) ? false : (seen.add(r.product_id), true))).map(rowToProduct);
}
/** 운영자용: 모든 버전 */
export function allProductVersions(store) { return store.all('SELECT * FROM product_versions ORDER BY product_id, version DESC').map(rowToProduct); }
/**
* 운영자: 새 버전 만들기(기존 버전을 바꾸지 않는다). base 가 있으면 그 버전을 복사한 뒤 patch 를 덮는다.
* @returns 새 버전 객체
*/
export function createProductVersion(store, { productId, base = null, patch = {}, by = 'ops' }) {
const prev = base ? getProductVersion(store, base) : null;
const maxV = store.get('SELECT COALESCE(MAX(version),0) AS v FROM product_versions WHERE product_id=?', productId).v;
const p = { ...(prev || {}), ...patch, product_id: productId, version: maxV + 1 };
const problems = validateCatalog({ payment_instructions: { bank: 'x', account: 'x', holder: 'x', deadline_days: 1 }, products: [{ ...p, options: p.options || [], includes: p.includes || [], excludes: p.excludes || [], policy: p.policy || {}, warn_levels: p.warn_levels || [70, 80, 90, 95], effective_from: p.effective_from || nowIso() }] });
if (problems.length) { const e = new Error('상품 버전 검증 실패: ' + problems.join('; ')); e.status = 400; e.code = 'product_invalid'; e.detail = { problems }; throw e; }
const id = newId('pv_');
store.run('INSERT INTO product_versions(id, product_id, version, name, description, audience, kind, billing_cycle, price, currency, tax_rate, limits_json, options_json, includes_json, excludes_json, policy_json, warn_levels_json, approval, effective_from, effective_to, published, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, productId, maxV + 1, p.name, p.description || null, p.audience, p.kind || 'base', p.billing_cycle, Math.round(p.price), p.currency || 'KRW', Number(p.tax_rate ?? 0.1), toJson(p.limits), toJson(p.options || []), toJson(p.includes || []), toJson(p.excludes || []), toJson(p.policy || {}), toJson(p.warn_levels || [70, 80, 90, 95]), 'manual', p.effective_from || nowIso(), p.effective_to || null, patch.published ? 1 : 0, by, nowIso());
return getProductVersion(store, id);
}
/** 운영자: 공개/비공개·유효 종료. 가격·한도는 바꾸지 않는다(새 버전으로). */
export function publishProductVersion(store, id, { published = true, effectiveTo = undefined } = {}) {
const pv = getProductVersion(store, id); if (!pv) { const e = new Error('상품 버전이 없습니다'); e.status = 404; e.code = 'not_found'; throw e; }
store.run('UPDATE product_versions SET published=?, effective_to=COALESCE(?, effective_to) WHERE id=?', published ? 1 : 0, effectiveTo === undefined ? null : effectiveTo, id);
if (published) store.run('UPDATE product_versions SET published=0 WHERE product_id=? AND id<>? AND version<?', pv.product_id, id, pv.version); // 같은 상품의 옛 버전은 신규 판매 중단(기존 고객 적용은 유지)
return getProductVersion(store, id);
}
/** 미확정 정책 항목 표시용 변환: { value:null, status:'deferred' } → { deferred:true, label:'미확정 정책', note } */
export function renderPolicyValue(v) { if (v && typeof v === 'object' && !Array.isArray(v) && v.status === 'deferred') return { deferred: true, label: '미확정 정책', note: v.note || '' }; return { deferred: false, value: v }; }
export function deferredPolicies(pv) { return Object.entries(pv.policy || {}).filter(([, v]) => v && typeof v === 'object' && v.status === 'deferred').map(([key, v]) => ({ key, note: v.note || '' })); }
/** 고객 화면용 상품 요약(가격·세금·한도·옵션·미확정 정책) */
export function publicProduct(pv) {
const tax = Math.round(pv.price * pv.tax_rate);
return { id: pv.id, productId: pv.product_id, version: pv.version, name: pv.name, description: pv.description, audience: pv.audience, billingCycle: pv.billing_cycle, price: pv.price, tax, total: pv.price + tax, currency: pv.currency, taxRate: pv.tax_rate,
limits: pv.limits, options: pv.options.map((o) => ({ ...o, tax: Math.round((o.price || 0) * pv.tax_rate), policy: o.policy ? Object.fromEntries(Object.entries(o.policy).map(([k, v]) => [k, renderPolicyValue(v)])) : null })), includes: pv.includes, excludes: pv.excludes,
policy: Object.fromEntries(Object.entries(pv.policy || {}).map(([k, v]) => [k, renderPolicyValue(v)])), deferred: deferredPolicies(pv), warnLevels: pv.warn_levels, approval: pv.approval, effectiveFrom: pv.effective_from, paymentMethods: ['manual_transfer'] };
}
+35
View File
@@ -0,0 +1,35 @@
/**
* portal/commerce/model.mjs — 상거래 영역 세부 표(§6·§27).
*
* 중심 표 orders·product_versions·settings 는 portal/core/model.mjs 에 있고, 여기서는 다음을 더한다.
* orders 추가 열 entitlement_ids_json(승인 때 부여한 권리 id — 재승인해도 중복 부여하지 않는 근거), terms_json(동의한 약관 버전), subscription_id
* subscriptions 활성 상품(그룹의 구독). 갱신 예정일·갱신 결제 대기·축소 예약·해지 예약을 가진다
* credits 환불·크레딧 원장(운영자만 기록)
* downsize_holds 축소 보류: 새 한도 안에 들어오지 않아 정리해야 할 항목 목록과 재검증 결과
* overage_optins 초과 사용 허용: 항목별 최대 금액/사용량·기간·철회
*/
export const MIGRATIONS = [{ id: 'commerce_0001', sql: `
ALTER TABLE orders ADD COLUMN entitlement_ids_json TEXT;
ALTER TABLE orders ADD COLUMN terms_json TEXT;
ALTER TABLE orders ADD COLUMN subscription_id TEXT;
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, product_version_id TEXT NOT NULL, order_id TEXT, items_json TEXT NOT NULL, billing_cycle TEXT NOT NULL,
amount INTEGER NOT NULL, tax INTEGER NOT NULL, total INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW',
status TEXT NOT NULL DEFAULT 'active', started_at TEXT NOT NULL, next_billing_at TEXT, renewal_order_id TEXT, renewal_notified_at TEXT,
payment_due_at TEXT, restrict_at TEXT, cancel_at TEXT, cancel_requested_at TEXT, cancelled_at TEXT, entitlement_ids_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS subscriptions_group ON subscriptions(group_id, status);
CREATE TABLE IF NOT EXISTS credits (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, kind TEXT NOT NULL, amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW', order_id TEXT, reason TEXT, created_by TEXT, created_at TEXT NOT NULL, applies_at TEXT, applied_order_id TEXT
);
CREATE INDEX IF NOT EXISTS credits_group ON credits(group_id, created_at);
CREATE TABLE IF NOT EXISTS downsize_holds (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, subscription_id TEXT, target_product_version_id TEXT NOT NULL, items_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'holding',
shortages_json TEXT NOT NULL, last_checked_at TEXT NOT NULL, order_id TEXT, created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS overage_optins (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, cap_amount INTEGER, cap_units INTEGER, unit_price_json TEXT, period_start TEXT NOT NULL, period_end TEXT NOT NULL,
created_by TEXT, created_at TEXT NOT NULL, revoked_at TEXT, revoked_by TEXT
);
CREATE INDEX IF NOT EXISTS overage_group ON overage_optins(group_id, resource);
` }];
+20
View File
@@ -0,0 +1,20 @@
/**
* portal/commerce/opsauth.mjs — 운영자 콘솔(HAL)에서 들어오는 내부 호출(/internal/ops/*) 인증.
*
* 운영자 계정·세션은 HAL 에 있고(§25), HAL 이 포털의 내부 API 를 서비스 키로 호출한다(lib/serviceauth).
* 비밀은 포털 보관소의 'service:<keyId>' 에 있다. 허용 키 id 는 'hal' 뿐이다.
* 호출 주체 운영자 식별(감사 기록용)은 HAL 이 X-Ops-Actor 헤더로 전달한다(이름만, 비밀 없음).
* 테스트에서는 deps.opsAuthBypass=true 로 서명 없이 부를 수 있다(운영 코드 경로에서는 절대 켜지 않는다).
*/
import { HttpError } from '../../lib/http.mjs';
import { verifyCtx } from '../../lib/serviceauth.mjs';
const ALLOWED_KEYS = new Set(['hal']);
/** 검증 후 { keyId, actor } 를 돌려준다. 실패는 401. */
export async function requireOps(ctx, deps) {
const actor = String(ctx.headers['x-ops-actor'] || 'operator').slice(0, 80);
if (deps.opsAuthBypass) return { keyId: 'test', actor };
const r = await verifyCtx(ctx, (id) => (ALLOWED_KEYS.has(id) ? deps.vault.get('service:' + id) : null));
if (!r.ok) throw new HttpError(401, 'service_auth_failed', `운영자 콘솔 호출 인증 실패(${r.reason})`);
return { keyId: r.keyId, actor };
}
+237
View File
@@ -0,0 +1,237 @@
/**
* 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 };
+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();
})();
+17
View File
@@ -0,0 +1,17 @@
/* portal/commerce/public/order-detail.js — 주문 상세: 상태·금액·입금 안내·다음 단계·취소 가능 단계·남는 서비스. 처리 중이면 주기 갱신. */
(() => {
const el = document.getElementById('order'); const esc = aw.esc; const money = (n, c) => Number(n).toLocaleString('ko-KR') + (c === 'KRW' ? '원' : ' ' + c);
async function load() {
try { const { order: o } = await aw.api('GET', `/api/commerce/orders/${el.dataset.id}`); const pi = o.payment_instructions || {}; const kind = o.status === 'approved' ? 'done' : ['rejected', 'expired', 'cancelled'].includes(o.status) ? 'failed' : 'waiting';
el.innerHTML = `<div class="card"><div class="ph-row"><span class="badge ${kind}">${kind === 'done' ? '✔' : kind === 'failed' ? '✘' : '◔'} ${esc(o.statusLabel)}</span><span class="sub">마지막 갱신 ${aw.fmt(o.updated_at)}</span></div><p>${esc(o.nextStep)}</p>${o.remaining ? `<p class="sub">${esc(o.remaining)}</p>` : ''}
<dl class="kv"><dt>주문 번호</dt><dd class="mono">${esc(o.order_no)}</dd><dt>종류</dt><dd>${esc({ new: '신규', renewal: '갱신', downsize: '축소' }[o.kind] || o.kind)}</dd><dt>금액</dt><dd>${money(o.amount, o.currency)} + 세금 ${money(o.tax, o.currency)} = <b>${money(o.total, o.currency)}</b></dd><dt>결제 방식</dt><dd>계좌 이체(수동 결제 확인)</dd>${o.due_at ? `<dt>입금 기한</dt><dd>${aw.fmt(o.due_at)}</dd>` : ''}${o.paid_at ? `<dt>입금 확인</dt><dd>${aw.fmt(o.paid_at)}</dd>` : ''}${o.approved_at ? `<dt>승인</dt><dd>${aw.fmt(o.approved_at)}</dd>` : ''}<dt>취소 가능 단계</dt><dd>${esc(o.lastCancellableStep)}</dd></dl>
${o.status === 'awaiting_payment' ? `<h3>입금 안내</h3><p>${esc(pi.bank || '')} <span class="copy" data-action="copy" data-copy="${esc(pi.account || '')}">${esc(pi.account || '')}</span> (예금주 ${esc(pi.holder || '')})</p><p class="sub">${esc(pi.note || '')}</p>` : ''}
<table class="r"><thead><tr><th>항목</th><th>수량</th><th>적용 대상</th><th>단가</th><th>세금</th><th>합계</th></tr></thead><tbody>${o.items.map((i) => `<tr><td data-l="항목">${esc(i.name)}</td><td data-l="수량">${i.qty}</td><td data-l="대상">${i.target_type === 'site' ? '사이트 ' + esc(i.target_id || '') : '사용자 그룹'}</td><td data-l="단가">${money(i.unit_price, o.currency)}</td><td data-l="세금">${money(i.tax, o.currency)}</td><td data-l="합계">${money(i.amount + i.tax, o.currency)}</td></tr>`).join('')}</tbody></table>
${o.policyNotes ? `<p class="sub">입금 기한 ${o.policyNotes.paymentDeadlineDays}일 · 미납 ${o.policyNotes.restrictionAfterUnpaidDays}일 후 ${esc(o.policyNotes.restrictionEffect || '')}</p>` : ''}
<div class="actions"><a class="btn" href="/billing/invoice/${esc(o.id)}" target="_blank">${o.status === 'approved' ? '영수증' : '청구서'} 보기</a>${o.canCancel ? '<button class="btn danger" id="cancel">주문 취소</button>' : ''}${['rejected', 'expired'].includes(o.status) ? '<a class="btn primary" href="/products">다시 주문</a><a class="btn" href="/help">문의</a>' : ''}</div></div>`;
const c = el.querySelector('#cancel'); if (c) c.onclick = async () => { if (!(await aw.confirmDanger({ title: '주문 취소', target: o.order_no, impacts: ['권리 변화 없음', '입금한 경우 운영자에게 환불을 문의하세요'], confirmLabel: '취소' }))) return; try { await aw.api('POST', `/api/commerce/orders/${o.id}/cancel`); load(); } catch (e) { aw.toast(e.message); } };
return kind === 'waiting';
} catch (e) { aw.state(el, e.status === 404 ? 'empty' : 'failed', e.message, { retry: load }); return false; }
}
aw.poll(load, 10000);
})();
+28
View File
@@ -0,0 +1,28 @@
/* portal/commerce/public/order.js — 주문 흐름(§6.2): 상품·옵션 → 적용 대상 → 가격·청구 주기 → 약관·제한 → 결제 방식 → 제출. 멱등 키로 연타·새로 고침 중복을 막는다. */
(() => {
const el = document.getElementById('order-new'); const esc = aw.esc; const money = (n) => Number(n).toLocaleString('ko-KR') + '원';
const idem = sessionStorage.getItem('order-idem') || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())); sessionStorage.setItem('order-idem', idem);
let data; let pv; const sel = { includeBase: true, items: [] };
async function load() {
try { data = await aw.api('GET', '/api/commerce/products'); pv = data.products.find((p) => p.id === el.dataset.product) || data.products[0]; if (!pv) { aw.state(el, 'empty', '판매 중인 상품이 없습니다'); return; }
if (el.dataset.option) { sel.includeBase = false; sel.items = [{ optionId: el.dataset.option, qty: 1, targetId: el.dataset.target || null }]; } render(); }
catch (e) { aw.state(el, 'failed', e.message, { retry: load }); }
}
async function render() {
let quote; try { quote = await aw.api('POST', '/api/commerce/quote', { productVersionId: pv.id, includeBase: sel.includeBase, items: sel.items }); } catch (e) { aw.state(el, 'failed', e.message, { retry: render }); return; }
const q = quote.quote; const pi = quote.paymentInstructions;
el.innerHTML = `<div class="card"><h2>1. 상품과 옵션</h2><p><label><input type="checkbox" id="base" ${sel.includeBase ? 'checked' : ''}> ${esc(pv.name)} 기본 상품 (${money(pv.price)}/${pv.billingCycle === 'monthly' ? '월' : '년'})</label></p>
<table class="r"><thead><tr><th>옵션</th><th>가격</th><th>수량</th><th>적용 대상</th></tr></thead><tbody>${pv.options.map((o) => { const it = sel.items.find((x) => x.optionId === o.id); return `<tr><td data-l="옵션">${esc(o.name)}</td><td data-l="가격">${money(o.price)}${o.per === 'site' ? ' / 사이트' : ''}</td><td data-l="수량"><input type="number" min="0" max="${o.max_qty || 99}" value="${it ? it.qty : 0}" data-opt="${o.id}" style="width:5em"></td><td data-l="대상">${o.per === 'site' ? `<select data-target="${o.id}"><option value="">사이트 선택</option>${data.sites.map((s) => `<option value="${esc(s.id)}" ${it && it.targetId === s.id ? 'selected' : ''}>${esc(s.name)}</option>`).join('')}</select>` : '사용자 그룹'}</td></tr>`; }).join('')}</tbody></table></div>
<div class="card"><h2>2. 가격과 청구 주기</h2><table><tbody>${q.items.map((i) => `<tr><td>${esc(i.name)} × ${i.qty}</td><td class="right">${money(i.amount)} + 세금 ${money(i.tax)}</td></tr>`).join('')}<tr><th>합계(세금 포함)</th><th class="right">${money(q.total)} ${q.currency}</th></tr></tbody></table><p class="sub">청구 주기: ${pv.billingCycle === 'monthly' ? '매월' : '매년'} · 갱신 전 청구 예정일과 금액을 알려 드립니다</p></div>
<div class="card"><h2>3. 약관과 제한</h2><ul>${quote.terms.notes.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>${pv.deferred.length ? `<p><span class="badge warning">! 미확정 정책</span> ${pv.deferred.map((d) => esc(d.key)).join(', ')} — 확정 전에는 약속으로 보지 마세요</p>` : ''}<p><label><input type="checkbox" id="terms"> 약관·개인정보 처리방침·제한 사항에 동의합니다</label></p></div>
<div class="card"><h2>4. 결제 방식</h2><p><label><input type="radio" name="pm" value="manual_transfer" checked> 계좌 이체(수동 결제 확인)</label></p><p class="sub">${esc(pi.bank)} ${esc(pi.account)} (예금주 ${esc(pi.holder)}) · 입금 기한 ${pi.deadline_days}일 · ${esc(pi.note || '')}</p><p class="sub">운영자가 입금을 확인하고 승인한 뒤에만 사이트 수·용량·메일함·백업 권리를 쓸 수 있습니다.</p></div>
<div class="actions"><button class="btn primary" id="submit" ${q.items.length ? '' : 'disabled'}>주문 제출</button><a class="btn" href="/products">취소</a></div><p class="err" id="err"></p>`;
el.querySelector('#base').onchange = (e) => { sel.includeBase = e.target.checked; render(); };
el.querySelectorAll('[data-opt]').forEach((inp) => { inp.onchange = () => { const id = inp.dataset.opt; const qty = Number(inp.value) || 0; const t = el.querySelector(`[data-target="${id}"]`); sel.items = sel.items.filter((x) => x.optionId !== id); if (qty > 0) sel.items.push({ optionId: id, qty, targetId: t ? t.value || null : null }); render(); }; });
el.querySelectorAll('[data-target]').forEach((s) => { s.onchange = () => { const it = sel.items.find((x) => x.optionId === s.dataset.target); if (it) it.targetId = s.value || null; }; });
el.querySelector('#submit').onclick = async () => { const btn = el.querySelector('#submit'); btn.disabled = true; const err = el.querySelector('#err'); err.textContent = '';
try { const r = await aw.api('POST', '/api/commerce/orders', { productVersionId: pv.id, includeBase: sel.includeBase, items: sel.items, paymentMethod: 'manual_transfer', acceptTerms: el.querySelector('#terms').checked, idempotencyKey: idem }); sessionStorage.removeItem('order-idem'); location.href = `/orders/${r.order.id}`; }
catch (e) { err.textContent = e.message + (e.detail?.action === 'verify_email' ? ' — 내 계정에서 이메일 확인을 완료하세요' : ''); btn.disabled = false; } };
}
load();
})();
+20
View File
@@ -0,0 +1,20 @@
/* portal/commerce/public/products.js — 상품 목록(§6.1). 가격·한도는 서버(product_versions)에서 온다. 미확정 정책은 숫자 대신 '미확정 정책' 으로 보인다. */
(() => {
const el = document.getElementById('products'); const esc = aw.esc; const GB = 1024 ** 3;
const money = (n) => Number(n).toLocaleString('ko-KR') + '원'; const gb = (b) => (b / GB).toFixed(0) + ' GB';
async function load() {
try {
const d = await aw.api('GET', '/api/commerce/products');
if (!d.products.length) { aw.state(el, 'empty', '판매 중인 상품이 없습니다. 운영자에게 문의하세요'); return; }
el.innerHTML = `<p class="alert info">ⓘ 결제 방식: ${esc(d.paymentMethods.map((m) => m.label).join(', '))} — 자동 결제는 지원하지 않습니다.</p>` + `<div class="cards">${d.products.map((p) => `<div class="card"><h2>${esc(p.name)} <span class="sub">v${p.version}</span></h2><p class="sub">${esc(p.audience)}</p><p>${esc(p.description || '')}</p><p class="stat">${money(p.price)}<span class="sub">/${p.billingCycle === 'monthly' ? '월' : '년'} + 세금 ${money(p.tax)} = ${money(p.total)}</span></p>
<dl class="kv"><dt>사이트 수</dt><dd>${p.limits.sites}개</dd><dt>웹 디스크</dt><dd>${gb(p.limits.web_disk)}</dd><dt>월간 웹 아웃바운드</dt><dd>${gb(p.limits.web_out)}</dd><dt>메일 저장 공간</dt><dd>${gb(p.limits.mail_storage)}</dd><dt>월간 메일 통수</dt><dd>${p.limits.mail_msgs.toLocaleString('ko-KR')}통(송신+수신)</dd><dt>메일함</dt><dd>${p.limits.mailboxes}개</dd></dl>
<h3>추가 옵션</h3><ul>${p.options.map((o) => `<li>${esc(o.name)} — ${money(o.price)}${o.per === 'site' ? ' (사이트당)' : ''}</li>`).join('')}</ul>
<h3>제공 범위</h3><ul>${p.includes.map((x) => `<li>${esc(x)}</li>`).join('')}</ul><h3>제외</h3><ul>${p.excludes.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
${p.deferred.length ? `<h3>미확정 정책</h3><ul>${p.deferred.map((x) => `<li><span class="badge warning">! 미확정 정책</span> ${esc(x.key)} — ${esc(x.note)}</li>`).join('')}</ul>` : ''}
<p class="sub">사용량 경고 ${p.warnLevels.join('·')}% · 승인 방식: 운영자 수동 승인 · 적용 ${esc(p.effectiveFrom.slice(0, 10))}</p>
<div class="actions"><a class="btn primary" href="/order/new?product=${esc(p.id)}">주문</a></div></div>`).join('')}</div>` +
`<div class="card"><h2>도메인 등록</h2>${d.domainRegistration.enabled ? `<p>등록기관(${esc(d.domainRegistration.registrar || '')})을 통해 도메인을 검색·등록할 수 있습니다.</p>` : `<p class="muted">${esc(d.domainRegistration.note)}</p>`}</div>`;
} catch (e) { aw.state(el, 'failed', e.message, { retry: load }); }
}
load();
})();
+17
View File
@@ -0,0 +1,17 @@
/**
* portal/commerce/seed.mjs — 출시 검증용 Catalog 시드 명령.
* node portal/commerce/seed.mjs [catalog 파일] (DB 위치는 ARIWEB_PORTAL_DATA 또는 ./data)
* 같은 (product_id, version) 이 있으면 건너뛴다. 검증 실패면 아무것도 넣지 않고 종료 코드 1.
*/
import { openDb } from '../../lib/db.mjs';
import { allMigrations } from '../schema.mjs';
import { loadConfig } from '../config.mjs';
import { seedCatalog, loadCatalogFile, CATALOG_FILE } from './catalog.mjs';
import { join } from 'node:path';
const cfg = loadConfig();
const store = openDb(join(cfg.dataDir, 'portal.sqlite'), await allMigrations());
const file = process.argv[2] || CATALOG_FILE;
const r = seedCatalog(store, loadCatalogFile(file), 'seed-cli');
if (r.problems.length) { console.error('Catalog 검증 실패:\n - ' + r.problems.join('\n - ')); process.exit(1); }
console.log(`Catalog 시드 완료(${file}): 추가 ${r.inserted} · 이미 있음 ${r.skipped}`);
+97
View File
@@ -0,0 +1,97 @@
/**
* portal/usage/api.mjs — 사용량·배정 화면과 API(§13), 운영자 내부 API.
*
* 화면 /usage 그룹 전체 사용량(구매 총량·배정·실제 사용·미배정·예약)과 서비스별 표, 경고·제한, 적용 상품정책 버전, 다음 KST 월 초기화, 마지막 계측
* /usage/allocate/:subject 서비스(site:<id> | mail_service:<id>)별 보장량 배정 변경(§13.3)
* API GET /api/usage 요약 JSON
* GET /api/usage/allocate/:subject 자원별 수치(구매 총량·사용 중·다른 서비스 배정·예약·미배정·변경 후 값 계산용)
* POST /api/usage/allocate/:subject { resource, amount } → lib/quota.setAllocation (동시 요청 안전, 사용량 미만 거부)
* GET /api/usage/limits 경고·제한 목록
* POST /api/usage/evaluate 경고 재평가(새로 고침)
* 운영자 /internal/ops/usage/overview · /internal/ops/usage/group/:groupId · /internal/ops/usage/limits · POST /internal/ops/usage/evaluate
* 지연 처리: 대상의 마지막 계측이 settings 'usage.stale_minutes'(기본 30분) 보다 오래되면 '정보 확인 지연' 을 표시하고 배정 증가를 막는다(오래된 정보로 신규 증가를 허용하지 않음).
*/
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as quota from '../../lib/quota.mjs';
import { HttpError } from '../../lib/http.mjs';
import { nowIso } from '../../lib/db.mjs';
import { nextMonthStart, monthStart, fmtKst } from '../../lib/kst.mjs';
import { requireGroup, requireRole } from '../auth/guard.mjs';
import { requireOps } from '../commerce/opsauth.mjs';
import { getSetting } from '../commerce/catalog.mjs';
import { appliedProduct, activeSubscription } from '../commerce/orders.mjs';
import { listLimits, limitsFor, LIMIT_SEMANTICS } from './limits.mjs';
import { evaluate, evaluateAll, fmtAmount } from './warnings.mjs';
import { ALLOC_LABEL } from '../../lib/status.mjs';
import { audit } from '../../lib/audit.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
export const SUBJECT_TYPES = { site: { table: 'sites', label: '사이트', resources: ['web_disk', 'web_out', 'mailboxes', 'mail_storage', 'mail_msgs'] }, mail_service: { table: 'mail_services', label: '메일 서비스', resources: ['mail_storage', 'mail_msgs', 'mailboxes'] } };
/** 'site:abc' → { type, id, row } (현재 그룹 소속 확인) */
function parseSubject(store, groupId, subject) {
const i = String(subject || '').indexOf(':'); const type = subject.slice(0, i); const id = subject.slice(i + 1); const def = SUBJECT_TYPES[type];
if (!def || !id) throw new HttpError(400, 'bad_subject', '대상은 site:<id> 또는 mail_service:<id> 형식입니다');
const row = store.get(`SELECT * FROM ${def.table} WHERE id=? AND group_id=?`, id, groupId); if (!row) throw new HttpError(404, 'not_found', '요청한 항목을 찾을 수 없습니다');
return { type, id, row, def };
}
export function staleMinutes(store) { return Number(getSetting(store, 'usage.stale_minutes', 30)) || 30; }
const isStale = (measuredAt, mins, now = Date.now()) => !!measuredAt && now - Date.parse(measuredAt) > mins * 60000;
/** 그룹 요약 JSON */
export function usageSummary(deps, groupId, at = nowIso()) {
const { store } = deps; const mins = staleMinutes(store); const pv = appliedProduct(store, groupId); const sub = activeSubscription(store, groupId);
const resources = quota.summary(store, groupId, at).map((f) => ({ ...f, percent: quota.percent(f.allocated + f.reserved, f.purchased), usedPercent: quota.percent(f.used, f.purchased), semantics: LIMIT_SEMANTICS[f.resource] || null, monthly: f.unit.endsWith('_month') }));
const allocs = quota.listAllocations(store, groupId).map((a) => { const def = SUBJECT_TYPES[a.subject_type]; const row = def ? store.get(`SELECT name FROM ${def.table} WHERE id=?`, a.subject_id) : null; return { ...a, subjectName: row?.name || a.subject_id, subjectLabel: def?.label || a.subject_type, stateLabel: ALLOC_LABEL[a.state] || a.state, percent: a.used == null ? null : quota.percent(a.used, a.amount), stale: isStale(a.measured_at, mins), unit: quota.RESOURCES[a.resource].unit, label: quota.RESOURCES[a.resource].label, usedText: a.used == null ? '계측 전' : fmtAmount(a.used, quota.RESOURCES[a.resource].unit), amountText: fmtAmount(a.amount, quota.RESOURCES[a.resource].unit) }; });
const reservations = quota.listReservations(store, groupId).map((r) => ({ ...r, stateLabel: ALLOC_LABEL[r.state] || r.state, label: quota.RESOURCES[r.resource]?.label }));
const measured = allocs.map((a) => a.measured_at).filter(Boolean).sort(); const lastMeasuredAt = measured[measured.length - 1] || null;
return { resources, allocations: allocs, reservations, limits: listLimits(store, groupId), product: pv ? { id: pv.id, name: pv.name, version: pv.version, warnLevels: pv.warn_levels } : null, subscription: sub ? { id: sub.id, status: sub.status } : null,
monthStart: monthStart(at), nextResetAt: nextMonthStart(at), nextResetText: fmtKst(nextMonthStart(at)), lastMeasuredAt, lastMeasuredText: lastMeasuredAt ? fmtKst(lastMeasuredAt) : '계측 전', staleMinutes: mins, stale: !!lastMeasuredAt && isStale(lastMeasuredAt, mins), generatedAt: at };
}
/** 배정 화면 수치(§13.3) */
export function allocationView(deps, groupId, subjectStr, at = nowIso()) {
const { store } = deps; const s = parseSubject(store, groupId, subjectStr); const mins = staleMinutes(store); const ms = monthStart(at);
const rows = s.def.resources.map((r) => {
const f = quota.figures(store, groupId, r, at); const mine = quota.allocationOf(store, groupId, r, s.type, s.id); const u = quota.usageOf(store, groupId, r, s.type, s.id);
const monthly = quota.RESOURCES[r].unit.endsWith('_month'); const used = u ? (monthly && u.period_start && u.period_start < ms ? 0 : u.used) : null;
const stale = isStale(u?.measured_at, mins); const myAmount = mine ? mine.amount : 0;
return { resource: r, label: f.label, unit: f.unit, monthly, purchased: f.purchased, mine: myAmount, mineState: mine ? (ALLOC_LABEL[mine.state] || mine.state) : ALLOC_LABEL.available, others: f.allocated - myAmount, reserved: f.reserved, unallocated: f.unallocated, max: f.unallocated + myAmount, used, monthUsed: monthly ? used : null, floor: used || 0, measuredAt: u?.measured_at || null, stale, canIncrease: !stale, percent: used == null || !myAmount ? null : quota.percent(used, myAmount), limit: limitsFor(store, s.type, s.id).find((l) => l.resource === r) || null, texts: { purchased: fmtAmount(f.purchased, f.unit), mine: fmtAmount(myAmount, f.unit), others: fmtAmount(f.allocated - myAmount, f.unit), reserved: fmtAmount(f.reserved, f.unit), unallocated: fmtAmount(f.unallocated, f.unit), used: used == null ? '계측 전' : fmtAmount(used, f.unit) } };
});
return { subject: { type: s.type, id: s.id, name: s.row.name, label: s.def.label }, resources: rows, nextResetText: fmtKst(nextMonthStart(at)), staleMinutes: mins };
}
/** 배정 변경 */
export function changeAllocation(deps, { groupId, userId, subjectStr, resource, amount }) {
const { store } = deps; const s = parseSubject(store, groupId, subjectStr); if (!s.def.resources.includes(resource)) throw new HttpError(400, 'bad_resource', `${s.def.label}에는 ${resource} 를 배정할 수 없습니다`);
const sub = activeSubscription(store, groupId); if (sub && sub.status === 'restricted') throw new HttpError(409, 'subscription_restricted', '미납 제한 중에는 배정을 늘릴 수 없습니다. 입금 확인 후 다시 시도하세요');
const view = allocationView(deps, groupId, subjectStr).resources.find((r) => r.resource === resource);
amount = Math.round(Number(amount)); if (!(amount >= 0)) throw new HttpError(400, 'bad_amount', '배정량을 입력하세요');
if (amount > view.mine && view.stale) throw new HttpError(409, 'usage_stale', `마지막 계측(${fmtKst(view.measuredAt)})이 오래되어 배정을 늘릴 수 없습니다. 계측이 갱신된 뒤 다시 시도하세요(정보 확인 지연)`, { measuredAt: view.measuredAt });
const r = quota.setAllocation(store, { groupId, resource, subjectType: s.type, subjectId: s.id, amount, usedNow: view.used || 0, monthUsed: view.monthUsed || 0 });
audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: s.type, subjectId: s.id, action: 'allocation.change', detail: { resource, before: r.before, after: r.after } });
try { evaluate(deps, groupId); } catch (_) {}
return { ...r, view: allocationView(deps, groupId, subjectStr).resources.find((x) => x.resource === resource) };
}
export async function register(app, deps) {
const { store, page } = deps;
app.static('/ui/usage', join(HERE, 'public'));
const shell = (ctx, o) => page({ user: ctx.user, group: ctx.group, groups: ctx.groups || [ctx.group].filter(Boolean), csrf: ctx.session?.csrf, unread: ctx.unread, nav: 'usage', ...o });
app.get('/usage', (ctx) => { requireGroup(ctx); return ctx.html(shell(ctx, { title: '사용량', crumbs: [{ label: '사용량' }], actions: [{ label: '경고 다시 평가', action: 'evaluate' }], body: `<div id="usage" data-group="${ctx.group.id}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/usage/usage.js'] })); });
app.get('/usage/allocate/:subject', (ctx) => { requireGroup(ctx); return ctx.html(shell(ctx, { title: '보장량 배정 변경', crumbs: [{ label: '사용량', href: '/usage' }, { label: '배정 변경' }], body: `<div id="alloc" data-subject="${ctx.params.subject.replace(/[^a-zA-Z0-9_:.-]/g, '')}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/usage/allocate.js'] })); });
app.get('/api/usage', (ctx) => { requireGroup(ctx); return usageSummary(deps, ctx.group.id); });
app.get('/api/usage/limits', (ctx) => { requireGroup(ctx); return { limits: listLimits(store, ctx.group.id), semantics: LIMIT_SEMANTICS }; });
app.post('/api/usage/evaluate', (ctx) => { requireRole(ctx, ['owner', 'admin', 'billing', 'member']); return { ok: true, ...evaluate(deps, ctx.group.id) }; });
app.get('/api/usage/allocate/:subject', (ctx) => { requireGroup(ctx); return allocationView(deps, ctx.group.id, ctx.params.subject); });
app.post('/api/usage/allocate/:subject', async (ctx) => { requireRole(ctx, 'admin'); const b = await ctx.body(); try { return { ok: true, ...changeAllocation(deps, { groupId: ctx.group.id, userId: ctx.user.id, subjectStr: ctx.params.subject, resource: b.resource, amount: b.amount }) }; } catch (e) { if (e instanceof quota.QuotaError) throw new HttpError(409, e.code, e.message, e.detail); throw e; } });
// 운영자 콘솔용
app.get('/internal/ops/usage/overview', async (ctx) => { await requireOps(ctx, deps); const groups = store.all('SELECT DISTINCT group_id FROM quota_entitlements'); return { groups: groups.map((g) => { const gr = store.get('SELECT id, name, kind FROM groups WHERE id=?', g.group_id); return { group: gr || { id: g.group_id }, resources: quota.summary(store, g.group_id).map((f) => ({ resource: f.resource, label: f.label, unit: f.unit, purchased: f.purchased, allocated: f.allocated, reserved: f.reserved, used: f.used, percent: quota.percent(f.used, f.purchased) })), limits: listLimits(store, g.group_id).length }; }), staleMinutes: staleMinutes(store) }; });
app.get('/internal/ops/usage/group/:groupId', async (ctx) => { await requireOps(ctx, deps); return usageSummary(deps, ctx.params.groupId); });
app.get('/internal/ops/usage/limits', async (ctx) => { await requireOps(ctx, deps); return { limits: store.all('SELECT * FROM usage_limits WHERE level>0 ORDER BY level DESC, evaluated_at DESC').map((r) => ({ ...r, limited: !!r.limited, semantics: LIMIT_SEMANTICS[r.resource] || null })) }; });
app.post('/internal/ops/usage/evaluate', async (ctx) => { await requireOps(ctx, deps); return { ok: true, results: evaluateAll(deps) }; });
// 주기 작업 등록(스케줄러가 있으면)
if (deps.scheduler?.every) deps.scheduler.every('usage.evaluate', 5 * 60 * 1000, () => evaluateAll(deps));
deps.usage = { evaluate: (groupId) => evaluate(deps, groupId), evaluateAll: () => evaluateAll(deps), summary: (groupId) => usageSummary(deps, groupId) };
}
+32
View File
@@ -0,0 +1,32 @@
/**
* portal/usage/limits.mjs — 100% 제한 조회(다른 영역이 쓰는 읽기 전용 API)와 자원별 제한 의미(§13.4).
*
* 자원별 100% 동작
* web_out 사이트 대신 제한 안내 화면(유지: 관리자 화면·조회·삭제·내보내기 / 제한: 공개 접속). 다음 KST 월 시작 또는 추가 용량 적용 후 복구.
* web_disk 유지: 기존 사이트 조회·삭제·내보내기 / 제한: 새 업로드·데이터베이스 증가·새 배포.
* mail_storage 유지: 기존 메일 조회·삭제 / 제한: 새 수신(발신 서버가 재시도하도록 임시 실패 4xx)·저장.
* mail_msgs 유지: 조회·삭제 / 제한: 새 발신 차단 + 새 수신 임시 실패. 다음 KST 월 시작 또는 추가 용량 적용 후 복구.
* mailboxes 유지: 기존 메일함 / 제한: 새 메일함 생성.
* sites·mail_services·source·backup 은 개수 권리라 100% = "추가 생성 불가"(lib/quota.reserve 가 거부).
* 초과 사용 허용(overage opt-in)이 유효하고 상한 안이면 limited=false 로 두되 overage=1 로 표시한다.
* 고객 데이터는 어떤 경우에도 자동 삭제하지 않는다.
*/
export const LIMIT_SEMANTICS = Object.freeze({
web_out: { kept: ['관리 화면 접근', '파일 조회·내보내기', '삭제'], blocked: ['공개 사이트 접속(제한 안내 화면 표시)'], recovery: '다음 KST 월 시작 또는 추가 용량(옵션 구매·배정 증가) 적용 후 자동 복구', monthly: true },
web_disk: { kept: ['기존 사이트 조회', '삭제·내보내기'], blocked: ['새 업로드', '데이터베이스 증가', '새 배포'], recovery: '파일·데이터 정리 또는 추가 용량 적용 후 복구', monthly: false },
mail_storage: { kept: ['기존 메일 조회', '삭제'], blocked: ['새 수신(임시 실패 응답 — 발신 서버가 재시도)', '새 저장'], recovery: '메일 정리 또는 추가 용량 적용 후 복구', monthly: false },
mail_msgs: { kept: ['기존 메일 조회', '삭제'], blocked: ['새 발신', '새 수신(임시 실패 응답)'], recovery: '다음 KST 월 시작 또는 추가 통수 적용 후 복구', monthly: true },
mailboxes: { kept: ['기존 메일함'], blocked: ['새 메일함 생성'], recovery: '메일함 정리 또는 추가 메일함 옵션 적용 후 복구', monthly: false },
sites: { kept: ['운영 중 사이트'], blocked: ['새 공개 배포(사이트 수 확보)'], recovery: '사이트 수 반환(§14) 또는 상품 구매', monthly: false },
mail_services: { kept: ['기존 메일 서비스'], blocked: ['새 독립 메일 서비스'], recovery: '옵션 구매', monthly: false },
source: { kept: ['기존 저장소 조회'], blocked: ['새 Source 옵션 적용'], recovery: '옵션 구매', monthly: false },
backup: { kept: ['기존 백업 보관'], blocked: ['새 백업 옵션 활성화'], recovery: '옵션 구매', monthly: false },
});
/** 대상(사이트·메일 서비스)의 자원이 100% 제한 중인지 */
export function isLimited(store, resource, subjectType, subjectId) { const r = store.get('SELECT limited FROM usage_limits WHERE resource=? AND subject_type=? AND subject_id=?', resource, subjectType, subjectId); return !!(r && r.limited); }
/** 제한 상세(화면용): { level, limited, used, limit, since, reason, semantics, overage } 또는 null */
export function limitInfo(store, resource, subjectType, subjectId) { const r = store.get('SELECT * FROM usage_limits WHERE resource=? AND subject_type=? AND subject_id=?', resource, subjectType, subjectId); if (!r) return null; return { resource, level: r.level, limited: !!r.limited, used: r.used, limit: r.limit, since: r.since, reason: r.reason, overage: !!r.overage, evaluatedAt: r.evaluated_at, semantics: LIMIT_SEMANTICS[resource] || null }; }
/** 그룹 전체의 제한·경고 목록 */
export function listLimits(store, groupId) { return store.all('SELECT * FROM usage_limits WHERE group_id=? AND level>0 ORDER BY level DESC, resource', groupId).map((r) => ({ ...r, limited: !!r.limited, overage: !!r.overage, semantics: LIMIT_SEMANTICS[r.resource] || null })); }
/** 대상에 걸린 모든 제한(배포 전 확인 등에서 한 번에) */
export function limitsFor(store, subjectType, subjectId) { return store.all('SELECT * FROM usage_limits WHERE subject_type=? AND subject_id=? AND level>0', subjectType, subjectId).map((r) => ({ ...r, limited: !!r.limited, semantics: LIMIT_SEMANTICS[r.resource] || null })); }
+11
View File
@@ -0,0 +1,11 @@
/**
* portal/usage/model.mjs — 사용량 영역 표(§13.4).
* usage_limits 그룹·자원·대상별 현재 경고 구간(level)과 제한 여부(limited). 다른 영역(사이트·메일·배포)은 portal/usage/limits.mjs 로 읽는다.
*/
export const MIGRATIONS = [{ id: 'usage_0001', sql: `
CREATE TABLE IF NOT EXISTS usage_limits (
group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, level INTEGER NOT NULL DEFAULT 0, limited INTEGER NOT NULL DEFAULT 0,
used INTEGER NOT NULL DEFAULT 0, "limit" INTEGER NOT NULL DEFAULT 0, since TEXT, reason TEXT, overage INTEGER NOT NULL DEFAULT 0, evaluated_at TEXT NOT NULL, period_start TEXT,
PRIMARY KEY(group_id, resource, subject_type, subject_id)
);
` }];
+19
View File
@@ -0,0 +1,19 @@
/* portal/usage/public/allocate.js — 서비스별 보장량 배정 변경(§13.3). 구매 총량·사용 중·다른 서비스 배정·예약·미배정·변경 후 값을 함께 보인다. */
(() => {
const el = document.getElementById('alloc'); const subject = el.dataset.subject; const esc = aw.esc;
const fmt = (n, unit) => (unit.startsWith('bytes') ? (n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(2) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(0) + ' MB' : n + ' B') : Number(n).toLocaleString('ko-KR'));
const GB = 1024 ** 3;
async function load() {
try {
const d = await aw.api('GET', `/api/usage/allocate/${encodeURIComponent(subject)}`);
el.innerHTML = `<div class="card"><h2>${esc(d.subject.label)} · ${esc(d.subject.name)}</h2><p class="sub">월간 항목은 ${esc(d.nextResetText)} 에 초기화됩니다. 실제 사용량보다 작게 줄일 수 없고, 월간 항목은 이번 달 누적보다 작게 줄일 수 없습니다.</p></div>` +
d.resources.map((r) => { const bytes = r.unit.startsWith('bytes'); const step = bytes ? GB : 1; return `<div class="card" data-res="${r.resource}"><h2>${esc(r.label)}${r.monthly ? ' (월간)' : ''}</h2>
<dl class="kv"><dt>구매 총량</dt><dd>${esc(r.texts.purchased)}</dd><dt>이 서비스 사용 중(배정)</dt><dd>${esc(r.texts.mine)} <span class="badge neutral">○ ${esc(r.mineState)}</span></dd><dt>다른 서비스 배정</dt><dd>${esc(r.texts.others)}</dd><dt>진행 중 작업 예약</dt><dd>${esc(r.texts.reserved)}</dd><dt>미배정</dt><dd>${esc(r.texts.unallocated)}</dd><dt>실제 사용${r.monthly ? '(이번 달 누적)' : ''}</dt><dd>${esc(r.texts.used)}${r.measuredAt ? ` <span class="sub">계측 ${aw.fmt(r.measuredAt)}</span>` : ''}${r.stale ? ' <span class="badge warning">! 정보 확인 지연 — 늘릴 수 없음</span>' : ''}</dd>${r.limit ? `<dt>제한</dt><dd class="err">${r.limit.limited ? '100% 제한 적용 중' : r.limit.level + '% 경고'}</dd>` : ''}</dl>
<form class="f" data-dirty-guard><label for="in-${r.resource}">변경 후 값 (${bytes ? 'GB' : '개'})</label><input id="in-${r.resource}" type="number" min="${Math.ceil(r.floor / step)}" max="${Math.floor(r.max / step)}" step="${bytes ? '0.5' : '1'}" value="${bytes ? (r.mine / GB).toFixed(1) : r.mine}"><p class="hint">허용 범위 ${fmt(r.floor, r.unit)} ~ ${fmt(r.max, r.unit)} (미배정 ${esc(r.texts.unallocated)} 안에서)</p><p class="err" id="err-${r.resource}"></p><div class="actions"><button class="btn primary" type="submit">적용</button></div></form></div>`; }).join('');
el.querySelectorAll('form').forEach((f) => { f.onsubmit = async (ev) => { ev.preventDefault(); const res = f.closest('[data-res]').dataset.res; const r = d.resources.find((x) => x.resource === res); const v = Number(f.querySelector('input').value); const amount = r.unit.startsWith('bytes') ? Math.round(v * GB) : Math.round(v); const errEl = f.querySelector('.err'); errEl.textContent = '';
try { const out = await aw.api('POST', `/api/usage/allocate/${encodeURIComponent(subject)}`, { resource: res, amount }); aw.markClean(); aw.toast(`${r.label} 배정을 ${fmt(out.before, r.unit)} → ${fmt(out.after, r.unit)} 로 바꿨습니다`); load(); }
catch (e) { errEl.textContent = e.message + (e.detail?.cleanupNeeded ? ` (정리 필요: ${fmt(e.detail.cleanupNeeded, r.unit)})` : e.detail?.shortage ? ` (부족: ${fmt(e.detail.shortage, r.unit)})` : ''); } }; });
} catch (e) { aw.state(el, e.status === 403 ? 'forbidden' : e.status === 404 ? 'empty' : 'failed', e.message, { retry: load }); }
}
load();
})();
+21
View File
@@ -0,0 +1,21 @@
/* portal/usage/public/usage.js — 사용량 화면(§13.2·§13.4). /api/usage 를 그려 준다. */
(() => {
const el = document.getElementById('usage'); const esc = aw.esc;
const bar = (pct, lvl) => `<div class="bar w${lvl || 0}" aria-label="${pct}%"><i style="width:${Math.min(100, pct)}%"></i></div>`;
const lvlOf = (p) => (p >= 100 ? 100 : p >= 95 ? 95 : p >= 90 ? 90 : p >= 80 ? 80 : p >= 70 ? 70 : 0);
async function load() {
try {
const d = await aw.api('GET', '/api/usage');
if (!d.product) { aw.state(el, 'empty', '활성 상품이 없습니다. 상품을 주문하면 사용량이 표시됩니다', { href: '/products', hrefLabel: '상품 보기' }); return; }
let h = `<div class="card"><dl class="kv"><dt>적용 상품정책</dt><dd>${esc(d.product.name)} v${d.product.version} (경고 구간 ${d.product.warnLevels.join('·')}%)</dd><dt>다음 KST 월 초기화</dt><dd>${esc(d.nextResetText)}</dd><dt>마지막 계측</dt><dd>${esc(d.lastMeasuredText)}${d.stale ? ' <span class="badge warning">! 정보 확인 지연</span>' : ''}</dd></dl></div>`;
if (d.limits.length) h += `<div class="card"><h2>경고·제한</h2><ul>${d.limits.map((l) => `<li><span class="badge ${l.limited ? 'failed' : 'warning'}">${l.limited ? '✘ 제한' : '! ' + l.level + '%'}</span> ${esc(l.resource)} · ${esc(l.subject_type)} ${esc(l.subject_id)}${l.limited && l.semantics ? ` — 제한: ${esc(l.semantics.blocked.join(', '))} / 유지: ${esc(l.semantics.kept.join(', '))} / 해제: ${esc(l.semantics.recovery)}` : ''}${l.overage ? ' (초과 사용 허용 중)' : ''}</li>`).join('')}</ul></div>`;
h += `<div class="card"><h2>그룹 전체(구매 총량 기준)</h2><table class="r"><thead><tr><th>자원</th><th>구매 총량</th><th>배정</th><th>예약(진행 중 작업)</th><th>미배정</th><th>실제 사용</th><th>사용률</th></tr></thead><tbody>${d.resources.map((r) => `<tr><td data-l="자원">${esc(r.label)}${r.monthly ? ' <span class="sub">(월간)</span>' : ''}</td><td data-l="구매">${fmt(r.purchased, r.unit)}</td><td data-l="배정">${fmt(r.allocated, r.unit)}</td><td data-l="예약">${fmt(r.reserved, r.unit)}</td><td data-l="미배정">${fmt(r.unallocated, r.unit)}</td><td data-l="사용">${fmt(r.used, r.unit)}</td><td data-l="사용률">${bar(r.usedPercent, lvlOf(r.usedPercent))} ${r.usedPercent}%</td></tr>`).join('')}</tbody></table></div>`;
h += `<div class="card"><h2>서비스별 배정과 사용</h2>${d.allocations.length ? `<table class="r"><thead><tr><th>서비스</th><th>자원</th><th>배정</th><th>상태</th><th>사용</th><th>계측</th><th></th></tr></thead><tbody>${d.allocations.map((a) => `<tr><td data-l="서비스">${esc(a.subjectLabel)} · ${esc(a.subjectName)}</td><td data-l="자원">${esc(a.label)}</td><td data-l="배정">${esc(a.amountText)}</td><td data-l="상태"><span class="badge neutral">○ ${esc(a.stateLabel)}</span></td><td data-l="사용">${esc(a.usedText)} ${a.percent != null ? bar(a.percent, lvlOf(a.percent)) + a.percent + '%' : ''}</td><td data-l="계측">${a.measured_at ? aw.fmt(a.measured_at) : '계측 전'}${a.stale ? ' <span class="badge warning">! 지연</span>' : ''}</td><td data-l=""><a class="btn" href="/usage/allocate/${esc(a.subject_type)}:${esc(a.subject_id)}">배정 변경</a></td></tr>`).join('')}</tbody></table>` : '<p class="muted">아직 배정된 서비스가 없습니다. 사이트나 메일 서비스를 만들면 여기에 표시됩니다.</p>'}</div>`;
if (d.reservations.length) h += `<div class="card"><h2>진행 중 작업의 예약</h2><table class="r"><thead><tr><th>자원</th><th>양</th><th>상태</th><th>사유</th><th>만료·반환 예정</th></tr></thead><tbody>${d.reservations.map((r) => `<tr><td data-l="자원">${esc(r.label)}</td><td data-l="양">${r.amount}</td><td data-l="상태">${esc(r.stateLabel)}</td><td data-l="사유">${esc(r.reason || '')}</td><td data-l="예정">${r.return_due_at ? aw.fmt(r.return_due_at) : r.expires_at ? aw.fmt(r.expires_at) : ''}</td></tr>`).join('')}</tbody></table></div>`;
el.innerHTML = h;
} catch (e) { aw.state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: load }); }
}
function fmt(n, unit) { if (unit.startsWith('bytes')) return n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(0) + ' MB' : n + ' B'; return Number(n).toLocaleString('ko-KR'); }
document.addEventListener('click', async (e) => { if (e.target.closest('[data-action=evaluate]')) { try { const r = await aw.api('POST', '/api/usage/evaluate'); aw.toast(`평가 ${r.evaluated}건 · 새 알림 ${r.notified.length}건`); load(); } catch (err) { aw.toast(err.message); } } });
load();
})();
+65
View File
@@ -0,0 +1,65 @@
/**
* portal/usage/warnings.mjs — 사용량 경고·제한 평가 엔진(§13.4·§17.1·§35).
*
* evaluate(deps, groupId, at) 가 그룹의 모든 배정(lib/quota.listAllocations)에 대해
* level = warnLevel(사용량, 배정량, 상품정책의 warn_levels) (0 | 70 | 80 | 90 | 95 | 100)
* 를 계산하고, usage_limits 표의 이전 level 과 비교해 **올라간 구간마다 한 번만** 알림을 만든다(같은 구간은 incident_key 로 묶여 갱신).
* 70 Portal 사전 안내 80 Portal + 외부 이메일 경고
* 90 예상 소진 시각과 증설·정리 방법을 강조한 긴급 경고 95 곧 제한될 기능과 즉시 필요한 조치
* 100 자원별 제한(limits.LIMIT_SEMANTICS) — 유지되는 기능·해제 방법 표시, limited=1
* 월간 자원(web_out·mail_msgs)은 계측 period_start 가 현재 KST 월보다 앞이면 사용량 0 으로 본다(월 초기화). 구간이 내려가면 상위 사건을 종료한다.
* 초과 사용 허용이 유효하면 100% 에서도 limited=0(overage=1) 로 두고 "초과 사용 과금 중" 알림을 만든다.
* 계측이 없는 대상(used 가 null)은 평가하지 않는다 — 없는 정보로 경고하지 않는다.
*/
import * as quota from '../../lib/quota.mjs';
import { notify, resolveIncident } from '../../lib/notify.mjs';
import { audit } from '../../lib/audit.mjs';
import { nowIso } from '../../lib/db.mjs';
import { monthStart, nextMonthStart, fmtKst } from '../../lib/kst.mjs';
import { LIMIT_SEMANTICS } from './limits.mjs';
import { appliedProduct, activeOverage } from '../commerce/orders.mjs';
const fmtBytes = (n) => (n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(0) + ' MB' : `${n} B`);
export const fmtAmount = (n, unit) => (unit.startsWith('bytes') ? fmtBytes(n) : `${Number(n).toLocaleString('ko-KR')}`);
/** 대상 이름(사이트·메일 서비스) */
function subjectName(store, type, id) { try { if (type === 'site') return store.get('SELECT name FROM sites WHERE id=?', id)?.name || id; if (type === 'mail_service') return store.get('SELECT name FROM mail_services WHERE id=?', id)?.name || id; } catch (_) {} return id; }
function subjectLink(type, id) { return type === 'site' ? `/sites/${id}/usage` : type === 'mail_service' ? `/mail/${id}` : '/usage'; }
/** 월간 자원의 예상 소진 시각(이번 달 평균 속도 기준). 산정 불가면 null */
export function projectExhaustion(used, limit, at = new Date()) { const start = Date.parse(monthStart(at)); const elapsed = Date.parse(at) - start; if (elapsed < 3600000 || used <= 0) return null; const rate = used / elapsed; const remain = Math.max(0, limit - used); const eta = Date.parse(at) + remain / rate; return eta < Date.parse(nextMonthStart(at)) ? new Date(eta).toISOString() : null; }
/**
* 평가 실행. 반환 { evaluated, notified:[{resource, subjectId, level}], limited:[...] }
*/
export function evaluate(deps, groupId, at = nowIso()) {
const { store } = deps; const pv = appliedProduct(store, groupId); const levels = pv?.warn_levels || quota.DEFAULT_WARN_LEVELS;
const out = { evaluated: 0, notified: [], limited: [] }; const ms = monthStart(at);
for (const a of quota.listAllocations(store, groupId)) {
if (a.used == null) continue; // 계측 없음
const monthly = quota.RESOURCES[a.resource]?.unit.endsWith('_month'); const u = quota.usageOf(store, groupId, a.resource, a.subject_type, a.subject_id);
const used = monthly && u?.period_start && u.period_start < ms ? 0 : a.used; // 지난 달 계측값은 0 으로(월 초기화)
const level = quota.warnLevel(used, a.amount, levels); const prev = store.get('SELECT * FROM usage_limits WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, a.resource, a.subject_type, a.subject_id);
const ov = level >= 100 ? activeOverage(store, groupId, a.resource, at) : null; const limited = level >= 100 && !ov; out.evaluated++;
const name = subjectName(store, a.subject_type, a.subject_id); const label = quota.RESOURCES[a.resource].label; const unit = quota.RESOURCES[a.resource].unit; const link = subjectLink(a.subject_type, a.subject_id);
const keyBase = `${groupId}:${a.resource}:${a.subject_type}:${a.subject_id}`;
if (level > (prev?.level || 0)) {
// 올라간 구간: 지난 구간들의 사건은 닫고 새 구간 알림
for (const l of [...levels, 100]) if (l < level) resolveIncident(store, `${keyBase}:${l}`, `상위 구간(${level}%)으로 올라감`);
const pct = quota.percent(used, a.amount); const common = { groupId, category: 'usage', subjectType: a.subject_type, subjectId: a.subject_id, incidentKey: `${keyBase}:${level}`, link };
if (level < 80) notify(store, { ...common, severity: 'info', title: `${name} ${label} ${pct}% 사용 — 사전 안내`, body: `${fmtAmount(used, unit)} / ${fmtAmount(a.amount, unit)}. ${monthly ? `다음 초기화 ${fmtKst(nextMonthStart(at))}` : '정리하거나 배정을 늘릴 수 있습니다'}` });
else if (level < 90) notify(store, { ...common, severity: 'warning', email: true, title: `${name} ${label} ${pct}% 사용 — 경고`, body: `${fmtAmount(used, unit)} / ${fmtAmount(a.amount, unit)}. 사용량 화면에서 배정을 늘리거나 추가 옵션을 구매하세요`, actionRequired: '배정 조정 또는 정리' });
else if (level < 100) { const eta = monthly ? projectExhaustion(used, a.amount, new Date(at)) : null; notify(store, { ...common, severity: 'critical', mandatory: true, title: level >= 95 ? `${name} ${label} ${pct}% — 곧 제한됩니다(최종 경고)` : `${name} ${label} ${pct}% — 긴급 경고`, body: `${fmtAmount(used, unit)} / ${fmtAmount(a.amount, unit)}. ${eta ? `현재 속도면 ${fmtKst(eta)} 소진 예상. ` : monthly ? '예상 소진 시각은 산정할 수 없습니다. ' : ''}${level >= 95 ? `제한 시: ${(LIMIT_SEMANTICS[a.resource]?.blocked || []).join(', ')}. 지금 배정을 늘리거나 정리하세요` : '증설(옵션 구매·배정 증가) 또는 정리(파일·메일 삭제)로 소진을 막으세요'}`, actionRequired: '즉시 증설 또는 정리' }); }
else notify(store, { ...common, severity: 'critical', mandatory: true, title: ov ? `${name} ${label} 100% — 초과 사용 과금 중` : `${name} ${label} 100% — 제한 적용`, body: ov ? `초과 사용 허용(상한 ${ov.cap_amount ? ov.cap_amount.toLocaleString('ko-KR') + '원' : ov.cap_units + ' 단위'}) 안에서 계속 제공합니다` : `제한: ${(LIMIT_SEMANTICS[a.resource]?.blocked || []).join(', ')}. 유지: ${(LIMIT_SEMANTICS[a.resource]?.kept || []).join(', ')}. 해제: ${LIMIT_SEMANTICS[a.resource]?.recovery || ''}`, actionRequired: ov ? null : '추가 용량 적용 또는 정리' });
out.notified.push({ resource: a.resource, subjectId: a.subject_id, level });
if (limited) audit(store, { actorType: 'system', automatic: true, groupId, subjectType: a.subject_type, subjectId: a.subject_id, action: 'usage.limit', detail: { resource: a.resource, used, limit: a.amount } });
} else if (level < (prev?.level || 0)) {
for (const l of [...levels, 100]) if (l > level) resolveIncident(store, `${keyBase}:${l}`, `사용량이 ${quota.percent(used, a.amount)}% 로 내려감`);
if (prev?.limited) { notify(store, { groupId, category: 'usage', severity: 'info', subjectType: a.subject_type, subjectId: a.subject_id, link, title: `${name} ${label} 제한 해제`, body: `현재 ${fmtAmount(used, unit)} / ${fmtAmount(a.amount, unit)}`, incidentKey: `${keyBase}:release` }); audit(store, { actorType: 'system', automatic: true, groupId, subjectType: a.subject_type, subjectId: a.subject_id, action: 'usage.limit.release', detail: { resource: a.resource } }); }
}
if (limited) out.limited.push({ resource: a.resource, subjectType: a.subject_type, subjectId: a.subject_id });
store.run('INSERT INTO usage_limits(group_id, resource, subject_type, subject_id, level, limited, used, "limit", since, reason, overage, evaluated_at, period_start) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(group_id, resource, subject_type, subject_id) DO UPDATE SET level=excluded.level, limited=excluded.limited, used=excluded.used, "limit"=excluded."limit", since=CASE WHEN excluded.level>0 AND (usage_limits.level<>excluded.level OR usage_limits.since IS NULL) THEN excluded.since WHEN excluded.level=0 THEN NULL ELSE usage_limits.since END, reason=excluded.reason, overage=excluded.overage, evaluated_at=excluded.evaluated_at, period_start=excluded.period_start',
groupId, a.resource, a.subject_type, a.subject_id, level, limited ? 1 : 0, used, a.amount, at, limited ? `${label} 100% 도달` : level ? `${label} ${level}% 구간` : null, ov ? 1 : 0, at, monthly ? ms : null);
}
return out;
}
/** 모든 그룹 평가(주기 작업) */
export function evaluateAll(deps, at = nowIso()) { const groups = deps.store.all('SELECT DISTINCT group_id FROM quota_allocations'); const r = []; for (const g of groups) { try { r.push({ groupId: g.group_id, ...evaluate(deps, g.group_id, at) }); } catch (e) { deps.log?.(`[usage] 평가 실패 ${g.group_id}: ${e.message}`); } } return r; }
+120
View File
@@ -0,0 +1,120 @@
/** test/commerce.test.mjs — 상품·주문·수동 결제·승인·권리·축소·해지·갱신·초과 사용·운영자 API 회귀 테스트. */
import { test, eq, ok, done } from '../lib/test.mjs';
import { makeDeps, seedGroup, appFor, call } from './helpers.mjs';
import { invoke } from '../lib/http.mjs';
import { signHeaders } from '../lib/serviceauth.mjs';
import * as quota from '../lib/quota.mjs';
import * as cat from '../portal/commerce/catalog.mjs';
import * as ord from '../portal/commerce/orders.mjs';
import { newId } from '../lib/crypto.mjs';
async function setup({ verified = true } = {}) {
const deps = await makeDeps({ opsAuthBypass: true, autoSeedCatalog: true }); const who = seedGroup(deps, { verified });
const app = await appFor(deps, ['commerce', 'usage'], who); return { deps, who, app, store: deps.store };
}
const ops = (app, method, path, body) => invoke(app, method, path, { body, headers: { 'x-ops-actor': 'tester' } });
const mkSite = (store, gid, name = '사이트') => { const id = newId('s_'); const now = new Date().toISOString(); store.run('INSERT INTO sites(id, group_id, name, created_at, updated_at) VALUES(?,?,?,?,?)', id, gid, name, now, now); return id; };
await test('catalog: 시드 검증·중복 시드 건너뜀·빈 필드 탐지·미확정 정책 렌더링', async () => {
const doc = cat.loadCatalogFile(); eq(cat.validateCatalog(doc), []);
const bad = JSON.parse(JSON.stringify(doc)); bad.products[0].limits.mail_msgs = 0; delete bad.products[1].policy.retention_days; ok(cat.validateCatalog(bad).length >= 2, '빈 필드 탐지');
const { store, app } = await setup(); eq(cat.seedCatalog(store).inserted, 0, '자동 시드 뒤 재시드는 0건'); eq(cat.publishedProducts(store).length, 2);
const r = await call(app, 'GET', '/api/commerce/products'); eq(r.status, 200); const p = r.json.products[0]; ok(p.policy.alias_counting.deferred); eq(p.policy.alias_counting.label, '미확정 정책'); ok(p.deferred.length >= 3); eq(r.json.paymentMethods.map((m) => m.id), ['manual_transfer']); eq(r.json.domainRegistration.enabled, false);
});
await test('order: 멱등(같은 키·같은 상품 대기 중)·약관 필수·자동 결제 거부·주문 번호·입금 안내', async () => {
const { app, store } = await setup(); const pv = cat.publishedProducts(store)[0];
let r = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: false, idempotencyKey: 'k1' }); eq(r.json.error, 'terms_required');
r = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, paymentMethod: 'card' }); eq(r.json.error, 'payment_method_unsupported');
r = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'k1' }); eq(r.status, 200); ok(r.json.created); const o = r.json.order; ok(/^AW-\d{8}-\d{4}$/.test(o.order_no)); eq(o.status, 'awaiting_payment'); eq(o.total, 19000 + 1900); ok(o.payment_instructions.account); ok(o.due_at);
const r2 = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'k1' }); eq(r2.json.created, false); eq(r2.json.order.id, o.id);
const r3 = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'k2' }); eq(r3.json.created, false, '같은 상품 결제 대기 중이면 중복 주문 없음'); eq(store.get('SELECT COUNT(*) AS n FROM orders').n, 1);
const lst = await call(app, 'GET', '/api/commerce/orders'); eq(lst.json.orders.length, 1); eq(lst.json.orders[0].statusLabel, '결제 대기');
});
await test('order: 이메일 미확인 사용자는 유료 상품 주문 불가', async () => { const { app, store } = await setup({ verified: false }); const pv = cat.publishedProducts(store)[0]; const r = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true }); eq(r.status, 403); eq(r.json.error, 'email_unverified'); });
await test('approval: 입금 확인 전 승인 거부 → 입금 확인 → 승인 → 권리·구독 생성, 재승인해도 권리 중복 없음, 알림·감사', async () => {
const { app, store, who } = await setup(); const pv = cat.publishedProducts(store)[0]; const sid = mkSite(store, who.group.id);
const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, items: [{ optionId: 'backup', qty: 1, targetId: sid }, { optionId: 'extra_disk', qty: 2 }] })).json.order; eq(o.total, Math.round((19000 + 7000 + 6000) * 1.1));
let r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); eq(r.status, 409); eq(r.json.error, 'payment_not_confirmed');
r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, { ref: '홍길동 10/26' }); eq(r.json.order.status, 'awaiting_approval');
const list = await ops(app, 'GET', '/internal/ops/commerce/orders?status=awaiting_approval'); eq(list.json.orders.length, 1); ok(list.json.orders[0].entitlementDelta.find((d) => d.resource === 'backup' && d.target === sid)); ok(list.json.orders[0].group.name);
r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); eq(r.status, 200); eq(r.json.already, false); ok(r.json.granted >= 7);
const f = quota.figures(store, who.group.id, 'web_disk'); eq(f.purchased, 5368709120 * 3); eq(quota.figures(store, who.group.id, 'backup').purchased, 1); eq(quota.figures(store, who.group.id, 'sites').purchased, 1);
r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); eq(r.json.already, true); eq(quota.figures(store, who.group.id, 'web_disk').purchased, 5368709120 * 3, '중복 부여 없음'); eq(store.get('SELECT COUNT(*) AS n FROM quota_entitlements').n, r.json.order.entitlement_ids.length);
const sub = ord.activeSubscription(store, who.group.id); ok(sub); eq(sub.status, 'active'); ok(sub.next_billing_at > sub.started_at);
ok(store.get("SELECT 1 AS x FROM notifications WHERE group_id=? AND title LIKE '%승인%'", who.group.id)); eq(store.get("SELECT immutable FROM audit_log WHERE action='order.approve'").immutable, 1);
const b = await call(app, 'GET', '/api/commerce/billing'); eq(b.json.subscriptions.length, 1); eq(b.json.monthEstimate, o.total); eq(b.json.awaitingTotal, 0); ok(b.json.entitlements.find((e) => e.resource === 'sites').purchased === 1);
const inv = await call(app, 'GET', `/billing/invoice/${o.id}`); ok(inv.text.includes('영수증'));
});
await test('order: 거절 사유 필수·취소·기한 만료·운영자 메모·환불 원장', async () => {
const { app, store, who } = await setup(); const pv = cat.publishedProducts(store)[0];
const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true })).json.order;
let r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/reject`, {}); eq(r.json.error, 'reason_required');
r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/reject`, { reason: '입금자 불일치' }); eq(r.json.order.status, 'rejected'); ok(r.json.order.nextStep.includes('입금자 불일치'));
const o2 = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'x' })).json.order; r = await call(app, 'POST', `/api/commerce/orders/${o2.id}/cancel`); eq(r.json.order.status, 'cancelled');
const o3 = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'y' })).json.order; store.run('UPDATE orders SET due_at=? WHERE id=?', new Date(0).toISOString(), o3.id); eq(ord.expireOrders(store).length, 1); eq(ord.getOrder(store, o3.id).status, 'expired');
r = await ops(app, 'POST', '/internal/ops/commerce/credits', { groupId: who.group.id, kind: 'refund', amount: 5000, reason: '중복 입금' }); eq(r.status, 200); const b = await call(app, 'GET', '/api/commerce/billing'); eq(b.json.credits.length, 1);
r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/note`, { note: '재처리 예정' }); eq(r.json.order.note, '재처리 예정');
});
await test('downsize: 사용량 초과면 보류·정리 목록, 정리 후 재검증하면 축소 주문, 승인 시 권리 교체(즉시 회수 없음)', async () => {
const { app, store, who } = await setup(); const [basic, team] = cat.publishedProducts(store).sort((a, b) => a.price - b.price);
const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: team.id, acceptTerms: true })).json.order; await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`);
const s1 = mkSite(store, who.group.id, 'A'); const s2 = mkSite(store, who.group.id, 'B');
for (const s of [s1, s2]) { quota.commit(store, quota.reserve(store, { groupId: who.group.id, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s }).id); }
quota.setAllocation(store, { groupId: who.group.id, resource: 'web_disk', subjectType: 'site', subjectId: s1, amount: 8 * 1024 ** 3 });
let r = await call(app, 'POST', '/api/commerce/downsize/preview', { productVersionId: basic.id }); eq(r.json.shortages.map((s) => s.resource).sort(), ['sites', 'web_disk']); eq(r.json.shortages.find((s) => s.resource === 'sites').reduceBy, 1);
r = await call(app, 'POST', '/api/commerce/downsize/request', { productVersionId: basic.id, acceptTerms: true }); eq(r.json.fits, false); ok(r.json.hold); eq(r.json.hold.status, 'holding');
eq(quota.figures(store, who.group.id, 'sites').purchased, 3, '보류 중 권리 회수 없음');
r = await call(app, 'POST', '/api/commerce/downsize/revalidate'); eq(r.json.fits, false);
// 정리: 사이트 B 반환, 디스크 배정 축소
quota.finishReturn(store, quota.allocationOf(store, who.group.id, 'sites', 'site', s2).id); quota.setAllocation(store, { groupId: who.group.id, resource: 'web_disk', subjectType: 'site', subjectId: s1, amount: 4 * 1024 ** 3 });
r = await call(app, 'POST', '/api/commerce/downsize/revalidate'); eq(r.json.fits, true); eq(r.json.order.kind, 'downsize'); eq(r.json.order.status, 'awaiting_payment');
await ops(app, 'POST', `/internal/ops/commerce/orders/${r.json.order.id}/paid`, {}); const a = await ops(app, 'POST', `/internal/ops/commerce/orders/${r.json.order.id}/approve`); eq(a.status, 200);
eq(quota.figures(store, who.group.id, 'sites').purchased, 1); eq(quota.figures(store, who.group.id, 'sites').allocated, 1); eq(ord.activeSubscription(store, who.group.id).product_version_id, basic.id); eq(store.get("SELECT status FROM downsize_holds").status, 'applied');
});
await test('cancel/renewal: 해지 예약·철회·적용 시 권리 종료, 갱신 주문 생성·미납 제한 표시 일치', async () => {
const { app, store, who } = await setup(); const pv = cat.publishedProducts(store)[0];
const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true })).json.order; await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`);
let r = await call(app, 'GET', '/api/commerce/cancel/preview'); ok(r.json.effectiveAt); eq(r.json.retention.site, 30);
r = await call(app, 'POST', '/api/commerce/cancel'); eq(r.json.subscription.status, 'cancel_scheduled'); r = await call(app, 'POST', '/api/commerce/cancel/undo'); eq(r.json.subscription.status, 'active');
// 갱신: 청구일을 3일 뒤로 당기면 갱신 주문이 생긴다
const sub = ord.activeSubscription(store, who.group.id); store.run('UPDATE subscriptions SET next_billing_at=? WHERE id=?', new Date(Date.now() + 3 * 86400000).toISOString(), sub.id);
const pr = ord.processRenewals(store); eq(pr.created, [sub.id]); const s2 = ord.activeSubscription(store, who.group.id); eq(s2.status, 'awaiting_renewal_payment'); ok(s2.renewal_order_id); ok(s2.restrict_at > s2.payment_due_at);
const ro = ord.getOrder(store, s2.renewal_order_id); eq(ro.kind, 'renewal'); eq(ro.total, o.total); eq(ord.processRenewals(store).created.length, 0, '중복 생성 없음');
const g = await ops(app, 'GET', `/internal/ops/commerce/groups/${who.group.id}`); eq(g.json.billing.subscriptions[0].restrict_at, s2.restrict_at, '고객·운영자 동일 표시');
store.run('UPDATE subscriptions SET restrict_at=? WHERE id=?', new Date(0).toISOString(), sub.id); eq(ord.processRenewals(store).restricted, [sub.id]); eq(ord.activeSubscription(store, who.group.id).status, 'restricted');
await ops(app, 'POST', `/internal/ops/commerce/orders/${ro.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${ro.id}/approve`); const s3 = ord.activeSubscription(store, who.group.id); eq(s3.status, 'active'); ok(s3.next_billing_at > s2.next_billing_at); eq(quota.figures(store, who.group.id, 'sites').purchased, 1, '갱신은 권리 유지');
// 해지 적용
await call(app, 'POST', '/api/commerce/cancel'); store.run('UPDATE subscriptions SET cancel_at=? WHERE id=?', new Date(0).toISOString(), sub.id); ord.processRenewals(store); eq(quota.figures(store, who.group.id, 'sites').purchased, 0); eq(ord.listSubscriptions(store, who.group.id)[0].status, 'cancelled');
});
await test('overage: 상한·기간 필수, 상품에 없는 자원 거부, 철회', async () => {
const { app, store, who } = await setup(); const pv = cat.publishedProducts(store)[0]; const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true })).json.order; await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`);
const ps = new Date().toISOString(); const pe = new Date(Date.now() + 30 * 86400000).toISOString();
let r = await call(app, 'POST', '/api/commerce/overage', { resource: 'web_out', periodStart: ps, periodEnd: pe }); eq(r.json.error, 'cap_required');
r = await call(app, 'POST', '/api/commerce/overage', { resource: 'web_disk', capAmount: 1000, periodStart: ps, periodEnd: pe }); eq(r.json.error, 'overage_not_offered');
r = await call(app, 'POST', '/api/commerce/overage', { resource: 'web_out', capAmount: 5000, periodStart: ps, periodEnd: pe }); eq(r.status, 200); ok(ord.activeOverage(store, who.group.id, 'web_out'));
r = await call(app, 'POST', '/api/commerce/overage', { revoke: true, resource: 'web_out' }); eq(r.json.revoked, 1); eq(ord.activeOverage(store, who.group.id, 'web_out'), null);
});
await test('ops products: 새 버전 생성은 옛 버전을 바꾸지 않고, 공개하면 옛 버전 신규 판매 중단·기존 구독 유지; 설정 변경', async () => {
const { app, store, who } = await setup(); const basic = cat.publishedProducts(store).find((p) => p.product_id === 'basic');
const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: basic.id, acceptTerms: true })).json.order; await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`);
let r = await ops(app, 'POST', '/internal/ops/commerce/products', { productId: 'basic', baseVersionId: basic.id, patch: { price: 21000 } }); eq(r.status, 200); eq(r.json.product.version, 2); eq(r.json.product.published, false);
eq(cat.getProductVersion(store, basic.id).price, 19000, '옛 버전 가격 유지'); eq(cat.publishedProducts(store).find((p) => p.product_id === 'basic').version, 1);
r = await ops(app, 'POST', `/internal/ops/commerce/products/${r.json.product.id}/publish`, {}); eq(r.json.product.published, true); eq(cat.publishedProducts(store).find((p) => p.product_id === 'basic').version, 2); eq(cat.getProductVersion(store, basic.id).published, false);
eq(ord.activeSubscription(store, who.group.id).product_version_id, basic.id, '기존 고객은 옛 버전 유지');
r = await ops(app, 'POST', '/internal/ops/commerce/products', { productId: 'basic', patch: { price: 1, limits: { sites: 0 } } }); eq(r.status, 400); eq(r.json.error, 'product_invalid');
r = await ops(app, 'POST', '/internal/ops/commerce/settings', { domain_registration: { enabled: true, registrar: 'test-registrar' } }); eq(r.status, 200); eq((await call(app, 'GET', '/api/commerce/domain-registration')).json.enabled, true);
r = await ops(app, 'POST', '/internal/ops/commerce/settings', { 'payment_instructions.secret': 'x' }); eq(r.status, 200, '알 수 없는 키는 무시');
const lst = await ops(app, 'GET', '/internal/ops/commerce/products'); eq(lst.json.products.length, 3); ok(lst.json.products.find((p) => p.id === basic.id).subscribers === 1);
});
await test('ops auth: 서명 없는 호출 401, 보관소 비밀로 서명한 호출 허용, 시계 차이·다른 키 거부', async () => {
const deps = await makeDeps({ autoSeedCatalog: true }); const who = seedGroup(deps); const app = await appFor(deps, ['commerce'], who); deps.vault.set('service:hal', 'test-secret-value-0001');
let r = await invoke(app, 'GET', '/internal/ops/commerce/orders'); eq(r.status, 401);
const h = Object.fromEntries(Object.entries(signHeaders('hal', 'test-secret-value-0001', 'GET', '/internal/ops/commerce/orders', '')).map(([k, v]) => [k.toLowerCase(), v])); r = await invoke(app, 'GET', '/internal/ops/commerce/orders', { headers: h }); eq(r.status, 200);
const h2 = Object.fromEntries(Object.entries(signHeaders('portal', 'test-secret-value-0001', 'GET', '/internal/ops/commerce/orders', '')).map(([k, v]) => [k.toLowerCase(), v])); r = await invoke(app, 'GET', '/internal/ops/commerce/orders', { headers: h2 }); eq(r.status, 401);
const body = JSON.stringify({ ref: 'x' }); const h3 = Object.fromEntries(Object.entries(signHeaders('hal', 'test-secret-value-0001', 'POST', '/internal/ops/commerce/orders/nope/paid', body)).map(([k, v]) => [k.toLowerCase(), v])); r = await invoke(app, 'POST', '/internal/ops/commerce/orders/nope/paid', { headers: h3, body }); eq(r.status, 404, '서명 통과 뒤 본문 사용');
});
await test('pages: 상품·주문·결제·청구서 화면이 열리고 권한 없는 역할은 거부', async () => {
const { app, deps, who } = await setup(); for (const p of ['/products', '/billing', '/order/new']) { const r = await call(app, 'GET', p); eq(r.status, 200, p); ok(r.text.includes('<html')); }
const viewer = { ...who, membership: { ...who.membership, role: 'viewer' } }; const app2 = await appFor(deps, ['commerce'], viewer); const r = await call(app2, 'POST', '/api/commerce/orders', { productVersionId: 'x', acceptTerms: true }); eq(r.status, 403); eq(r.json.error, 'forbidden'); ok(r.json.detail.requiredRole);
});
done();
+78
View File
@@ -0,0 +1,78 @@
/** test/usage.test.mjs — 사용량 요약·배정 변경(동시성·사용량 미만·지연)·경고 엔진(구간별 1회·사건 묶기·100% 제한·초과 사용·KST 월 초기화)·운영자 API. */
import { test, eq, ok, done } from '../lib/test.mjs';
import { makeDeps, seedGroup, appFor, call } from './helpers.mjs';
import { invoke } from '../lib/http.mjs';
import * as quota from '../lib/quota.mjs';
import * as cat from '../portal/commerce/catalog.mjs';
import * as ord from '../portal/commerce/orders.mjs';
import { evaluate } from '../portal/usage/warnings.mjs';
import { isLimited, limitInfo, listLimits } from '../portal/usage/limits.mjs';
import { monthStart } from '../lib/kst.mjs';
import { newId } from '../lib/crypto.mjs';
const GB = 1024 ** 3;
async function setup() {
const deps = await makeDeps({ opsAuthBypass: true, autoSeedCatalog: true }); const who = seedGroup(deps); const app = await appFor(deps, ['commerce', 'usage'], who); const store = deps.store;
const pv = cat.publishedProducts(store).find((p) => p.product_id === 'basic');
const o = ord.createOrder(store, { groupId: who.group.id, userId: who.user.id, userVerified: true, productVersionId: pv.id, acceptTerms: true, items: [{ optionId: 'extra_disk', qty: 1 }] }).order; ord.markPaid(store, o.id, { by: 't' }); ord.approveOrder(store, o.id, { by: 't' });
const mk = (name) => { const id = newId('s_'); const now = new Date().toISOString(); store.run('INSERT INTO sites(id, group_id, name, created_at, updated_at) VALUES(?,?,?,?,?)', id, who.group.id, name, now, now); return id; };
return { deps, who, app, store, s1: mk('A'), s2: mk('B'), gid: who.group.id };
}
const usage = (store, gid, res, sid, used, extra = {}) => quota.recordUsage(store, { groupId: gid, resource: res, subjectType: 'site', subjectId: sid, used, measuredAt: new Date().toISOString(), ...extra });
await test('summary: 구매·배정·예약·사용·미배정과 상품정책·다음 KST 초기화·마지막 계측', async () => {
const { app, store, gid, s1 } = await setup(); quota.setAllocation(store, { groupId: gid, resource: 'web_disk', subjectType: 'site', subjectId: s1, amount: 4 * GB }); usage(store, gid, 'web_disk', s1, 1 * GB);
quota.reserve(store, { groupId: gid, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s1, reason: '최초 공개 배포' });
const r = await call(app, 'GET', '/api/usage'); eq(r.status, 200); const d = r.json; eq(d.product.name, '베이직'); eq(d.product.warnLevels, [70, 80, 90, 95]); ok(d.nextResetAt > new Date().toISOString());
const wd = d.resources.find((x) => x.resource === 'web_disk'); eq([wd.purchased, wd.allocated, wd.unallocated, wd.used], [10 * GB, 4 * GB, 6 * GB, 1 * GB]); eq(d.resources.find((x) => x.resource === 'sites').reserved, 1);
eq(d.allocations[0].subjectName, 'A'); eq(d.allocations[0].stateLabel, '사용 중'); eq(d.allocations[0].percent, 25); eq(d.reservations[0].stateLabel, '예약됨'); eq(d.stale, false); ok(d.lastMeasuredText.endsWith('KST'));
const pg = await call(app, 'GET', '/usage'); eq(pg.status, 200); ok(pg.text.includes('/ui/usage/usage.js'));
});
await test('allocate: 화면 수치(구매·내 배정·다른 서비스·예약·미배정·변경 후 최대), 마지막 단위 동시 요청 중 하나만 성공, 사용량 미만 거부, 월간 누적 미만 거부, 지연 시 증가 차단', async () => {
const { app, store, gid, s1, s2 } = await setup();
let r = await call(app, 'GET', `/api/usage/allocate/site:${s1}`); eq(r.status, 200); const wd = r.json.resources.find((x) => x.resource === 'web_disk'); eq([wd.purchased, wd.mine, wd.others, wd.unallocated, wd.max], [10 * GB, 0, 0, 10 * GB, 10 * GB]); eq(wd.mineState, '사용 가능');
r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_disk', amount: 6 * GB }); eq(r.status, 200); eq(r.json.after, 6 * GB);
r = await call(app, 'GET', `/api/usage/allocate/site:${s2}`); eq(r.json.resources.find((x) => x.resource === 'web_disk').others, 6 * GB);
// 남은 4GB 를 두 요청이 동시에 요청 → 하나만 성공
const [a, b] = await Promise.all([call(app, 'POST', `/api/usage/allocate/site:${s2}`, { resource: 'web_disk', amount: 4 * GB }), call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_disk', amount: 10 * GB })]);
eq([a.status, b.status].sort(), [200, 409]); const failed = a.status === 409 ? a : b; eq(failed.json.error, 'quota_insufficient'); ok(failed.json.detail.shortage > 0); eq(quota.figures(store, gid, 'web_disk').allocated, 10 * GB);
// 사용량 미만 축소 거부
usage(store, gid, 'web_disk', s1, 5 * GB); r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_disk', amount: 4 * GB }); eq(r.status, 409); eq(r.json.error, 'allocation_below_usage'); eq(r.json.detail.cleanupNeeded, 1 * GB);
// 월간 자원: 이번 달 누적보다 작게 불가
quota.setAllocation(store, { groupId: gid, resource: 'web_out', subjectType: 'site', subjectId: s1, amount: 20 * GB }); usage(store, gid, 'web_out', s1, 15 * GB, { periodStart: monthStart() }); r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_out', amount: 10 * GB }); eq(r.json.error, 'allocation_below_usage');
// 지연: 계측이 오래되면 증가 차단, 축소는 허용
store.run('UPDATE quota_usage SET measured_at=? WHERE subject_id=? AND resource=?', new Date(Date.now() - 3 * 3600000).toISOString(), s1, 'web_out'); r = await call(app, 'GET', `/api/usage/allocate/site:${s1}`); ok(r.json.resources.find((x) => x.resource === 'web_out').stale);
r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_out', amount: 30 * GB }); eq(r.json.error, 'usage_stale'); r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_out', amount: 16 * GB }); eq(r.status, 200);
// 다른 그룹 대상·잘못된 대상
r = await call(app, 'GET', '/api/usage/allocate/site:nope'); eq(r.status, 404); r = await call(app, 'GET', '/api/usage/allocate/bogus'); eq(r.status, 400);
// 역할: member 는 배정 변경 불가
const { deps, who } = await (async () => { const d2 = await makeDeps({ opsAuthBypass: true, autoSeedCatalog: true }); return { deps: d2, who: seedGroup(d2, { role: 'member' }) }; })(); const app2 = await appFor(deps, ['commerce', 'usage'], who); r = await call(app2, 'POST', '/api/usage/allocate/site:x', { resource: 'web_disk', amount: 1 }); eq(r.status, 403);
});
await test('warnings: 구간별 알림 1회·같은 구간은 묶음·상위 구간으로 올라가면 하위 종료·100% 제한 플래그·초과 사용 허용 시 제한 없음·내려가면 해제', async () => {
const { deps, store, gid, s1, who } = await setup(); quota.setAllocation(store, { groupId: gid, resource: 'web_disk', subjectType: 'site', subjectId: s1, amount: 10 * GB });
const notifs = () => store.all("SELECT title, state, count, incident_key, severity, mandatory FROM notifications WHERE category='usage' ORDER BY first_at");
eq(evaluate(deps, gid).evaluated, 0, '계측 전에는 평가하지 않음');
usage(store, gid, 'web_disk', s1, 7.5 * GB); let r = evaluate(deps, gid); eq(r.notified, [{ resource: 'web_disk', subjectId: s1, level: 70 }]); eq(notifs().length, 1); eq(notifs()[0].severity, 'info');
r = evaluate(deps, gid); eq(r.notified.length, 0, '같은 구간 재평가는 알림 없음'); eq(notifs().length, 1);
usage(store, gid, 'web_disk', s1, 8.5 * GB); r = evaluate(deps, gid); eq(r.notified[0].level, 80); eq(notifs().find((n) => n.incident_key.endsWith(':70')).state, 'resolved'); eq(notifs().find((n) => n.incident_key.endsWith(':80')).severity, 'warning');
usage(store, gid, 'web_disk', s1, 9.1 * GB); evaluate(deps, gid); const n90 = notifs().find((n) => n.incident_key.endsWith(':90')); eq(n90.mandatory, 1); ok(n90.title.includes('긴급'));
usage(store, gid, 'web_disk', s1, 9.6 * GB); evaluate(deps, gid); ok(notifs().find((n) => n.incident_key.endsWith(':95')).title.includes('곧 제한'));
usage(store, gid, 'web_disk', s1, 10 * GB); r = evaluate(deps, gid); eq(r.limited.length, 1); ok(isLimited(store, 'web_disk', 'site', s1)); const li = limitInfo(store, 'web_disk', 'site', s1); eq(li.level, 100); ok(li.semantics.blocked.includes('새 배포')); ok(notifs().find((n) => n.incident_key.endsWith(':100')).title.includes('제한 적용')); eq(store.get("SELECT COUNT(*) AS n FROM audit_log WHERE action='usage.limit'").n, 1);
eq(listLimits(store, gid).length, 1);
// 초과 사용 허용(월간 자원만 상품에 있음): web_out 100% 에서 제한 없음
quota.setAllocation(store, { groupId: gid, resource: 'web_out', subjectType: 'site', subjectId: s1, amount: 10 * GB }); ord.optInOverage(store, { groupId: gid, userId: who.user.id, resource: 'web_out', capAmount: 3000, periodStart: new Date(Date.now() - 1000).toISOString(), periodEnd: new Date(Date.now() + 86400000 * 20).toISOString() });
usage(store, gid, 'web_out', s1, 10 * GB, { periodStart: monthStart() }); r = evaluate(deps, gid); ok(!isLimited(store, 'web_out', 'site', s1)); ok(limitInfo(store, 'web_out', 'site', s1).overage); ok(notifs().find((n) => n.incident_key.includes('web_out') && n.incident_key.endsWith(':100')).title.includes('초과 사용'));
ord.revokeOverage(store, { groupId: gid, userId: who.user.id, resource: 'web_out' }); evaluate(deps, gid); ok(isLimited(store, 'web_out', 'site', s1), '철회 즉시 제한');
// 사용량이 내려가면 제한 해제·사건 종료
usage(store, gid, 'web_disk', s1, 5 * GB); r = evaluate(deps, gid); ok(!isLimited(store, 'web_disk', 'site', s1)); eq(notifs().filter((n) => n.incident_key.includes('web_disk') && n.state === 'open').length, 1); ok(notifs().find((n) => n.title.includes('제한 해제'))); eq(store.get("SELECT level FROM usage_limits WHERE resource='web_disk'").level, 0);
});
await test('warnings: 월간 자원은 KST 월이 바뀌면 사용량 0 으로(초기화), 운영자 API', async () => {
const { deps, app, store, gid, s1 } = await setup(); quota.setAllocation(store, { groupId: gid, resource: 'mail_msgs', subjectType: 'site', subjectId: s1, amount: 1000 });
const lastMonth = new Date(Date.parse(monthStart()) - 1000).toISOString();
usage(store, gid, 'mail_msgs', s1, 1000, { periodStart: monthStart(lastMonth) }); let r = evaluate(deps, gid); eq(r.limited.length, 0); eq(store.get("SELECT level FROM usage_limits WHERE resource='mail_msgs'").level, 0, '지난 달 계측은 0 으로');
usage(store, gid, 'mail_msgs', s1, 1000, { periodStart: monthStart() }); r = evaluate(deps, gid); eq(r.limited.length, 1); ok(limitInfo(store, 'mail_msgs', 'site', s1).semantics.blocked.includes('새 발신'));
r = await invoke(app, 'GET', '/internal/ops/usage/overview'); eq(r.status, 200); eq(r.json.groups.length, 1); eq(r.json.groups[0].limits, 1);
r = await invoke(app, 'GET', '/internal/ops/usage/limits'); eq(r.json.limits[0].resource, 'mail_msgs'); r = await invoke(app, 'POST', '/internal/ops/usage/evaluate'); eq(r.json.results.length, 1);
r = await call(app, 'POST', '/api/usage/evaluate'); eq(r.status, 200); r = await call(app, 'GET', '/api/usage/limits'); eq(r.json.limits.length, 1); ok(r.json.semantics.web_out);
});
done();