Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
112 lines
12 KiB
JavaScript
112 lines
12 KiB
JavaScript
/**
|
|
* 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'] };
|
|
}
|