# How Securo Handles Split Transactions in Its Database Model

> Discover how Securo manages split transactions with a normalized database model. Learn about parent-child tables and atomic updates for data integrity.

- Repository: [securo-finance/securo](https://github.com/securo-finance/securo)
- Tags: database-model
- Published: 2026-08-28

---

**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)](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)](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`](https://github.com/securo-finance/securo/blob/main/backend/app/models/transaction.py), the `Transaction` model defines a relationship to its splits:

```python

# 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)](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.

```python

# 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)](https://github.com/securo-finance/securo/blob/main/backend/tests/test_transaction_splits_api.py):

```python
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.

```python
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.

```python
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)](https://github.com/securo-finance/securo/blob/main/backend/tests/test_transaction_splits_api.py) confirms proper error propagation:

```python
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.

```python
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)](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:

```sql
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)](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.