Fracti uses a single-table DynamoDB design optimized for an expense-splitting application. This design enables efficient queries for both group-centric and user-centric access patterns.
| Component | Value | Description |
|---|---|---|
| Table Name | FractiTable |
Single table for all entities |
| Partition Key | PK (String) |
Entity partition |
| Sort Key | SK (String) |
Entity type + identifier |
| Billing Mode | PAY_PER_REQUEST |
On-demand scaling |
| Attribute | Type | Description |
|---|---|---|
GSI1PK |
String | USER#<telegramId> |
GSI1SK |
String | Varies by entity type |
Purpose:
- Find all groups for a user (
GSI1SKbegins withGROUP#) - Find all expenses where user is a beneficiary (
GSI1SKbegins withOWES#)
| Attribute | Type | Description |
|---|---|---|
GSI2PK |
String | EXPENSE#<id> or SETTLEMENT#<id> |
GSI2SK |
String | <groupId> |
Purpose: O(1) lookup by expense or settlement ID
| Attribute | Type | Description |
|---|---|---|
GSI3PK |
String | USER#<telegramId> |
GSI3SK |
String | TX#<ts> or SETTLE#<ts> |
Purpose:
- Find all expenses paid by a user
- Find all settlements made by a user
- Get user activity timeline
Represents a Telegram group/chat where expenses are tracked.
| Attribute | Key | Type | Description |
|---|---|---|---|
PK |
PK | String | GROUP#<id> |
SK |
SK | String | METADATA |
id |
String | UUID | |
chatId |
String | Telegram chat ID | |
title |
String | Group name | |
currency |
String | Default currency (TON, USD, etc.) | |
createdAt |
String | ISO timestamp | |
memberCount |
Number | Cached member count |
Example:
{
"PK": "GROUP#550e8400-e29b-41d4-a716-446655440000",
"SK": "METADATA",
"id": "550e8400-e29b-41d4-a716-446655440000",
"chatId": "-1001234567890",
"title": "Roommates",
"currency": "TON",
"createdAt": "2024-01-15T10:30:00.000Z",
"memberCount": 4
}Represents a user's membership in a group.
| Attribute | Key | Type | Description |
|---|---|---|---|
PK |
PK | String | GROUP#<groupId> |
SK |
SK | String | USER#<telegramId> |
GSI1PK |
GSI1 | String | USER#<telegramId> |
GSI1SK |
GSI1 | String | GROUP#<groupId> |
id |
String | Telegram user ID (string) | |
telegramId |
Number | Telegram user ID (number) | |
name |
String | Display name | |
username |
String | Telegram @username | |
wallet |
String | TON wallet address | |
avatarUrl |
String | S3 avatar path | |
joinedAt |
String | ISO timestamp |
Example:
{
"PK": "GROUP#550e8400-e29b-41d4-a716-446655440000",
"SK": "USER#123456789",
"GSI1PK": "USER#123456789",
"GSI1SK": "GROUP#550e8400-e29b-41d4-a716-446655440000",
"id": "123456789",
"telegramId": 123456789,
"name": "Alice Smith",
"username": "alice",
"wallet": "EQBvI0aFLnw2XHNc7mpXc...",
"avatarUrl": "avatars/123456789.jpg",
"joinedAt": "2024-01-15T10:35:00.000Z"
}Represents a shared expense within a group.
| Attribute | Key | Type | Description |
|---|---|---|---|
PK |
PK | String | GROUP#<groupId> |
SK |
SK | String | TX#<timestamp> |
GSI2PK |
GSI2 | String | EXPENSE#<id> |
GSI2SK |
GSI2 | String | <groupId> |
GSI3PK |
GSI3 | String | USER#<payerId> |
GSI3SK |
GSI3 | String | TX#<timestamp> |
id |
String | UUID | |
groupId |
String | Group UUID | |
payerId |
String | Telegram ID of payer | |
payerName |
String | Display name of payer | |
amount |
Number | Total expense amount | |
currency |
String | Currency code | |
description |
String | Expense description | |
splitType |
String | equal, exact, or percentage |
|
splits |
Array | Split breakdown | |
category |
String | Expense category | |
createdAt |
String | ISO timestamp |
Splits Array Structure:
{
userId: string; // Telegram ID
userName: string; // Display name
amount: number; // Amount owed
percentage?: number; // If split by percentage
}Example:
{
"PK": "GROUP#550e8400-e29b-41d4-a716-446655440000",
"SK": "TX#2024-01-20T18:30:00.000Z",
"GSI2PK": "EXPENSE#660e8400-e29b-41d4-a716-446655440001",
"GSI2SK": "550e8400-e29b-41d4-a716-446655440000",
"GSI3PK": "USER#123456789",
"GSI3SK": "TX#2024-01-20T18:30:00.000Z",
"id": "660e8400-e29b-41d4-a716-446655440001",
"groupId": "550e8400-e29b-41d4-a716-446655440000",
"payerId": "123456789",
"payerName": "Alice Smith",
"amount": 100,
"currency": "TON",
"description": "Dinner at restaurant",
"splitType": "equal",
"splits": [
{ "userId": "123456789", "userName": "Alice Smith", "amount": 25 },
{ "userId": "987654321", "userName": "Bob Jones", "amount": 25 },
{ "userId": "456789123", "userName": "Carol White", "amount": 25 },
{ "userId": "789123456", "userName": "Dave Brown", "amount": 25 }
],
"category": "food",
"createdAt": "2024-01-20T18:30:00.000Z"
}Denormalized record for querying expenses where a user owes money (beneficiary queries).
| Attribute | Key | Type | Description |
|---|---|---|---|
PK |
PK | String | GROUP#<groupId> |
SK |
SK | String | PART#<expenseId>#<userId> |
GSI1PK |
GSI1 | String | USER#<userId> |
GSI1SK |
GSI1 | String | OWES#<timestamp> |
expenseId |
String | Expense UUID | |
groupId |
String | Group UUID | |
groupTitle |
String | Group name (denormalized) | |
userId |
String | Beneficiary Telegram ID | |
userName |
String | Beneficiary name | |
amount |
Number | Amount this user owes | |
payerId |
String | Who paid | |
payerName |
String | Payer name | |
description |
String | Expense description | |
totalAmount |
Number | Total expense amount | |
createdAt |
String | ISO timestamp |
Example:
{
"PK": "GROUP#550e8400-e29b-41d4-a716-446655440000",
"SK": "PART#660e8400-e29b-41d4-a716-446655440001#987654321",
"GSI1PK": "USER#987654321",
"GSI1SK": "OWES#2024-01-20T18:30:00.000Z",
"expenseId": "660e8400-e29b-41d4-a716-446655440001",
"groupId": "550e8400-e29b-41d4-a716-446655440000",
"groupTitle": "Roommates",
"userId": "987654321",
"userName": "Bob Jones",
"amount": 25,
"payerId": "123456789",
"payerName": "Alice Smith",
"description": "Dinner at restaurant",
"totalAmount": 100,
"createdAt": "2024-01-20T18:30:00.000Z"
}Represents a payment between two users to settle debt.
| Attribute | Key | Type | Description |
|---|---|---|---|
PK |
PK | String | GROUP#<groupId> |
SK |
SK | String | SETTLE#<timestamp> |
GSI2PK |
GSI2 | String | SETTLEMENT#<id> |
GSI2SK |
GSI2 | String | <groupId> |
GSI3PK |
GSI3 | String | USER#<fromUserId> |
GSI3SK |
GSI3 | String | SETTLE#<timestamp> |
id |
String | UUID | |
groupId |
String | Group UUID | |
fromUserId |
String | Payer Telegram ID | |
fromUserName |
String | Payer name | |
toUserId |
String | Recipient Telegram ID | |
toUserName |
String | Recipient name | |
amount |
Number | Settlement amount | |
currency |
String | Currency code | |
txHash |
String | TON transaction hash | |
status |
String | pending, completed, failed |
|
createdAt |
String | ISO timestamp | |
completedAt |
String | Completion timestamp |
Example:
{
"PK": "GROUP#550e8400-e29b-41d4-a716-446655440000",
"SK": "SETTLE#2024-01-21T10:00:00.000Z",
"GSI2PK": "SETTLEMENT#770e8400-e29b-41d4-a716-446655440002",
"GSI2SK": "550e8400-e29b-41d4-a716-446655440000",
"GSI3PK": "USER#987654321",
"GSI3SK": "SETTLE#2024-01-21T10:00:00.000Z",
"id": "770e8400-e29b-41d4-a716-446655440002",
"groupId": "550e8400-e29b-41d4-a716-446655440000",
"fromUserId": "987654321",
"fromUserName": "Bob Jones",
"toUserId": "123456789",
"toUserName": "Alice Smith",
"amount": 25,
"currency": "TON",
"txHash": "a1b2c3d4e5f6...",
"status": "completed",
"createdAt": "2024-01-21T10:00:00.000Z",
"completedAt": "2024-01-21T10:00:05.000Z"
}| # | Pattern | Index | Key Condition | Complexity |
|---|---|---|---|---|
| 1 | Get group by ID | Main | PK = GROUP#<id>, SK = METADATA |
O(1) |
| 2 | Get all members in group | Main | PK = GROUP#<id>, SK begins_with USER# |
O(log n) |
| 3 | Get member by ID in group | Main | PK = GROUP#<id>, SK = USER#<tgId> |
O(1) |
| 4 | Get expenses in group | Main | PK = GROUP#<id>, SK begins_with TX# |
O(log n) |
| 5 | Get settlements in group | Main | PK = GROUP#<id>, SK begins_with SETTLE# |
O(log n) |
| 6 | Get expense participants | Main | PK = GROUP#<id>, SK begins_with PART#<expId> |
O(log n) |
| # | Pattern | Index | Key Condition | Complexity |
|---|---|---|---|---|
| 7 | Get expense by ID | GSI2 | GSI2PK = EXPENSE#<id> |
O(1) |
| 8 | Get settlement by ID | GSI2 | GSI2PK = SETTLEMENT#<id> |
O(1) |
| # | Pattern | Index | Key Condition | Complexity |
|---|---|---|---|---|
| 9 | Get all groups for user | GSI1 | GSI1PK = USER#<id>, SK begins_with GROUP# |
O(log n) |
| 10 | Get expenses user owes | GSI1 | GSI1PK = USER#<id>, SK begins_with OWES# |
O(log n) |
| 11 | Get expenses user paid | GSI3 | GSI3PK = USER#<id>, SK begins_with TX# |
O(log n) |
| 12 | Get settlements user made | GSI3 | GSI3PK = USER#<id>, SK begins_with SETTLE# |
O(log n) |
| 13 | Get all user activity | GSI3 | GSI3PK = USER#<id> |
O(log n) |
┌─────────────────────────────────────────────────────────────────────────────┐
│ MAIN TABLE │
├─────────────────────────────────────────────────────────────────────────────┤
│ PK = GROUP#abc123 │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ SK = METADATA → Group info (title, currency, etc.) ││
│ │ SK = USER#111 → Alice (member, GSI1→groups, GSI1→debts) ││
│ │ SK = USER#222 → Bob (member) ││
│ │ SK = USER#333 → Carol (member) ││
│ │ SK = TX#2024-01-20T18:30 → Expense $100 (GSI2→id, GSI3→payer) ││
│ │ SK = TX#2024-01-21T12:00 → Expense $50 (GSI2→id, GSI3→payer) ││
│ │ SK = PART#exp1#222 → Bob owes $25 on exp1 (GSI1→owes) ││
│ │ SK = PART#exp1#333 → Carol owes $25 on exp1 (GSI1→owes) ││
│ │ SK = SETTLE#2024-01-22 → Settlement $25 (GSI2→id, GSI3→payer) ││
│ └─────────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ GSI1: User Index │
│ GSI1PK = USER#222 (Bob) │
│ ┌─────────────────────────────────────┐│
│ │ GSI1SK = GROUP#abc123 → Membership ││
│ │ GSI1SK = GROUP#def456 → Membership ││
│ │ GSI1SK = OWES#2024-01-20 → Owes $25 ││
│ │ GSI1SK = OWES#2024-01-21 → Owes $15 ││
│ └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ GSI2: Entity Lookup │
│ ┌─────────────────────────────────────┐│
│ │ GSI2PK = EXPENSE#exp1 → O(1) ││
│ │ GSI2PK = SETTLEMENT#stl1 → O(1) ││
│ └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ GSI3: User Activity │
│ GSI3PK = USER#111 (Alice) │
│ ┌─────────────────────────────────────┐│
│ │ GSI3SK = TX#2024-01-20 → Paid $100││
│ │ GSI3SK = TX#2024-01-21 → Paid $50 ││
│ │ GSI3SK = SETTLE#2024-01 → Sent $25 ││
│ └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/groups |
List user's groups |
| GET | /api/groups/:groupId |
Get group with members |
| POST | /api/groups |
Create new group |
| POST | /api/groups/:groupId/join |
Join a group |
| PUT | /api/groups/:groupId/wallet |
Update wallet address |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/groups/:groupId/expenses |
List group expenses (paginated) |
| GET | /api/groups/:groupId/expenses/:expenseId |
Get single expense |
| POST | /api/groups/:groupId/expenses |
Create expense |
| DELETE | /api/groups/:groupId/expenses/:expenseId |
Delete expense |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/groups/:groupId/debts |
Calculate debt graph |
| GET | /api/groups/:groupId/settlements |
List settlements (paginated) |
| POST | /api/groups/:groupId/settlements |
Create settlement |
| PUT | /api/groups/:groupId/settlements/:settlementId |
Update settlement |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/users/me |
Get current user profile |
| GET | /api/users/me/expenses |
Expenses user paid (all groups) |
| GET | /api/users/me/debts |
Expenses user owes (all groups) |
| GET | /api/users/me/settlements |
Settlements user made (all groups) |
| GET | /api/users/me/activity |
Combined activity feed |
| GET | /api/users/me/summary |
Financial summary |
When creating an expense, the following items are written atomically:
- EXPENSE record - Main expense with GSI2 + GSI3 keys
- EXPENSE_PARTICIPANT records - One per beneficiary (excluding payer) with GSI1 keys
When deleting an expense:
- Delete the EXPENSE record
- Delete all EXPENSE_PARTICIPANT records for that expense
- Use
TransactWriteItemsfor atomic operations across multiple items - Implements optimistic locking where necessary
{
TableName: "FractiTable",
KeySchema: [
{ AttributeName: "PK", KeyType: "HASH" },
{ AttributeName: "SK", KeyType: "RANGE" }
],
AttributeDefinitions: [
{ AttributeName: "PK", AttributeType: "S" },
{ AttributeName: "SK", AttributeType: "S" },
{ AttributeName: "GSI1PK", AttributeType: "S" },
{ AttributeName: "GSI1SK", AttributeType: "S" },
{ AttributeName: "GSI2PK", AttributeType: "S" },
{ AttributeName: "GSI2SK", AttributeType: "S" },
{ AttributeName: "GSI3PK", AttributeType: "S" },
{ AttributeName: "GSI3SK", AttributeType: "S" }
],
GlobalSecondaryIndexes: [
{
IndexName: "GSI1",
KeySchema: [
{ AttributeName: "GSI1PK", KeyType: "HASH" },
{ AttributeName: "GSI1SK", KeyType: "RANGE" }
],
Projection: { ProjectionType: "ALL" }
},
{
IndexName: "GSI2",
KeySchema: [
{ AttributeName: "GSI2PK", KeyType: "HASH" },
{ AttributeName: "GSI2SK", KeyType: "RANGE" }
],
Projection: { ProjectionType: "ALL" }
},
{
IndexName: "GSI3",
KeySchema: [
{ AttributeName: "GSI3PK", KeyType: "HASH" },
{ AttributeName: "GSI3SK", KeyType: "RANGE" }
],
Projection: { ProjectionType: "ALL" }
}
],
BillingMode: "PAY_PER_REQUEST"
}When migrating from the previous schema:
- Add GSI3 - New index for user activity queries
- Backfill EXPENSE_PARTICIPANT records - For existing expenses
- Add GSI3PK/GSI3SK to existing EXPENSE and SETTLEMENT records
Migration script should:
- Scan all existing expenses
- For each expense, create EXPENSE_PARTICIPANT records for non-payer beneficiaries
- Update expenses with GSI3PK/GSI3SK attributes
- Update settlements with GSI3PK/GSI3SK attributes