| |
| |
|
|
| import { supabase } from './supabase' |
| import { getNatureSubject, getDefaultNatureSubject } from './natureSubjects' |
| import type { Campaign } from './database.types' |
| import { appendLedgerEntry } from './ledger' |
|
|
| |
| |
| |
| |
| |
| 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, |
| }, |
| }) |
| } |
|
|
| |
|
|
| export function calculateRewardPoints( |
| co2Grams: number, |
| missionRewardPoints: number | null, |
| campaign: { budget_krw: number; reward_pool_rate: number; target_co2_kg: number }, |
| ): { points: number; krw: number } { |
| |
| if (missionRewardPoints != null && missionRewardPoints > 0) { |
| return { points: missionRewardPoints, krw: missionRewardPoints } |
| } |
|
|
| |
| const rewardPool = campaign.budget_krw * campaign.reward_pool_rate |
| const co2Kg = co2Grams / 1000 |
| const targetKg = campaign.target_co2_kg || 1 |
| const points = Math.max(1, Math.floor((co2Kg / targetKg) * rewardPool)) |
| return { points, krw: points } |
| } |
|
|
| |
|
|
| 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 |
| } |
|
|
| |
|
|
| 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, |
| })) |
| } |
|
|
|
|
|
|
| |
|
|
| |
| |
| |
| |
| |
| export async function triggerCampaignSettlementOnApproval(mappingId: string): Promise<void> { |
| |
| 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})`) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| let amountTon = 0 |
| if (totalCo2Grams > 0) { |
| amountTon = totalCo2Grams / 1000000 |
| } else { |
| const targetKg = Number(camp.target_co2_kg || 0) |
| amountTon = targetKg / 1000 |
| } |
|
|
| |
| if (amountTon <= 0) { |
| amountTon = 1.0 |
| } |
|
|
| |
| const transactionId = `txn-issuance-${mappingId}-${Date.now()}` |
| const idempotencyKey = `idem-issuance-${mappingId}` |
|
|
| |
| 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)`, |
| }) |
|
|
| |
| 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) |
|
|
| |
| |
| 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)`, |
| }) |
| } |
| } |
|
|
|
|
|
|
|
|
|
|
|
|
|
|