CarboAny / src /lib /settlementTrigger.ts
Esketch's picture
deploy: [P4] Strangler Pattern API Adapter Release (Orphan Clean Build v2)
daaf9d7
Raw
History Blame Contribute Delete
10 kB
// 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<Campaign, 'id' | 'nature_beneficiary_id' | 'nature_beneficiary_name'>
senderId: string
senderName: string
amountTon: number
idempotencyKey: string
description?: string
scope3CertId?: string
}): Promise<void> {
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<void> {
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<Array<{
id: string
type: string
amount: number
description: string | null
createdAt: string
}>> {
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<void> {
// 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)`,
})
}
}