CarboAny / src /lib /distributionEngine.ts
Esketch's picture
deploy: [P4] Strangler Pattern API Adapter Release (Orphan Clean Build v2)
daaf9d7
Raw
History Blame Contribute Delete
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;
}