// 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 { 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 { 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 { 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 = { citizen: '시민', enterprise: '수혜 기업', platform: '플랫폼', reserve: '준비금', } return MAP[t] } export function payoutStatusBadge(s: PayoutStatus): { label: string; color: string } { const MAP: Record = { 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] }