Skip to content

Feature: Unequal Bill Split Support with Hybrid Allocation #39

@DevinByteX

Description

@DevinByteX

Feature: Unequal Bill Split Support

🎯 Overview

Add support for custom/unequal bill splitting alongside the existing equal split functionality. Users should be able to allocate bills using hybrid allocation methods (fixed amounts, percentages, and remainder splitting) through a dedicated screen interface.

💡 Motivation

Currently, TipMate only supports equal bill splitting where the total is divided evenly among people. Many real-world scenarios require unequal splits:

  • Different people ordering different priced items
  • Someone covering a larger portion of the bill
  • Splitting based on individual consumption rather than equal division

🏗️ Architecture Decisions

1. Allocation Strategy: Hybrid

Support three allocation types per person:

  • Fixed Amount: Assign specific dollar amount to a person
  • Percentage: Assign percentage of total bill to a person
  • Remainder: Split remaining amount equally among remainder persons

2. Rounding Distribution: Largest Decimal Remainder

When rounding creates penny differences, distribute extra cents by:

  1. Sort persons by decimal remainder (descending)
  2. Add $0.01 sequentially to persons with largest remainders
  3. Continue until all penny differences are allocated

3. Validation: Require 100% Allocation

  • Users must allocate exactly 100% of the bill before saving
  • UI shows real-time validation with visual indicators
  • Save button disabled until allocation is complete

4. UX Approach: Dedicated Screen

Use a full-screen interface instead of modal for better mobile UX:

  • More space for multiple person entries
  • Better scrolling experience
  • Clearer navigation flow with back button

5. Backward Compatibility: Default to Equal

Existing SavedTip records without splitType field default to 'equal' split type

📋 Implementation Plan

Step 1: Data Model Extension

File: app/context/types.ts

Add new interfaces and extend existing types:

// New interface for individual split configuration
export interface IndividualSplit {
  id: string;
  name: string;
  allocationType: 'fixed' | 'percentage' | 'remainder';
  value?: number; // Dollar amount for 'fixed', percentage for 'percentage', undefined for 'remainder'
  calculatedAmount?: number; // Computed final amount after calculation
}

// Extend SavedTip interface
export interface SavedTip {
  id: string;
  timestamp: number;
  amount: number;
  tip: number;
  total: number;
  tipPercentage: number;
  numberOfPeople: number;
  splitType?: 'equal' | 'custom'; // Add split type (default 'equal' when undefined)
  perPerson?: {
    amount: number;
    tip: number;
    total: number;
  };
  individualSplits?: IndividualSplit[]; // Array of individual split details for custom splits
  currencySymbol: string;
  currencyCode: string;
}

// Extend AppState
export interface AppState {
  tips: TipOptionState[];
  splits: SplitOptionState[];
  tipSliderConfig: TipSliderConfigValues;
  splitSliderConfig: SplitSliderConfigValues;
  currencyConfig: CurrencyType;
  savedTips: SavedTip[];
  activeSplitConfig?: {
    type: 'equal' | 'custom';
    customSplits?: IndividualSplit[];
  };
}

Step 2: Create CustomSplitScreen

File: app/screens/TipScreens/CustomSplitScreen.tsx

Create dedicated full-screen component with:

Header Section:

  • Use StyledHeader with back button and title "Custom Split"
  • Subtitle showing total bill amount

Person List Section (Scrollable):

  • FlatList/ScrollView of person cards
  • Each card contains:
    • Text input for person name (default: "Person 1", "Person 2", etc.)
    • Picker/Selector for allocation type (Fixed/Percentage/Remainder)
    • Numeric input for value (hidden for remainder type)
    • Currency/percentage formatting based on type
    • Delete button for removing person
  • Add Person button at bottom of list

Validation Footer (Sticky):

  • Display allocated amount vs total bill amount
  • Visual indicators:
    • Green checkmark + "100% Allocated - Ready to save" when complete
    • Red warning + "X% Allocated - Need Y% more" when incomplete
    • Orange warning + "Over-allocated by X%" when exceeds 100%
  • Breakdown: "Fixed: $X | Percentage: Y% | Remainder: Z people"

Save Button:

  • Disabled (grayed out) until 100% allocated
  • On press: Save to app state and navigate back to HomeTipScreen

Constraints:

  • Minimum 2 persons, maximum 15 persons
  • Validation prevents invalid inputs (negative values, >100% total percentage, etc.)

Step 3: Hybrid Calculation Logic

File: app/hooks/calculateBill.ts

Create new calculation function:

export type CustomSplitCalculationType = {
  overall: {
    total: string;
    tip: string;
    subtotal: string;
  };
  individuals: IndividualSplit[]; // Array with calculated amounts per person
  disabledRoundingMethods: DisabledRoundingMethodsType;
};

export const calculateBillValuesCustomSplit = (
  tipPercentage: number,
  billAmount: number,
  roundingMethod: RoundingMethodType,
  individualSplits: IndividualSplit[],
): CustomSplitCalculationType => {
  // 1. Calculate overall tip and total
  const tipTotal = (tipPercentage / 100) * billAmount;
  const totalBill = billAmount + tipTotal;
  
  // 2. Apply rounding to overall amounts
  const roundedOverallTotal = applyRoundingMethod(totalBill, roundingMethod);
  const roundedOverallTip = applyRoundingMethod(tipTotal, roundingMethod);
  const roundedOverallSubtotal = applyRoundingMethod(billAmount, roundingMethod);
  
  // 3. Process allocations by type
  let remainingAmount = roundedOverallTotal;
  const processedSplits: IndividualSplit[] = [];
  
  // 3a. Process FIXED amounts first
  const fixedSplits = individualSplits.filter(s => s.allocationType === 'fixed');
  fixedSplits.forEach(split => {
    const amount = split.value || 0;
    remainingAmount -= amount;
    processedSplits.push({ ...split, calculatedAmount: amount });
  });
  
  // 3b. Process PERCENTAGE allocations second
  const percentageSplits = individualSplits.filter(s => s.allocationType === 'percentage');
  percentageSplits.forEach(split => {
    const percentage = split.value || 0;
    const amount = (percentage / 100) * roundedOverallTotal;
    remainingAmount -= amount;
    processedSplits.push({ ...split, calculatedAmount: amount });
  });
  
  // 3c. Process REMAINDER splits (divide remaining equally)
  const remainderSplits = individualSplits.filter(s => s.allocationType === 'remainder');
  if (remainderSplits.length > 0) {
    const amountPerRemainder = remainingAmount / remainderSplits.length;
    remainderSplits.forEach(split => {
      processedSplits.push({ ...split, calculatedAmount: amountPerRemainder });
    });
  }
  
  // 4. Distribute penny differences using largest decimal remainder method
  const splitsWithDecimals = processedSplits.map(split => ({
    ...split,
    decimalPart: (split.calculatedAmount || 0) % 1,
  }));
  
  // Sort by decimal remainder descending
  splitsWithDecimals.sort((a, b) => b.decimalPart - a.decimalPart);
  
  // Calculate total after flooring all amounts
  const totalFloored = splitsWithDecimals.reduce(
    (sum, split) => sum + Math.floor(split.calculatedAmount || 0),
    0
  );
  const pennyDifference = roundedOverallTotal - totalFloored;
  
  // Add $0.01 to persons with largest decimal remainders
  for (let i = 0; i < pennyDifference && i < splitsWithDecimals.length; i++) {
    splitsWithDecimals[i].calculatedAmount = 
      Math.floor(splitsWithDecimals[i].calculatedAmount || 0) + 1;
  }
  
  // 5. Format and return
  return {
    overall: {
      total: toFixedWithoutRounding(roundedOverallTotal, 2),
      tip: toFixedWithoutRounding(roundedOverallTip, 2),
      subtotal: toFixedWithoutRounding(roundedOverallSubtotal, 2),
    },
    individuals: splitsWithDecimals.map(split => ({
      id: split.id,
      name: split.name,
      allocationType: split.allocationType,
      value: split.value,
      calculatedAmount: split.calculatedAmount,
    })),
    disabledRoundingMethods: {
      UP: totalBill === Math.ceil(totalBill),
      DOWN: totalBill === Math.floor(totalBill),
      NO: false,
    },
  };
};

Step 4: Update StyledSplitOptions Navigation

File: app/components/StyledSplitOptions/index.tsx

Add navigation to custom split screen with "Custom Split" button below existing equal split options. Show active indicator badge when custom split is configured.

Step 5: Modify StyledBillBox for Custom Display

File: app/components/StyledBillBox/index.tsx

Add conditional rendering based on split type. For custom splits, create expandable accordion section showing individual person breakdowns with name, subtotal, tip, and total amounts.

Step 6: State Management & Reducers

Files:

  • app/context/reducers.ts - Add SET_ACTIVE_SPLIT_CONFIG and CLEAR_ACTIVE_SPLIT_CONFIG actions
  • app/context/AppContext.tsx - Add migration logic to default splitType: 'equal' for existing records

Step 7: Update HomeTipScreen Logic

File: app/screens/TipScreens/HomeTipScreen.tsx

Integrate custom split calculation alongside existing equal split logic based on activeSplitConfig state.

Step 8: Update Share/Export Hooks

Files:

  • app/hooks/useShareTipDetailsText.ts - Update text template for custom splits
  • app/hooks/useShareTipDetailsPDF.ts - Update PDF template for custom splits

Step 9: Register CustomSplitScreen in Navigation

File: app/navigation/StackNavigation.tsx

Add CustomSplitScreen to stack navigator.

🎨 UI/UX Specifications

CustomSplitScreen Layout

┌─────────────────────────────┐
│ ← Custom Split              │ Header
│ Total Bill: $150.00         │
├─────────────────────────────┤
│                             │
│ ┌─────────────────────────┐ │
│ │ Person 1            [×] │ │ Person Card
│ │ ○ Fixed  ○ % ● Remain  │ │
│ │ $50.00                  │ │
│ └─────────────────────────┘ │
│                             │
│ ┌─────────────────────────┐ │
│ │ Person 2            [×] │ │
│ │ ● Fixed  ○ % ○ Remain  │ │
│ │ $60.00                  │ │
│ └─────────────────────────┘ │
│                             │
│ ┌─────────────────────────┐ │
│ │ Person 3            [×] │ │
│ │ ○ Fixed  ● % ○ Remain  │ │
│ │ 30%                     │ │
│ └─────────────────────────┘ │
│                             │
│ [+ Add Person]              │
│                             │
├─────────────────────────────┤
│ ✓ 100% Allocated - Ready!   │ Validation Footer
│ Fixed: $110  |  %: 30%      │
│ Remainder: 1 person         │
│                             │
│ [    SAVE SPLIT    ]        │ Save Button
└─────────────────────────────┘

Validation States

  • Incomplete (<100%): Red warning icon + "Need X% more"
  • Complete (=100%): Green checkmark + "Ready to save"
  • Over-allocated (>100%): Orange warning + "Over by X%"

Person Card Components

  • Name Input: Text field with placeholder "Person 1", "Person 2", etc.
  • Allocation Type: Segmented control or radio buttons (Fixed / % / Remainder)
  • Value Input:
    • Numeric keyboard for Fixed (show currency symbol)
    • Numeric keyboard for % (show % symbol)
    • Hidden/disabled for Remainder
  • Delete Button: Icon button (X or trash) on right side

🔧 Technical Considerations

Person Limits

  • Minimum: 2 persons (validation error if trying to go below)
  • Maximum: 15 persons (matching current equal split slider max)
  • Default: Start with 2 persons when screen opens

Default Person Names

Use editable defaults to reduce friction:

  • Person 1, Person 2, Person 3, etc.
  • Users can edit inline by tapping name field
  • Consider saving frequently used names for future use (optional enhancement)

Validation Rules

  1. At least one person must exist
  2. All person names must be non-empty
  3. Fixed amounts must be ≥ $0.01
  4. Percentages must be between 0.01% and 100%
  5. Total allocation must equal 100% (with 0.01% tolerance for floating point)
  6. At least one person must have allocation type selected

Error Handling

  • Show inline validation errors on person cards
  • Disable save button until all validations pass
  • Toast notifications for critical errors
  • Preserve form state when navigating away (warn user about unsaved changes)

✅ Acceptance Criteria

  • User can navigate to CustomSplitScreen from HomeTipScreen
  • User can add/remove persons (2-15 limit enforced)
  • User can set allocation type and value for each person
  • Real-time validation shows allocation status
  • Save button disabled until 100% allocated
  • Custom split calculations match expected results (±$0.01 tolerance)
  • Penny differences distributed using largest remainder method
  • Custom splits display correctly in StyledBillBox with expandable list
  • Share/export includes individual person breakdowns
  • Saved tips with custom splits persist across app restarts
  • Existing saved tips without splitType default to 'equal'
  • User can switch between equal and custom split modes seamlessly
  • UI is responsive and performant on various screen sizes

🧪 Testing Requirements

Unit Tests

  • calculateBillValuesCustomSplit function with various allocation combinations
  • Penny distribution logic with edge cases (1¢, 2¢, 10¢ differences)
  • Validation logic for allocation totals
  • Data migration for existing SavedTip records

Integration Tests

  • Navigation flow: HomeTipScreen → CustomSplitScreen → Save → Back
  • State persistence across app restarts
  • Share/export functionality with custom splits
  • Saved tips with custom splits displayed correctly

E2E Test Scenarios

  1. Create custom split with 3 people (1 fixed, 1 percentage, 1 remainder)
  2. Edit existing custom split configuration
  3. Switch between equal and custom split modes
  4. Save and view custom split tip in history
  5. Share custom split via text and PDF

🚀 Future Enhancements (Out of Scope)

  • Item-based splitting (assign specific items to people)
  • Saved custom split templates/presets
  • Import contacts for person names
  • Split history analytics
  • Venmo/PayPal integration for payment requests
  • QR code generation for split details

📁 Related Files

  • app/context/types.ts - Data models
  • app/hooks/calculateBill.ts - Calculation logic
  • app/screens/TipScreens/CustomSplitScreen.tsx - New screen (create)
  • app/screens/TipScreens/HomeTipScreen.tsx - Integration point
  • app/components/StyledSplitOptions/index.tsx - Navigation trigger
  • app/components/StyledBillBox/index.tsx - Display component
  • app/context/reducers.ts - State actions
  • app/context/AppContext.tsx - Migration logic
  • app/navigation/StackNavigation.tsx - Screen registration
  • app/hooks/useShareTipDetailsText.ts - Share formatting
  • app/hooks/useShareTipDetailsPDF.ts - PDF formatting

🎨 Design Assets Needed

  • Icon for "Custom Split" button
  • Icon for person cards (user icon)
  • Icons for allocation types (dollar, percent, users)
  • Success/warning/error icons for validation states

📚 Documentation Updates

  • Update README.md with custom split feature
  • Add code comments explaining calculation logic
  • Create user guide/help screen explaining custom splits
  • Update app store description with new feature

Estimated Effort: 5-7 days

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions