- 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
256 lines
6.7 KiB
Markdown
Executable File
256 lines
6.7 KiB
Markdown
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)**:
|
|
```typescript
|
|
// 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**:
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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:
|
|
```typescript
|
|
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
|
|
- [x] Created `/apps/web/src/utils/walletCalculations.ts`
|
|
- [x] Updated `WalletCard.tsx` to use utility functions
|
|
- [x] Fixed percentage calculations to always sum to 100%
|
|
- [x] Fixed stacked bar to fill edge-to-edge
|
|
- [x] Fixed `formatCurrency()` to use `Intl.NumberFormat` for reliable thousand separators
|
|
- [x] Fixed backend to return asset balances in units, not IDR value
|
|
- [x] Implemented wallet-centric UX (available first, green bar)
|
|
- [x] Fixed table view calculations to match card view
|
|
- [x] 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`
|