CarboAny / src /lib /settlementReport.ts
Esketch's picture
deploy: [P4] Strangler Pattern API Adapter Release (Orphan Clean Build v2)
daaf9d7
Raw
History Blame Contribute Delete
7.57 kB
// WS-4 Settlement Report: client wrapper + row-level rendering helpers
// 의쑴: supabase, settlement_reconciliation_v1 view, settlement_distribution_cycles
import { supabase } from './supabase'
// ─── Types ──────────────────────────────────────────────────────────────────
export type ParticipantType = 'citizen' | 'enterprise' | 'platform' | 'reserve'
export type PayoutStatus = 'pending' | 'sent' | 'confirmed' | 'failed'
export type CycleStatus = 'pending' | 'completed' | 'failed' | 'compensating'
export interface SettlementParticipantRow {
rowId: string
cycleId: string
tradeId: string
cycleKey: string
cycleStatus: CycleStatus
grossProceeds: bigint
platformFee: bigint
beneficiaryPool: bigint
citizenPool: bigint
reservePool: bigint
roundingResidual: bigint
ruleSetVersion: string
participantId: string
participantType: ParticipantType
participantName: string
basisLabel: string | null
co2Grams: bigint | null
contributionWeight: number | null
grossAmount: bigint
withholdingTax: bigint
netAmount: bigint
ledgerTxnId: string | null
idempotencyKey: string
payoutStatus: PayoutStatus
distributedAt: string
}
export interface SettlementSummary {
cycleId: string
tradeId: string
cycleKey: string
cycleStatus: CycleStatus
grossProceeds: bigint
platformFee: bigint
beneficiaryPool: bigint
citizenPool: bigint
reservePool: bigint
roundingResidual: bigint
ruleSetVersion: string
participantCounts: { citizen: number; enterprise: number; platform: number; reserve: number }
totalDistributed: bigint
invariantOk: boolean
}
export interface DistributeResult {
cycleId: string
cycleKey: string
status: string
grossProceeds: number
platformFee: number
beneficiaryPool: number
citizenPool: number
reservePool: number
roundingResidual: number
totalDistributed: number
ruleSetVersion: string
}
// ─── Raw DB row mapping ──────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mapRow(r: any): SettlementParticipantRow {
return {
rowId: r.row_id,
cycleId: r.cycle_id,
tradeId: r.trade_id,
cycleKey: r.cycle_key,
cycleStatus: r.cycle_status as CycleStatus,
grossProceeds: BigInt(r.gross_proceeds ?? 0),
platformFee: BigInt(r.platform_fee ?? 0),
beneficiaryPool: BigInt(r.beneficiary_pool ?? 0),
citizenPool: BigInt(r.citizen_pool ?? 0),
reservePool: BigInt(r.reserve_pool ?? 0),
roundingResidual: BigInt(r.rounding_residual ?? 0),
ruleSetVersion: r.rule_set_version ?? 'v1.0',
participantId: r.participant_id,
participantType: r.participant_type as ParticipantType,
participantName: r.participant_name,
basisLabel: r.basis_label ?? null,
co2Grams: r.co2_grams != null ? BigInt(r.co2_grams) : null,
contributionWeight: r.contribution_weight != null ? Number(r.contribution_weight) : null,
grossAmount: BigInt(r.gross_amount ?? 0),
withholdingTax: BigInt(r.withholding_tax ?? 0),
netAmount: BigInt(r.net_amount ?? 0),
ledgerTxnId: r.ledger_txn_id ?? null,
idempotencyKey: r.idempotency_key,
payoutStatus: r.payout_status as PayoutStatus,
distributedAt: r.distributed_at,
}
}
// ─── API ─────────────────────────────────────────────────────────────────────
/**
* RPC 호좜: 거래 λŒ€κΈˆμ„ μ›μžμ μœΌλ‘œ λΆ„λ°°ν•œλ‹€.
* 동일 trade_id 재호좜 μ‹œ idempotent.
*/
export async function distributeProceeds(tradeId: string): Promise<DistributeResult> {
const { data, error } = await supabase.rpc('distribute_sale_proceeds', {
p_trade_id: tradeId,
})
if (error) throw error
return data as DistributeResult
}
/**
* νŠΉμ • 거래의 전체 μ •μ‚° 행을 μ‘°νšŒν•œλ‹€.
*/
export async function getReconciliationRows(tradeId: string): Promise<SettlementParticipantRow[]> {
const { data, error } = await supabase
.from('settlement_reconciliation_v1')
.select('*')
.eq('trade_id', tradeId)
.order('participant_type')
.order('gross_amount', { ascending: false })
if (error) throw error
return (data ?? []).map(mapRow)
}
/**
* νŠΉμ • μ‹œλ―Ό μ°Έμ—¬μžμ˜ λͺ¨λ“  μ •μ‚° 내역을 μ‘°νšŒν•œλ‹€.
* APP-14 "이번 μ •μ‚° μ˜ˆμƒ λΆ„λ°°" 프리뷰 μΉ΄λ“œμ— μ‚¬μš©.
*/
export async function getCitizenSettlements(
userId: string,
limit = 20,
): Promise<SettlementParticipantRow[]> {
const { data, error } = await supabase
.from('settlement_reconciliation_v1')
.select('*')
.eq('participant_id', userId)
.eq('participant_type', 'citizen')
.order('distributed_at', { ascending: false })
.limit(limit)
if (error) throw error
return (data ?? []).map(mapRow)
}
/**
* 거래 μ •μ‚° μš”μ•½ (집계)을 λ°˜ν™˜ν•œλ‹€.
* λΆˆλ³€ 검증: sum(net_amount) + rounding_residual == gross_proceeds
*/
export function buildSummary(rows: SettlementParticipantRow[]): SettlementSummary | null {
if (rows.length === 0) return null
const first = rows[0]
const counts = { citizen: 0, enterprise: 0, platform: 0, reserve: 0 }
let totalDistributed = 0n
for (const r of rows) {
counts[r.participantType]++
totalDistributed += r.netAmount
}
const invariantOk =
totalDistributed + first.roundingResidual === first.grossProceeds
return {
cycleId: first.cycleId,
tradeId: first.tradeId,
cycleKey: first.cycleKey,
cycleStatus: first.cycleStatus,
grossProceeds: first.grossProceeds,
platformFee: first.platformFee,
beneficiaryPool: first.beneficiaryPool,
citizenPool: first.citizenPool,
reservePool: first.reservePool,
roundingResidual: first.roundingResidual,
ruleSetVersion: first.ruleSetVersion,
participantCounts: counts,
totalDistributed,
invariantOk,
}
}
// ─── Rendering helpers ───────────────────────────────────────────────────────
const KRW_LOCALE: Intl.NumberFormatOptions = {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
}
export function formatKrw(amount: bigint | number): string {
return new Intl.NumberFormat('ko-KR', KRW_LOCALE).format(Number(amount))
}
export function participantTypeLabel(t: ParticipantType): string {
const MAP: Record<ParticipantType, string> = {
citizen: 'μ‹œλ―Ό',
enterprise: '수혜 κΈ°μ—…',
platform: 'ν”Œλž«νΌ',
reserve: 'μ€€λΉ„κΈˆ',
}
return MAP[t]
}
export function payoutStatusBadge(s: PayoutStatus): { label: string; color: string } {
const MAP: Record<PayoutStatus, { label: string; color: string }> = {
pending: { label: 'λŒ€κΈ°', color: 'text-amber-600 bg-amber-50' },
sent: { label: 'λ°œμ†‘', color: 'text-blue-600 bg-blue-50' },
confirmed: { label: 'ν™•μ •', color: 'text-green-600 bg-green-50' },
failed: { label: 'μ‹€νŒ¨', color: 'text-red-600 bg-red-50' },
}
return MAP[s]
}