Skip to content

Feature: Unequal Bill Split Support with Hybrid Allocation #39

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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