Files
AriWeb-Suite/portal/commerce/api.mjs
T

86 lines
20 KiB
JavaScript

/**
* 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) };
}