constnext={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};
}
exportasyncfunctionregister(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')){constr=cat.seedCatalog(store);deps.log?.(`[commerce] catalog 시드: 추가 ${r.inserted}${r.problems.length?' 문제 '+r.problems.join('; '):''}`);}
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('/orders/:id',(ctx)=>{requireGroup(ctx);consto=ord.getOrder(store,ctx.params.id,ctx.group.id);if(!o)returnctx.html(shell(ctx,{title:'주문',body:deps.statePanel('empty','주문을 찾을 수 없습니다',{href:'/billing'})}),404);returnctx.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);returnctx.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('/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/quote',async(ctx)=>{requireGroup(ctx);constb=awaitctx.body();constpv=cat.getProductVersion(store,b.productVersionId);if(!pv)thrownewHttpError(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/billing',async(ctx)=>{requireGroup(ctx);consts=ord.billingSummary(store,ctx.group.id);letwallet=null;try{wallet=deps.webmaker?.credits?awaitdeps.webmaker.credits(deps.webmaker.loginFor?deps.webmaker.loginFor(ctx.user,ctx.group):ctx.user.id)/* [통합] Webmaker 계정명은 어댑터의 loginFor 규칙 */: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.get('/api/commerce/cancel/preview',(ctx)=>{requireGroup(ctx);consts=ord.activeSubscription(store,ctx.group.id);constpv=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);constb=awaitctx.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})};});
constopsOrder=(o)=>{constg=store.get('SELECT id, name, kind, contact_email FROM groups WHERE id=?',o.group_id);constpv=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/groups/:groupId',async(ctx)=>{awaitrequireOps(ctx,deps);constgid=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}=awaitrequireOps(ctx,deps);consts=store.get('SELECT * FROM subscriptions WHERE id=?',ctx.params.id);if(!s)thrownewHttpError(404,'not_found','구독이 없습니다');constb=awaitctx.body();store.run('UPDATE subscriptions SET status=?, updated_at=? WHERE id=?',b.release?'active':'cancel_scheduled',newDate().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.get('/internal/ops/commerce/products',async(ctx)=>{awaitrequireOps(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}=awaitrequireOps(ctx,deps);constb=awaitctx.body();if(!b.productId)thrownewHttpError(400,'product_id_required','productId 가 필요합니다');return{ok:true,product:cat.createProductVersion(store,{productId:b.productId,base:b.baseVersionId||null,patch:b.patch||{},by:actor})};});