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 parenttransactionstablegroup_member_id— identifies which group member receives this split portionshare_amount— the calculated monetary value this member owes or receivescreated_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:
- Validation — checks that all referenced
group_member_idvalues exist and belong to the transaction's group - Deletion — removes existing
transaction_splitrows for this transaction (if any) - Calculation — computes
share_amountfor each member based onshare_type - Insertion — creates new
TransactionSplitobjects 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
transactionsandtransaction_splittables, linking via foreign key with cascade delete for data integrity. - Atomic updates: The
split_service.replace_splitsmethod 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_amountfrom 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →