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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:26:35 +00:00
co-authored by Claude Fable 5.1
parent c90ac27596
commit c397452acd
21 changed files with 1147 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
/**
* portal/commerce/api.mjs — 상품·주문·결제·구독·축소·해지·AI Wallet·초과 사용·도메인 등록 안내 화면과 API(§6·§27.1), 운영자 내부 API.
*
* 고객 화면 /products /order/new /orders/:id /billing /billing/invoice/:orderId(인쇄용)
* 고객 API /api/commerce/products · /orders · /orders/:id · /orders/:id/cancel · /billing · /downsize/preview|request|revalidate|cancel · /cancel|/cancel/undo · /overage · /wallet · /domain-registration
* 운영자 API /internal/ops/commerce/orders(목록: 승인 대기 우선, 대상·금액·권리 변화·현재 사용량) · /orders/:id · /orders/:id/paid|approve|reject|note · /credits · /products(버전 목록·새 버전·공개) · /settings(결제 안내·도메인 등록)
* 결제 방식은 수동 결제 확인(계좌 이체)뿐이며 자동 결제수단은 화면에 보이지 않는다. 가격·한도는 모두 product_versions 에서 온다.
*/
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { HttpError, esc } from '../../lib/http.mjs';
import * as quota from '../../lib/quota.mjs';
import { requireUser, requireGroup, requireRole, requireBilling } from '../auth/guard.mjs';
import { requireOps } from './opsauth.mjs';
import * as cat from './catalog.mjs';
import * as ord from './orders.mjs';
import { audit } from '../../lib/audit.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const money = (n, c = 'KRW') => `${Math.round(n).toLocaleString('ko-KR')} ${c === 'KRW' ? '원' : c}`;
/** 주문 화면용 JSON(라벨·안내·다음 단계 포함) */
function orderView(store, o) {
const pv = cat.getProductVersion(store, o.product_version_id);
const next = { awaiting_payment: `안내된 계좌로 ${money(o.total, o.currency)} 을 입금하세요. 운영자가 입금을 확인한 뒤 승인하면 권리가 활성화됩니다.`, awaiting_approval: '입금이 확인되었습니다. 운영자 승인을 기다리는 중입니다.', approved: '권리가 활성화되었습니다. 사이트·메일 화면에서 바로 이용할 수 있습니다.', rejected: `거절 사유: ${o.rejected_reason || ''}. 다시 주문하거나 문의하세요.`, expired: '입금 기한이 지나 주문이 만료되었습니다. 다시 주문하거나 문의하세요.', cancelled: '취소된 주문입니다.' }[o.status];
return { ...o, statusLabel: ord.ORDER_LABEL[o.status] || o.status, product: pv ? { id: pv.id, name: pv.name, version: pv.version, billingCycle: pv.billing_cycle } : null, nextStep: next, canCancel: ['awaiting_payment', 'awaiting_approval'].includes(o.status), lastCancellableStep: '운영자 승인 전까지', remaining: o.status === 'rejected' || o.status === 'expired' || o.status === 'cancelled' ? '권리 변화 없음 — 기존 서비스와 데이터는 그대로입니다' : null, policyNotes: pv ? { paymentDeadlineDays: pv.policy.payment_deadline_days, restrictionAfterUnpaidDays: pv.policy.restriction_after_unpaid_days, restrictionEffect: pv.policy.restriction_effect } : null };
}
export async function register(app, deps) {
const { store, page } = deps;
app.static('/ui/commerce', join(HERE, 'public'));
// 검증 환경 시드: 상품이 하나도 없고 자동 시드가 허용되면 넣는다(운영에서는 운영자가 화면에서 관리)
if ((deps.autoSeedCatalog ?? process.env.ARIWEB_AUTOSEED_CATALOG === '1') && !store.get('SELECT id FROM product_versions LIMIT 1')) { const r = cat.seedCatalog(store); deps.log?.(`[commerce] catalog 시드: 추가 ${r.inserted}${r.problems.length ? ' 문제 ' + r.problems.join('; ') : ''}`); }
const shell = (ctx, o) => page({ user: ctx.user, group: ctx.group, groups: ctx.groups || [ctx.group].filter(Boolean), csrf: ctx.session?.csrf, unread: ctx.unread, nav: 'billing', ...o });
// ---------- 화면 ----------
app.get('/products', (ctx) => ctx.html(shell(ctx, { title: '상품', crumbs: [{ label: '상품 및 결제', href: '/billing' }, { label: '상품' }], body: `<div id="products">${deps.statePanel('loading')}</div>`, scripts: ['/ui/commerce/products.js'] })));
app.get('/order/new', (ctx) => { requireGroup(ctx); return ctx.html(shell(ctx, { title: '주문', crumbs: [{ label: '상품', href: '/products' }, { label: '주문' }], body: `<div id="order-new" data-product="${esc(ctx.query.product || '')}" data-option="${esc(ctx.query.option || '')}" data-target="${esc(ctx.query.target || '')}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/commerce/order.js'] })); });
app.get('/orders/:id', (ctx) => { requireGroup(ctx); const o = ord.getOrder(store, ctx.params.id, ctx.group.id); if (!o) return ctx.html(shell(ctx, { title: '주문', body: deps.statePanel('empty', '주문을 찾을 수 없습니다', { href: '/billing' }) }), 404); return ctx.html(shell(ctx, { title: `주문 ${o.order_no}`, crumbs: [{ label: '상품 및 결제', href: '/billing' }, { label: o.order_no }], body: `<div id="order" data-id="${esc(o.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/commerce/order-detail.js'] })); });
app.get('/billing', (ctx) => { requireGroup(ctx); return ctx.html(shell(ctx, { title: '상품 및 결제', crumbs: [{ label: '상품 및 결제' }], actions: [{ label: '상품 보기', href: '/products' }], body: `<div id="billing">${deps.statePanel('loading')}</div>`, scripts: ['/ui/commerce/billing.js'] })); });
app.get('/billing/invoice/:orderId', (ctx) => { requireGroup(ctx); const o = ord.getOrder(store, ctx.params.orderId, ctx.group.id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); const pi = o.payment_instructions || {}; const kind = o.status === 'approved' ? '영수증' : '청구서';
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>${kind} ${esc(o.order_no)}</title><style>body{font-family:sans-serif;max-width:720px;margin:24px auto;padding:0 16px}table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid #ddd;padding:8px;text-align:left}.r{text-align:right}@media print{button{display:none}}</style></head><body><h1>${kind}</h1><p>주문 번호 ${esc(o.order_no)} · ${esc(ctx.group.name)} · 발행 ${esc(o.created_at.slice(0, 10))}${o.approved_at ? ` · 승인 ${esc(o.approved_at.slice(0, 10))}` : ''}</p><table><thead><tr><th>항목</th><th>수량</th><th>적용 대상</th><th class="r">단가</th><th class="r">세금</th><th class="r">합계</th></tr></thead><tbody>${o.items.map((i) => `<tr><td>${esc(i.name)}</td><td>${i.qty}</td><td>${esc(i.target_type === 'site' ? '사이트 ' + (i.target_id || '') : '그룹')}</td><td class="r">${money(i.unit_price, o.currency)}</td><td class="r">${money(i.tax, o.currency)}</td><td class="r">${money(i.amount + i.tax, o.currency)}</td></tr>`).join('')}</tbody><tfoot><tr><th colspan="5" class="r">합계(세금 포함)</th><th class="r">${money(o.total, o.currency)}</th></tr></tfoot></table>${o.status !== 'approved' ? `<h2>입금 안내</h2><p>${esc(pi.bank || '')} ${esc(pi.account || '')} (예금주 ${esc(pi.holder || '')})<br>기한 ${esc((o.due_at || '').slice(0, 10))} · ${esc(pi.note || '')}</p>` : ''}<p>상태: ${esc(ord.ORDER_LABEL[o.status] || o.status)}</p><button onclick="print()">인쇄</button></body></html>`); });
// ---------- 고객 API ----------
app.get('/api/commerce/products', (ctx) => ({ products: cat.publishedProducts(store).map(cat.publicProduct), paymentMethods: [{ id: 'manual_transfer', label: '계좌 이체(수동 결제 확인)' }], domainRegistration: ord.domainRegistration(store), sites: ctx.group ? store.all('SELECT id, name, lifecycle, deploy_mode FROM sites WHERE group_id=? AND lifecycle<>\'deleted\' ORDER BY name', ctx.group.id) : [] }));
app.post('/api/commerce/orders', async (ctx) => { requireBilling(ctx); const b = await ctx.body(); const r = ord.createOrder(store, { groupId: ctx.group.id, userId: ctx.user.id, userVerified: !!ctx.user.email_verified_at, productVersionId: b.productVersionId, includeBase: b.includeBase !== false, items: b.items || [], paymentMethod: b.paymentMethod || 'manual_transfer', acceptTerms: !!b.acceptTerms, idempotencyKey: b.idempotencyKey ? `${ctx.group.id}:${String(b.idempotencyKey).slice(0, 80)}` : null }); return { ok: true, created: r.created, order: orderView(store, r.order) }; });
app.post('/api/commerce/quote', async (ctx) => { requireGroup(ctx); const b = await ctx.body(); const pv = cat.getProductVersion(store, b.productVersionId); if (!pv) throw new HttpError(404, 'product_not_found', '상품이 없습니다'); return { quote: ord.priceItems(pv, { includeBase: b.includeBase !== false, items: b.items || [] }), paymentInstructions: ord.paymentInstructions(store), terms: { version: 'v0.1.1', notes: [...pv.excludes, '자동 결제는 지원하지 않습니다', `입금 기한 ${pv.policy.payment_deadline_days || 7}일`, `미납 ${pv.policy.restriction_after_unpaid_days || 14}일 후 신규 변경 제한(데이터 삭제 없음)`] } }; });
app.get('/api/commerce/orders', (ctx) => { requireGroup(ctx); return { orders: ord.listOrders(store, { groupId: ctx.group.id }).map((o) => orderView(store, o)) }; });
app.get('/api/commerce/orders/:id', (ctx) => { requireGroup(ctx); const o = ord.getOrder(store, ctx.params.id, ctx.group.id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); return { order: orderView(store, o) }; });
app.post('/api/commerce/orders/:id/cancel', (ctx) => { requireBilling(ctx); return { ok: true, order: orderView(store, ord.cancelOrder(store, ctx.params.id, ctx.group.id, ctx.user.id)) }; });
app.get('/api/commerce/billing', async (ctx) => { requireGroup(ctx); const s = ord.billingSummary(store, ctx.group.id); let wallet = null; try { wallet = deps.webmaker?.credits ? await deps.webmaker.credits(ctx.user.id, ctx.group.id) : null; } catch (e) { wallet = { error: 'unavailable', message: 'AI Wallet 정보를 지금 확인할 수 없습니다(정보 확인 지연)' }; }
return { ...s, orders: ord.listOrders(store, { groupId: ctx.group.id }).map((o) => orderView(store, o)), hold: ord.openHold(store, ctx.group.id), overage: ord.listOverage(store, ctx.group.id), wallet: wallet || { available: false, message: 'AI Wallet 은 Webmaker 제작 화면에서 확인·충전합니다(호스팅 사용량과 별도)' }, domainRegistration: ord.domainRegistration(store), entitlements: quota.summary(store, ctx.group.id) }; });
app.post('/api/commerce/downsize/preview', async (ctx) => { requireBilling(ctx); const b = await ctx.body(); const pv = cat.getProductVersion(store, b.productVersionId); if (!pv) throw new HttpError(404, 'product_not_found', '상품이 없습니다'); const priced = ord.priceItems(pv, { includeBase: true, items: b.items || [] }); return { ...ord.evaluateDownsize(store, ctx.group.id, pv, priced.items), quote: priced }; });
app.post('/api/commerce/downsize/request', async (ctx) => { requireBilling(ctx); const b = await ctx.body(); const r = ord.requestDownsize(store, { groupId: ctx.group.id, userId: ctx.user.id, userVerified: !!ctx.user.email_verified_at, productVersionId: b.productVersionId, items: b.items || [], acceptTerms: !!b.acceptTerms }); return { ok: true, fits: r.fits, shortages: r.shortages, order: r.order ? orderView(store, r.order) : null, hold: r.hold || null }; });
app.post('/api/commerce/downsize/revalidate', (ctx) => { requireBilling(ctx); const r = ord.revalidateHold(store, { groupId: ctx.group.id, userId: ctx.user.id, userVerified: !!ctx.user.email_verified_at }); return { ok: true, fits: r.fits, shortages: r.shortages, order: r.order ? orderView(store, r.order) : null, hold: r.hold || null }; });
app.post('/api/commerce/downsize/cancel', (ctx) => { requireBilling(ctx); return { ok: true, cancelled: ord.cancelHold(store, ctx.group.id) }; });
app.post('/api/commerce/cancel', (ctx) => { requireBilling(ctx); return { ok: true, subscription: ord.scheduleCancel(store, { groupId: ctx.group.id, userId: ctx.user.id }) }; });
app.post('/api/commerce/cancel/undo', (ctx) => { requireBilling(ctx); return { ok: true, subscription: ord.undoCancel(store, { groupId: ctx.group.id, userId: ctx.user.id }) }; });
app.get('/api/commerce/cancel/preview', (ctx) => { requireGroup(ctx); const s = ord.activeSubscription(store, ctx.group.id); const pv = s ? cat.getProductVersion(store, s.product_version_id) : null; return { subscription: s, effectiveAt: s?.next_billing_at || null, retention: pv?.policy?.retention_days || null, lifecycle: pv?.policy?.lifecycle || null, usage: quota.summary(store, ctx.group.id), effects: ['적용일까지 서비스·권리 유지', '적용 후 공개 서비스 중지와 권리 종료', '보존 기간 동안 읽기·내보내기 제공', '보존 종료 후 삭제(복구 불가)'] }; });
app.post('/api/commerce/overage', async (ctx) => { requireBilling(ctx); const b = await ctx.body(); if (b.revoke) return { ok: true, revoked: ord.revokeOverage(store, { groupId: ctx.group.id, userId: ctx.user.id, resource: b.resource }), effect: '철회 즉시 100% 제한이 다시 적용됩니다' }; return { ok: true, optin: ord.optInOverage(store, { groupId: ctx.group.id, userId: ctx.user.id, resource: b.resource, capAmount: b.capAmount, capUnits: b.capUnits, periodStart: b.periodStart, periodEnd: b.periodEnd }) }; });
app.get('/api/commerce/domain-registration', (ctx) => ord.domainRegistration(store));
// ---------- 운영자 내부 API ----------
const opsOrder = (o) => { const g = store.get('SELECT id, name, kind, contact_email FROM groups WHERE id=?', o.group_id); const pv = cat.getProductVersion(store, o.product_version_id); return { ...orderView(store, o), group: g, product: pv ? { id: pv.id, name: pv.name, version: pv.version } : null, entitlementDelta: o.items.flatMap((i) => Object.entries(i.grants || {}).map(([r, a]) => ({ resource: r, label: quota.RESOURCES[r]?.label, amount: a, target: i.target_type === 'site' ? i.target_id : 'group' }))), currentUsage: quota.summary(store, o.group_id).filter((f) => f.purchased || f.used) }; };
app.get('/internal/ops/commerce/orders', async (ctx) => { await requireOps(ctx, deps); const status = ctx.query.status ? ctx.query.status.split(',') : null; const list = ord.listOrders(store, { status, limit: 500 }); const rank = { awaiting_approval: 0, awaiting_payment: 1 }; list.sort((a, b) => (rank[a.status] ?? 9) - (rank[b.status] ?? 9) || b.created_at.localeCompare(a.created_at)); return { orders: list.map(opsOrder) }; });
app.get('/internal/ops/commerce/orders/:id', async (ctx) => { await requireOps(ctx, deps); const o = ord.getOrder(store, ctx.params.id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); return { order: opsOrder(o) }; });
app.post('/internal/ops/commerce/orders/:id/paid', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); return { ok: true, order: opsOrder(ord.markPaid(store, ctx.params.id, { by: actor, ref: b.ref || '' })) }; });
app.post('/internal/ops/commerce/orders/:id/approve', async (ctx) => { const { actor } = await requireOps(ctx, deps); const r = ord.approveOrder(store, ctx.params.id, { by: actor }); return { ok: true, already: r.already, granted: r.granted.length, order: opsOrder(r.order) }; });
app.post('/internal/ops/commerce/orders/:id/reject', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); return { ok: true, order: opsOrder(ord.rejectOrder(store, ctx.params.id, { by: actor, reason: b.reason })) }; });
app.post('/internal/ops/commerce/orders/:id/note', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); return { ok: true, order: opsOrder(ord.noteOrder(store, ctx.params.id, { by: actor, note: b.note })) }; });
app.post('/internal/ops/commerce/credits', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); return { ok: true, credit: ord.addCredit(store, { groupId: b.groupId, kind: b.kind, amount: b.amount, orderId: b.orderId, reason: b.reason, by: actor, appliesAt: b.appliesAt }) }; });
app.get('/internal/ops/commerce/groups/:groupId', async (ctx) => { await requireOps(ctx, deps); const gid = ctx.params.groupId; return { group: store.get('SELECT id, name, kind, status FROM groups WHERE id=?', gid), billing: ord.billingSummary(store, gid), orders: ord.listOrders(store, { groupId: gid }).map((o) => orderView(store, o)), hold: ord.openHold(store, gid), overage: ord.listOverage(store, gid), entitlements: quota.summary(store, gid) }; });
app.post('/internal/ops/commerce/subscriptions/:id/cancel-hold', async (ctx) => { const { actor } = await requireOps(ctx, deps); const s = store.get('SELECT * FROM subscriptions WHERE id=?', ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '구독이 없습니다'); const b = await ctx.body(); store.run('UPDATE subscriptions SET status=?, updated_at=? WHERE id=?', b.release ? 'active' : 'cancel_scheduled', new Date().toISOString(), s.id); audit(store, { actorType: 'operator', actorId: actor, groupId: s.group_id, subjectType: 'subscription', subjectId: s.id, action: b.release ? 'cancel.release_hold' : 'cancel.hold' }); return { ok: true }; });
app.post('/internal/ops/commerce/renewals/run', async (ctx) => { await requireOps(ctx, deps); return { ok: true, ...ord.processRenewals(store), expired: ord.expireOrders(store).length }; });
app.get('/internal/ops/commerce/products', async (ctx) => { await requireOps(ctx, deps); return { products: cat.allProductVersions(store).map((p) => ({ ...p, deferred: cat.deferredPolicies(p), subscribers: store.get('SELECT COUNT(*) AS n FROM subscriptions WHERE product_version_id=? AND status<>\'cancelled\'', p.id).n })) }; });
app.post('/internal/ops/commerce/products', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); if (!b.productId) throw new HttpError(400, 'product_id_required', 'productId 가 필요합니다'); return { ok: true, product: cat.createProductVersion(store, { productId: b.productId, base: b.baseVersionId || null, patch: b.patch || {}, by: actor }) }; });
app.post('/internal/ops/commerce/products/:id/publish', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); const p = cat.publishProductVersion(store, ctx.params.id, { published: b.published !== false, effectiveTo: b.effectiveTo }); audit(store, { actorType: 'operator', actorId: actor, subjectType: 'product_version', subjectId: p.id, action: b.published === false ? 'product.unpublish' : 'product.publish' }); return { ok: true, product: p }; });
app.post('/internal/ops/commerce/catalog/seed', async (ctx) => { await requireOps(ctx, deps); return { ok: true, ...cat.seedCatalog(store, cat.loadCatalogFile(), 'ops') }; });
app.get('/internal/ops/commerce/settings', async (ctx) => { await requireOps(ctx, deps); return { paymentInstructions: ord.paymentInstructions(store), domainRegistration: ord.domainRegistration(store), staleMinutes: cat.getSetting(store, 'usage.stale_minutes', 30) }; });
app.post('/internal/ops/commerce/settings', async (ctx) => { const { actor } = await requireOps(ctx, deps); const b = await ctx.body(); for (const k of ['payment_instructions', 'domain_registration', 'usage.stale_minutes']) if (b[k] !== undefined) cat.setSetting(store, k, b[k], actor); audit(store, { actorType: 'operator', actorId: actor, subjectType: 'settings', subjectId: 'commerce', action: 'settings.update', detail: Object.keys(b) }); return { ok: true }; });
// 주기 작업(스케줄러가 있으면): 주문 만료·갱신·해지 적용
if (deps.scheduler?.every) deps.scheduler.every('commerce.maintain', 10 * 60 * 1000, () => { ord.expireOrders(store); ord.processRenewals(store); });
deps.commerce = { expireOrders: () => ord.expireOrders(store), processRenewals: () => ord.processRenewals(store), appliedProduct: (gid) => ord.appliedProduct(store, gid), activeSubscription: (gid) => ord.activeSubscription(store, gid), seedCatalog: () => cat.seedCatalog(store) };
}
+111
View File
@@ -0,0 +1,111 @@
/**
* portal/commerce/catalog.mjs — 상품정책(ProductVersion) 조회·관리·시드(§6.1·§27.2).
*
* 원칙
* - 가격·한도는 코드에 고정하지 않고 product_versions 표에서 읽는다. 고객 화면은 "공개(published)·지금 유효(effective_from ≤ now < effective_to)" 인 버전만 본다.
* - 이미 판매된 조건은 바꾸지 않는다: 운영자가 상품을 고치면 같은 product_id 의 다음 version 행이 생기고, 기존 구독·권리는 옛 버전 id 를 계속 참조한다.
* - 미확정 정책은 policy_json 의 { value:null, status:'deferred', note } 로 표현하고 화면은 숫자 대신 "미확정 정책" 과 note 를 보인다(확정된 약속처럼 보이지 않게).
* 시드: catalog/release-0.1.1.json(출시 검증 환경용). seedCatalog() 는 같은 (product_id, version) 이 있으면 건드리지 않는다.
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { newId } from '../../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
import { RESOURCES } from '../../lib/quota.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const CATALOG_FILE = join(ROOT, 'catalog', 'release-0.1.1.json');
/** 시드 파일에서 비어 있으면 안 되는 상품 항목(출시 검증 조건) */
export const REQUIRED_PRODUCT_FIELDS = ['product_id', 'version', 'name', 'audience', 'billing_cycle', 'price', 'currency', 'tax_rate', 'limits', 'options', 'includes', 'excludes', 'policy', 'warn_levels', 'effective_from'];
export const REQUIRED_LIMITS = ['sites', 'web_disk', 'web_out', 'mail_storage', 'mail_msgs', 'mailboxes'];
export const REQUIRED_OPTIONS = ['standalone_mail', 'source', 'backup'];
export const REQUIRED_POLICY = ['approval', 'payment_deadline_days', 'renewal_notice_days', 'restriction_after_unpaid_days', 'retention_days', 'lifecycle', 'relocation'];
export const REQUIRED_BACKUP_POLICY = ['schedule', 'schedule_time_kst', 'retention', 'storage_bytes', 'first_backup_within_hours', 'remote_copy_verification', 'restore_cost', 'expired_backup_retention_days'];
/** 시드 파일 검증. 문제 목록(비어 있으면 통과). */
export function validateCatalog(doc) {
const problems = [];
const empty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0) || (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0);
if (!doc || !Array.isArray(doc.products) || !doc.products.length) return ['products 가 비어 있습니다'];
if (!doc.payment_instructions || ['bank', 'account', 'holder', 'deadline_days'].some((k) => empty(doc.payment_instructions[k]))) problems.push('payment_instructions(은행·계좌·예금주·기한) 가 비어 있습니다');
for (const p of doc.products) {
const tag = `${p.product_id || '?'}@${p.version || '?'}`;
for (const f of REQUIRED_PRODUCT_FIELDS) if (empty(p[f])) problems.push(`${tag}: ${f} 비어 있음`);
for (const r of REQUIRED_LIMITS) if (!(Number(p.limits?.[r]) > 0)) problems.push(`${tag}: limits.${r} 가 0 이거나 없음`);
for (const o of REQUIRED_OPTIONS) if (!(p.options || []).some((x) => x.id === o)) problems.push(`${tag}: 옵션 ${o} 없음`);
for (const o of p.options || []) { if (empty(o.id) || empty(o.name) || !(Number(o.price) >= 0) || !o.grants) problems.push(`${tag}: 옵션 ${o.id || '?'} 의 id/name/price/grants 확인`); for (const r of Object.keys(o.grants || {})) if (!RESOURCES[r]) problems.push(`${tag}: 옵션 ${o.id} 의 자원 ${r} 알 수 없음`); }
const bk = (p.options || []).find((x) => x.id === 'backup'); if (bk) for (const k of REQUIRED_BACKUP_POLICY) if (empty(bk.policy?.[k])) problems.push(`${tag}: 백업 옵션 policy.${k} 비어 있음`);
for (const k of REQUIRED_POLICY) if (empty(p.policy?.[k])) problems.push(`${tag}: policy.${k} 비어 있음`);
if (!Array.isArray(p.warn_levels) || p.warn_levels.length < 1 || p.warn_levels.some((n) => !(n > 0 && n < 100))) problems.push(`${tag}: warn_levels 는 0~100 사이 값 목록이어야 함`);
if (!['monthly', 'yearly'].includes(p.billing_cycle)) problems.push(`${tag}: billing_cycle 은 monthly|yearly`);
if (p.policy?.approval && p.policy.approval !== 'manual') problems.push(`${tag}: 0.1.1 은 수동 승인(manual)만 지원`);
}
return problems;
}
export function loadCatalogFile(file = CATALOG_FILE) { return JSON.parse(readFileSync(file, 'utf8')); }
/**
* 시드 적용. 반환 { inserted, skipped, problems }. problems 가 있으면 아무것도 넣지 않는다.
* settings 항목(결제 안내·도메인 등록 가능 여부·사용량 지연 기준)도 없을 때만 넣는다.
*/
export function seedCatalog(store, doc = loadCatalogFile(), by = 'seed') {
const problems = validateCatalog(doc); if (problems.length) return { inserted: 0, skipped: 0, problems };
let inserted = 0; let skipped = 0; const now = nowIso();
store.tx(() => {
for (const p of doc.products) {
if (store.get('SELECT id FROM product_versions WHERE product_id=? AND version=?', p.product_id, p.version)) { skipped++; continue; }
store.run('INSERT INTO product_versions(id, product_id, version, name, description, audience, kind, billing_cycle, price, currency, tax_rate, limits_json, options_json, includes_json, excludes_json, policy_json, warn_levels_json, approval, effective_from, effective_to, published, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
newId('pv_'), p.product_id, p.version, p.name, p.description || null, p.audience, p.kind || 'base', p.billing_cycle, Math.round(p.price), p.currency, Number(p.tax_rate), toJson(p.limits), toJson(p.options || []), toJson(p.includes || []), toJson(p.excludes || []), toJson(p.policy || {}), toJson(p.warn_levels), p.policy?.approval || 'manual', p.effective_from, p.effective_to || null, p.published ? 1 : 0, by, now);
inserted++;
}
if (!store.get('SELECT key FROM settings WHERE key=?', 'payment_instructions')) store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?)', 'payment_instructions', toJson(doc.payment_instructions), now, by);
for (const [k, v] of Object.entries(doc.settings || {})) if (!store.get('SELECT key FROM settings WHERE key=?', k)) store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?)', k, toJson(v), now, by);
});
return { inserted, skipped, problems: [] };
}
/** settings 읽기/쓰기(운영자 관리 값. 비밀값 금지는 호출자가 보장) */
export function getSetting(store, key, fallback = null) { const r = store.get('SELECT value_json FROM settings WHERE key=?', key); return r ? parseJson(r.value_json, fallback) : fallback; }
export function setSetting(store, key, value, by = 'ops') { if (/key|secret|token|pass/i.test(key)) throw new Error('비밀값은 settings 에 둘 수 없습니다'); store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=excluded.updated_at, updated_by=excluded.updated_by', key, toJson(value), nowIso(), by); }
/** 행 → 객체(JSON 열 해석) */
export function rowToProduct(r) { if (!r) return null; return { ...r, limits: parseJson(r.limits_json, {}), options: parseJson(r.options_json, []), includes: parseJson(r.includes_json, []), excludes: parseJson(r.excludes_json, []), policy: parseJson(r.policy_json, {}), warn_levels: parseJson(r.warn_levels_json, [70, 80, 90, 95]), published: !!r.published }; }
export function getProductVersion(store, id) { return rowToProduct(store.get('SELECT * FROM product_versions WHERE id=?', id)); }
/** 고객용: 공개·유효한 최신 버전(product_id 마다 하나) */
export function publishedProducts(store, at = nowIso()) {
const rows = store.all('SELECT * FROM product_versions WHERE published=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>?) ORDER BY product_id, version DESC', at, at);
const seen = new Set(); return rows.filter((r) => (seen.has(r.product_id) ? false : (seen.add(r.product_id), true))).map(rowToProduct);
}
/** 운영자용: 모든 버전 */
export function allProductVersions(store) { return store.all('SELECT * FROM product_versions ORDER BY product_id, version DESC').map(rowToProduct); }
/**
* 운영자: 새 버전 만들기(기존 버전을 바꾸지 않는다). base 가 있으면 그 버전을 복사한 뒤 patch 를 덮는다.
* @returns 새 버전 객체
*/
export function createProductVersion(store, { productId, base = null, patch = {}, by = 'ops' }) {
const prev = base ? getProductVersion(store, base) : null;
const maxV = store.get('SELECT COALESCE(MAX(version),0) AS v FROM product_versions WHERE product_id=?', productId).v;
const p = { ...(prev || {}), ...patch, product_id: productId, version: maxV + 1 };
const problems = validateCatalog({ payment_instructions: { bank: 'x', account: 'x', holder: 'x', deadline_days: 1 }, products: [{ ...p, options: p.options || [], includes: p.includes || [], excludes: p.excludes || [], policy: p.policy || {}, warn_levels: p.warn_levels || [70, 80, 90, 95], effective_from: p.effective_from || nowIso() }] });
if (problems.length) { const e = new Error('상품 버전 검증 실패: ' + problems.join('; ')); e.status = 400; e.code = 'product_invalid'; e.detail = { problems }; throw e; }
const id = newId('pv_');
store.run('INSERT INTO product_versions(id, product_id, version, name, description, audience, kind, billing_cycle, price, currency, tax_rate, limits_json, options_json, includes_json, excludes_json, policy_json, warn_levels_json, approval, effective_from, effective_to, published, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, productId, maxV + 1, p.name, p.description || null, p.audience, p.kind || 'base', p.billing_cycle, Math.round(p.price), p.currency || 'KRW', Number(p.tax_rate ?? 0.1), toJson(p.limits), toJson(p.options || []), toJson(p.includes || []), toJson(p.excludes || []), toJson(p.policy || {}), toJson(p.warn_levels || [70, 80, 90, 95]), 'manual', p.effective_from || nowIso(), p.effective_to || null, patch.published ? 1 : 0, by, nowIso());
return getProductVersion(store, id);
}
/** 운영자: 공개/비공개·유효 종료. 가격·한도는 바꾸지 않는다(새 버전으로). */
export function publishProductVersion(store, id, { published = true, effectiveTo = undefined } = {}) {
const pv = getProductVersion(store, id); if (!pv) { const e = new Error('상품 버전이 없습니다'); e.status = 404; e.code = 'not_found'; throw e; }
store.run('UPDATE product_versions SET published=?, effective_to=COALESCE(?, effective_to) WHERE id=?', published ? 1 : 0, effectiveTo === undefined ? null : effectiveTo, id);
if (published) store.run('UPDATE product_versions SET published=0 WHERE product_id=? AND id<>? AND version<?', pv.product_id, id, pv.version); // 같은 상품의 옛 버전은 신규 판매 중단(기존 고객 적용은 유지)
return getProductVersion(store, id);
}
/** 미확정 정책 항목 표시용 변환: { value:null, status:'deferred' } → { deferred:true, label:'미확정 정책', note } */
export function renderPolicyValue(v) { if (v && typeof v === 'object' && !Array.isArray(v) && v.status === 'deferred') return { deferred: true, label: '미확정 정책', note: v.note || '' }; return { deferred: false, value: v }; }
export function deferredPolicies(pv) { return Object.entries(pv.policy || {}).filter(([, v]) => v && typeof v === 'object' && v.status === 'deferred').map(([key, v]) => ({ key, note: v.note || '' })); }
/** 고객 화면용 상품 요약(가격·세금·한도·옵션·미확정 정책) */
export function publicProduct(pv) {
const tax = Math.round(pv.price * pv.tax_rate);
return { id: pv.id, productId: pv.product_id, version: pv.version, name: pv.name, description: pv.description, audience: pv.audience, billingCycle: pv.billing_cycle, price: pv.price, tax, total: pv.price + tax, currency: pv.currency, taxRate: pv.tax_rate,
limits: pv.limits, options: pv.options.map((o) => ({ ...o, tax: Math.round((o.price || 0) * pv.tax_rate), policy: o.policy ? Object.fromEntries(Object.entries(o.policy).map(([k, v]) => [k, renderPolicyValue(v)])) : null })), includes: pv.includes, excludes: pv.excludes,
policy: Object.fromEntries(Object.entries(pv.policy || {}).map(([k, v]) => [k, renderPolicyValue(v)])), deferred: deferredPolicies(pv), warnLevels: pv.warn_levels, approval: pv.approval, effectiveFrom: pv.effective_from, paymentMethods: ['manual_transfer'] };
}
+35
View File
@@ -0,0 +1,35 @@
/**
* portal/commerce/model.mjs — 상거래 영역 세부 표(§6·§27).
*
* 중심 표 orders·product_versions·settings 는 portal/core/model.mjs 에 있고, 여기서는 다음을 더한다.
* orders 추가 열 entitlement_ids_json(승인 때 부여한 권리 id — 재승인해도 중복 부여하지 않는 근거), terms_json(동의한 약관 버전), subscription_id
* subscriptions 활성 상품(그룹의 구독). 갱신 예정일·갱신 결제 대기·축소 예약·해지 예약을 가진다
* credits 환불·크레딧 원장(운영자만 기록)
* downsize_holds 축소 보류: 새 한도 안에 들어오지 않아 정리해야 할 항목 목록과 재검증 결과
* overage_optins 초과 사용 허용: 항목별 최대 금액/사용량·기간·철회
*/
export const MIGRATIONS = [{ id: 'commerce_0001', sql: `
ALTER TABLE orders ADD COLUMN entitlement_ids_json TEXT;
ALTER TABLE orders ADD COLUMN terms_json TEXT;
ALTER TABLE orders ADD COLUMN subscription_id TEXT;
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, product_version_id TEXT NOT NULL, order_id TEXT, items_json TEXT NOT NULL, billing_cycle TEXT NOT NULL,
amount INTEGER NOT NULL, tax INTEGER NOT NULL, total INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW',
status TEXT NOT NULL DEFAULT 'active', started_at TEXT NOT NULL, next_billing_at TEXT, renewal_order_id TEXT, renewal_notified_at TEXT,
payment_due_at TEXT, restrict_at TEXT, cancel_at TEXT, cancel_requested_at TEXT, cancelled_at TEXT, entitlement_ids_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS subscriptions_group ON subscriptions(group_id, status);
CREATE TABLE IF NOT EXISTS credits (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, kind TEXT NOT NULL, amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW', order_id TEXT, reason TEXT, created_by TEXT, created_at TEXT NOT NULL, applies_at TEXT, applied_order_id TEXT
);
CREATE INDEX IF NOT EXISTS credits_group ON credits(group_id, created_at);
CREATE TABLE IF NOT EXISTS downsize_holds (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, subscription_id TEXT, target_product_version_id TEXT NOT NULL, items_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'holding',
shortages_json TEXT NOT NULL, last_checked_at TEXT NOT NULL, order_id TEXT, created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS overage_optins (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, cap_amount INTEGER, cap_units INTEGER, unit_price_json TEXT, period_start TEXT NOT NULL, period_end TEXT NOT NULL,
created_by TEXT, created_at TEXT NOT NULL, revoked_at TEXT, revoked_by TEXT
);
CREATE INDEX IF NOT EXISTS overage_group ON overage_optins(group_id, resource);
` }];
+20
View File
@@ -0,0 +1,20 @@
/**
* portal/commerce/opsauth.mjs — 운영자 콘솔(HAL)에서 들어오는 내부 호출(/internal/ops/*) 인증.
*
* 운영자 계정·세션은 HAL 에 있고(§25), HAL 이 포털의 내부 API 를 서비스 키로 호출한다(lib/serviceauth).
* 비밀은 포털 보관소의 'service:<keyId>' 에 있다. 허용 키 id 는 'hal' 뿐이다.
* 호출 주체 운영자 식별(감사 기록용)은 HAL 이 X-Ops-Actor 헤더로 전달한다(이름만, 비밀 없음).
* 테스트에서는 deps.opsAuthBypass=true 로 서명 없이 부를 수 있다(운영 코드 경로에서는 절대 켜지 않는다).
*/
import { HttpError } from '../../lib/http.mjs';
import { verifyCtx } from '../../lib/serviceauth.mjs';
const ALLOWED_KEYS = new Set(['hal']);
/** 검증 후 { keyId, actor } 를 돌려준다. 실패는 401. */
export async function requireOps(ctx, deps) {
const actor = String(ctx.headers['x-ops-actor'] || 'operator').slice(0, 80);
if (deps.opsAuthBypass) return { keyId: 'test', actor };
const r = await verifyCtx(ctx, (id) => (ALLOWED_KEYS.has(id) ? deps.vault.get('service:' + id) : null));
if (!r.ok) throw new HttpError(401, 'service_auth_failed', `운영자 콘솔 호출 인증 실패(${r.reason})`);
return { keyId: r.keyId, actor };
}
+237
View File
@@ -0,0 +1,237 @@
/**
* portal/commerce/orders.mjs — 주문·수동 결제 확인·승인·권리 활성화·구독·갱신·축소·해지·환불/크레딧·초과 사용(§6.2~6.5, §27.1, §35).
*
* 흐름(0.1.1 기본 = 수동 결제 확인)
* 1) 고객이 상품(+옵션)·적용 대상·결제 방식(manual_transfer 만)·약관 동의를 보내면 주문(awaiting_payment)이 생긴다. 주문 번호·금액·통화·세금·입금 기한·입금 안내를 보인다.
* 2) 운영자가 실제 입금을 확인(markPaid)하고 승인(approve)하거나 거절(reject)한다. 승인 때만 권리(lib/quota entitlement)가 생긴다.
* 3) 같은 승인을 두 번 눌러도 권리가 중복 생기지 않는다: 승인 트랜잭션 안에서 status 를 검사하고 부여한 권리 id 를 orders.entitlement_ids_json 에 남긴다.
* 4) 기한이 지나면 expired. 거절·만료 사유와 재주문·문의 안내를 화면에 보인다.
* 멱등: 고객이 보낸 idempotency_key 가 같으면 기존 주문을 돌려준다(새로 고침·연타 §36).
* 구독(subscriptions): 기본 상품 주문이 승인되면 활성 구독이 되고 next_billing_at 이 정해진다. 갱신은 renewal_notice_days 전에 갱신 주문(kind='renewal')을 만들어 알리고,
* 입금 확인 전까지 'awaiting_renewal_payment' 로 보인다. 미납 제한 예정 시각(restrict_at)은 정책의 restriction_after_unpaid_days 로 계산해 고객·운영자에게 똑같이 보인다. 데이터는 지우지 않는다.
* 축소(§6.4): 새 상품 한도 안에 현재 사용량·배정이 들어오면 즉시 축소 주문(kind='downsize', 0원이면 바로 승인 대기)이, 아니면 downsize_holds 에 보류되고 줄여야 할 항목을 구체적으로 보인다.
* 해지: cancel_at(=다음 청구일)에 권리를 종료한다. 사용 중 권리를 경고 없이 즉시 회수하지 않는다.
*/
import { newId } from '../../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
import { HttpError } from '../../lib/http.mjs';
import * as quota from '../../lib/quota.mjs';
import { notify } from '../../lib/notify.mjs';
import { audit } from '../../lib/audit.mjs';
import { getProductVersion, getSetting, publicProduct, rowToProduct } from './catalog.mjs';
export const ORDER_STATUS = { AWAITING_PAYMENT: 'awaiting_payment', AWAITING_APPROVAL: 'awaiting_approval', APPROVED: 'approved', REJECTED: 'rejected', EXPIRED: 'expired', CANCELLED: 'cancelled' };
export const ORDER_LABEL = { awaiting_payment: '결제 대기', awaiting_approval: '승인 대기', approved: '승인 완료', rejected: '거절됨', expired: '기한 만료', cancelled: '취소됨' };
export const SUB_LABEL = { active: '사용 중', awaiting_renewal_payment: '갱신 결제 대기', restricted: '미납 제한', cancel_scheduled: '해지 예약', cancelled: '해지됨', downsize_scheduled: '축소 예약' };
const DAY = 86400000;
export const rowToOrder = (r) => (r ? { ...r, items: parseJson(r.items_json, []), payment_instructions: parseJson(r.payment_instructions_json, null), entitlement_ids: parseJson(r.entitlement_ids_json, []), terms: parseJson(r.terms_json, null) } : null);
export const rowToSub = (r) => (r ? { ...r, items: parseJson(r.items_json, []), entitlement_ids: parseJson(r.entitlement_ids_json, []) } : null);
/** 주문 번호 AW-YYYYMMDD-NNNN (그날 순번) */
function orderNo(store) { const d = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const n = store.get("SELECT COUNT(*) AS n FROM orders WHERE order_no LIKE ?", `AW-${d}-%`).n + 1; return `AW-${d}-${String(n).padStart(4, '0')}`; }
/**
* 주문 항목 계산. input.items = [{ optionId?, qty, targetType?, targetId? }] + 기본 상품 포함 여부(includeBase).
* 각 항목에 단가·세금·합계·부여 자원(grants)을 붙인다. 옵션 수량 상한(max_qty)·per(site 면 대상 필요)을 검사한다.
*/
export function priceItems(pv, { includeBase = true, items = [] } = {}) {
const out = []; const addTax = (amt) => Math.round(amt * pv.tax_rate);
if (includeBase) out.push({ type: 'product', id: pv.product_id, name: pv.name, qty: 1, unit_price: pv.price, amount: pv.price, tax: addTax(pv.price), target_type: 'group', target_id: null, grants: pv.limits });
for (const it of items) {
const o = pv.options.find((x) => x.id === it.optionId); if (!o) throw new HttpError(400, 'bad_option', `알 수 없는 옵션: ${it.optionId}`);
const qty = Math.max(1, Math.min(Number(it.qty) || 1, o.max_qty || 99));
if (o.per === 'site' && !it.targetId) throw new HttpError(400, 'target_required', `${o.name} 은(는) 적용할 사이트를 골라야 합니다`);
const amount = o.price * qty; const grants = Object.fromEntries(Object.entries(o.grants || {}).map(([k, v]) => [k, v * qty]));
out.push({ type: 'option', id: o.id, name: o.name, qty, unit_price: o.price, amount, tax: addTax(amount), target_type: o.per === 'site' ? 'site' : 'group', target_id: it.targetId || null, grants, policy: o.policy || null });
}
const amount = out.reduce((s, x) => s + x.amount, 0); const tax = out.reduce((s, x) => s + x.tax, 0);
return { items: out, amount, tax, total: amount + tax, currency: pv.currency };
}
/** 입금 안내(settings.payment_instructions, 운영자 관리). 비밀값 없음. */
export function paymentInstructions(store) { return getSetting(store, 'payment_instructions', { method: 'manual_transfer', bank: '', account: '', holder: '', note: '', deadline_days: 7 }); }
/**
* 주문 만들기. 이메일 미확인 사용자는 유료 상품을 살 수 없다(§3.1). idempotencyKey 가 같으면 기존 주문.
* @returns {{order, created}}
*/
export function createOrder(store, { groupId, userId, userVerified, productVersionId, includeBase = true, items = [], paymentMethod = 'manual_transfer', acceptTerms, idempotencyKey = null, kind = 'new', subscriptionId = null }) {
const pv = getProductVersion(store, productVersionId); if (!pv || !pv.published) throw new HttpError(404, 'product_not_found', '판매 중인 상품이 아닙니다');
if (paymentMethod !== 'manual_transfer') throw new HttpError(400, 'payment_method_unsupported', '버전 0.1.1 은 수동 결제 확인(계좌 이체)만 지원합니다');
if (!acceptTerms) throw new HttpError(400, 'terms_required', '약관과 제한 사항에 동의해야 주문할 수 있습니다');
const priced = priceItems(pv, { includeBase, items });
if (priced.total > 0 && !userVerified) throw new HttpError(403, 'email_unverified', '이메일 확인이 끝나야 유료 상품을 주문할 수 있습니다', { action: 'verify_email' });
if (!priced.items.length) throw new HttpError(400, 'empty_order', '주문 항목이 없습니다');
return store.tx(() => {
if (idempotencyKey) { const ex = store.get('SELECT * FROM orders WHERE idempotency_key=?', idempotencyKey); if (ex) return { order: rowToOrder(ex), created: false }; }
// 같은 그룹에 결제 대기 중인 같은 상품 주문이 있으면 그것을 돌려준다(새로 고침 뒤 다른 키로 다시 눌러도 중복 생성 안 함)
if (kind === 'new' && includeBase) { const dup = store.get('SELECT * FROM orders WHERE group_id=? AND product_version_id=? AND kind=? AND status IN (?,?)', groupId, pv.id, 'new', 'awaiting_payment', 'awaiting_approval'); if (dup) return { order: rowToOrder(dup), created: false }; }
const pi = paymentInstructions(store); const now = nowIso(); const dueDays = Number(pv.policy?.payment_deadline_days || pi.deadline_days || 7);
const id = newId('o_');
store.run('INSERT INTO orders(id, group_id, order_no, kind, product_version_id, items_json, target_type, target_id, amount, tax, total, currency, status, payment_method, payment_instructions_json, due_at, idempotency_key, created_by, created_at, updated_at, terms_json, subscription_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, groupId, orderNo(store), kind, pv.id, toJson(priced.items), 'group', groupId, priced.amount, priced.tax, priced.total, priced.currency, priced.total === 0 ? ORDER_STATUS.AWAITING_APPROVAL : ORDER_STATUS.AWAITING_PAYMENT, paymentMethod, toJson(pi), new Date(Date.now() + dueDays * DAY).toISOString(), idempotencyKey, userId, now, now, toJson({ acceptedAt: now, productVersion: pv.id, terms: 'v0.1.1' }), subscriptionId);
const order = rowToOrder(store.get('SELECT * FROM orders WHERE id=?', id));
audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'order', subjectId: id, action: 'order.create', detail: { orderNo: order.order_no, total: order.total, kind } });
notify(store, { groupId, category: 'billing', severity: 'info', title: `주문 ${order.order_no} 접수 — ${order.status === 'awaiting_payment' ? '입금 확인 대기' : '승인 대기'}`, body: `금액 ${order.total.toLocaleString('ko-KR')} ${order.currency}(세금 포함) · 기한 ${order.due_at.slice(0, 10)}`, link: `/orders/${id}`, subjectType: 'order', subjectId: id, incidentKey: `order:${id}:status` });
return { order, created: true };
});
}
export function getOrder(store, id, groupId = null) { const r = store.get(`SELECT * FROM orders WHERE id=? ${groupId ? 'AND group_id=?' : ''}`, ...(groupId ? [id, groupId] : [id])); return rowToOrder(r); }
export function listOrders(store, { groupId, status, limit = 100 } = {}) { const w = []; const p = []; if (groupId) { w.push('group_id=?'); p.push(groupId); } if (status) { w.push(Array.isArray(status) ? `status IN (${status.map(() => '?').join(',')})` : 'status=?'); p.push(...[].concat(status)); } return store.all(`SELECT * FROM orders ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY created_at DESC LIMIT ?`, ...p, limit).map(rowToOrder); }
/** 고객 취소: 결제·승인 전에만 */
export function cancelOrder(store, id, groupId, userId) { const o = getOrder(store, id, groupId); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); if (!['awaiting_payment', 'awaiting_approval'].includes(o.status)) throw new HttpError(409, 'order_not_cancellable', `${ORDER_LABEL[o.status]} 상태의 주문은 취소할 수 없습니다`); store.run('UPDATE orders SET status=?, cancelled_at=?, updated_at=? WHERE id=?', 'cancelled', nowIso(), nowIso(), id); audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'order', subjectId: id, action: 'order.cancel' }); return getOrder(store, id); }
/** 운영자: 입금 확인(승인 전 단계). 입금 참조(입금자명·일시)는 비밀 아님. */
export function markPaid(store, id, { by, ref = '' }) { const o = getOrder(store, id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); if (o.status !== 'awaiting_payment') throw new HttpError(409, 'order_not_awaiting_payment', `${ORDER_LABEL[o.status]} 상태에서는 입금 확인을 할 수 없습니다`); store.run('UPDATE orders SET status=?, paid_at=?, paid_ref=?, updated_at=? WHERE id=?', 'awaiting_approval', nowIso(), String(ref).slice(0, 120), nowIso(), id); audit(store, { actorType: 'operator', actorId: by, groupId: o.group_id, subjectType: 'order', subjectId: id, action: 'payment.confirm', detail: { ref } }); notify(store, { groupId: o.group_id, category: 'billing', severity: 'info', title: `주문 ${o.order_no} 입금 확인 — 승인 대기`, link: `/orders/${id}`, subjectType: 'order', subjectId: id, incidentKey: `order:${id}:status` }); return getOrder(store, id); }
/**
* 운영자 승인 → 권리 활성화(§35). 멱등: 이미 approved 면 그대로 돌려준다(권리 재부여 없음).
* 기본 상품 주문이면 구독을 만들고(또는 갱신/축소 주문이면 구독을 갱신하고) 다음 청구일을 정한다.
*/
export function approveOrder(store, id, { by }) {
return store.tx(() => {
const o = getOrder(store, id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다');
if (o.status === 'approved') return { order: o, granted: [], already: true };
if (!['awaiting_approval', 'awaiting_payment'].includes(o.status)) throw new HttpError(409, 'order_not_approvable', `${ORDER_LABEL[o.status]} 상태의 주문은 승인할 수 없습니다`);
if (o.status === 'awaiting_payment' && o.total > 0) throw new HttpError(409, 'payment_not_confirmed', '입금 확인을 먼저 기록하세요');
const pv = getProductVersion(store, o.product_version_id); const now = nowIso();
let sub = o.subscription_id ? rowToSub(store.get('SELECT * FROM subscriptions WHERE id=?', o.subscription_id)) : null;
const granted = [];
if (o.kind === 'renewal' && sub) {
// 갱신: 권리는 그대로, 다음 청구일만 한 주기 뒤로
store.run('UPDATE subscriptions SET status=?, next_billing_at=?, renewal_order_id=NULL, renewal_notified_at=NULL, payment_due_at=NULL, restrict_at=NULL, updated_at=? WHERE id=?', 'active', addCycle(sub.next_billing_at || now, sub.billing_cycle), now, sub.id);
} else if (o.kind === 'downsize' && sub) {
// 축소: 옛 권리를 끝내고 새 상품 한도로 다시 부여(승인 시점에 다시 검증 — 그 사이 사용량이 늘었으면 거절)
const check = evaluateDownsize(store, o.group_id, pv, o.items);
if (check.shortages.length) throw new HttpError(409, 'downsize_blocked', '승인 시점 재검증에서 새 한도를 넘는 사용량이 있습니다', { shortages: check.shortages });
for (const eid of sub.entitlement_ids) quota.endEntitlement(store, eid, now);
for (const it of o.items) for (const [res, amt] of Object.entries(it.grants || {})) if (amt > 0) granted.push(quota.grantEntitlement(store, { groupId: o.group_id, resource: res, amount: amt, sourceId: o.id, productVersionId: pv.id, note: it.name }));
store.run('UPDATE subscriptions SET product_version_id=?, items_json=?, amount=?, tax=?, total=?, entitlement_ids_json=?, status=?, updated_at=? WHERE id=?', pv.id, toJson(o.items), o.amount, o.tax, o.total, toJson(granted), 'active', now, sub.id);
store.run("UPDATE downsize_holds SET status='applied', order_id=?, updated_at=? WHERE group_id=? AND status='ordered' AND order_id=?", o.id, now, o.group_id, o.id);
} else {
for (const it of o.items) for (const [res, amt] of Object.entries(it.grants || {})) if (amt > 0) granted.push(quota.grantEntitlement(store, { groupId: o.group_id, resource: res, amount: amt, sourceId: o.id, productVersionId: pv.id, note: it.target_type === 'site' && it.target_id ? `${it.name} (사이트 ${it.target_id})` : it.name }));
if (o.items.some((i) => i.type === 'product')) {
const sid = newId('sub_');
store.run('INSERT INTO subscriptions(id, group_id, product_version_id, order_id, items_json, billing_cycle, amount, tax, total, currency, status, started_at, next_billing_at, entitlement_ids_json, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', sid, o.group_id, pv.id, o.id, toJson(o.items), pv.billing_cycle, o.amount, o.tax, o.total, o.currency, 'active', now, addCycle(now, pv.billing_cycle), toJson(granted), now, now);
store.run('UPDATE orders SET subscription_id=? WHERE id=?', sid, o.id); sub = rowToSub(store.get('SELECT * FROM subscriptions WHERE id=?', sid));
} else if (sub) {
// 기존 구독의 추가 옵션: 구독 권리 목록에 합치고 금액을 더한다(다음 청구에 반영)
store.run('UPDATE subscriptions SET items_json=?, amount=amount+?, tax=tax+?, total=total+?, entitlement_ids_json=?, updated_at=? WHERE id=?', toJson([...sub.items, ...o.items]), o.amount, o.tax, o.total, toJson([...sub.entitlement_ids, ...granted]), now, sub.id);
}
}
store.run('UPDATE orders SET status=?, approved_at=?, approved_by=?, entitlement_ids_json=?, updated_at=? WHERE id=?', 'approved', now, by, toJson(granted), now, id);
audit(store, { actorType: 'operator', actorId: by, groupId: o.group_id, subjectType: 'order', subjectId: id, action: 'order.approve', detail: { orderNo: o.order_no, granted: granted.length, kind: o.kind } });
notify(store, { groupId: o.group_id, category: 'billing', severity: 'info', mandatory: true, title: `주문 ${o.order_no} 승인 — 권리가 활성화되었습니다`, body: o.items.map((i) => `${i.name}×${i.qty}`).join(', '), link: `/orders/${id}`, subjectType: 'order', subjectId: id, incidentKey: `order:${id}:status` });
return { order: getOrder(store, id), granted, already: false };
});
}
export function rejectOrder(store, id, { by, reason }) { const o = getOrder(store, id); if (!o) throw new HttpError(404, 'not_found', '주문이 없습니다'); if (!['awaiting_payment', 'awaiting_approval'].includes(o.status)) throw new HttpError(409, 'order_not_rejectable', `${ORDER_LABEL[o.status]} 상태의 주문은 거절할 수 없습니다`); if (!reason) throw new HttpError(400, 'reason_required', '거절 사유를 적으세요'); store.run('UPDATE orders SET status=?, rejected_at=?, rejected_reason=?, updated_at=? WHERE id=?', 'rejected', nowIso(), String(reason).slice(0, 500), nowIso(), id); audit(store, { actorType: 'operator', actorId: by, groupId: o.group_id, subjectType: 'order', subjectId: id, action: 'order.reject', detail: { reason } }); notify(store, { groupId: o.group_id, category: 'billing', severity: 'warning', mandatory: true, title: `주문 ${o.order_no} 거절`, body: `사유: ${reason}. 다시 주문하거나 문의하세요`, link: `/orders/${id}`, subjectType: 'order', subjectId: id, incidentKey: `order:${id}:status` }); return getOrder(store, id); }
/** 기한 만료 처리(주기 작업). 만료된 주문 목록 반환 */
export function expireOrders(store, at = nowIso()) {
const rows = store.all("SELECT * FROM orders WHERE status='awaiting_payment' AND due_at IS NOT NULL AND due_at<?", at).map(rowToOrder);
for (const o of rows) { store.run('UPDATE orders SET status=?, expired_at=?, updated_at=? WHERE id=?', 'expired', at, at, o.id); notify(store, { groupId: o.group_id, category: 'billing', severity: 'warning', title: `주문 ${o.order_no} 입금 기한 만료`, body: '입금이 확인되지 않아 주문이 만료되었습니다. 다시 주문하거나 문의하세요', link: `/orders/${o.id}`, subjectType: 'order', subjectId: o.id, incidentKey: `order:${o.id}:status` }); audit(store, { actorType: 'system', automatic: true, groupId: o.group_id, subjectType: 'order', subjectId: o.id, action: 'order.expire' }); }
return rows;
}
/** 운영자: 환불·크레딧 기록(원장). 서비스 영향 시점은 applies_at. */
export function addCredit(store, { groupId, kind, amount, orderId = null, reason, by, appliesAt = null }) { if (!['refund', 'credit', 'adjust'].includes(kind)) throw new HttpError(400, 'bad_kind', 'kind 는 refund|credit|adjust'); if (!(Number(amount) !== 0)) throw new HttpError(400, 'bad_amount', '금액을 입력하세요'); if (!reason) throw new HttpError(400, 'reason_required', '사유를 적으세요'); const id = newId('cr_'); store.run('INSERT INTO credits(id, group_id, kind, amount, order_id, reason, created_by, created_at, applies_at) VALUES(?,?,?,?,?,?,?,?,?)', id, groupId, kind, Math.round(amount), orderId, String(reason).slice(0, 500), by, nowIso(), appliesAt || nowIso()); audit(store, { actorType: 'operator', actorId: by, groupId, subjectType: 'credit', subjectId: id, action: kind === 'refund' ? 'refund.record' : 'credit.record', detail: { amount, orderId, reason } }); notify(store, { groupId, category: 'billing', severity: 'info', mandatory: kind === 'refund', title: kind === 'refund' ? `환불 ${Math.round(amount).toLocaleString('ko-KR')}원 처리` : `크레딧 ${Math.round(amount).toLocaleString('ko-KR')}원 적립`, body: reason, link: '/billing' }); return store.get('SELECT * FROM credits WHERE id=?', id); }
export function listCredits(store, groupId) { return store.all('SELECT * FROM credits WHERE group_id=? ORDER BY created_at DESC', groupId); }
/** 청구 주기 더하기 */
export function addCycle(iso, cycle) { const d = new Date(iso); if (cycle === 'yearly') d.setUTCFullYear(d.getUTCFullYear() + 1); else d.setUTCMonth(d.getUTCMonth() + 1); return d.toISOString(); }
export function listSubscriptions(store, groupId) { return store.all('SELECT * FROM subscriptions WHERE group_id=? ORDER BY created_at DESC', groupId).map(rowToSub); }
export function activeSubscription(store, groupId) { return rowToSub(store.get("SELECT * FROM subscriptions WHERE group_id=? AND status NOT IN ('cancelled') ORDER BY created_at DESC LIMIT 1", groupId)); }
/** 그룹에 적용 중인 상품정책(경고 구간 등). 구독이 없으면 null */
export function appliedProduct(store, groupId) { const s = activeSubscription(store, groupId); return s ? getProductVersion(store, s.product_version_id) : null; }
/**
* 갱신 처리(주기 작업). notice_days 전에 갱신 주문을 만들고 알린다. 청구일이 지나도 미납이면 awaiting_renewal_payment, 기한이 더 지나면 restricted(제한 예정 시각은 미리 알림).
* 반환: { created:[subId], restricted:[subId] }
*/
export function processRenewals(store, at = nowIso()) {
const created = []; const restricted = []; const now = Date.parse(at);
for (const s of store.all("SELECT * FROM subscriptions WHERE status IN ('active','awaiting_renewal_payment') AND next_billing_at IS NOT NULL").map(rowToSub)) {
const pv = getProductVersion(store, s.product_version_id); if (!pv) continue;
const noticeDays = Number(pv.policy?.renewal_notice_days || 7); const deadlineDays = Number(pv.policy?.payment_deadline_days || 7); const restrictDays = Number(pv.policy?.restriction_after_unpaid_days || 14);
const billing = Date.parse(s.next_billing_at);
if (!s.renewal_order_id && now >= billing - noticeDays * DAY && s.status === 'active' && !s.cancel_at) {
const id = newId('o_'); const pi = paymentInstructions(store); const due = new Date(billing + deadlineDays * DAY).toISOString(); const restrictAt = new Date(billing + restrictDays * DAY).toISOString();
store.run('INSERT INTO orders(id, group_id, order_no, kind, product_version_id, items_json, target_type, target_id, amount, tax, total, currency, status, payment_method, payment_instructions_json, due_at, created_by, created_at, updated_at, subscription_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', id, s.group_id, orderNo(store), 'renewal', pv.id, s.items_json, 'group', s.group_id, s.amount, s.tax, s.total, s.currency, 'awaiting_payment', 'manual_transfer', toJson(pi), due, 'system', at, at, s.id);
store.run('UPDATE subscriptions SET renewal_order_id=?, renewal_notified_at=?, payment_due_at=?, restrict_at=?, status=?, updated_at=? WHERE id=?', id, at, due, restrictAt, 'awaiting_renewal_payment', at, s.id);
notify(store, { groupId: s.group_id, category: 'billing', severity: 'warning', mandatory: true, title: `갱신 결제 안내 — ${s.next_billing_at.slice(0, 10)} 청구 예정 ${s.total.toLocaleString('ko-KR')} ${s.currency}`, body: `입금 기한 ${due.slice(0, 10)} · 미납 시 ${restrictAt.slice(0, 10)} 부터 신규 배포·설정 변경이 제한됩니다(데이터는 삭제하지 않습니다)`, link: `/orders/${id}`, subjectType: 'subscription', subjectId: s.id, incidentKey: `sub:${s.id}:renewal` });
created.push(s.id);
} else if (s.status === 'awaiting_renewal_payment' && s.restrict_at && now >= Date.parse(s.restrict_at)) {
store.run('UPDATE subscriptions SET status=?, updated_at=? WHERE id=?', 'restricted', at, s.id); restricted.push(s.id);
notify(store, { groupId: s.group_id, category: 'billing', severity: 'critical', mandatory: true, title: '미납으로 신규 변경이 제한되었습니다', body: `${pv.policy?.restriction_effect || '신규 배포·설정 변경 제한'}. 입금이 확인되면 즉시 해제됩니다`, link: `/orders/${s.renewal_order_id}`, subjectType: 'subscription', subjectId: s.id, incidentKey: `sub:${s.id}:restricted` });
}
}
// 해지 예약 도래: 권리 종료
for (const s of store.all("SELECT * FROM subscriptions WHERE status='cancel_scheduled' AND cancel_at<=?", at).map(rowToSub)) { for (const eid of s.entitlement_ids) quota.endEntitlement(store, eid, at); store.run('UPDATE subscriptions SET status=?, cancelled_at=?, updated_at=? WHERE id=?', 'cancelled', at, at, s.id); notify(store, { groupId: s.group_id, category: 'billing', severity: 'warning', mandatory: true, title: '상품 해지가 적용되었습니다', body: '권리가 종료되었습니다. 데이터는 보존 기간 동안 조회·내보내기가 가능합니다', link: '/billing', subjectType: 'subscription', subjectId: s.id }); audit(store, { actorType: 'system', automatic: true, groupId: s.group_id, subjectType: 'subscription', subjectId: s.id, action: 'cancel.apply' }); }
return { created, restricted };
}
/**
* 축소 가능성 평가(§6.4). 새 상품 한도(+옵션 grants) 와 현재 배정·예약·실제 사용을 자원별로 비교한다.
* shortages: [{ resource, label, newLimit, inUse(배정+예약), used(계측), reduceBy, items:[{subjectType, subjectId, amount}] }]
*/
export function evaluateDownsize(store, groupId, pv, items = []) {
const newLimits = { ...pv.limits }; for (const it of items) if (it.type === 'option') for (const [r, a] of Object.entries(it.grants || {})) newLimits[r] = (newLimits[r] || 0) + a;
const shortages = []; const resources = [];
for (const r of Object.keys(quota.RESOURCES)) {
const f = quota.figures(store, groupId, r); const limit = newLimits[r] || 0; const inUse = f.allocated + f.reserved; const need = Math.max(inUse, f.used);
resources.push({ resource: r, label: f.label, unit: f.unit, current: f.purchased, newLimit: limit, allocated: f.allocated, reserved: f.reserved, used: f.used });
if (need > limit) shortages.push({ resource: r, label: f.label, unit: f.unit, newLimit: limit, inUse, used: f.used, reduceBy: need - limit, items: quota.listAllocations(store, groupId, r).map((a) => ({ subjectType: a.subject_type, subjectId: a.subject_id, amount: a.amount, used: a.used || 0 })) });
}
return { newLimits, resources, shortages };
}
/**
* 축소 요청: 범위 안이면 축소 주문(승인 대기; 차액은 0 — 차기 청구부터 새 금액)을 만들고, 아니면 보류(hold)를 만든다.
* @returns {{ fits:boolean, order?, hold?, shortages }}
*/
export function requestDownsize(store, { groupId, userId, userVerified, productVersionId, items = [], acceptTerms }) {
const sub = activeSubscription(store, groupId); if (!sub) throw new HttpError(409, 'no_subscription', '활성 상품이 없습니다');
const pv = getProductVersion(store, productVersionId); if (!pv || !pv.published) throw new HttpError(404, 'product_not_found', '판매 중인 상품이 아닙니다');
const priced = priceItems(pv, { includeBase: true, items }); const ev = evaluateDownsize(store, groupId, pv, priced.items);
const now = nowIso();
if (ev.shortages.length) {
const open = store.get("SELECT * FROM downsize_holds WHERE group_id=? AND status='holding'", groupId);
const id = open ? open.id : newId('dh_');
if (open) store.run('UPDATE downsize_holds SET target_product_version_id=?, items_json=?, shortages_json=?, last_checked_at=?, updated_at=? WHERE id=?', pv.id, toJson(priced.items), toJson(ev.shortages), now, now, id);
else store.run('INSERT INTO downsize_holds(id, group_id, subscription_id, target_product_version_id, items_json, status, shortages_json, last_checked_at, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, groupId, sub.id, pv.id, toJson(priced.items), 'holding', toJson(ev.shortages), now, userId, now, now);
notify(store, { groupId, category: 'billing', severity: 'warning', title: '상품 축소 보류 — 정리가 필요한 항목이 있습니다', body: ev.shortages.map((s) => `${s.label} ${s.reduceBy} 줄이기`).join(', '), link: '/billing#downsize', subjectType: 'downsize_hold', subjectId: id, incidentKey: `downsize:${groupId}` });
return { fits: false, hold: rowToHold(store.get('SELECT * FROM downsize_holds WHERE id=?', id)), shortages: ev.shortages };
}
const { order } = createOrder(store, { groupId, userId, userVerified, productVersionId, items, acceptTerms, kind: 'downsize', subscriptionId: sub.id, idempotencyKey: `downsize:${groupId}:${pv.id}:${sub.id}` });
store.run("UPDATE downsize_holds SET status='ordered', order_id=?, updated_at=? WHERE group_id=? AND status='holding'", order.id, now, groupId);
return { fits: true, order, shortages: [] };
}
export const rowToHold = (r) => (r ? { ...r, items: parseJson(r.items_json, []), shortages: parseJson(r.shortages_json, []) } : null);
export function openHold(store, groupId) { return rowToHold(store.get("SELECT * FROM downsize_holds WHERE group_id=? AND status='holding'", groupId)); }
/** 보류 재검증: 정리가 끝났으면 축소 주문으로 전환 */
export function revalidateHold(store, { groupId, userId, userVerified }) { const h = openHold(store, groupId); if (!h) throw new HttpError(404, 'no_hold', '보류 중인 축소 요청이 없습니다'); return requestDownsize(store, { groupId, userId, userVerified, productVersionId: h.target_product_version_id, items: h.items.filter((i) => i.type === 'option').map((i) => ({ optionId: i.id, qty: i.qty, targetId: i.target_id })), acceptTerms: true }); }
export function cancelHold(store, groupId) { return store.run("UPDATE downsize_holds SET status='cancelled', updated_at=? WHERE group_id=? AND status='holding'", nowIso(), groupId).changes; }
/** 해지 예약/철회(§6.4·§23). 다음 청구일에 권리 종료. */
export function scheduleCancel(store, { groupId, userId }) { const s = activeSubscription(store, groupId); if (!s) throw new HttpError(409, 'no_subscription', '활성 상품이 없습니다'); if (s.status === 'cancel_scheduled') return s; const at = s.next_billing_at || nowIso(); store.run('UPDATE subscriptions SET status=?, cancel_at=?, cancel_requested_at=?, updated_at=? WHERE id=?', 'cancel_scheduled', at, nowIso(), nowIso(), s.id); audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'subscription', subjectId: s.id, action: 'cancel.schedule', detail: { cancelAt: at } }); notify(store, { groupId, category: 'billing', severity: 'warning', mandatory: true, title: `상품 해지 예약 — ${at.slice(0, 10)} 적용`, body: '적용일까지 서비스와 권리는 유지됩니다. 적용 후 데이터는 보존 기간 동안 읽기·내보내기만 가능합니다', link: '/billing', subjectType: 'subscription', subjectId: s.id, incidentKey: `sub:${s.id}:cancel` }); return activeSubscription(store, groupId); }
export function undoCancel(store, { groupId, userId }) { const s = activeSubscription(store, groupId); if (!s || s.status !== 'cancel_scheduled') throw new HttpError(409, 'not_scheduled', '해지 예약이 없습니다'); store.run('UPDATE subscriptions SET status=?, cancel_at=NULL, cancel_requested_at=NULL, updated_at=? WHERE id=?', 'active', nowIso(), s.id); audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'subscription', subjectId: s.id, action: 'cancel.undo' }); return activeSubscription(store, groupId); }
/** 운영자: 수동 결제 실패/재처리 등 메모 */
export function noteOrder(store, id, { by, note }) { store.run('UPDATE orders SET note=?, updated_at=? WHERE id=?', String(note || '').slice(0, 1000), nowIso(), id); audit(store, { actorType: 'operator', actorId: by, subjectType: 'order', subjectId: id, action: 'order.note', detail: { note } }); return getOrder(store, id); }
/** 초과 사용 허용(§6.5): 항목별 최대 금액 또는 최대 사용량·기간 필수. 철회하면 그 즉시 100% 제한이 다시 적용된다. */
export function optInOverage(store, { groupId, userId, resource, capAmount = null, capUnits = null, periodStart, periodEnd }) {
if (!quota.RESOURCES[resource]) throw new HttpError(400, 'unknown_resource', '알 수 없는 자원'); if (!(capAmount > 0) && !(capUnits > 0)) throw new HttpError(400, 'cap_required', '최대 금액 또는 최대 사용량을 정해야 합니다'); if (!periodStart || !periodEnd || Date.parse(periodEnd) <= Date.parse(periodStart)) throw new HttpError(400, 'period_required', '적용 기간을 정해야 합니다');
const pv = appliedProduct(store, groupId); const unit = pv?.policy?.overage?.unit_prices?.[resource] || null; if (!unit) throw new HttpError(409, 'overage_not_offered', '이 자원은 초과 사용 상품이 없습니다');
store.run('UPDATE overage_optins SET revoked_at=?, revoked_by=? WHERE group_id=? AND resource=? AND revoked_at IS NULL', nowIso(), userId, groupId, resource);
const id = newId('ov_'); store.run('INSERT INTO overage_optins(id, group_id, resource, cap_amount, cap_units, unit_price_json, period_start, period_end, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?,?)', id, groupId, resource, capAmount, capUnits, toJson(unit), periodStart, periodEnd, userId, nowIso());
audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'overage', subjectId: id, action: 'overage.optin', detail: { resource, capAmount, capUnits, periodStart, periodEnd } });
return store.get('SELECT * FROM overage_optins WHERE id=?', id);
}
export function revokeOverage(store, { groupId, userId, resource }) { const n = store.run('UPDATE overage_optins SET revoked_at=?, revoked_by=? WHERE group_id=? AND resource=? AND revoked_at IS NULL', nowIso(), userId, groupId, resource).changes; if (n) audit(store, { actorType: 'user', actorId: userId, groupId, subjectType: 'overage', subjectId: resource, action: 'overage.revoke' }); return n; }
export function activeOverage(store, groupId, resource, at = nowIso()) { return store.get('SELECT * FROM overage_optins WHERE group_id=? AND resource=? AND revoked_at IS NULL AND period_start<=? AND period_end>? ORDER BY created_at DESC LIMIT 1', groupId, resource, at, at) || null; }
export function listOverage(store, groupId) { return store.all('SELECT * FROM overage_optins WHERE group_id=? ORDER BY created_at DESC', groupId); }
/** 도메인 등록 기능 제공 여부(settings.domain_registration, 운영자가 등록기관 연결 후 켠다) */
export function domainRegistration(store) { const v = getSetting(store, 'domain_registration', { enabled: false }); return { enabled: !!v.enabled, registrar: v.registrar || null, note: v.note || '도메인 등록 기능이 연결되어 있지 않습니다. 외부 등록기관에서 등록한 뒤 도메인 화면에서 연결하세요' }; }
/** 이번 달 예상 비용·결제 대기 금액(§6.3) */
export function billingSummary(store, groupId) {
const subs = listSubscriptions(store, groupId); const act = subs.filter((s) => !['cancelled'].includes(s.status));
const awaiting = listOrders(store, { groupId, status: ['awaiting_payment', 'awaiting_approval'] });
const credits = listCredits(store, groupId); const creditBalance = credits.reduce((s, c) => s + (c.kind === 'refund' ? 0 : c.amount), 0);
return { subscriptions: act.map((s) => ({ ...s, label: SUB_LABEL[s.status] || s.status, product: publicProduct(getProductVersion(store, s.product_version_id)) })), nextBillingAt: act.map((s) => s.next_billing_at).filter(Boolean).sort()[0] || null, monthEstimate: act.reduce((s, x) => s + (x.billing_cycle === 'monthly' ? x.total : Math.round(x.total / 12)), 0), awaitingTotal: awaiting.reduce((s, o) => s + o.total, 0), awaitingOrders: awaiting, creditBalance, credits, currency: 'KRW' };
}
export { getProductVersion, rowToProduct };
+28
View File
@@ -0,0 +1,28 @@
/* portal/commerce/public/billing.js — 결제 화면(§6.3~6.5): 활성 상품·다음 청구일·예상 금액·결제 대기·주문 목록·권리·환불/크레딧·축소 보류·해지·AI Wallet·초과 사용·도메인 등록 안내. */
(() => {
const el = document.getElementById('billing'); const esc = aw.esc; const money = (n) => Number(n).toLocaleString('ko-KR') + '원'; const GB = 1024 ** 3;
const fmt = (n, unit) => (unit.startsWith('bytes') ? (n / GB).toFixed(1) + ' GB' : Number(n).toLocaleString('ko-KR'));
async function load() {
try { const d = await aw.api('GET', '/api/commerce/billing');
let h = `<div class="cards"><div class="card"><h2>이번 달 예상</h2><p class="stat">${money(d.monthEstimate)}</p><p class="sub">다음 청구일 ${d.nextBillingAt ? aw.fmt(d.nextBillingAt) : '없음'}</p></div><div class="card"><h2>결제 대기</h2><p class="stat">${money(d.awaitingTotal)}</p><p class="sub">${d.awaitingOrders.length}건</p></div><div class="card"><h2>크레딧 잔액</h2><p class="stat">${money(d.creditBalance)}</p></div></div>`;
h += `<div class="card"><h2>활성 상품과 옵션</h2>${d.subscriptions.length ? d.subscriptions.map((s) => `<dl class="kv"><dt>상품</dt><dd>${esc(s.product.name)} v${s.product.version} <span class="badge ${s.status === 'active' ? 'ok' : 'warning'}">${s.status === 'active' ? '●' : '!'} ${esc(s.label)}</span></dd><dt>항목</dt><dd>${s.items.map((i) => esc(i.name) + '×' + i.qty).join(', ')}</dd><dt>청구</dt><dd>${money(s.total)} / ${s.billing_cycle === 'monthly' ? '월' : '년'} · 다음 ${s.next_billing_at ? aw.fmt(s.next_billing_at) : '-'}</dd>${s.payment_due_at ? `<dt>갱신 입금 기한</dt><dd>${aw.fmt(s.payment_due_at)} · 미납 제한 예정 ${aw.fmt(s.restrict_at)}</dd>` : ''}${s.cancel_at ? `<dt>해지 적용일</dt><dd>${aw.fmt(s.cancel_at)}</dd>` : ''}</dl><div class="actions">${s.status === 'cancel_scheduled' ? '<button class="btn" data-act="undo-cancel">해지 철회</button>' : '<button class="btn danger" data-act="cancel">상품 해지</button>'}<a class="btn" href="/products">상품 변경·축소</a></div>`).join('') : '<p class="muted">활성 상품이 없습니다.</p><a class="btn primary" href="/products">상품 보기</a>'}</div>`;
if (d.hold) h += `<div class="card" id="downsize"><h2>상품 축소 보류</h2><p>새 한도 안에 들어오지 않는 항목이 있어 축소가 보류되었습니다. 아래 항목을 정리한 뒤 "다시 검증" 을 누르세요.</p><ul>${d.hold.shortages.map((s) => `<li><b>${esc(s.label)}</b>: ${fmt(s.reduceBy, s.unit)} 줄이기 (새 한도 ${fmt(s.newLimit, s.unit)}, 배정+예약 ${fmt(s.inUse, s.unit)}, 사용 ${fmt(s.used, s.unit)})<ul>${s.items.map((i) => `<li>${esc(i.subjectType)} ${esc(i.subjectId)}: 배정 ${fmt(i.amount, s.unit)}</li>`).join('')}</ul></li>`).join('')}</ul><p class="sub">마지막 검증 ${aw.fmt(d.hold.last_checked_at)}</p><div class="actions"><button class="btn primary" data-act="revalidate">다시 검증</button><button class="btn" data-act="cancel-hold">축소 요청 취소</button></div></div>`;
h += `<div class="card"><h2>활성화된 권리</h2><table class="r"><thead><tr><th>자원</th><th>구매</th><th>배정</th><th>사용</th></tr></thead><tbody>${d.entitlements.filter((e) => e.purchased).map((e) => `<tr><td data-l="자원">${esc(e.label)}</td><td data-l="구매">${fmt(e.purchased, e.unit)}</td><td data-l="배정">${fmt(e.allocated, e.unit)}</td><td data-l="사용">${fmt(e.used, e.unit)}</td></tr>`).join('') || '<tr><td colspan="4" class="muted">없음</td></tr>'}</tbody></table></div>`;
h += `<div class="card"><h2>주문</h2>${d.orders.length ? `<table class="r"><thead><tr><th>번호</th><th>종류</th><th>금액</th><th>상태</th><th>일시</th></tr></thead><tbody>${d.orders.map((o) => `<tr><td data-l="번호"><a href="/orders/${esc(o.id)}">${esc(o.order_no)}</a></td><td data-l="종류">${esc({ new: '신규', renewal: '갱신', downsize: '축소' }[o.kind] || o.kind)}</td><td data-l="금액">${money(o.total)}</td><td data-l="상태">${esc(o.statusLabel)}</td><td data-l="일시">${aw.fmt(o.created_at)}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">주문이 없습니다.</p>'}</div>`;
h += `<div class="card"><h2>환불·크레딧</h2>${d.credits.length ? `<table class="r"><thead><tr><th>종류</th><th>금액</th><th>사유</th><th>적용일</th></tr></thead><tbody>${d.credits.map((c) => `<tr><td data-l="종류">${esc({ refund: '환불', credit: '크레딧', adjust: '조정' }[c.kind])}</td><td data-l="금액">${money(c.amount)}</td><td data-l="사유">${esc(c.reason)}</td><td data-l="적용일">${aw.fmt(c.applies_at)}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">없음</p>'}</div>`;
h += `<div class="card"><h2>AI Wallet</h2><p class="sub">제작(AI) 기능 전용이며 호스팅 디스크·트래픽·메일 사용량과 분리되어 있습니다.</p>${d.wallet && d.wallet.balance !== undefined ? `<p class="stat">${esc(d.wallet.unit ? d.wallet.balanceText || d.wallet.balance : money(d.wallet.balance))}</p>${(d.wallet.ledger || []).slice(0, 10).map((l) => `<p class="sub">${aw.fmt(l.at)} ${esc(l.type)} ${esc(String(l.amount))} ${esc(l.memo || '')}${l.refunded ? ' (실패 환급)' : ''}</p>`).join('')}<a class="btn" href="${esc(d.wallet.chargeUrl || '/sites')}">충전·이력</a>` : `<p class="muted">${esc(d.wallet?.message || '')}</p>`}</div>`;
h += `<div class="card"><h2>초과 사용 허용</h2><p class="sub">자동 초과 사용과 자동 추가 과금은 기본으로 꺼져 있습니다. 허용하려면 항목별 최대 금액 또는 최대 사용량과 기간을 정해야 하며, 철회하면 즉시 100% 제한이 다시 적용됩니다.</p>${d.overage.filter((o) => !o.revoked_at).map((o) => `<p>${esc(o.resource)}: 상한 ${o.cap_amount ? money(o.cap_amount) : o.cap_units + ' 단위'} · ${o.period_start.slice(0, 10)}~${o.period_end.slice(0, 10)} <button class="btn" data-act="revoke-overage" data-res="${esc(o.resource)}">철회</button></p>`).join('') || '<p class="muted">허용한 항목 없음</p>'}<form id="ov" class="f"><label>자원</label><select name="resource"><option value="web_out">월간 웹 아웃바운드</option><option value="mail_msgs">월간 메일 통수</option></select><label>최대 금액(원)</label><input name="capAmount" type="number" min="0"><label>기간</label><input name="periodStart" type="date" required> ~ <input name="periodEnd" type="date" required><div class="actions"><button class="btn">초과 사용 허용</button></div></form></div>`;
h += `<div class="card"><h2>도메인 등록</h2>${d.domainRegistration.enabled ? '<p>등록기관을 통해 도메인을 검색·등록할 수 있습니다.</p>' : `<p class="muted">${esc(d.domainRegistration.note)}</p>`}</div>`;
el.innerHTML = h;
el.querySelectorAll('[data-act]').forEach((b) => { b.onclick = async () => { const a = b.dataset.act; try {
if (a === 'cancel') { const p = await aw.api('GET', '/api/commerce/cancel/preview'); if (!(await aw.confirmDanger({ title: '상품 해지', target: '현재 그룹의 활성 상품', impacts: [`적용일: ${p.effectiveAt ? aw.fmt(p.effectiveAt) : '즉시'}`, ...p.effects, `보존: 사이트 ${p.retention?.site}일 · 메일 ${p.retention?.mail}일 · 소스 ${p.retention?.source}일`], typeToConfirm: '해지', confirmLabel: '해지 예약' }))) return; await aw.api('POST', '/api/commerce/cancel'); }
if (a === 'undo-cancel') await aw.api('POST', '/api/commerce/cancel/undo');
if (a === 'revalidate') { const r = await aw.api('POST', '/api/commerce/downsize/revalidate'); aw.toast(r.fits ? '정리가 확인되어 축소 주문이 만들어졌습니다' : '아직 정리할 항목이 남아 있습니다'); }
if (a === 'cancel-hold') await aw.api('POST', '/api/commerce/downsize/cancel');
if (a === 'revoke-overage') { if (!(await aw.confirmDanger({ title: '초과 사용 철회', target: b.dataset.res, impacts: ['철회 즉시 100% 제한이 다시 적용됩니다'] }))) return; await aw.api('POST', '/api/commerce/overage', { revoke: true, resource: b.dataset.res }); }
load(); } catch (e) { aw.toast(e.message); } }; });
el.querySelector('#ov').onsubmit = async (ev) => { ev.preventDefault(); const f = new FormData(ev.target); try { await aw.api('POST', '/api/commerce/overage', { resource: f.get('resource'), capAmount: Number(f.get('capAmount')) || null, periodStart: new Date(f.get('periodStart')).toISOString(), periodEnd: new Date(f.get('periodEnd') + 'T23:59:59').toISOString() }); load(); } catch (e) { aw.toast(e.message); } };
} catch (e) { aw.state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: load }); }
}
load();
})();
+17
View File
@@ -0,0 +1,17 @@
/* portal/commerce/public/order-detail.js — 주문 상세: 상태·금액·입금 안내·다음 단계·취소 가능 단계·남는 서비스. 처리 중이면 주기 갱신. */
(() => {
const el = document.getElementById('order'); const esc = aw.esc; const money = (n, c) => Number(n).toLocaleString('ko-KR') + (c === 'KRW' ? '원' : ' ' + c);
async function load() {
try { const { order: o } = await aw.api('GET', `/api/commerce/orders/${el.dataset.id}`); const pi = o.payment_instructions || {}; const kind = o.status === 'approved' ? 'done' : ['rejected', 'expired', 'cancelled'].includes(o.status) ? 'failed' : 'waiting';
el.innerHTML = `<div class="card"><div class="ph-row"><span class="badge ${kind}">${kind === 'done' ? '✔' : kind === 'failed' ? '✘' : '◔'} ${esc(o.statusLabel)}</span><span class="sub">마지막 갱신 ${aw.fmt(o.updated_at)}</span></div><p>${esc(o.nextStep)}</p>${o.remaining ? `<p class="sub">${esc(o.remaining)}</p>` : ''}
<dl class="kv"><dt>주문 번호</dt><dd class="mono">${esc(o.order_no)}</dd><dt>종류</dt><dd>${esc({ new: '신규', renewal: '갱신', downsize: '축소' }[o.kind] || o.kind)}</dd><dt>금액</dt><dd>${money(o.amount, o.currency)} + 세금 ${money(o.tax, o.currency)} = <b>${money(o.total, o.currency)}</b></dd><dt>결제 방식</dt><dd>계좌 이체(수동 결제 확인)</dd>${o.due_at ? `<dt>입금 기한</dt><dd>${aw.fmt(o.due_at)}</dd>` : ''}${o.paid_at ? `<dt>입금 확인</dt><dd>${aw.fmt(o.paid_at)}</dd>` : ''}${o.approved_at ? `<dt>승인</dt><dd>${aw.fmt(o.approved_at)}</dd>` : ''}<dt>취소 가능 단계</dt><dd>${esc(o.lastCancellableStep)}</dd></dl>
${o.status === 'awaiting_payment' ? `<h3>입금 안내</h3><p>${esc(pi.bank || '')} <span class="copy" data-action="copy" data-copy="${esc(pi.account || '')}">${esc(pi.account || '')}</span> (예금주 ${esc(pi.holder || '')})</p><p class="sub">${esc(pi.note || '')}</p>` : ''}
<table class="r"><thead><tr><th>항목</th><th>수량</th><th>적용 대상</th><th>단가</th><th>세금</th><th>합계</th></tr></thead><tbody>${o.items.map((i) => `<tr><td data-l="항목">${esc(i.name)}</td><td data-l="수량">${i.qty}</td><td data-l="대상">${i.target_type === 'site' ? '사이트 ' + esc(i.target_id || '') : '사용자 그룹'}</td><td data-l="단가">${money(i.unit_price, o.currency)}</td><td data-l="세금">${money(i.tax, o.currency)}</td><td data-l="합계">${money(i.amount + i.tax, o.currency)}</td></tr>`).join('')}</tbody></table>
${o.policyNotes ? `<p class="sub">입금 기한 ${o.policyNotes.paymentDeadlineDays}일 · 미납 ${o.policyNotes.restrictionAfterUnpaidDays}일 후 ${esc(o.policyNotes.restrictionEffect || '')}</p>` : ''}
<div class="actions"><a class="btn" href="/billing/invoice/${esc(o.id)}" target="_blank">${o.status === 'approved' ? '영수증' : '청구서'} 보기</a>${o.canCancel ? '<button class="btn danger" id="cancel">주문 취소</button>' : ''}${['rejected', 'expired'].includes(o.status) ? '<a class="btn primary" href="/products">다시 주문</a><a class="btn" href="/help">문의</a>' : ''}</div></div>`;
const c = el.querySelector('#cancel'); if (c) c.onclick = async () => { if (!(await aw.confirmDanger({ title: '주문 취소', target: o.order_no, impacts: ['권리 변화 없음', '입금한 경우 운영자에게 환불을 문의하세요'], confirmLabel: '취소' }))) return; try { await aw.api('POST', `/api/commerce/orders/${o.id}/cancel`); load(); } catch (e) { aw.toast(e.message); } };
return kind === 'waiting';
} catch (e) { aw.state(el, e.status === 404 ? 'empty' : 'failed', e.message, { retry: load }); return false; }
}
aw.poll(load, 10000);
})();
+28
View File
@@ -0,0 +1,28 @@
/* portal/commerce/public/order.js — 주문 흐름(§6.2): 상품·옵션 → 적용 대상 → 가격·청구 주기 → 약관·제한 → 결제 방식 → 제출. 멱등 키로 연타·새로 고침 중복을 막는다. */
(() => {
const el = document.getElementById('order-new'); const esc = aw.esc; const money = (n) => Number(n).toLocaleString('ko-KR') + '원';
const idem = sessionStorage.getItem('order-idem') || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())); sessionStorage.setItem('order-idem', idem);
let data; let pv; const sel = { includeBase: true, items: [] };
async function load() {
try { data = await aw.api('GET', '/api/commerce/products'); pv = data.products.find((p) => p.id === el.dataset.product) || data.products[0]; if (!pv) { aw.state(el, 'empty', '판매 중인 상품이 없습니다'); return; }
if (el.dataset.option) { sel.includeBase = false; sel.items = [{ optionId: el.dataset.option, qty: 1, targetId: el.dataset.target || null }]; } render(); }
catch (e) { aw.state(el, 'failed', e.message, { retry: load }); }
}
async function render() {
let quote; try { quote = await aw.api('POST', '/api/commerce/quote', { productVersionId: pv.id, includeBase: sel.includeBase, items: sel.items }); } catch (e) { aw.state(el, 'failed', e.message, { retry: render }); return; }
const q = quote.quote; const pi = quote.paymentInstructions;
el.innerHTML = `<div class="card"><h2>1. 상품과 옵션</h2><p><label><input type="checkbox" id="base" ${sel.includeBase ? 'checked' : ''}> ${esc(pv.name)} 기본 상품 (${money(pv.price)}/${pv.billingCycle === 'monthly' ? '월' : '년'})</label></p>
<table class="r"><thead><tr><th>옵션</th><th>가격</th><th>수량</th><th>적용 대상</th></tr></thead><tbody>${pv.options.map((o) => { const it = sel.items.find((x) => x.optionId === o.id); return `<tr><td data-l="옵션">${esc(o.name)}</td><td data-l="가격">${money(o.price)}${o.per === 'site' ? ' / 사이트' : ''}</td><td data-l="수량"><input type="number" min="0" max="${o.max_qty || 99}" value="${it ? it.qty : 0}" data-opt="${o.id}" style="width:5em"></td><td data-l="대상">${o.per === 'site' ? `<select data-target="${o.id}"><option value="">사이트 선택</option>${data.sites.map((s) => `<option value="${esc(s.id)}" ${it && it.targetId === s.id ? 'selected' : ''}>${esc(s.name)}</option>`).join('')}</select>` : '사용자 그룹'}</td></tr>`; }).join('')}</tbody></table></div>
<div class="card"><h2>2. 가격과 청구 주기</h2><table><tbody>${q.items.map((i) => `<tr><td>${esc(i.name)} × ${i.qty}</td><td class="right">${money(i.amount)} + 세금 ${money(i.tax)}</td></tr>`).join('')}<tr><th>합계(세금 포함)</th><th class="right">${money(q.total)} ${q.currency}</th></tr></tbody></table><p class="sub">청구 주기: ${pv.billingCycle === 'monthly' ? '매월' : '매년'} · 갱신 전 청구 예정일과 금액을 알려 드립니다</p></div>
<div class="card"><h2>3. 약관과 제한</h2><ul>${quote.terms.notes.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>${pv.deferred.length ? `<p><span class="badge warning">! 미확정 정책</span> ${pv.deferred.map((d) => esc(d.key)).join(', ')} — 확정 전에는 약속으로 보지 마세요</p>` : ''}<p><label><input type="checkbox" id="terms"> 약관·개인정보 처리방침·제한 사항에 동의합니다</label></p></div>
<div class="card"><h2>4. 결제 방식</h2><p><label><input type="radio" name="pm" value="manual_transfer" checked> 계좌 이체(수동 결제 확인)</label></p><p class="sub">${esc(pi.bank)} ${esc(pi.account)} (예금주 ${esc(pi.holder)}) · 입금 기한 ${pi.deadline_days}일 · ${esc(pi.note || '')}</p><p class="sub">운영자가 입금을 확인하고 승인한 뒤에만 사이트 수·용량·메일함·백업 권리를 쓸 수 있습니다.</p></div>
<div class="actions"><button class="btn primary" id="submit" ${q.items.length ? '' : 'disabled'}>주문 제출</button><a class="btn" href="/products">취소</a></div><p class="err" id="err"></p>`;
el.querySelector('#base').onchange = (e) => { sel.includeBase = e.target.checked; render(); };
el.querySelectorAll('[data-opt]').forEach((inp) => { inp.onchange = () => { const id = inp.dataset.opt; const qty = Number(inp.value) || 0; const t = el.querySelector(`[data-target="${id}"]`); sel.items = sel.items.filter((x) => x.optionId !== id); if (qty > 0) sel.items.push({ optionId: id, qty, targetId: t ? t.value || null : null }); render(); }; });
el.querySelectorAll('[data-target]').forEach((s) => { s.onchange = () => { const it = sel.items.find((x) => x.optionId === s.dataset.target); if (it) it.targetId = s.value || null; }; });
el.querySelector('#submit').onclick = async () => { const btn = el.querySelector('#submit'); btn.disabled = true; const err = el.querySelector('#err'); err.textContent = '';
try { const r = await aw.api('POST', '/api/commerce/orders', { productVersionId: pv.id, includeBase: sel.includeBase, items: sel.items, paymentMethod: 'manual_transfer', acceptTerms: el.querySelector('#terms').checked, idempotencyKey: idem }); sessionStorage.removeItem('order-idem'); location.href = `/orders/${r.order.id}`; }
catch (e) { err.textContent = e.message + (e.detail?.action === 'verify_email' ? ' — 내 계정에서 이메일 확인을 완료하세요' : ''); btn.disabled = false; } };
}
load();
})();
+20
View File
@@ -0,0 +1,20 @@
/* portal/commerce/public/products.js — 상품 목록(§6.1). 가격·한도는 서버(product_versions)에서 온다. 미확정 정책은 숫자 대신 '미확정 정책' 으로 보인다. */
(() => {
const el = document.getElementById('products'); const esc = aw.esc; const GB = 1024 ** 3;
const money = (n) => Number(n).toLocaleString('ko-KR') + '원'; const gb = (b) => (b / GB).toFixed(0) + ' GB';
async function load() {
try {
const d = await aw.api('GET', '/api/commerce/products');
if (!d.products.length) { aw.state(el, 'empty', '판매 중인 상품이 없습니다. 운영자에게 문의하세요'); return; }
el.innerHTML = `<p class="alert info">ⓘ 결제 방식: ${esc(d.paymentMethods.map((m) => m.label).join(', '))} — 자동 결제는 지원하지 않습니다.</p>` + `<div class="cards">${d.products.map((p) => `<div class="card"><h2>${esc(p.name)} <span class="sub">v${p.version}</span></h2><p class="sub">${esc(p.audience)}</p><p>${esc(p.description || '')}</p><p class="stat">${money(p.price)}<span class="sub">/${p.billingCycle === 'monthly' ? '월' : '년'} + 세금 ${money(p.tax)} = ${money(p.total)}</span></p>
<dl class="kv"><dt>사이트 수</dt><dd>${p.limits.sites}개</dd><dt>웹 디스크</dt><dd>${gb(p.limits.web_disk)}</dd><dt>월간 웹 아웃바운드</dt><dd>${gb(p.limits.web_out)}</dd><dt>메일 저장 공간</dt><dd>${gb(p.limits.mail_storage)}</dd><dt>월간 메일 통수</dt><dd>${p.limits.mail_msgs.toLocaleString('ko-KR')}통(송신+수신)</dd><dt>메일함</dt><dd>${p.limits.mailboxes}개</dd></dl>
<h3>추가 옵션</h3><ul>${p.options.map((o) => `<li>${esc(o.name)} — ${money(o.price)}${o.per === 'site' ? ' (사이트당)' : ''}</li>`).join('')}</ul>
<h3>제공 범위</h3><ul>${p.includes.map((x) => `<li>${esc(x)}</li>`).join('')}</ul><h3>제외</h3><ul>${p.excludes.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
${p.deferred.length ? `<h3>미확정 정책</h3><ul>${p.deferred.map((x) => `<li><span class="badge warning">! 미확정 정책</span> ${esc(x.key)} — ${esc(x.note)}</li>`).join('')}</ul>` : ''}
<p class="sub">사용량 경고 ${p.warnLevels.join('·')}% · 승인 방식: 운영자 수동 승인 · 적용 ${esc(p.effectiveFrom.slice(0, 10))}</p>
<div class="actions"><a class="btn primary" href="/order/new?product=${esc(p.id)}">주문</a></div></div>`).join('')}</div>` +
`<div class="card"><h2>도메인 등록</h2>${d.domainRegistration.enabled ? `<p>등록기관(${esc(d.domainRegistration.registrar || '')})을 통해 도메인을 검색·등록할 수 있습니다.</p>` : `<p class="muted">${esc(d.domainRegistration.note)}</p>`}</div>`;
} catch (e) { aw.state(el, 'failed', e.message, { retry: load }); }
}
load();
})();
+17
View File
@@ -0,0 +1,17 @@
/**
* portal/commerce/seed.mjs — 출시 검증용 Catalog 시드 명령.
* node portal/commerce/seed.mjs [catalog 파일] (DB 위치는 ARIWEB_PORTAL_DATA 또는 ./data)
* 같은 (product_id, version) 이 있으면 건너뛴다. 검증 실패면 아무것도 넣지 않고 종료 코드 1.
*/
import { openDb } from '../../lib/db.mjs';
import { allMigrations } from '../schema.mjs';
import { loadConfig } from '../config.mjs';
import { seedCatalog, loadCatalogFile, CATALOG_FILE } from './catalog.mjs';
import { join } from 'node:path';
const cfg = loadConfig();
const store = openDb(join(cfg.dataDir, 'portal.sqlite'), await allMigrations());
const file = process.argv[2] || CATALOG_FILE;
const r = seedCatalog(store, loadCatalogFile(file), 'seed-cli');
if (r.problems.length) { console.error('Catalog 검증 실패:\n - ' + r.problems.join('\n - ')); process.exit(1); }
console.log(`Catalog 시드 완료(${file}): 추가 ${r.inserted} · 이미 있음 ${r.skipped}`);