swiftops-backend / docs /devlogs /fixes /tende_pay_export_fix.md
kamau1's picture
Enhance stats endpoints with time-based breakdowns for richer dashboard analytics
3a13ba4

Tende Pay Export Bug Fix

Issue

Date: 2024-12-10
Severity: Critical
Status: Fixed

Problem Description

When exporting expenses for payment, if all expenses failed validation (e.g., invalid phone numbers), the system would:

  1. βœ… Skip generating CSV rows (correct)
  2. ❌ Still mark all expenses as is_paid = true (WRONG!)
  3. ❌ Return an empty CSV file with only warnings (confusing)

This caused expenses to be permanently marked as paid even though no payment was actually processed.

Root Cause

The code was marking expenses as paid before checking if any CSV rows were successfully generated:

# OLD CODE (BUGGY)
for key, group_expenses in grouped.items():
    # ... validation and row building ...
    if validation_fails:
        warnings.append("Skipped...")
        continue  # Skip this group
    csv_rows.append(row)

# Mark ALL expenses as paid, even if csv_rows is empty!
for expense in expenses:
    expense.is_paid = True
db.commit()

Impact

Affected Exports:

  • Export reference: CSV_EXPORT_20251210_132341_c5cf92be-4172-4fe2-af5c-f05d83b3a938
  • Expense IDs: 35db9201-3c05-4853-be91-5eb6f30782d1, d73cb843-e4fb-4200-aa3e-243887714422
  • User: Viyisa Sasa
  • Issue: Phone number +2547994597823 had 13 digits instead of 12

User Experience:

  1. User tried to export 2 expenses
  2. Export "succeeded" but generated empty CSV (only warnings)
  3. Expenses marked as paid in database
  4. User fixed phone number
  5. Second export attempt failed with 400 error (expenses already paid)
  6. User confused - expenses show as paid but no payment was made

Fix

1. Validate Before Marking as Paid

Added check to ensure CSV rows were generated before marking expenses as paid:

# NEW CODE (FIXED)
for key, group_expenses in grouped.items():
    # ... validation and row building ...
    if validation_fails:
        warnings.append("Skipped...")
        continue
    csv_rows.append(row)

# Only mark as paid if we generated rows!
if not csv_rows:
    raise HTTPException(
        status_code=400,
        detail=f"No expenses could be exported. Warnings: {'; '.join(warnings)}"
    )

# Now safe to mark as paid
for expense in expenses:
    expense.is_paid = True
db.commit()

2. Improved Phone Validation

Enhanced phone number validation with better error messages:

# OLD: Generic warning
if len(normalized) != 12:
    logger.warning(f"Invalid phone number format: {phone} -> {normalized}")
    return ""

# NEW: Specific error message
if len(normalized) != 12:
    logger.warning(
        f"Invalid phone number length (expected 12 digits, got {len(normalized)}): "
        f"{phone} -> {normalized}"
    )
    return ""

3. Better Error Responses

Changed behavior:

  • Before: Return empty CSV with 200 OK
  • After: Return 400 Bad Request with detailed error message

Example error response:

{
  "detail": "No expenses could be exported. Check warnings for details. Warnings: Invalid phone number length (expected 12 digits, got 13): +2547994597823 -> 2547994597823"
}

Recovery Steps

For Affected Expenses

Run the SQL script to reset the incorrectly marked expenses:

-- Reset expenses from failed export
UPDATE ticket_expenses
SET 
    is_paid = false,
    paid_at = NULL,
    paid_to_user_id = NULL,
    payment_reference = NULL,
    updated_at = timezone('utc'::text, now())
WHERE 
    payment_reference = 'CSV_EXPORT_20251210_132341_c5cf92be-4172-4fe2-af5c-f05d83b3a938'
    AND is_paid = true
    AND deleted_at IS NULL;

Script location: scripts/reset_failed_export_expenses.sql

For User

  1. Run the SQL script to reset the expenses
  2. User should update phone number in their financial account
  3. User can now export again successfully

Testing

Test Cases Added

  1. βœ… Export with all invalid phone numbers β†’ Should return 400 error
  2. βœ… Export with mix of valid/invalid β†’ Should export valid ones only
  3. βœ… Export with no eligible expenses β†’ Should return 400 error
  4. βœ… Verify expenses NOT marked as paid when export fails

Manual Testing

# Test 1: Export with invalid phone
POST /api/v1/ticket-expenses/bulk-export?expense_ids=uuid1
# Expected: 400 Bad Request with warning details
# Verify: Expenses still have is_paid=false

# Test 2: Export with valid phone
POST /api/v1/ticket-expenses/bulk-export?expense_ids=uuid2
# Expected: 200 OK with CSV file
# Verify: Expenses now have is_paid=true

# Test 3: Try to re-export same expenses
POST /api/v1/ticket-expenses/bulk-export?expense_ids=uuid2
# Expected: 400 Bad Request (already paid)

Prevention

Code Review Checklist

When modifying export logic:

  • Validate data BEFORE marking as paid
  • Check if CSV rows were generated
  • Return appropriate HTTP status codes
  • Include detailed error messages
  • Test with invalid data
  • Test with empty result sets

Monitoring

Add alerts for:

  • Exports with 0 rows generated
  • Expenses marked as paid with no payment reference
  • High rate of export failures

Files Changed

  1. src/app/api/v1/ticket_expenses.py

    • Added validation before marking as paid in bulk_export_expenses()
    • Added validation in export_expenses_for_payment()
  2. src/app/services/ticket_expense_service.py

    • Added early return if no CSV rows generated
    • Don't mark as paid if export failed
  3. src/app/services/tende_pay_formatter.py

    • Improved phone number validation messages
    • Added digit-only validation
  4. scripts/reset_failed_export_expenses.sql

    • Recovery script for affected expenses

Lessons Learned

  1. Validate before side effects: Always check if operation succeeded before making irreversible changes
  2. Fail fast: Return errors immediately when validation fails
  3. Clear error messages: Include specific details about what went wrong
  4. Test edge cases: Empty results, all invalid data, partial failures
  5. Audit trail: Log warnings even when operation "succeeds"

Related Issues

  • Phone number validation needs to be more strict
  • Consider adding dry-run mode for exports
  • Add export preview before marking as paid
  • Consider two-phase commit: generate CSV first, then mark as paid only after download

Status

βœ… Fixed and deployed

  • Code changes committed
  • Recovery script created
  • Documentation updated
  • Ready for production deployment