Cryptocurrency Tax Accounting System Development
Crypto tax accounting is more complex than it seems: each swap, staking reward, airdrop, NFT sale is a potentially taxable event. For a user with active DeFi history, that's hundreds of transactions per year. The system must classify them, calculate cost basis, and generate reports in the format needed for the jurisdiction.
Tax Event Classification
The key step before any calculation is correctly classifying each transaction:
enum TaxEventType {
DISPOSAL = "disposal", // sale, swap — capital gains/losses
INCOME = "income", // mining reward, staking, airdrops — ordinary income
PURCHASE = "purchase", // crypto purchase with fiat — not taxable in itself
TRANSFER = "transfer", // transfer between own wallets — not taxable
GAS_FEE = "gas_fee", // can be added to cost basis or deducted
GIFT_SENT = "gift_sent",
GIFT_RECEIVED = "gift_received",
FORK = "fork", // hard fork / airdrop
}
interface TaxEvent {
id: string;
userId: string;
timestamp: Date;
type: TaxEventType;
asset: string;
amount: number;
usdValueAtTime: number; // fair market value at time of event
costBasis?: number; // for disposal: cost basis of realized asset
gainsOrLoss?: number; // proceeds - cost_basis
isLongTerm?: boolean; // > 1 year holding (in US: lower rate)
txHash: string;
exchange?: string;
notes?: string;
}
Cost Basis Methods
Different jurisdictions require different methods:
FIFO (First In, First Out): first purchased — first sold. USA, UK, most jurisdictions by default.
LIFO (Last In, First Out): last purchased — first sold. Allowed in some cases in US.
HIFO (Highest In, First Out): sell most expensive first — minimizes tax. Beneficial in bull markets.
Weighted Average (Average Cost): Germany, Netherlands and several other EU countries.
class CostBasisCalculator {
// FIFO implementation
async calculateFIFO(
asset: string,
userId: string,
disposalAmount: number,
disposalDate: Date
): Promise<CostBasisResult> {
// Get all purchases in chronological order
const lots = await this.db.getAssetLots(userId, asset, {
orderBy: "acquired_at ASC",
remainingAmount: "> 0",
});
let remainingToDispose = disposalAmount;
let totalCostBasis = 0;
const usedLots: LotUsage[] = [];
for (const lot of lots) {
if (remainingToDispose <= 0) break;
const amountFromThisLot = Math.min(lot.remainingAmount, remainingToDispose);
const costBasisFromLot = (amountFromThisLot / lot.originalAmount) * lot.totalCostBasis;
totalCostBasis += costBasisFromLot;
remainingToDispose -= amountFromThisLot;
usedLots.push({
lotId: lot.id,
amountUsed: amountFromThisLot,
costBasisUsed: costBasisFromLot,
acquiredAt: lot.acquiredAt,
holdingPeriodDays: Math.floor(
(disposalDate.getTime() - lot.acquiredAt.getTime()) / 86400000
),
});
// Update lot balance
await this.db.reduceLotAmount(lot.id, amountFromThisLot);
}
return { totalCostBasis, usedLots, isLongTerm: this.isLongTerm(usedLots) };
}
// Weighted Average (for DE, NL)
async calculateAverageCost(
asset: string,
userId: string,
disposalAmount: number
): Promise<CostBasisResult> {
const { totalAmount, totalCost } = await this.db.getAggregatedPosition(userId, asset);
const averageCostPerUnit = totalCost / totalAmount;
return {
totalCostBasis: averageCostPerUnit * disposalAmount,
usedLots: [], // no separate lots in average cost
};
}
}
Historical Prices
Cost basis requires fair market value at the time of each transaction. Sources:
class PriceHistoryService {
async getHistoricalPrice(asset: string, timestamp: Date): Promise<number> {
// 1. Check own cache
const cached = await this.cache.get(asset, timestamp);
if (cached) return cached;
// 2. CoinGecko API (free tier: 1 year history)
const price = await this.coingecko.getHistoricalPrice(asset, timestamp);
// 3. Fallback: CryptoCompare, Messari
if (!price) {
return this.cryptoCompare.getHistoricalClose(asset, timestamp);
}
await this.cache.set(asset, timestamp, price);
return price;
}
}
Problem: for illiquid tokens (obscure altcoins), historical prices may not exist. In such cases, either use CEX data or document as "price not determinable".
DeFi Specifics
DeFi transactions are the most complex part:
Liquidity provision (Uniswap V2 LP): deposit two tokens → receive LP tokens. This is NOT taxable in itself in most jurisdictions. But on liquidity withdrawal — each received token is compared against LP token cost basis.
Uniswap V3 (concentrated liquidity): even more complex as position has range and impermanent loss. Each fee change is potential income event.
Yield farming / staking rewards: most jurisdictions treat as ordinary income at receipt by fair market value.
Airdrop: controversial. US: taxable income at receipt. Several EU countries: taxable only on sale.
Tax Report Generation
Different formats for different jurisdictions:
// US: Schedule D compatible format
function generateScheduleD(events: TaxEvent[]): ScheduleDRow[] {
return events
.filter(e => e.type === TaxEventType.DISPOSAL)
.map(e => ({
description: `${e.amount} ${e.asset}`,
dateAcquired: formatDate(e.costBasisLot.acquiredAt),
dateSold: formatDate(e.timestamp),
proceeds: e.usdValueAtTime,
costBasis: e.costBasis!,
gainOrLoss: e.gainsOrLoss!,
term: e.isLongTerm ? "LONG" : "SHORT",
}));
}
// UK: HMRC Capital Gains Summary
function generateHMRCSummary(events: TaxEvent[], taxYear: string): HMRCSummary {
// UK uses "pool" method (Section 104 pool) + 30-day same-day rule
const ukEvents = applyUKPoolingRules(events);
return formatHMRCReport(ukEvents, taxYear);
}
Stack
| Component | Technology |
|---|---|
| Transaction import | Exchange APIs (Binance, Coinbase) + wallet indexing |
| Price history | CoinGecko + CryptoCompare |
| Cost basis engine | Node.js + PostgreSQL |
| Report generation | PDF (PDFKit) + CSV + Excel |
| Frontend | React + TypeScript |
Crypto tax accounting system with FIFO/LIFO/Average, DeFi classification and multi-jurisdictional reports — 6-10 weeks development.







