How Securo Handles Split Transactions in Its Database Model

Securo stores split transactions using a normalized relational design: a parent transactions table holds the common transaction data, while child rows in transaction_split track each member's share, with the split_service.replace_splits method ensuring atomic updates and data integrity.

Split transactions are essential for group expense tracking in Securo, a personal finance application built on Python and SQLAlchemy. The platform's database architecture separates the core transaction record from its division logic, enabling flexible splitting strategies without duplicating transaction metadata. This article examines the schema design, validation rules, and service-layer implementation that power Securo's split transaction handling, referencing actual source files from the securo-finance/securo repository.

Database Schema Design for Split Transactions

Core Tables Structure

Securo uses a one-to-many relationship between transactions and their splits. The schema resides in two primary locations:

Component File Path Purpose
Split model definition [backend/app/models/transaction_split.py](https://github.com/securo-finance/securo/blob/main/backend/app/models/transaction_split.py) SQLAlchemy ORM class mapping split rows to database
Split schemas [backend/app/schemas/transaction_split.py](https://github.com/securo-finance/securo/blob/main/backend/app/schemas/transaction_split.py) Pydantic models for API request/response validation

The transaction_split table contains these key columns:

  • transaction_id — foreign key linking to the parent transactions table
  • group_member_id — identifies which group member receives this split portion
  • share_amount — the calculated monetary value this member owes or receives
  • created_at / updated_at — standard timestamp fields

This normalization ensures that updating transaction metadata (description, date, category) never requires touching split rows, while split modifications remain isolated and auditable.

Relationship Between Models

In backend/app/models/transaction.py, the Transaction model defines a relationship to its splits:


# Conceptual representation based on ORM patterns used in Securo

splits: Mapped[List["TransactionSplit"]] = relationship(
    "TransactionSplit", 
    back_populates="transaction",
    cascade="all, delete-orphan"
)

The cascade="all, delete-orphan" configuration guarantees that deleting a transaction automatically removes its associated split rows, preventing orphaned data.

Split Creation and Update Workflow

The replace_splits Service Method

All split modifications route through [backend/app/services/split_service.py](https://github.com/securo-finance/securo/blob/main/backend/app/services/split_service.py). The critical function is replace_splits, which handles both initial split creation and subsequent updates.


# Typical usage pattern from Securo's service layer

from app.services.split_service import replace_splits

# Inside transaction creation or update

replace_splits(
    db_session=db,
    transaction=transaction,
    splits_input=splits_data  # TransactionSplitsInput schema

)

The replace_splits method executes atomically:

  1. Validation — checks that all referenced group_member_id values exist and belong to the transaction's group
  2. Deletion — removes existing transaction_split rows for this transaction (if any)
  3. Calculation — computes share_amount for each member based on share_type
  4. Insertion — creates new TransactionSplit objects and flushes to database

Atomic Transaction Guarantees

The entire operation runs within a single database transaction. This design prevents partial split states—if validation fails after deletion but before insertion, the rollback restores original splits.

This behavior is verified in [backend/tests/test_transaction_splits_api.py](https://github.com/securo-finance/securo/blob/main/backend/tests/test_transaction_splits_api.py):

def test_update_rejects_invalid_splits_and_keeps_old(client, auth_headers):
    """
    Verify that failed split validation leaves original splits intact.
    """
    # Create transaction with valid 50/50 split

    txn = create_split_transaction(...)
    
    # Attempt invalid update (percentages sum to 110%)

    invalid_payload = {
        "splits": {
            "share_type": "percent",
            "member_splits": [
                {"group_member_id": m1_id, "percent": 60},
                {"group_member_id": m2_id, "percent": 50},  # Invalid: 110% total

            ]
        }
    }
    
    resp = client.patch(f"/transactions/{txn.id}", 
                       json=invalid_payload, 
                       headers=auth_headers)
    
    assert resp.status_code == 400
    # Original splits preserved due to transaction rollback

    refreshed = get_transaction(txn.id)
    assert refreshed.splits[0].share_amount == original_amount_1

Supported Split Types and Calculations

Equal Split Distribution

The simplest split type divides the transaction amount evenly among group members. Specify share_type="equal" with an empty splits list to auto-generate splits for all current group members.

payload = {
    "date": "2024-01-15",
    "type": "expense",
    "amount": 240.00,
    "account_id": checking_account.id,
    "splits": {
        "share_type": "equal",
        "splits": []  # Auto-populated for all group members

    }
}

resp = client.post("/transactions", json=payload, headers=auth_headers)
result = resp.json()

# Verify equal distribution among 4 members

assert len(result["splits"]) == 4
assert all(
    float(s["share_amount"]) == 60.00 
    for s in result["splits"]
)

The service calculates share_amount = total_amount / member_count and rounds to the currency's precision (typically 2 decimal places). Remainder handling—when division yields repeating decimals—distributes extra cents to members in deterministic order.

Percentage-Based Split

For uneven distributions, use share_type="percent" with explicit member_splits specifying each member's percentage allocation.

update_payload = {
    "splits": {
        "share_type": "percent",
        "member_splits": [
            {"group_member_id": members[0]["id"], "percent": 33.33},
            {"group_member_id": members[1]["id"], "percent": 33.33},
            {"group_member_id": members[2]["id"], "percent": 33.34},  # Rounding adjustment

        ]
    }
}

resp = client.patch(f"/transactions/{txn_id}", 
                   json=update_payload, 
                   headers=auth_headers)
updated = resp.json()

# Verify percentage calculations

total = sum(float(s["share_amount"]) for s in updated["splits"])
assert abs(total - original_amount) < 0.01  # Within rounding tolerance

The validation layer enforces that percentages sum to exactly 100.0, returning a 400 error otherwise.

Validation Error Handling

Input validation occurs at multiple layers:

Layer Validation Error Response
Pydantic schema Field types, required fields 422 Unprocessable Entity
Service layer Member existence, percentage sums 400 Bad Request with detail message
Database Foreign key constraints 500 (should not reach in normal flow)

The test test_split_validation_bubbles_400 in [backend/tests/test_transaction_splits_api.py](https://github.com/securo-finance/securo/blob/main/backend/tests/test_transaction_splits_api.py) confirms proper error propagation:

def test_split_validation_bubbles_400(client, auth_headers, group_with_members):
    """
    Invalid split configurations must return clear 400 responses
    rather than 500 errors or silent failures.
    """
    invalid_split = {
        "share_type": "equal",
        "splits": [{"group_member_id": 99999, "share_amount": 100}]  
        # Non-existent member ID

    }
    
    resp = client.post("/transactions", 
                      json={..., "splits": invalid_split},
                      headers=auth_headers)
    
    assert resp.status_code == 400
    assert "member" in resp.json()["detail"].lower()

Bulk Operations on Split Transactions

Securo supports applying splits to multiple transactions simultaneously via the bulk-add-to-group endpoint. This operation wraps individual replace_splits calls in a larger transaction scope.

bulk_payload = {
    "transaction_ids": [tx1.id, tx2.id, tx3.id],
    "group_id": group.id,
    "share_type": "equal",
}

resp = client.post("/transactions/bulk-add-to-group", 
                  json=bulk_payload, 
                  headers=auth_headers)

# Each transaction now has equal splits for all group members

for tx_id in bulk_payload["transaction_ids"]:
    tx = get_transaction(tx_id)
    assert len(tx.splits) == len(group.members)

The bulk operation maintains the same atomic guarantees: if any transaction fails validation, the entire batch rolls back. This is tested in [backend/tests/test_transaction_service.py](https://github.com/securo-finance/securo/blob/main/backend/tests/test_transaction_service.py).

Impact on Balance Calculations

Split transactions affect member balances through aggregated share_amount values rather than the raw transaction total. The balance calculation query—typically in a repository or service layer—performs SQL aggregation:

SELECT 
    ts.group_member_id,
    SUM(CASE 
        WHEN t.type = 'expense' THEN ts.share_amount 
        WHEN t.type = 'income' THEN -ts.share_amount 
    END) as net_balance
FROM transaction_split ts
JOIN transactions t ON ts.transaction_id = t.id
WHERE t.group_id = :group_id
GROUP BY ts.group_member_id

The test test_balances_reflect_splits in [backend/tests/test_transaction_splits_api.py](https://github.com/securo-finance/securo/blob/main/backend/tests/test_transaction_splits_api.py) validates this behavior end-to-end, ensuring that a $100 expense with two $50 splits correctly contributes $50 to each member's balance rather than $100 to either.

Performance Characteristics

Operation Time Complexity Notes
Create transaction with splits O(n) where n = member count Single INSERT for transaction, n INSERTs for splits
Update splits O(n + m) where m = existing split count DELETE existing + INSERT new
Balance calculation O(m) where m = split rows in group Single indexed query with aggregation
Bulk add to group O(t × n) where t = transaction count Wrapped in single transaction

Indexes on transaction_split.transaction_id and transaction_split.group_member_id ensure that balance queries remain performant even with thousands of split transactions.

Summary

  • Normalized schema: Securo separates transactions and transaction_split tables, linking via foreign key with cascade delete for data integrity.
  • Atomic updates: The split_service.replace_splits method performs validation, deletion, and insertion within a single database transaction.
  • Flexible split types: Equal splits auto-distribute amounts; percent splits allow custom allocations with enforced 100% validation.
  • Robust error handling: Validation failures return 400 Bad Request without corrupting existing split data.
  • Accurate balances: Balance calculations aggregate share_amount from splits, not raw transaction totals.

Frequently Asked Questions

What happens if split percentages don't sum to 100?

Securo's service layer validates percentage sums before any database modification. If the total differs from 100.0, it raises a ValueError that the API layer converts to a 400 Bad Request with a descriptive message. The original splits remain unchanged due to transaction rollback, as verified in test_split_validation_bubbles_400.

Can I update a transaction from equal splits to percentage splits?

Yes. Any split update replaces the entire split set. Patch the transaction with share_type="percent" and your desired member_splits array. The replace_splits method deletes existing equal-split rows and inserts new percentage-based rows, recalculating share_amount values accordingly.

How does Securo handle rounding in equal splits?

When dividing an amount doesn't yield exact cents, Securo distributes remainder cents to members in deterministic order (typically by member ID). This ensures the sum of share_amount values exactly equals the transaction total, preventing penny-off errors in balance calculations.

Are split transactions included in account-level exports?

Exported transaction data includes the total amount at the account level. Member-specific split details are available through separate group reporting endpoints that join transactions with transaction_split. The raw transactions table maintains the original amount for bank reconciliation purposes.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →