diff --git a/catalog/release-0.1.1.json b/catalog/release-0.1.1.json new file mode 100644 index 0000000..d3e6aae --- /dev/null +++ b/catalog/release-0.1.1.json @@ -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 + } +} diff --git a/lang/ko/commerce.json b/lang/ko/commerce.json new file mode 100644 index 0000000..6266f73 --- /dev/null +++ b/lang/ko/commerce.json @@ -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": "자동 초과 사용과 자동 추가 과금은 기본으로 꺼져 있습니다." + } +} diff --git a/lang/ko/usage.json b/lang/ko/usage.json new file mode 100644 index 0000000..00520d2 --- /dev/null +++ b/lang/ko/usage.json @@ -0,0 +1,9 @@ +{ + "usage": { + "allocState": { "available": "사용 가능", "reserved": "예약됨", "in_use": "사용 중", "returning": "반환 중", "returned": "반환됨" }, + "warn": { "70": "사전 안내", "80": "경고", "90": "긴급 경고", "95": "최종 경고", "100": "제한 적용" }, + "stale": "사용량 정보가 오래되어 배정을 늘릴 수 없습니다. 계측이 갱신된 뒤 다시 시도하세요.", + "belowUsage": "현재 사용량보다 작게 줄일 수 없습니다.", + "monthReset": "월간 항목은 다음 KST 월 시작에 초기화됩니다." + } +} diff --git a/portal/commerce/api.mjs b/portal/commerce/api.mjs new file mode 100644 index 0000000..eb8283a --- /dev/null +++ b/portal/commerce/api.mjs @@ -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: `
${deps.statePanel('loading')}
`, scripts: ['/ui/commerce/products.js'] }))); + app.get('/order/new', (ctx) => { requireGroup(ctx); return ctx.html(shell(ctx, { title: '주문', crumbs: [{ label: '상품', href: '/products' }, { label: '주문' }], body: `
${deps.statePanel('loading')}
`, 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: `
${deps.statePanel('loading')}
`, 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: `
${deps.statePanel('loading')}
`, 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(`${kind} ${esc(o.order_no)}

${kind}

주문 번호 ${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))}` : ''}

${o.items.map((i) => ``).join('')}
항목수량적용 대상단가세금합계
${esc(i.name)}${i.qty}${esc(i.target_type === 'site' ? '사이트 ' + (i.target_id || '') : '그룹')}${money(i.unit_price, o.currency)}${money(i.tax, o.currency)}${money(i.amount + i.tax, o.currency)}
합계(세금 포함)${money(o.total, o.currency)}
${o.status !== 'approved' ? `

입금 안내

${esc(pi.bank || '')} ${esc(pi.account || '')} (예금주 ${esc(pi.holder || '')})
기한 ${esc((o.due_at || '').slice(0, 10))} · ${esc(pi.note || '')}

` : ''}

상태: ${esc(ord.ORDER_LABEL[o.status] || o.status)}

`); }); + + // ---------- 고객 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) }; +} diff --git a/portal/commerce/catalog.mjs b/portal/commerce/catalog.mjs new file mode 100644 index 0000000..ebf8947 --- /dev/null +++ b/portal/commerce/catalog.mjs @@ -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 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'] }; +} diff --git a/portal/commerce/model.mjs b/portal/commerce/model.mjs new file mode 100644 index 0000000..ccc92a7 --- /dev/null +++ b/portal/commerce/model.mjs @@ -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); +` }]; diff --git a/portal/commerce/opsauth.mjs b/portal/commerce/opsauth.mjs new file mode 100644 index 0000000..12a882a --- /dev/null +++ b/portal/commerce/opsauth.mjs @@ -0,0 +1,20 @@ +/** + * portal/commerce/opsauth.mjs — 운영자 콘솔(HAL)에서 들어오는 내부 호출(/internal/ops/*) 인증. + * + * 운영자 계정·세션은 HAL 에 있고(§25), HAL 이 포털의 내부 API 를 서비스 키로 호출한다(lib/serviceauth). + * 비밀은 포털 보관소의 'service:' 에 있다. 허용 키 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 }; +} diff --git a/portal/commerce/orders.mjs b/portal/commerce/orders.mjs new file mode 100644 index 0000000..3c047e7 --- /dev/null +++ b/portal/commerce/orders.mjs @@ -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= 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 }; diff --git a/portal/commerce/public/billing.js b/portal/commerce/public/billing.js new file mode 100644 index 0000000..e5e6ae3 --- /dev/null +++ b/portal/commerce/public/billing.js @@ -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 = `

이번 달 예상

${money(d.monthEstimate)}

다음 청구일 ${d.nextBillingAt ? aw.fmt(d.nextBillingAt) : '없음'}

결제 대기

${money(d.awaitingTotal)}

${d.awaitingOrders.length}건

크레딧 잔액

${money(d.creditBalance)}

`; + h += `

활성 상품과 옵션

${d.subscriptions.length ? d.subscriptions.map((s) => `
상품
${esc(s.product.name)} v${s.product.version} ${s.status === 'active' ? '●' : '!'} ${esc(s.label)}
항목
${s.items.map((i) => esc(i.name) + '×' + i.qty).join(', ')}
청구
${money(s.total)} / ${s.billing_cycle === 'monthly' ? '월' : '년'} · 다음 ${s.next_billing_at ? aw.fmt(s.next_billing_at) : '-'}
${s.payment_due_at ? `
갱신 입금 기한
${aw.fmt(s.payment_due_at)} · 미납 제한 예정 ${aw.fmt(s.restrict_at)}
` : ''}${s.cancel_at ? `
해지 적용일
${aw.fmt(s.cancel_at)}
` : ''}
${s.status === 'cancel_scheduled' ? '' : ''}상품 변경·축소
`).join('') : '

활성 상품이 없습니다.

상품 보기'}
`; + if (d.hold) h += `

상품 축소 보류

새 한도 안에 들어오지 않는 항목이 있어 축소가 보류되었습니다. 아래 항목을 정리한 뒤 "다시 검증" 을 누르세요.

마지막 검증 ${aw.fmt(d.hold.last_checked_at)}

`; + h += `

활성화된 권리

${d.entitlements.filter((e) => e.purchased).map((e) => ``).join('') || ''}
자원구매배정사용
${esc(e.label)}${fmt(e.purchased, e.unit)}${fmt(e.allocated, e.unit)}${fmt(e.used, e.unit)}
없음
`; + h += `

주문

${d.orders.length ? `${d.orders.map((o) => ``).join('')}
번호종류금액상태일시
${esc(o.order_no)}${esc({ new: '신규', renewal: '갱신', downsize: '축소' }[o.kind] || o.kind)}${money(o.total)}${esc(o.statusLabel)}${aw.fmt(o.created_at)}
` : '

주문이 없습니다.

'}
`; + h += `

환불·크레딧

${d.credits.length ? `${d.credits.map((c) => ``).join('')}
종류금액사유적용일
${esc({ refund: '환불', credit: '크레딧', adjust: '조정' }[c.kind])}${money(c.amount)}${esc(c.reason)}${aw.fmt(c.applies_at)}
` : '

없음

'}
`; + h += `

AI Wallet

제작(AI) 기능 전용이며 호스팅 디스크·트래픽·메일 사용량과 분리되어 있습니다.

${d.wallet && d.wallet.balance !== undefined ? `

${esc(d.wallet.unit ? d.wallet.balanceText || d.wallet.balance : money(d.wallet.balance))}

${(d.wallet.ledger || []).slice(0, 10).map((l) => `

${aw.fmt(l.at)} ${esc(l.type)} ${esc(String(l.amount))} ${esc(l.memo || '')}${l.refunded ? ' (실패 환급)' : ''}

`).join('')}충전·이력` : `

${esc(d.wallet?.message || '')}

`}
`; + h += `

초과 사용 허용

자동 초과 사용과 자동 추가 과금은 기본으로 꺼져 있습니다. 허용하려면 항목별 최대 금액 또는 최대 사용량과 기간을 정해야 하며, 철회하면 즉시 100% 제한이 다시 적용됩니다.

${d.overage.filter((o) => !o.revoked_at).map((o) => `

${esc(o.resource)}: 상한 ${o.cap_amount ? money(o.cap_amount) : o.cap_units + ' 단위'} · ${o.period_start.slice(0, 10)}~${o.period_end.slice(0, 10)}

`).join('') || '

허용한 항목 없음

'}
~
`; + h += `

도메인 등록

${d.domainRegistration.enabled ? '

등록기관을 통해 도메인을 검색·등록할 수 있습니다.

' : `

${esc(d.domainRegistration.note)}

`}
`; + el.innerHTML = h; + el.querySelectorAll('[data-act]').forEach((b) => { b.onclick = async () => { const a = b.dataset.act; try { + if (a === 'cancel') { const p = await aw.api('GET', '/api/commerce/cancel/preview'); if (!(await aw.confirmDanger({ title: '상품 해지', target: '현재 그룹의 활성 상품', impacts: [`적용일: ${p.effectiveAt ? aw.fmt(p.effectiveAt) : '즉시'}`, ...p.effects, `보존: 사이트 ${p.retention?.site}일 · 메일 ${p.retention?.mail}일 · 소스 ${p.retention?.source}일`], typeToConfirm: '해지', confirmLabel: '해지 예약' }))) return; await aw.api('POST', '/api/commerce/cancel'); } + if (a === 'undo-cancel') await aw.api('POST', '/api/commerce/cancel/undo'); + if (a === 'revalidate') { const r = await aw.api('POST', '/api/commerce/downsize/revalidate'); aw.toast(r.fits ? '정리가 확인되어 축소 주문이 만들어졌습니다' : '아직 정리할 항목이 남아 있습니다'); } + if (a === 'cancel-hold') await aw.api('POST', '/api/commerce/downsize/cancel'); + if (a === 'revoke-overage') { if (!(await aw.confirmDanger({ title: '초과 사용 철회', target: b.dataset.res, impacts: ['철회 즉시 100% 제한이 다시 적용됩니다'] }))) return; await aw.api('POST', '/api/commerce/overage', { revoke: true, resource: b.dataset.res }); } + load(); } catch (e) { aw.toast(e.message); } }; }); + el.querySelector('#ov').onsubmit = async (ev) => { ev.preventDefault(); const f = new FormData(ev.target); try { await aw.api('POST', '/api/commerce/overage', { resource: f.get('resource'), capAmount: Number(f.get('capAmount')) || null, periodStart: new Date(f.get('periodStart')).toISOString(), periodEnd: new Date(f.get('periodEnd') + 'T23:59:59').toISOString() }); load(); } catch (e) { aw.toast(e.message); } }; + } catch (e) { aw.state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: load }); } + } + load(); +})(); diff --git a/portal/commerce/public/order-detail.js b/portal/commerce/public/order-detail.js new file mode 100644 index 0000000..6100c24 --- /dev/null +++ b/portal/commerce/public/order-detail.js @@ -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 = `
${kind === 'done' ? '✔' : kind === 'failed' ? '✘' : '◔'} ${esc(o.statusLabel)}마지막 갱신 ${aw.fmt(o.updated_at)}

${esc(o.nextStep)}

${o.remaining ? `

${esc(o.remaining)}

` : ''} +
주문 번호
${esc(o.order_no)}
종류
${esc({ new: '신규', renewal: '갱신', downsize: '축소' }[o.kind] || o.kind)}
금액
${money(o.amount, o.currency)} + 세금 ${money(o.tax, o.currency)} = ${money(o.total, o.currency)}
결제 방식
계좌 이체(수동 결제 확인)
${o.due_at ? `
입금 기한
${aw.fmt(o.due_at)}
` : ''}${o.paid_at ? `
입금 확인
${aw.fmt(o.paid_at)}
` : ''}${o.approved_at ? `
승인
${aw.fmt(o.approved_at)}
` : ''}
취소 가능 단계
${esc(o.lastCancellableStep)}
+ ${o.status === 'awaiting_payment' ? `

입금 안내

${esc(pi.bank || '')} ${esc(pi.account || '')} (예금주 ${esc(pi.holder || '')})

${esc(pi.note || '')}

` : ''} + ${o.items.map((i) => ``).join('')}
항목수량적용 대상단가세금합계
${esc(i.name)}${i.qty}${i.target_type === 'site' ? '사이트 ' + esc(i.target_id || '') : '사용자 그룹'}${money(i.unit_price, o.currency)}${money(i.tax, o.currency)}${money(i.amount + i.tax, o.currency)}
+ ${o.policyNotes ? `

입금 기한 ${o.policyNotes.paymentDeadlineDays}일 · 미납 ${o.policyNotes.restrictionAfterUnpaidDays}일 후 ${esc(o.policyNotes.restrictionEffect || '')}

` : ''} +
${o.status === 'approved' ? '영수증' : '청구서'} 보기${o.canCancel ? '' : ''}${['rejected', 'expired'].includes(o.status) ? '다시 주문문의' : ''}
`; + 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); +})(); diff --git a/portal/commerce/public/order.js b/portal/commerce/public/order.js new file mode 100644 index 0000000..7f553c6 --- /dev/null +++ b/portal/commerce/public/order.js @@ -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 = `

1. 상품과 옵션

+ ${pv.options.map((o) => { const it = sel.items.find((x) => x.optionId === o.id); return ``; }).join('')}
옵션가격수량적용 대상
${esc(o.name)}${money(o.price)}${o.per === 'site' ? ' / 사이트' : ''}${o.per === 'site' ? `` : '사용자 그룹'}
+

2. 가격과 청구 주기

${q.items.map((i) => ``).join('')}
${esc(i.name)} × ${i.qty}${money(i.amount)} + 세금 ${money(i.tax)}
합계(세금 포함)${money(q.total)} ${q.currency}

청구 주기: ${pv.billingCycle === 'monthly' ? '매월' : '매년'} · 갱신 전 청구 예정일과 금액을 알려 드립니다

+

3. 약관과 제한

${pv.deferred.length ? `

! 미확정 정책 ${pv.deferred.map((d) => esc(d.key)).join(', ')} — 확정 전에는 약속으로 보지 마세요

` : ''}

+

4. 결제 방식

${esc(pi.bank)} ${esc(pi.account)} (예금주 ${esc(pi.holder)}) · 입금 기한 ${pi.deadline_days}일 · ${esc(pi.note || '')}

운영자가 입금을 확인하고 승인한 뒤에만 사이트 수·용량·메일함·백업 권리를 쓸 수 있습니다.

+
취소

`; + 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(); +})(); diff --git a/portal/commerce/public/products.js b/portal/commerce/public/products.js new file mode 100644 index 0000000..68d95e9 --- /dev/null +++ b/portal/commerce/public/products.js @@ -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 = `

ⓘ 결제 방식: ${esc(d.paymentMethods.map((m) => m.label).join(', '))} — 자동 결제는 지원하지 않습니다.

` + `
${d.products.map((p) => `

${esc(p.name)} v${p.version}

${esc(p.audience)}

${esc(p.description || '')}

${money(p.price)}/${p.billingCycle === 'monthly' ? '월' : '년'} + 세금 ${money(p.tax)} = ${money(p.total)}

+
사이트 수
${p.limits.sites}개
웹 디스크
${gb(p.limits.web_disk)}
월간 웹 아웃바운드
${gb(p.limits.web_out)}
메일 저장 공간
${gb(p.limits.mail_storage)}
월간 메일 통수
${p.limits.mail_msgs.toLocaleString('ko-KR')}통(송신+수신)
메일함
${p.limits.mailboxes}개
+

추가 옵션

    ${p.options.map((o) => `
  • ${esc(o.name)} — ${money(o.price)}${o.per === 'site' ? ' (사이트당)' : ''}
  • `).join('')}
+

제공 범위

    ${p.includes.map((x) => `
  • ${esc(x)}
  • `).join('')}

제외

    ${p.excludes.map((x) => `
  • ${esc(x)}
  • `).join('')}
+ ${p.deferred.length ? `

미확정 정책

    ${p.deferred.map((x) => `
  • ! 미확정 정책 ${esc(x.key)} — ${esc(x.note)}
  • `).join('')}
` : ''} +

사용량 경고 ${p.warnLevels.join('·')}% · 승인 방식: 운영자 수동 승인 · 적용 ${esc(p.effectiveFrom.slice(0, 10))}

+
`).join('')}
` + + `

도메인 등록

${d.domainRegistration.enabled ? `

등록기관(${esc(d.domainRegistration.registrar || '')})을 통해 도메인을 검색·등록할 수 있습니다.

` : `

${esc(d.domainRegistration.note)}

`}
`; + } catch (e) { aw.state(el, 'failed', e.message, { retry: load }); } + } + load(); +})(); diff --git a/portal/commerce/seed.mjs b/portal/commerce/seed.mjs new file mode 100644 index 0000000..86bbd30 --- /dev/null +++ b/portal/commerce/seed.mjs @@ -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}`); diff --git a/portal/usage/api.mjs b/portal/usage/api.mjs new file mode 100644 index 0000000..c0ab986 --- /dev/null +++ b/portal/usage/api.mjs @@ -0,0 +1,97 @@ +/** + * portal/usage/api.mjs — 사용량·배정 화면과 API(§13), 운영자 내부 API. + * + * 화면 /usage 그룹 전체 사용량(구매 총량·배정·실제 사용·미배정·예약)과 서비스별 표, 경고·제한, 적용 상품정책 버전, 다음 KST 월 초기화, 마지막 계측 + * /usage/allocate/:subject 서비스(site: | mail_service:)별 보장량 배정 변경(§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: 또는 mail_service: 형식입니다'); + 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: `
${deps.statePanel('loading')}
`, 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: `
${deps.statePanel('loading')}
`, 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) }; +} diff --git a/portal/usage/limits.mjs b/portal/usage/limits.mjs new file mode 100644 index 0000000..892bcb7 --- /dev/null +++ b/portal/usage/limits.mjs @@ -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 })); } diff --git a/portal/usage/model.mjs b/portal/usage/model.mjs new file mode 100644 index 0000000..52ca926 --- /dev/null +++ b/portal/usage/model.mjs @@ -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) +); +` }]; diff --git a/portal/usage/public/allocate.js b/portal/usage/public/allocate.js new file mode 100644 index 0000000..1655317 --- /dev/null +++ b/portal/usage/public/allocate.js @@ -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 = `

${esc(d.subject.label)} · ${esc(d.subject.name)}

월간 항목은 ${esc(d.nextResetText)} 에 초기화됩니다. 실제 사용량보다 작게 줄일 수 없고, 월간 항목은 이번 달 누적보다 작게 줄일 수 없습니다.

` + + d.resources.map((r) => { const bytes = r.unit.startsWith('bytes'); const step = bytes ? GB : 1; return `

${esc(r.label)}${r.monthly ? ' (월간)' : ''}

+
구매 총량
${esc(r.texts.purchased)}
이 서비스 사용 중(배정)
${esc(r.texts.mine)} ○ ${esc(r.mineState)}
다른 서비스 배정
${esc(r.texts.others)}
진행 중 작업 예약
${esc(r.texts.reserved)}
미배정
${esc(r.texts.unallocated)}
실제 사용${r.monthly ? '(이번 달 누적)' : ''}
${esc(r.texts.used)}${r.measuredAt ? ` 계측 ${aw.fmt(r.measuredAt)}` : ''}${r.stale ? ' ! 정보 확인 지연 — 늘릴 수 없음' : ''}
${r.limit ? `
제한
${r.limit.limited ? '100% 제한 적용 중' : r.limit.level + '% 경고'}
` : ''}
+

허용 범위 ${fmt(r.floor, r.unit)} ~ ${fmt(r.max, r.unit)} (미배정 ${esc(r.texts.unallocated)} 안에서)

`; }).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(); +})(); diff --git a/portal/usage/public/usage.js b/portal/usage/public/usage.js new file mode 100644 index 0000000..479e207 --- /dev/null +++ b/portal/usage/public/usage.js @@ -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) => `
`; + 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 = `
적용 상품정책
${esc(d.product.name)} v${d.product.version} (경고 구간 ${d.product.warnLevels.join('·')}%)
다음 KST 월 초기화
${esc(d.nextResetText)}
마지막 계측
${esc(d.lastMeasuredText)}${d.stale ? ' ! 정보 확인 지연' : ''}
`; + if (d.limits.length) h += `

경고·제한

    ${d.limits.map((l) => `
  • ${l.limited ? '✘ 제한' : '! ' + l.level + '%'} ${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 ? ' (초과 사용 허용 중)' : ''}
  • `).join('')}
`; + h += `

그룹 전체(구매 총량 기준)

${d.resources.map((r) => ``).join('')}
자원구매 총량배정예약(진행 중 작업)미배정실제 사용사용률
${esc(r.label)}${r.monthly ? ' (월간)' : ''}${fmt(r.purchased, r.unit)}${fmt(r.allocated, r.unit)}${fmt(r.reserved, r.unit)}${fmt(r.unallocated, r.unit)}${fmt(r.used, r.unit)}${bar(r.usedPercent, lvlOf(r.usedPercent))} ${r.usedPercent}%
`; + h += `

서비스별 배정과 사용

${d.allocations.length ? `${d.allocations.map((a) => ``).join('')}
서비스자원배정상태사용계측
${esc(a.subjectLabel)} · ${esc(a.subjectName)}${esc(a.label)}${esc(a.amountText)}○ ${esc(a.stateLabel)}${esc(a.usedText)} ${a.percent != null ? bar(a.percent, lvlOf(a.percent)) + a.percent + '%' : ''}${a.measured_at ? aw.fmt(a.measured_at) : '계측 전'}${a.stale ? ' ! 지연' : ''}배정 변경
` : '

아직 배정된 서비스가 없습니다. 사이트나 메일 서비스를 만들면 여기에 표시됩니다.

'}
`; + if (d.reservations.length) h += `

진행 중 작업의 예약

${d.reservations.map((r) => ``).join('')}
자원양상태사유만료·반환 예정
${esc(r.label)}${r.amount}${esc(r.stateLabel)}${esc(r.reason || '')}${r.return_due_at ? aw.fmt(r.return_due_at) : r.expires_at ? aw.fmt(r.expires_at) : ''}
`; + 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(); +})(); diff --git a/portal/usage/warnings.mjs b/portal/usage/warnings.mjs new file mode 100644 index 0000000..0716156 --- /dev/null +++ b/portal/usage/warnings.mjs @@ -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; } diff --git a/test/commerce.test.mjs b/test/commerce.test.mjs new file mode 100644 index 0000000..5301f78 --- /dev/null +++ b/test/commerce.test.mjs @@ -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(' 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();