Files
tabungin/SINGLE_SOURCE_OF_TRUTH.md
Dwindi Ramadhana 6a6e74562c checkpoint: goals feature, wallet balance, and goals/wallet detail UI
- Add goals feature (models, migrations, API, web pages)
- Add reserved/centralized wallet balance service
- Add wallet detail page and overview components
- Add new UI components (progress, multi-select, FAB)
- Remove stray empty -H/-d files from working tree
2026-06-17 20:40:00 +07:00

6.7 KiB
Executable File

🎯 Single Source of Truth - Tabungin Project

📋 Core Rules

1. Currency & Exchange Rates

A. Exchange Rate Format

  • Source: External API (fetched via fetchExchangeRates())
  • Format: { USD: 0.0000602159, EUR: 0.000051876, ... }
  • Meaning: 1 IDR = X foreign currency
  • Example: USD: 0.0000602159 means 1 IDR = 0.0000602159 USD

B. Currency Symbols

  • Source: /apps/web/src/constants/currencies.ts
  • Function: formatCurrency(amount, currencyCode)
  • Features:
    • Automatic thousand separators (useGrouping: true)
    • IDR: No decimals, id-ID locale (e.g., Rp 100.000)
    • Other currencies: 2 decimals, en-US locale (e.g., $ 17.90)
    • Units: 0-2 decimals, id-ID locale (e.g., 80 gram)

2. Price Format & Conversions

A. Single Source Utility

File: /apps/web/src/utils/walletCalculations.ts

Functions:

  1. convertIDRToWalletCurrency() - Convert IDR to wallet's native currency/units
  2. convertWalletCurrencyToIDR() - Convert wallet's native currency/units to IDR
  3. formatWalletBalance() - Format balance with equivalent
  4. formatAllocationAmount() - Format allocation (IDR) with wallet equivalent

B. Conversion Rules

For Money Wallets (non-IDR):

// IDR → Foreign Currency
foreignAmount = idrAmount * exchangeRate
// Example: 100,000 IDR * 0.0000602159 = 6.02 USD

// Foreign Currency → IDR
idrAmount = foreignAmount / exchangeRate
// Example: 17.90 USD / 0.0000602159 = 297,264 IDR

For Asset Wallets:

// IDR → Units
units = idrAmount / pricePerUnit
// Example: 100,000 IDR / 2,073,000 = 0.048 gram

// Units → IDR
idrAmount = units * pricePerUnit
// Example: 80 gram * 2,073,000 = 165,840,000 IDR

3. Data Storage Rules

A. Goals & Allocations

  • Storage: Always in IDR
  • Fields:
    • Goal.targetAmount: IDR
    • Goal.currentAmount: IDR
    • GoalAllocation.amount: IDR
    • GoalAllocation.currency: Always 'IDR'
    • GoalAllocation.amountInGoalCurrency: IDR (same as amount)

B. Wallet Balances

  • Storage: In wallet's native currency/units
  • Fields:
    • Wallet.initialAmount: Native currency/units
    • WalletBalance.totalBalance: Native currency/units
    • WalletBalance.reservedBalance: Native currency/units
    • WalletBalance.availableBalance: Native currency/units
    • For assets: WalletBalance.totalUnits: Units

C. Transactions

  • Storage: In wallet's native currency/units
  • Fields:
    • Transaction.amount: Native currency/units
    • Transaction.type: 'in' | 'out'

4. Display Consistency

A. Wallet Cards

Primary: Native currency/units Secondary: IDR equivalent (if not IDR)

$ 17.90 ≈ Rp 297.264
80 gram ≈ Rp 165.840.000

B. Wallet Card Stacked Bar (UX Priority)

Visual Order: Available FIRST (green), then allocations (colored)

  • Green bar = Available money (what user can spend)
  • Colored segments = Allocated to goals (locked)

Example: 67% available → 67% green bar, 33% blue segments

C. Allocation Popover (UX Priority)

Display Order:

  1. Available (most important - what user can spend)
  2. Separator line
  3. Allocations (secondary - locked for goals)

Available Format:

  • Primary: Native currency/units
  • Secondary: IDR equivalent

Allocation Format:

  • Primary: IDR amount
  • Secondary: Native currency/units equivalent
Available (67.0%)
$ 11.90
≈ Rp 197.264

Allocations:
---
MacbookPro (33.0%)
Rp 100.000
≈ $ 6.02

D. Wallet Table View

Same as card view: Native currency/units first, IDR secondary

Setoran Column (Total Balance):

$ 17.90
≈ Rp 297.264

Alokasi Column (Reserved Balance):

10,13 gram
≈ Rp 10

E. Goal Progress

Always: IDR

Rp 21.000.000 / Rp 25.000.000

5. Percentage Calculations

A. Wallet Card Stacked Bar

// Convert allocation (IDR) to wallet currency
amountInWalletCurrency = convertIDRToWalletCurrency(
  allocation.amount,
  wallet,
  exchangeRates,
  pricePerUnit
);

// Calculate percentage
percentage = (amountInWalletCurrency / balance.totalBalance) * 100;

// Clamp to prevent overflow
width = Math.min(percentage, 100);

B. Reserved Percentage

// Already in wallet's native currency
reservedPercentage = (balance.reservedBalance / balance.totalBalance) * 100;

6. Backend Validation

A. Add Allocation Flow

  1. Receive amount in IDR from frontend
  2. Convert to wallet's native currency for validation:
    amountInWalletCurrency = convertIDRToWalletCurrency(idrAmount, ...);
    if (amountInWalletCurrency > wallet.availableBalance) throw Error;
    
  3. Store allocation in IDR
  4. Update wallet reserved balance in native currency

B. Spend Transaction Flow

  1. Create transaction in wallet's native currency
  2. If allocated, reduce goal progress (IDR)
  3. Update wallet reserved balance (native currency)

🔧 Implementation Checklist

Completed

  • Created /apps/web/src/utils/walletCalculations.ts
  • Updated WalletCard.tsx to use utility functions
  • Fixed percentage calculations to always sum to 100%
  • Fixed stacked bar to fill edge-to-edge
  • Fixed formatCurrency() to use Intl.NumberFormat for reliable thousand separators
  • Fixed backend to return asset balances in units, not IDR value
  • Implemented wallet-centric UX (available first, green bar)
  • Fixed table view calculations to match card view
  • Consistent allocation display with thousand separators

🚧 To Verify

  • Test non-IDR money wallet allocations
  • Test asset wallet allocations
  • Test IDR wallet allocations
  • Verify thousand separators in all displays
  • Verify stacked bar percentages add up correctly

📝 Future Enhancements

  • Add spend transaction flow with goal progress reduction
  • Add allocation history with proper currency display
  • Add multi-currency goal support (if needed)

🎨 Style Guide

Display Format Examples

Money (IDR):

Rp 4.490.000

Money (non-IDR):

$ 17.90 ≈ Rp 297.264

Asset:

80 gram ≈ Rp 165.840.000

Allocation (in popover):

MacbookPro (33.6%)
Rp 100.000
≈ $ 6.02

🐛 Common Pitfalls

  1. Don't use balance.pricePerUnit for money wallets - use exchange rates
  2. Don't mix IDR amounts with native currency amounts in calculations
  3. Don't forget to clamp percentages to 0-100 range
  4. Don't store allocations in wallet's native currency
  5. Do always convert IDR to native currency before comparing with wallet balance
  6. Do always use formatCurrency() for display
  7. Do always use utility functions from walletCalculations.ts