- 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
6.7 KiB
Executable File
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.0000602159means1 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)
- Automatic thousand separators (
2. Price Format & Conversions
A. Single Source Utility
File: /apps/web/src/utils/walletCalculations.ts
Functions:
convertIDRToWalletCurrency()- Convert IDR to wallet's native currency/unitsconvertWalletCurrencyToIDR()- Convert wallet's native currency/units to IDRformatWalletBalance()- Format balance with equivalentformatAllocationAmount()- 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: IDRGoal.currentAmount: IDRGoalAllocation.amount: IDRGoalAllocation.currency: Always 'IDR'GoalAllocation.amountInGoalCurrency: IDR (same as amount)
B. Wallet Balances
- Storage: In wallet's native currency/units
- Fields:
Wallet.initialAmount: Native currency/unitsWalletBalance.totalBalance: Native currency/unitsWalletBalance.reservedBalance: Native currency/unitsWalletBalance.availableBalance: Native currency/units- For assets:
WalletBalance.totalUnits: Units
C. Transactions
- Storage: In wallet's native currency/units
- Fields:
Transaction.amount: Native currency/unitsTransaction.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:
- Available (most important - what user can spend)
- Separator line
- 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
- Receive amount in IDR from frontend
- Convert to wallet's native currency for validation:
amountInWalletCurrency = convertIDRToWalletCurrency(idrAmount, ...); if (amountInWalletCurrency > wallet.availableBalance) throw Error; - Store allocation in IDR
- Update wallet reserved balance in native currency
B. Spend Transaction Flow
- Create transaction in wallet's native currency
- If allocated, reduce goal progress (IDR)
- Update wallet reserved balance (native currency)
🔧 Implementation Checklist
✅ Completed
- Created
/apps/web/src/utils/walletCalculations.ts - Updated
WalletCard.tsxto use utility functions - Fixed percentage calculations to always sum to 100%
- Fixed stacked bar to fill edge-to-edge
- Fixed
formatCurrency()to useIntl.NumberFormatfor 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
- ❌ Don't use
balance.pricePerUnitfor money wallets - use exchange rates - ❌ Don't mix IDR amounts with native currency amounts in calculations
- ❌ Don't forget to clamp percentages to 0-100 range
- ❌ Don't store allocations in wallet's native currency
- ✅ Do always convert IDR to native currency before comparing with wallet balance
- ✅ Do always use
formatCurrency()for display - ✅ Do always use utility functions from
walletCalculations.ts