-
Notifications
You must be signed in to change notification settings - Fork 0
Add‐Ons Architecture
Add-ons are structured metadata attached to an Expense or Cash Withdrawal that represent extra charges (fees, tips, surcharges) or reductions (discounts) beyond the base item price.
Unlike a flat "total includes everything" model, add-ons decompose the financial event into base cost + extras, enabling:
- Analytics: "We spent 4% of our budget on ATM fees."
- Transparency: "The €80 dinner was actually €66.67 food + €13.33 included tip."
- Flexibility: Each add-on can have its own currency, exchange rate, and payment method — a bank fee in EUR on a MXN boat trip, or a cash tip on a card-paid dinner.
// domain/model/AddOn.kt
data class AddOn(
val id: String = "", // Client-side UUID
val type: AddOnType = AddOnType.FEE,
val mode: AddOnMode = AddOnMode.ON_TOP,
val valueType: AddOnValueType = AddOnValueType.EXACT,
val amountCents: Long = 0, // Resolved absolute amount in add-on's OWN currency
val currency: String = "EUR", // Can differ from parent expense
val exchangeRate: BigDecimal = BigDecimal.ONE, // Add-on currency → group currency
val groupAmountCents: Long = 0, // Converted amount in group currency (used in balances)
val paymentMethod: PaymentMethod = PaymentMethod.OTHER,
val description: String? = null // Optional label ("ATM fee", "Service charge")
)Key design decisions:
| Field | Rationale |
|---|---|
id |
Generated client-side via UUID.randomUUID() — never from Firestore .add(). Follows the offline-first write protocol. |
amountCents |
Always the resolved absolute amount regardless of whether the user entered "10%" or "2.50 EUR". Stored in the add-on's own currency minor units. |
exchangeRate |
Stored as BigDecimal for precision. Serialized as String at the Firestore boundary via toPlainString(). |
groupAmountCents |
The value actually used in balance calculations. Pre-converted so downstream consumers don't need the rate. |
| Value | Description | Example |
|---|---|---|
TIP |
Voluntary gratuity | Restaurant tip, bellhop tip |
FEE |
Involuntary charge | ATM fee, bank transfer fee, booking fee |
SURCHARGE |
Extra charge | Credit card surcharge, tourist tax |
DISCOUNT |
Price reduction | Early-bird discount, coupon |
This is the most nuanced enum. It determines how the add-on interacts with the expense's stored amounts.
| Mode | Meaning | Storage Effect | Example |
|---|---|---|---|
ON_TOP |
Added on top of the base amount |
groupAmount stores the base; add-on grows the effective total |
"100 EUR dinner + 10 EUR tip on top → effective 110 EUR" |
INCLUDED |
Extracted from within the user-entered total |
groupAmount stores the derived base cost (total minus included portions); add-on captures the extracted portion |
"80 EUR total already includes 20% tip → base 66.67 EUR + tip 13.33 EUR" |
Both modes decompose the payment into base + add-on. The difference is the input flow:
ON_TOP: User enters the base, add-on is extra.INCLUDED: User enters the total, add-on is extracted to derive the base.
| Value | Meaning | Resolution |
|---|---|---|
EXACT |
User entered an absolute amount |
"5.50" → 550 cents (via input × 10^decimalDigits) |
PERCENTAGE |
User entered a percentage of the base |
"10" on 10000 cents → 1000 cents (via sourceAmountCents × input / 100) |
AddOnValueTypeis metadata for display/edit purposes only. After resolution, both types produce the sameamountCents. The enum tells the UI which input widget to show and how to re-populate the field on edit.
Add-ons are embedded inside their parent entity — they are not standalone documents.
| Parent Entity | Field | Typical Use Cases |
|---|---|---|
Expense |
val addOns: List<AddOn> |
Tips, service fees, surcharges, discounts on expenses |
CashWithdrawal |
val addOns: List<AddOn> |
ATM fees on cash withdrawals |
An expense can have multiple add-ons of different types and modes. Example:
Dinner: 100 EUR base
+ 10 EUR tip (ON_TOP, TIP) → effective 110 EUR
+ 2.50 EUR bank fee (ON_TOP, FEE) → effective 112.50 EUR
− 5 EUR discount (ON_TOP, DISCOUNT) → effective 107.50 EUR
A cash withdrawal typically has zero or one add-on: the ATM fee.
ATM Withdrawal: 10,000 THB
+ 260 THB ATM fee (ON_TOP, FEE) → fee converted to 7.06 EUR at withdrawal rate
The ATM fee add-on is always ON_TOP and FEE. It can have its own currency (the fee might be charged in EUR while the withdrawal is in THB) and its own exchange rate.
Location: domain/service/AddOnCalculationService.kt
The central service for all add-on math. It uses AddOnResolverFactory internally for amount resolution.
| Method | Purpose | When Called |
|---|---|---|
resolveAddOnAmountCents(...) |
Converts user input → absolute cents (EXACT or PERCENTAGE) | Real-time as user types in add-on amount field |
calculateTotalOnTopAddOns(addOns) |
Sums ON_TOP non-discount add-ons (group currency) | Display: "Extra costs beyond the base" |
calculateTotalAddOnExtras(addOns) |
Sums ALL non-discount add-ons (ON_TOP + INCLUDED) | Balance screen "Extras" line item |
calculateEffectiveGroupAmount(base, addOns) |
Computes the real group debt: base + ON_TOP + INCLUDED − ON_TOP_DISCOUNT
|
Balance calculation, paired contribution amount |
calculateIncludedBaseCost(...) |
Extracts base cost from a total that includes embedded add-ons | Submission: deriving the stored groupAmount
|
calculateEffectiveDeductedAmount(base, addOns) |
Effective withdrawal deduction including ATM fees | Cash withdrawal balance calculation |
convertGroupToSourceCents(...) |
Reverse-converts group cents → add-on currency cents | Adjusting percentage add-on amounts after base cost extraction |
sumPercentagesFromInputs(inputs) |
Sums raw percentage input strings | Base cost calculation for INCLUDED percentage add-ons |
Location: domain/service/addon/
Mirrors the ExpenseSplitCalculatorFactory pattern.
AddOnResolverFactory
├── ExactAddOnResolver → input × 10^decimalDigits
└── PercentageAddOnResolver → sourceAmountCents × input / 100
Both implement the AddOnAmountResolver interface:
interface AddOnAmountResolver {
fun resolve(normalizedInput: BigDecimal, decimalDigits: Int, sourceAmountCents: Long): Long
}Add-on validation lives alongside other expense validation:
| Rule | Implementation |
|---|---|
| Amount must be > 0 |
addOn.amountCents <= 0 → Invalid |
| Currency must not be blank |
addOn.currency.isBlank() → Invalid |
| INCLUDED amount must be < source amount |
addOn.amountCents >= sourceAmountCents → Invalid |
The validateAddOns(addOns, sourceAmountCents) method iterates all add-ons and returns the first error (fail-fast).
The effective group amount determines the real debt for balance calculations:
effectiveGroupAmount = baseGroupAmount
+ Σ(ON_TOP non-discount add-ons)
+ Σ(INCLUDED non-discount add-ons)
− Σ(ON_TOP DISCOUNT add-ons)
INCLUDED DISCOUNT add-ons are informational only — the user already entered the discounted price, so they don't affect the effective amount.
Examples:
| Scenario | Base | Add-Ons | Effective |
|---|---|---|---|
| No add-ons | 10,000 | — | 10,000 |
| +250 fee ON_TOP | 10,000 | +250 | 10,250 |
| −500 discount ON_TOP | 10,000 | −500 | 9,500 |
| +1,000 tip INCLUDED | 9,000 (derived base) | +1,000 | 10,000 (reconstructs original total) |
| Mixed: +1,000 tip ON_TOP, +250 fee ON_TOP, +800 tip INCLUDED, −500 discount | 10,000 | +1,000 +250 +800 −500 | 11,550 |
The simplest case. The user enters the base cost, then adds extras on top.
User enters: 100.00 EUR (base)
User adds: 10.00 EUR tip ON_TOP
Stored:
expense.groupAmount = 10000 (base)
addOn.groupAmountCents = 1000 (tip)
Effective total = 10000 + 1000 = 11000 (110.00 EUR)
The groupAmount stores the base cost directly. No adjustment needed at submission.
The user enters the total (which already contains the tip/fee), and the system extracts the base cost.
User enters: 80.00 EUR (total, already includes 20% tip)
User marks: 20% tip as INCLUDED
At submission, the system computes:
baseCost = 80.00 / (1 + 0.20) = 66.67 EUR
tipAmount = 80.00 − 66.67 = 13.33 EUR
Stored:
expense.groupAmount = 6667 (derived base cost)
addOn.groupAmountCents = 1333 (extracted tip)
Effective total = 6667 + 1333 = 8000 (80.00 EUR — matches user input)
Base cost extraction uses calculateIncludedBaseCost:
-
EXACT INCLUDED amounts are subtracted directly:
afterExact = total − sumOfExactIncludedCents -
PERCENTAGE INCLUDED amounts are extracted via division:
baseCost = afterExact / (1 + sumOfPercentages / 100)
For mixed cases (both EXACT and PERCENTAGE INCLUDED add-ons), exact amounts are subtracted first, then percentages are extracted from the remainder.
Discounts reduce the stored base amount. Unlike tips/fees that are stored as separate add-ons and reconstructed, ON_TOP discounts are "baked" into groupAmount at submission:
User enters: 100.00 EUR (base)
User adds: 5.00 EUR discount ON_TOP
At submission (adjustForOnTopDiscounts):
expense.groupAmount = 9500 (100.00 − 5.00 = 95.00 EUR)
addOn.groupAmountCents = 0 (zeroed to prevent double-subtraction)
Effective total = 9500 + 0 = 9500 (95.00 EUR)
The groupAmountCents is zeroed after baking so that calculateEffectiveGroupAmount doesn't subtract the discount again. The original amount is still recoverable from addOn.amountCents and addOn.exchangeRate.
Informational only. The user already entered the discounted price:
User enters: 95.00 EUR (already discounted)
User marks: 5.00 EUR discount as INCLUDED (for tracking: "we got a 5 EUR coupon")
Stored:
expense.groupAmount = 9500 (no adjustment — user already entered discounted price)
addOn.groupAmountCents = 500 (recorded for analytics, not used in calculation)
Each add-on can have its own currency independent of the parent expense. This enables scenarios like:
- A bank fee in EUR on a THB boat trip
- A cash tip in local currency on a card-paid dinner
When the add-on currency differs from the group currency, the add-on carries its own exchangeRate (add-on currency → group currency) and groupAmountCents (pre-converted for balance calculations).
Expense: 4000 MXN boat trip (group currency: EUR, rate: 0.05 → 200.00 EUR)
Add-on: 2.50 EUR bank fee (same as group currency → rate: 1.0)
Stored:
expense.groupAmount = 20000 (200.00 EUR base)
addOn.amountCents = 250 (2.50 EUR in add-on currency)
addOn.currency = "EUR"
addOn.exchangeRate = 1.0
addOn.groupAmountCents = 250 (2.50 EUR in group currency)
Effective = 20000 + 250 = 20250 (202.50 EUR)
When an add-on's payment method is CASH and the currency is foreign:
- The exchange rate is locked (not user-editable)
- The rate is derived from ATM withdrawal FIFO blended rates
- The
AddOnExchangeRateDelegatefetches the rate viaPreviewCashExchangeRateUseCase - If insufficient cash is available, a warning hint is displayed
When switching from CASH to non-CASH, the previously saved exchange rate is restored (or an API rate is fetched if none was saved).
The submission flow for add-ons involves several adjustments before the expense is saved:
UI State ──► mapToDomain() ──► adjustForIncludedAddOns() ──► adjustForOnTopDiscounts() ──► AddExpenseUseCase
Converts AddOnUiModel → domain AddOn. Only includes add-ons with resolvedAmountCents > 0.
When INCLUDED non-discount add-ons are present:
-
Computes base costs in both group and source currencies via
calculateIncludedBaseCost+computeProportionalAmount -
Adjusts PERCENTAGE INCLUDED add-ons using a residual approach:
residual = originalGroupAmount − includedExactCents − baseCostGroup- Distributes residual proportionally across percentage add-ons (floor rounding + remainder redistribution)
- Guarantees
base + includedExact + sum(includedPct) == originalGroupAmountexactly
- Rescales splits proportionally to the new base source amount
Bakes ON_TOP discount amounts into the expense:
- Subtracts discount cents from
groupAmountandsourceAmount - Zeroes each ON_TOP discount add-on's
groupAmountCentsto prevent double-subtraction - Rescales splits to the new amounts
The use case computes the effective amount for the paired contribution (when the expense is paid by a specific user):
val effectiveAmount = addOnCalculationService.calculateEffectiveGroupAmount(
expense.groupAmount, expense.addOns
)This ensures the contribution offsets the full effective cost, not just the base.
Add-ons affect balance calculations in two places:
GetMemberBalancesFlowUseCase uses calculateEffectiveGroupAmount to determine the real group debt. Splits are distributed against the base groupAmount, while the effective amount (including add-ons) determines total cost attribution.
calculateEffectiveDeductedAmount adds ON_TOP add-on amounts (ATM fees) to the base deducted amount:
fun calculateEffectiveDeductedAmount(baseDeductedAmount: Long, addOns: List<AddOn>): Long {
val addOnTotal = addOns.filter { it.mode == AddOnMode.ON_TOP }.sumOf { it.groupAmountCents }
return baseDeductedAmount + addOnTotal
}INCLUDED add-ons are ignored for cash withdrawals — they don't apply to the ATM context.
Add-ons are stored as a nested array within the parent document (ExpenseDocument.addOns, CashWithdrawalDocument.addOns).
// AddOnDocument — Firestore representation
data class AddOnDocument(
val id: String = "",
val type: String = "FEE", // Enum names as strings
val mode: String = "ON_TOP",
val valueType: String = "EXACT",
val amountCents: Long = 0L,
val currency: String = "EUR",
val exchangeRate: String? = null, // BigDecimal → String (no IEEE 754 loss)
val groupAmountCents: Long = 0L,
val paymentMethod: String = "OTHER",
val description: String? = null
)Critical: exchangeRate is serialized as String via toPlainString() and deserialized with toBigDecimalOrNull() ?: BigDecimal.ONE. This prevents IEEE 754 floating-point precision loss that Double would introduce.
Add-ons are stored as a JSON string column via AddOnListConverter (Room @TypeConverter):
- Serialization: Manual JSON building (no
org.json) for pure JVM test compatibility - Exchange rates serialized as String (same precision rules as Firestore)
- Defensive parsing with safe defaults for all enum fields
Domain AddOn ←→ AddOnDocument (Firestore) via ExpenseDocumentMapper / CashWithdrawalDocumentMapper
Domain AddOn ←→ JSON String (Room) via AddOnListConverter
Domain AddOn ←→ AddOnUiModel (Presentation) via AddExpenseAddOnUiMapper
The UI model carries both domain data and UI-specific state:
| Field | Purpose |
|---|---|
amountInput |
Raw user input (editable text field, locale-aware) |
resolvedAmountCents |
Computed absolute amount in add-on currency |
groupAmountCents |
Converted to group currency |
showExchangeRateSection |
Whether add-on currency ≠ group currency |
isExchangeRateLocked |
CASH payment → rate is system-determined |
preCashExchangeRate |
Snapshot before switching to CASH (restored on switch back) |
isLoadingRate |
Rate fetch in progress |
isAmountValid |
Input validation state |
Add-on UI logic is managed by the AddOnEventHandler (implements AddExpenseEventHandler) with two delegates:
AddOnEventHandler (Event Handler — participates in bind())
├── AddOnCrudDelegate (Stateless delegate — creation, currency context, payment method switching)
└── AddOnExchangeRateDelegate (Lambda-based delegate — API rates, CASH/FIFO rates, debouncing)
Why delegates? The AddOnEventHandler handles 13 public event methods + 7 private helpers. Without delegation, it would exceed the 600-line Konsist limit. The delegates extract cohesive concerns:
-
AddOnCrudDelegate: Building new add-ons, resolving currency context flags, applying payment method switch state transitions. -
AddOnExchangeRateDelegate: API rate fetching, CASH/FIFO blended rate previews, forward/reverse recalculation, debounced amount-change rate updates. Manages its ownConcurrentHashMap<String, Job>maps for tracked in-flight requests.
The AddOnEventHandler exposes recalculateEffectiveTotal() for other handlers to call when the source amount or currency changes. This re-resolves all add-on amounts and updates the effective total and included base cost displays.
The AddExpenseUiState carries add-on-related display state:
| Field | Purpose |
|---|---|
addOns: ImmutableList<AddOnUiModel> |
The list of add-ons in the form |
isAddOnsSectionExpanded: Boolean |
Collapsible section toggle |
effectiveTotal: String |
Formatted effective amount (shown when add-ons modify the base) |
includedBaseCost: String |
Formatted derived base cost (shown when INCLUDED add-ons are present) |
addOnError: UiText? |
Validation error message for the add-on section |
The :features:withdrawals module supports a single optional ATM fee add-on:
- User toggles "ATM fee" on in the Details step
- Enters fee amount and optionally selects a different currency
- If fee currency ≠ group currency, a fee exchange rate step appears
- The fee is built as an
AddOn(type = FEE, mode = ON_TOP)byWithdrawalSubmitHandler.buildFeeAddOn()
The wizard step sequence adapts dynamically:
AMOUNT → [EXCHANGE_RATE] → SCOPE → DETAILS → [ATM_FEE] → [FEE_EXCHANGE_RATE] → REVIEW
Steps in brackets are conditionally shown based on form state.
-
AddOnCalculationServiceTest: Comprehensive tests for all calculation methods — effective amounts, base cost extraction, mixed add-on scenarios, cash withdrawal deduction. -
ExpenseValidationServiceTest: Add-on validation rules (amount > 0, currency not blank, INCLUDED < source).
-
GetMemberBalancesFlowUseCaseAddOnTest: End-to-end scenarios verifying that add-ons correctly affect member balances — ON_TOP fees, INCLUDED tips, mixed add-ons, multi-currency fees, cash withdrawal ATM fees.
-
AddExpenseUiMapperTest: Add-on mapping from UI model to domain, including exchange rate handling and description mapping. -
AddOnListConverterTest: Round-trip serialization, backward compatibility, defensive parsing of malformed JSON, BigDecimal precision preservation.
-
AddExpenseViewModelTest: Integration tests for add-on event handling through the full handler/delegate chain.
4,000 MXN boat trip. Bank charges 2.50 EUR fee.
Expense: 4000 MXN → 200.00 EUR (rate: 0.05)
Fee: 2.50 EUR (ON_TOP, FEE, same as group currency)
groupAmount = 20000
fee.groupAmountCents = 250
effectiveGroupAmount = 20250 (202.50 EUR)
5,000 THB withdrawal, ATM charges 260 THB fee.
Withdrawal: 5000 THB → 135.87 EUR (rate: 0.027135)
Fee: 260 THB → 7.06 EUR (ON_TOP, FEE)
deductedBaseAmount = 13587
fee.groupAmountCents = 706
effectiveDeductedAmount = 14293 (142.93 EUR)
80 EUR dinner, total already includes 20% tip.
User enters: 80.00 EUR
Marks: 20% tip INCLUDED
baseCost = 8000 / 1.20 = 6667 (66.67 EUR)
tip = 8000 − 6667 = 1333 (13.33 EUR)
Stored: groupAmount = 6667, tip.groupAmountCents = 1333
effectiveGroupAmount = 6667 + 1333 = 8000 (80.00 EUR) ✓
100 EUR base + 10 EUR tip ON_TOP + 2.50 EUR fee ON_TOP + 8 EUR tip INCLUDED − 5 EUR discount.
At submission:
adjustForIncludedAddOns → base becomes 92 EUR (100 − 8 = 92... simplified)
adjustForOnTopDiscounts → base becomes 87 EUR (92 − 5)
Effective = 87 + 10 + 2.50 + 8 = 107.50 EUR
| Layer | File | Purpose |
|---|---|---|
| Domain Model | domain/model/AddOn.kt |
Data class |
| Domain Enums |
domain/enums/AddOnType.kt, AddOnMode.kt, AddOnValueType.kt
|
Type, mode, and value type enums |
| Domain Service | domain/service/AddOnCalculationService.kt |
All add-on math |
| Domain Strategy |
domain/service/addon/AddOnResolverFactory.kt, ExactAddOnResolver.kt, PercentageAddOnResolver.kt
|
Amount resolution strategy |
| Domain Validation | domain/service/ExpenseValidationService.kt |
validateAddOn(), validateAddOns()
|
| Firestore | data/firebase/.../document/AddOnDocument.kt |
Cloud document model |
| Firestore Mappers |
data/firebase/.../mapper/ExpenseDocumentMapper.kt, CashWithdrawalDocumentMapper.kt
|
Domain ↔ Firestore |
| Room Converter | data/local/.../converter/AddOnListConverter.kt |
JSON serialization for Room |
| Room Entity Mapper | data/local/.../mapper/ExpenseEntityMapper.kt |
Domain ↔ Room entity |
| UI Model | features/expenses/.../model/AddOnUiModel.kt |
Presentation model |
| UI Mapper | features/expenses/.../mapper/AddExpenseAddOnUiMapper.kt |
UI model ↔ Domain |
| Event Handler | features/expenses/.../handler/AddOnEventHandler.kt |
Add-on form event handling |
| CRUD Delegate | features/expenses/.../handler/AddOnCrudDelegate.kt |
Creation & payment method switching |
| Rate Delegate | features/expenses/.../handler/AddOnExchangeRateDelegate.kt |
Exchange rate fetching & calculation |
| Submit Handler | features/expenses/.../handler/SubmitEventHandler.kt |
INCLUDED base cost extraction, ON_TOP discount baking |
| Withdrawal Fee | features/withdrawals/.../handler/WithdrawalSubmitHandler.kt |
ATM fee add-on builder |
| Tests | domain/src/test/.../AddOnCalculationServiceTest.kt |
Service tests |
| Tests | domain/src/test/.../GetMemberBalancesFlowUseCaseAddOnTest.kt |
Balance integration tests |
| Tests | data/local/src/test/.../AddOnListConverterTest.kt |
Converter round-trip tests |