/** * Distribution Engine v1.2: Two-Phase Settlement System * 1. Primary: CPM Budget -> Participants (minus Platform Fee) * 2. Secondary: Credit Sale (FIN) -> Participants (minus Fee + Verification) * 3. Donation: Credit Allocation to SME (No Cash) */ import { supabase } from './supabase'; import { appendLedgerEntry } from './ledger'; import type { TransactionCategory } from '../types'; export interface PayoutResult { transactionId: string; totalDistributed: number; platformFee: number; recipientCount: number; } export interface DistributionRule { id: string; campaignId: string; citizenRewardRatio: number; smeDonationRatio: number; platformFeeRatio: number; ruleType: string; description: string | null; } /** * 캠페인별 정의된 동적 분배 룰 비율을 데이터베이스에서 가져온다. (P1-8) */ export async function getDistributionRules(campaignId: string): Promise { const fallback: DistributionRule = { id: 'fallback-rule', campaignId, citizenRewardRatio: 0.80, smeDonationRatio: 0.15, platformFeeRatio: 0.05, ruleType: 'DEFAULT', description: '폴백 기본 비율 (시민 80%, SME 15%, 플랫폼 5%)', }; try { const { data, error } = await supabase .from('distribution_rules') .select('*') .eq('campaign_id', campaignId) .maybeSingle(); if (error || !data) return fallback; return { id: String(data.id), campaignId: String(data.campaign_id), citizenRewardRatio: Number(data.citizen_reward_ratio), smeDonationRatio: Number(data.sme_donation_ratio), platformFeeRatio: Number(data.platform_fee_ratio), ruleType: String(data.rule_type), description: data.description ? String(data.description) : null, }; } catch { return fallback; } } /** * [1차 분배] CPM이 예치한 캠페인 운영 예산을 시민 활동량에 따라 분배한다. * 플랫폼은 운영 대행 수수료(FEE)를 선취한다. (DB 정의 비율 기반 동적 분배) */ export async function distributeCampaignBudget(params: { campaignId: string; budgetAmount: number; platformFeeRate?: number; // 하위 호환성 유지 }): Promise { const { campaignId, budgetAmount, platformFeeRate } = params; const transactionId = crypto.randomUUID(); // 0. 동적 분배 룰 쿼리 (P1-8) const rules = await getDistributionRules(campaignId); const feeRate = platformFeeRate !== undefined ? platformFeeRate : rules.platformFeeRatio; // 1. 플랫폼 수수료 및 보상 풀 계산 const platformFee = Math.floor(budgetAmount * feeRate); const userPool = Math.floor(budgetAmount * rules.citizenRewardRatio); // 2. 활동 인증 데이터 집계 const { data: participants } = await supabase .from('my_nature_contributions_view') .select('user_id, display_name, co2_grams') .eq('campaign_id', campaignId) .gt('co2_grams', 0); const totalCo2 = (participants || []).reduce((sum, p) => sum + Number(p.co2_grams), 0); if (totalCo2 === 0 || !participants || participants.length === 0) { throw new Error('분배할 활동 데이터가 없습니다.'); } // 3. 플랫폼 수수료 원장 기록 await appendLedgerEntry({ transactionId, campaignId, senderId: 'CPM_ESCROW', senderName: '캠페인 예산 풀', receiverId: 'PLATFORM_OPERATIONS', receiverName: 'Carboany 플랫폼 운영', category: 'FEE_DEDUCTION' as TransactionCategory, asset: 'KRW', amount: platformFee, idempotencyKey: `p1-fee-${transactionId}`, description: `캠페인 운영 대행 수수료 (${(feeRate * 100).toFixed(1)}%)`, }); // 4. 사용자별 활동비(현금성 포인트) 지급 for (const p of participants) { const share = Number(p.co2_grams) / totalCo2; const amount = Math.floor(userPool * share); if (amount <= 0) continue; await appendLedgerEntry({ transactionId, campaignId, senderId: 'CPM_ESCROW', senderName: '캠페인 예산 풀', receiverId: p.user_id, receiverName: p.display_name, category: 'POINT_REWARD' as TransactionCategory, asset: 'POINT', amount, idempotencyKey: `p1-reward-${campaignId}-${p.user_id}-${new Date().toISOString().slice(0, 10)}`, description: `활동 인증 보상 (감축량 기여도 ${(share * 100).toFixed(2)}%)`, }); } return { transactionId, totalDistributed: userPool, platformFee, recipientCount: participants.length, }; } /** * [2차 분배] 발행된 탄소배출권을 금융사(FIN)에 매각하고, 그 수익을 시민에게 추가 분배한다. */ export async function distributeCreditSaleProceeds(params: { tradeId: string; campaignId: string; saleAmount: number; verificationCost: number; platformFeeRate?: number; // 하위 호환성 유지 }): Promise { const { tradeId, campaignId, saleAmount, verificationCost, platformFeeRate } = params; // 0. 동적 분배 룰 쿼리 (P1-8) const rules = await getDistributionRules(campaignId); const feeRate = platformFeeRate !== undefined ? platformFeeRate : rules.platformFeeRatio; // 1. 공제액 계산 const platformFee = Math.floor(saleAmount * feeRate); const netPool = Math.floor(saleAmount * rules.citizenRewardRatio) - verificationCost; if (netPool <= 0) throw new Error('공제 후 분배 가능한 잔액이 없습니다.'); // 2. 기여 참여자 목록 재조회 const { data: participants } = await supabase .from('my_nature_contributions_view') .select('user_id, display_name, co2_grams') .eq('campaign_id', campaignId) .gt('co2_grams', 0); const totalCo2 = (participants || []).reduce((sum, p) => sum + Number(p.co2_grams), 0); // 3. 원장 기록: 수수료 및 검증비 await appendLedgerEntry({ transactionId: tradeId, campaignId, senderId: 'FIN_PAYMENT', senderName: '금융사 매입 대금', receiverId: 'PLATFORM_FINANCE', receiverName: 'Carboany 금융 수수료', category: 'FEE_DEDUCTION' as TransactionCategory, asset: 'KRW', amount: platformFee, idempotencyKey: `p2-fee-${tradeId}`, description: `배출권 거래 중개 수수료 (${(feeRate * 100).toFixed(1)}%)`, }); await appendLedgerEntry({ transactionId: tradeId, campaignId, senderId: 'FIN_PAYMENT', senderName: '금융사 매입 대금', receiverId: 'VERIFICATION_ENTITY', receiverName: '외부 검증 기관', category: 'FEE_DEDUCTION' as TransactionCategory, asset: 'KRW', amount: verificationCost, idempotencyKey: `p2-verif-${tradeId}`, description: `외부 검증비 정산`, }); // 4. 시민 추가 수익 공유 if (totalCo2 > 0 && participants) { for (const p of participants) { const share = Number(p.co2_grams) / totalCo2; const amount = Math.floor(netPool * share); if (amount <= 0) continue; await appendLedgerEntry({ transactionId: tradeId, campaignId, senderId: 'FIN_PAYMENT', senderName: '금융사 매입 대금', receiverId: p.user_id, receiverName: p.display_name, category: 'POINT_REWARD' as TransactionCategory, asset: 'POINT', amount, idempotencyKey: `p2-dist-${tradeId}-${p.user_id}`, description: `탄소배출권 매각 수익 공유 (기여도 ${(share * 100).toFixed(2)}%)`, }); } } return { transactionId: tradeId, totalDistributed: netPool, platformFee, recipientCount: participants?.length || 0, }; } /** * [상생/기부] 탄소배출권을 SME에게 기부/기여한다. */ export async function donateCreditsToSme(params: { campaignId: string; smeId: string; smeName: string; amountTon: number; reason?: string; }): Promise { const { campaignId, smeId, smeName, amountTon, reason } = params; const transactionId = crypto.randomUUID(); await appendLedgerEntry({ transactionId, campaignId, senderId: 'CPM_DECISION', senderName: '캠페인 의사결정체', receiverId: smeId, receiverName: smeName, category: 'CREDIT_DONATION' as TransactionCategory, asset: 'TON', amount: amountTon, idempotencyKey: `sme-alloc-${transactionId}`, description: reason || `${smeName} 기업에 탄소배출권 기부/기여 (${amountTon} tCO₂e)`, }); return transactionId; }