@@ -46,6 +46,31 @@ export interface DashboardStats {
4646 paymentMethodDistribution : PaymentMethodDistribution [ ] ;
4747 periodAggregations : PeriodAggregation [ ] ;
4848 suggestedTaxReserve : Decimal | null ;
49+ stablecoinAggregation ?: StablecoinAggregation ;
50+ }
51+
52+ // ============================================
53+ // Stablecoin Aggregation Types (需求 7.3)
54+ // ============================================
55+
56+ export type StablecoinAsset = 'USDC' | 'USDT' ;
57+ export type Chain = 'arbitrum' | 'base' | 'polygon' ;
58+
59+ export interface ChainBreakdown {
60+ chain : Chain ;
61+ amount : Decimal ;
62+ percentage : number ;
63+ }
64+
65+ export interface AssetAggregation {
66+ asset : StablecoinAsset ;
67+ totalAmount : Decimal ;
68+ chainBreakdown : ChainBreakdown [ ] ;
69+ }
70+
71+ export interface StablecoinAggregation {
72+ totalStablecoinIncome : Decimal ;
73+ byAsset : AssetAggregation [ ] ;
4974}
5075
5176// ============================================
@@ -252,6 +277,103 @@ export function calculateTaxReserve(
252277 return totalIncome . mul ( taxRate ) ;
253278}
254279
280+ /**
281+ * Aggregate stablecoin income by asset and chain
282+ * Pure function for Property 17 testing
283+ * _需求: 7.3_
284+ */
285+ export function aggregateStablecoinIncome (
286+ entries : Array < {
287+ paymentMethod : PaymentMethod ;
288+ amountInDefaultCurrency : Decimal | string ;
289+ metadata ?: Record < string , unknown > | null ;
290+ } >
291+ ) : StablecoinAggregation {
292+ // Filter to only crypto payments
293+ const cryptoEntries = entries . filter (
294+ ( entry ) => entry . paymentMethod === 'crypto_usdc' || entry . paymentMethod === 'crypto_usdt'
295+ ) ;
296+
297+ // Map payment method to asset
298+ const getAsset = ( method : PaymentMethod ) : StablecoinAsset => {
299+ return method === 'crypto_usdc' ? 'USDC' : 'USDT' ;
300+ } ;
301+
302+ // Extract chain from metadata, default to 'arbitrum' if not specified
303+ const getChain = ( metadata ?: Record < string , unknown > | null ) : Chain => {
304+ if ( metadata && typeof metadata . chain === 'string' ) {
305+ const chain = metadata . chain as string ;
306+ if ( chain === 'arbitrum' || chain === 'base' || chain === 'polygon' ) {
307+ return chain ;
308+ }
309+ }
310+ return 'arbitrum' ; // Default chain
311+ } ;
312+
313+ // Aggregate by asset and chain
314+ const assetChainTotals = new Map < StablecoinAsset , Map < Chain , Decimal > > ( ) ;
315+
316+ for ( const entry of cryptoEntries ) {
317+ const asset = getAsset ( entry . paymentMethod ) ;
318+ const chain = getChain ( entry . metadata ) ;
319+ const amount = typeof entry . amountInDefaultCurrency === 'string'
320+ ? new Decimal ( entry . amountInDefaultCurrency )
321+ : entry . amountInDefaultCurrency ;
322+
323+ if ( ! assetChainTotals . has ( asset ) ) {
324+ assetChainTotals . set ( asset , new Map ( ) ) ;
325+ }
326+ const chainMap = assetChainTotals . get ( asset ) ! ;
327+ const current = chainMap . get ( chain ) || new Decimal ( 0 ) ;
328+ chainMap . set ( chain , current . add ( amount ) ) ;
329+ }
330+
331+ // Calculate total stablecoin income
332+ const totalStablecoinIncome = cryptoEntries . reduce ( ( sum , entry ) => {
333+ const amount = typeof entry . amountInDefaultCurrency === 'string'
334+ ? new Decimal ( entry . amountInDefaultCurrency )
335+ : entry . amountInDefaultCurrency ;
336+ return sum . add ( amount ) ;
337+ } , new Decimal ( 0 ) ) ;
338+
339+ // Build result structure
340+ const byAsset : AssetAggregation [ ] = [ ] ;
341+
342+ const assetKeys : StablecoinAsset [ ] = [ 'USDC' , 'USDT' ] ;
343+ for ( const asset of assetKeys ) {
344+ const chainMap = assetChainTotals . get ( asset ) ;
345+ if ( ! chainMap ) continue ;
346+
347+ const chainValues = Array . from ( chainMap . values ( ) ) ;
348+ const assetTotal = chainValues . reduce (
349+ ( sum : Decimal , amount : Decimal ) => sum . add ( amount ) ,
350+ new Decimal ( 0 )
351+ ) ;
352+
353+ const chainEntries = Array . from ( chainMap . entries ( ) ) ;
354+ const chainBreakdown : ChainBreakdown [ ] = chainEntries
355+ . map ( ( [ chain , amount ] : [ Chain , Decimal ] ) => ( {
356+ chain,
357+ amount,
358+ percentage : assetTotal . isZero ( ) ? 0 : amount . div ( assetTotal ) . mul ( 100 ) . toNumber ( ) ,
359+ } ) )
360+ . sort ( ( a : ChainBreakdown , b : ChainBreakdown ) => b . amount . minus ( a . amount ) . toNumber ( ) ) ;
361+
362+ byAsset . push ( {
363+ asset,
364+ totalAmount : assetTotal ,
365+ chainBreakdown,
366+ } ) ;
367+ }
368+
369+ // Sort by total amount descending
370+ byAsset . sort ( ( a , b ) => b . totalAmount . minus ( a . totalAmount ) . toNumber ( ) ) ;
371+
372+ return {
373+ totalStablecoinIncome,
374+ byAsset,
375+ } ;
376+ }
255377
256378// ============================================
257379// Database Query Functions
@@ -322,13 +444,23 @@ export async function getDashboardStats(
322444 }
323445 }
324446
447+ // Calculate stablecoin aggregation
448+ const stablecoinAggregation = aggregateStablecoinIncome (
449+ entries . map ( entry => ( {
450+ paymentMethod : entry . paymentMethod ,
451+ amountInDefaultCurrency : new Decimal ( entry . amountInDefaultCurrency . toString ( ) ) ,
452+ metadata : entry . metadata as Record < string , unknown > | null ,
453+ } ) )
454+ ) ;
455+
325456 return {
326457 totalIncome,
327458 totalIncomeInDefaultCurrency,
328459 topClients,
329460 paymentMethodDistribution,
330461 periodAggregations,
331462 suggestedTaxReserve,
463+ stablecoinAggregation,
332464 } ;
333465}
334466
@@ -428,3 +560,39 @@ export async function getPaymentMethodStats(
428560
429561 return calculatePaymentMethodDistribution ( entriesWithDecimal ) ;
430562}
563+
564+ /**
565+ * Get stablecoin income aggregation by asset and chain
566+ * _需求: 7.3_
567+ */
568+ export async function getStablecoinAggregation (
569+ userId : string ,
570+ startDate : Date ,
571+ endDate : Date
572+ ) : Promise < StablecoinAggregation > {
573+ const entries = await prisma . ledgerEntry . findMany ( {
574+ where : {
575+ userId,
576+ entryDate : {
577+ gte : startDate ,
578+ lte : endDate ,
579+ } ,
580+ paymentMethod : {
581+ in : [ 'crypto_usdc' , 'crypto_usdt' ] ,
582+ } ,
583+ } ,
584+ select : {
585+ paymentMethod : true ,
586+ amountInDefaultCurrency : true ,
587+ metadata : true ,
588+ } ,
589+ } ) ;
590+
591+ const entriesWithDecimal = entries . map ( entry => ( {
592+ paymentMethod : entry . paymentMethod ,
593+ amountInDefaultCurrency : new Decimal ( entry . amountInDefaultCurrency . toString ( ) ) ,
594+ metadata : entry . metadata as Record < string , unknown > | null ,
595+ } ) ) ;
596+
597+ return aggregateStablecoinIncome ( entriesWithDecimal ) ;
598+ }
0 commit comments