Skip to content

Commit 0b8d7f0

Browse files
committed
Stablecoin income aggregation
1 parent 42d1902 commit 0b8d7f0

3 files changed

Lines changed: 389 additions & 3 deletions

File tree

.kiro/specs/workwork-ledger-mvp/tasks.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,11 +330,11 @@
330330
- 时间选择器、统计卡片、图表
331331
- _需求: 9.1-9.6_
332332

333-
- [ ] 20. 稳定币收入聚合
334-
- [ ] 20.1 实现按资产和链的收入聚合
333+
- [x] 20. 稳定币收入聚合
334+
- [x] 20.1 实现按资产和链的收入聚合
335335
- 统一总额 + 链明细
336336
- _需求: 7.3_
337-
- [ ] 20.2 编写属性测试:稳定币收入聚合一致性
337+
- [x] 20.2 编写属性测试:稳定币收入聚合一致性
338338
- **属性 17: 稳定币收入聚合一致性**
339339
- **验证: 需求 7.3**
340340

src/server/dashboard/dashboard.service.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)