| |
| |
| |
| |
| |
| |
|
|
| 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; |
| } |
|
|
| |
| |
| |
| export async function getDistributionRules(campaignId: string): Promise<DistributionRule> { |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| export async function distributeCampaignBudget(params: { |
| campaignId: string; |
| budgetAmount: number; |
| platformFeeRate?: number; // ํ์ ํธํ์ฑ ์ ์ง |
| }): Promise<PayoutResult> { |
| const { campaignId, budgetAmount, platformFeeRate } = params; |
| const transactionId = crypto.randomUUID(); |
|
|
| |
| const rules = await getDistributionRules(campaignId); |
| const feeRate = platformFeeRate !== undefined ? platformFeeRate : rules.platformFeeRatio; |
|
|
| |
| const platformFee = Math.floor(budgetAmount * feeRate); |
| const userPool = Math.floor(budgetAmount * rules.citizenRewardRatio); |
|
|
| |
| 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('๋ถ๋ฐฐํ ํ๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.'); |
| } |
|
|
| |
| 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)}%)`, |
| }); |
|
|
| |
| 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, |
| }; |
| } |
|
|
| |
| |
| |
| export async function distributeCreditSaleProceeds(params: { |
| tradeId: string; |
| campaignId: string; |
| saleAmount: number; |
| verificationCost: number; |
| platformFeeRate?: number; // ํ์ ํธํ์ฑ ์ ์ง |
| }): Promise<PayoutResult> { |
| const { tradeId, campaignId, saleAmount, verificationCost, platformFeeRate } = params; |
|
|
| |
| const rules = await getDistributionRules(campaignId); |
| const feeRate = platformFeeRate !== undefined ? platformFeeRate : rules.platformFeeRatio; |
|
|
| |
| const platformFee = Math.floor(saleAmount * feeRate); |
| const netPool = Math.floor(saleAmount * rules.citizenRewardRatio) - verificationCost; |
|
|
| if (netPool <= 0) throw new Error('๊ณต์ ํ ๋ถ๋ฐฐ ๊ฐ๋ฅํ ์์ก์ด ์์ต๋๋ค.'); |
|
|
| |
| 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); |
|
|
| |
| 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: `์ธ๋ถ ๊ฒ์ฆ๋น ์ ์ฐ`, |
| }); |
|
|
| |
| 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, |
| }; |
| } |
|
|
| |
| |
| |
| export async function donateCreditsToSme(params: { |
| campaignId: string; |
| smeId: string; |
| smeName: string; |
| amountTon: number; |
| reason?: string; |
| }): Promise<string> { |
| 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; |
| } |
|
|