// Settlement Trigger: connects verification approval to point reward + escrow + ledger // Patent claims 1, 5: verification → settlement automatic binding import { supabase } from './supabase' import { getNatureSubject, getDefaultNatureSubject } from './natureSubjects' import type { Campaign } from './database.types' import { appendLedgerEntry } from './ledger' /** * 캠페인에 등록된 자연 주체로 크레딧을 상쇄(retire)한다. * receiver는 반드시 `nature:*` ID — 자연이 원장 거래 당사자로 기록됨. * 특허 청구항 1·4: 자연 권리 기록 모듈. */ export async function retireCreditToNature(params: { transactionId: string campaign: Pick senderId: string senderName: string amountTon: number idempotencyKey: string description?: string scope3CertId?: string }): Promise { const nature = getNatureSubject(params.campaign.nature_beneficiary_id) ?? getDefaultNatureSubject() await appendLedgerEntry({ transactionId: params.transactionId, campaignId: params.campaign.id, senderId: params.senderId, senderName: params.senderName, receiverId: nature.id, receiverName: params.campaign.nature_beneficiary_name ?? nature.name, category: 'CREDIT_RETIRE', asset: 'TON', amount: params.amountTon, idempotencyKey: params.idempotencyKey, description: params.description ?? `${params.senderName} → ${nature.name}로 ${params.amountTon.toFixed(4)} tCO₂e 회복`, referenceData: { natureBeneficiary: nature.id, scope3CertId: params.scope3CertId, }, }) } // --- Reward calculation --- export function calculateRewardPoints( co2Grams: number, missionRewardPoints: number | null, campaign: { budget_krw: number; reward_pool_rate: number; target_co2_kg: number }, ): { points: number; krw: number } { // Fixed points per mission if defined if (missionRewardPoints != null && missionRewardPoints > 0) { return { points: missionRewardPoints, krw: missionRewardPoints } } // Dynamic: proportional to CO2 contribution vs campaign target const rewardPool = campaign.budget_krw * campaign.reward_pool_rate const co2Kg = co2Grams / 1000 const targetKg = campaign.target_co2_kg || 1 // avoid division by zero const points = Math.max(1, Math.floor((co2Kg / targetKg) * rewardPool)) return { points, krw: points } } // --- Settlement pipeline (atomic via RPC) --- export async function processApprovedSubmission(params: { submissionId: string userId: string campaignId: string co2Grams: number rewardPoints: number rewardKrw: number }): Promise { const { error } = await supabase.rpc('process_approved_submission', { p_submission_id: params.submissionId, p_user_id: params.userId, p_campaign_id: params.campaignId, p_co2_grams: params.co2Grams, p_reward_points: params.rewardPoints, p_reward_krw: params.rewardKrw, }) if (error) throw error } // --- Query helpers --- export async function getUserPointBalance(userId: string, campaignId: string): Promise<{ totalEarned: number totalUsed: number available: number } | null> { const { data, error } = await supabase .from('point_balances') .select('*') .eq('user_id', userId) .eq('campaign_id', campaignId) .maybeSingle() if (error) throw error if (!data) return null return { totalEarned: data.total_earned, totalUsed: data.total_used, available: data.available, } } export async function getPointTransactions(userId: string, campaignId?: string): Promise> { let query = supabase .from('point_transactions') .select('*') .eq('user_id', userId) .order('created_at', { ascending: false }) if (campaignId) query = query.eq('campaign_id', campaignId) const { data, error } = await query if (error) throw error return (data ?? []).map((row) => ({ id: row.id, type: row.type, amount: row.amount, description: row.description, createdAt: row.created_at, })) } // --- Verification & Credit Issuance automatic binding --- /** * VCS/Gold Standard 등 외부 레지스트리 프로젝트 매핑이 최종 VVB approved 승인되었을 때, * 해당 캠페인에 대한 감축 실적을 바탕으로 공식 탄소 크레딧(TON)을 발행 및 정산 분배합니다. * 특허 청구항 1, 5: verification → settlement automatic binding */ export async function triggerCampaignSettlementOnApproval(mappingId: string): Promise { // 1. 매핑 정보 및 연결된 캠페인 조회 const { data: mapping, error: mapErr } = await supabase .from('external_project_mappings') .select('*, campaigns:internal_id(id, title, nature_beneficiary_id, nature_beneficiary_name, target_co2_kg)') .eq('id', mappingId) .single() if (mapErr || !mapping) { throw new Error(`[triggerCampaignSettlementOnApproval] 매핑 정보를 찾을 수 없습니다. (ID: ${mappingId})`) } // 이중 정산 발행 방지 가드 if (mapping.verification_level === 'approved') { throw new Error(`[triggerCampaignSettlementOnApproval] 이미 승인 및 정산 완료된 매핑입니다. (ID: ${mappingId})`) } const camp = mapping.campaigns as any if (!camp) { throw new Error(`[triggerCampaignSettlementOnApproval] 매핑에 연결된 캠페인 정보가 없습니다. (ID: ${mappingId})`) } // 2. 해당 캠페인의 총 승인 감축량(CO2 grams) 계산 const { data: activities, error: actErr } = await supabase .from('activities') .select('co2_grams') .eq('campaign_id', camp.id) .eq('status', 'approved') if (actErr) throw actErr let totalCo2Grams = 0 if (activities && activities.length > 0) { totalCo2Grams = activities.reduce((sum, act) => sum + (act.co2_grams || 0), 0) } // 감축 실적이 0일 경우, 캠페인 목표치 target_co2_kg를 폴백으로 사용 let amountTon = 0 if (totalCo2Grams > 0) { amountTon = totalCo2Grams / 1000000 // 1,000,000 g = 1 tCO2e = 1 TON } else { const targetKg = Number(camp.target_co2_kg || 0) amountTon = targetKg / 1000 // 1,000 kg = 1 TON } // 발행할 톤수가 0이하이면 발행 불가 if (amountTon <= 0) { amountTon = 1.0 // 기본 폴백 최소 1톤 보장 } // 고유한 transactionId 생성 (mappingId + timestamp) const transactionId = `txn-issuance-${mappingId}-${Date.now()}` const idempotencyKey = `idem-issuance-${mappingId}` // 3. appendLedgerEntry를 통해 원장에 CREDIT_ISSUANCE 공식 발행 기록 await appendLedgerEntry({ transactionId, campaignId: camp.id, senderId: mapping.registry_code, senderName: mapping.registry_code, receiverId: camp.nature_beneficiary_id || 'nature:unspecified', receiverName: camp.nature_beneficiary_name || '자연 보존림', category: 'CREDIT_ISSUANCE', asset: 'TON', amount: amountTon, idempotencyKey, description: `공식 검증 통과에 따른 탄소 크레딧 발행: ${mapping.registry_code} → ${camp.nature_beneficiary_name || '자연'} (${amountTon.toFixed(2)} TON)`, }) // 4. 캠페인 동적 분배 비율 조회 const { data: rules } = await supabase .from('distribution_rules') .select('*') .eq('campaign_id', camp.id) .maybeSingle() const reserveRate = Number(rules?.reserve_rate ?? 0.70) const donationRate = Number(rules?.donation_rate ?? 0.20) const platformRate = Number(rules?.platform_rate ?? 0.10) // 5. 산출된 각 지분 비율에 맞춰 크레딧 지분 분배 원장 3회 순차 기록 // 자바스크립트 부동소수점 오차 보정을 위해 소수점 4째 자리까지 반올림 정규화 처리 const reserveAmount = Math.round(amountTon * reserveRate * 10000) / 10000 if (reserveAmount > 0) { await appendLedgerEntry({ transactionId, campaignId: camp.id, senderId: camp.nature_beneficiary_id || 'nature:unspecified', senderName: camp.nature_beneficiary_name || '자연', receiverId: camp.nature_beneficiary_id || 'nature:unspecified', receiverName: camp.nature_beneficiary_name || '자연', category: 'CREDIT_RETIRE', // 자연에 영구 귀속 및 상쇄 asset: 'TON', amount: reserveAmount, idempotencyKey: `${idempotencyKey}-reserve`, description: `동적 지분 분배 [Reserve ${reserveRate * 100}%]: 자연 환원 크레딧 영구 상쇄 (${reserveAmount.toFixed(4)} TON)`, }) } const donationAmount = Math.round(amountTon * donationRate * 10000) / 10000 if (donationAmount > 0) { await appendLedgerEntry({ transactionId, campaignId: camp.id, senderId: camp.nature_beneficiary_id || 'nature:unspecified', senderName: camp.nature_beneficiary_name || '자연', receiverId: 'sme:allocated', receiverName: 'SME ESG 지원 수혜 풀', category: 'CREDIT_DONATION', asset: 'TON', amount: donationAmount, idempotencyKey: `${idempotencyKey}-donation`, description: `동적 지분 분배 [SME ${donationRate * 100}%]: 수혜 중소기업 ESG Scope 3 할당 풀 이관 (${donationAmount.toFixed(4)} TON)`, }) } const platformAmount = Math.round(amountTon * platformRate * 10000) / 10000 if (platformAmount > 0) { await appendLedgerEntry({ transactionId, campaignId: camp.id, senderId: camp.nature_beneficiary_id || 'nature:unspecified', senderName: camp.nature_beneficiary_name || '자연', receiverId: 'platform:fees', receiverName: 'Carboany 플랫폼 수수료 원장', category: 'FEE_DEDUCTION', asset: 'TON', amount: platformAmount, idempotencyKey: `${idempotencyKey}-platform`, description: `동적 지분 분배 [Platform ${platformRate * 100}%]: 플랫폼 운영 수수료 징수 (${platformAmount.toFixed(4)} TON)`, }) } }