Download src/lib/distributionEngine.ts from Esketch/CarboAny: direct link, hf CLI and curl.
- Browser
- Download file 8.45 kB
-
https://huggingface.co/spaces/Esketch/CarboAny/resolve/main/src/lib/distributionEngine.ts
- Command line
-
hf download hf://spaces/Esketch/CarboAny/src/lib/distributionEngine.ts
-
curl -L -o distributionEngine.ts https://huggingface.co/spaces/Esketch/CarboAny/resolve/main/src/lib/distributionEngine.ts
8.45 kB
| /** | |
| * 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<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; | |
| } | |
| } | |
| /** | |
| * [1์ฐจ ๋ถ๋ฐฐ] CPM์ด ์์นํ ์บ ํ์ธ ์ด์ ์์ฐ์ ์๋ฏผ ํ๋๋์ ๋ฐ๋ผ ๋ถ๋ฐฐํ๋ค. | |
| * ํ๋ซํผ์ ์ด์ ๋ํ ์์๋ฃ(FEE)๋ฅผ ์ ์ทจํ๋ค. (DB ์ ์ ๋น์จ ๊ธฐ๋ฐ ๋์ ๋ถ๋ฐฐ) | |
| */ | |
| export async function distributeCampaignBudget(params: { | |
| campaignId: string; | |
| budgetAmount: number; | |
| platformFeeRate?: number; // ํ์ ํธํ์ฑ ์ ์ง | |
| }): Promise<PayoutResult> { | |
| 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<PayoutResult> { | |
| 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<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; | |
| } | |