Overview¶
We use Firebase Realtime Database and Firebase Storage to store data.
- Storage is for blobs (images of receipts, avatars, etc) and archived group data (it costs way less than keeping it in RTDB)
- RTDB stores live data about users, groups, subscriptions, etc.
Both are secured by rules which check data integrity and user permissions. Some DB locations are read-only for users and only writable by the server.
RTDB schema can be generally split into 3 parts - group data, user data, and everything else.
Members × Users¶
Before we discuss individual entities there is an important distinction to be made. Entity User represents an actual person, user of the app. Member is a virtual entity specific to a single Group. Member can represent a User (e.g. John) but doesn't have to – it could be a representation of a couple ("Johnsons"). A Permission is a User allowed to access a Group. Again, User doesn't have to have a corresponding Member, leading to an m:n relationship between Users and Members.
Group data¶
Everything related to a single group.
/groups/<groupId>/- Among others contains
inviteLinkandinviteLinkHashfor sharing with others, this is generated server side - Click here to learn about Premium groups
premiumPurchasedByandpremiumPurchasedUntilare protected by rules, so the apps cannot change them- Sample
"group_id_1": { "ownerColor": "#ec1561", // fallback when user doesn't have own color in userGroups (like in group preview) "convertedToCurrency": "USD", "inviteLink": "https://join.settleup.app/abcdefgh", "inviteLinkHash": "test", "inviteLinkActive": true, "minimizeDebts": true, "remindOldDebts": true, "name": "Dogfood", "premiumPurchasedBy": "user_id_1", // this is a premium group, purchased by user_id_1 "premiumPurchasedUntil": 1457015264428, // time-limited group premium (lifetime if) "lastChanged": 1457015264428, // set automatically by server "defaultPermission": 10 // default permission for new members, enforced by server },
/members/<groupId>/<memberId>/- It shouldn't be possible to remove a member once they appear in any transaction or debt, but it does happen, so the apps need to be able to handle a missing member
- Sample
"member_id_1": { "active": true, // whether to include member in new transactions "bankAccount": "532224564654/0100", "lightningAddress": "ln@settleup.io", "defaultWeight": "1", // 0 = check off by default in transactions, otherwise default weight in new transactions "name": "David", "photoUrl": "https://lh3.googleusercontent.com/-BNa-7Enz7G8/AAAAAAAAAAI/AAAAAAAA16I/4cLGMI6XXl4/s120-c/photo.jpg", "paymentHandles": { "revolut": "bezy", "cashapp": "bezy" } },
/permissions/<groupId>/<uid>/- Permissions of
users(NOT members) - Levels are defined in database constants
- This should reflect User Groups, make sure to write to these locations atomically
- Sample
"user_id_1": { "level": 30 }
/transactions/<groupId>/<txId>/- A tx can be
expenseortransfer. In UI we also supportincomewhich is technicallyexpensewith negative amounts. - Category is an emoji, user can also assign custom names, see Categories tab
- Sample
"expense_id_1": { "category": "☕", // category of the transaction, can be one of the defaults or custom one "currencyCode": "CZK", "dateTime": 1457015264428, "exchangeRates": { "EUR": "27.05", // 1 = X in currencyCode currency "USD": "21.70" }, "fixedExchangeRate": false, // exchange rate changed manually, next transaction should be fixed as well "items": [ // first item main expense, second tip, third tax, you need to sum everything to get total { "amount": "200.33", "forWhom": [ { "memberId": "member_id_1", "weight": "1" }, { "memberId": "member_id_2", "weight": "2.3" } ] } ], "migrated": true, // not used anymore "purpose": "Pivo", "receiptUrl": "http://www.makereceipts.com/receipt_preview.jpg", "templateId": "template_id_1", // if it was generated from template (recurring or future) "timezone": "+01:00", // timezone of the user who created the transaction "type": "expense", "whoPaid": [ { "memberId": "member_id_1", "weight": "1" } ] }
/recurringTransactions/<groupId>/<rtxId>/- These are transaction "templates" which the server will use to generate actual transactions
- One-off txs are called future, repeated are recurring, but technically there is no difference
- See database constants for repetition setup
- Sample
"template_id_1": { "lastGenerated": 1457015264428, // set by server "runCount": 3, // how many already generated, set by server "recurrence": { "startDate": 1457015264428, "endDate": 1457015264428, "timezoneOffsetMillis": 3600000, // timezone offset of the user creating the template, used to generate transaction at "appropriate" time "endCount": 10, // maximum generated repetitions "period": "daily", "frequency": 1, // every day "monthlySetting": "lastDayOfMonth", // mandatory, only for period=monthly "weeklySetting": [ "mon","wed","fri" ] // optional, only for period=weekly. If missing day is determined by startDate }, "template": { "currencyCode": "CZK", "exchangeRates": { "EUR": "27.05" }, "fixedExchangeRate": true, "items": [ { "amount": "200.33", "forWhom": [ { "memberId": "member_id_1", "weight": "1" } ] } ], "purpose": "Pivo", "receiptUrl": "http://www.makereceipts.com/receipt_preview.jpg", "type": "expense", "whoPaid": [ { "memberId": "member_id_1", "weight": "1" } ] } },
/changes/<groupId>/<changeId>/- Generated by server for each change in a group (someone added a member, deleted a transaction, etc.)
- See database constants for possible values
- Sample
"change_id_1": { "action": "insert", "by": "user_id_1", // this can be missing if the change was made by the server "entity": "expense", "entityId": "transaction_id_1", "entityName": "Pivo", // generated for the given entity type "serverTimestamp": 147454656 },
/groupCategories/<groupId>/- Custom category names
- See algorithms for more details about how these are used to generate transaction title
- Sample
"group_id_1": { "☕": "Coffee", "🍦": "Ice Cream" }
/debts/<groupId>/- Debts are automatically generated by the server
- Manual recalculation can be done using calculateDebts server task
- Sample
"group_id_1": [{ "from": "member_id_1", "to": "member_id_2", "amount": "100.50" // amount in group currency }, { "from": "member_id_1", "to": "member_id_3", "amount": "100.50" }]
User data¶
Everything related to a single user
/users/<uid>/- A logged-in user
inviteLinkHashis used to join a new group - setting it to the group's hash will allow user to read group data- When a user isn't logged in yet the app should log in anonymously, then
authProvider(and most other fields) will be missing - Sample
"user_id_1": { "currentTabId": "group_id_1", // NEW_GROUP or groupId "authProvider": "google", "email": "me@destil.cz", "inviteLinkHash": "test", // used for joining a group "name": "David Vávra", "photoUrl": "https://lh3.googleusercontent.com/-BNa-7Enz7G8/AAAAAAAAAAI/AAAAAAAA16I/4cLGMI6XXl4/s120-c/photo.jpg", "superuser": true, "locale": "en-us", // system locale or app-specific locale "defaultPaymentHandles": { "revolut": "bezy", "cashapp": "bezy" } }
/userGroups/<uid>/<groupId>- Which groups the user is part of. This is a reverse of
permissions, writing to these two locations should be atomic to prevent discrepancies - Sample
"group_id_1": { "order": 1, "color": "#ec1561", // custom color which overrides group ownerColor "member": "member_id_1" // "This is me" in the app, connection between member and user, multiple users can have same member, optional },
/pushRegistrations/<uid>/<token>- Push registration tokens used to send push notifications
- Apps only write here, server is responsible for cleanup
- Sample
"push_token_1": { "platform": "android", "version": "1666", // version of the app, used to control if given push is supported "url": "url" // legacy, used only for Windows app },
/subscriptions/<uid>/<id>- Individual Premium, Group Premium, Gifts, User Rewards all go here
- Writable only by server
- Sample
"subscription_id_1" : { "active": true, // If subscription was cancelled or not "start" : 21312321321, "end" : 21312381321, "store": "googlePlay", "type" : "monthly", "receipt": "dsajldjsakljlkdsa", "sku": "premium_monthly" }, "subscription_id_2" : { "start" : 21312321321, "end" : 21312381321, "type" : "gift" }, "subscription_id_3" : { "start" : 21312321321, // No end = lifetime "store": "googlePlay", "type" : "group", "receipt": "dsajldjsakljlkdsa", "groupId": "group_id_5", "durationDays": 9999, "sku": "sku_7" }, "subscription_id_4" : { "start" : 21312321321, "end" : 41312321321, "store": "googlePlay", "type" : "group", "receipt": "dsajldjsakljlkdsa", "groupId": "group_id_5", "durationDays": 31, "sku": "sku" }, "subscription_id_5" : { "start" : 21312321321, "end" : 41312321321, "store": "googlePlay", "type" : "group", "receipt": "dsajldjsakljlkdsa", "groupId": "group_id_5", "durationDays": 7, "sku": "sku" }, "subscription_id_6" : { "start": 21312321321, "type": "featureReward", "feature": "colors" }
Everything else¶
/inviteLinkHashes/<hash>/- A map from invite link hash -> groupId
- Sample
"inviteLinkHashes": { "invite_hash_1": "group_id_1", "invite_hash_2": "group_id_2" },
/exchangeRatesToUsd/<latest|YYYYMMDD>/- Daily generated exchange rate to USD
- Contains "SAT" currency for Bitcoin Satoshi
- List of currencies is configured via Remote Config
- Sample
"latest": { "CZK": "21.43", "EUR": "0.94" }, "20150601": { "CZK": "21.42", "EUR": "0.94" }
/campaigns/<campaignId>/- Used for push notification campaigns
- This can be populated using the Admin tool
- Sample
"campaign_id_1": { "en-US": { "title": "We are awesome", "description": "Checkout how awesome we are", "imageUrl": "https://link.awesome.pic/yolo.png", "actionLink": "http://settleup.io/", "actionName": "Show" }, "cs-CZ": { "title": "Jsme úžasní", "description": "Mrkněte na to, jak jsme úžasní", "imageUrl": "https://link.awesome.pic/yolo.png", "actionLink": "http://settleup.io/", "actionName": "Ukaž" } }
/statistics/public/- Statistics about the app, can be shown in About screen
- Sample
"public": { "userCount": 200, "groupCount": 2000, "averageGroupCountPerUser": 10 },
/offers/<offerId>/- Promotional offers, like Black Friday
- Their availability is driven by the stores
- How to get
offerId? - Google Play: Play Store API gives us an available offer for a particular subscription. If the user is eligible, we get
offerIdand different prices. - App Store: We use Promotional Offer for a particular subscription. If the user is eligible, we get identifier and different prices for each Discount (Offer). Keep in mind that App Store allows only underscores (_) for discount identifier. The app automatically converts it to dashes (-) before requesting information from Firebase.
- Stripe: Stripe doesn't have a concept of offers, you just change a price. But you can add metadata to products. So there should be metadata with key
offerId, which signifies this product/price has an active offer. - Sample
"black-friday-2025": { // offerId from the Google Play / App Store / Stripe "endsAt": 1457015264428, // required; timestamp in ms, used for countdown "colorPrimaryLight": "#ff5607", // colour of the primary text (see below); a lighter version will be applied to the background of the campaign stripe around the promoted offer on the Premium screen; optional; if missing, Premium colour is used (IP or GP) "colorPrimaryDark": "#ff7845", // optional; if missing, Premium colour is used (IP or GP) "colorSecondaryLight": "#F7AD00", // colour of the secondary text and text inside the discount bubble; optional; if missing, onBackground colour is used (black) "colorSecondaryDark": "#F7AD00", // optional; if missing, onBackground colour is used (white) "colorTertiaryLight": "#F7AD00", // colour of the background of the campaign stripe and the discount bubble; optional; if missing, primary colour is used to calculate background colour "colorTertiaryDark": "#F7AD00", // optional; if missing, primary colour is used to calculate background colour "showDiscountIcon": true, // required; whether to show discount icon next to discount text in the discount bubble "localized": { "en": { // default if locale doesn't match "titlePrimary": "BLACK", // required; this string will have Primary colour "titleSecondary": "FRIDAY", // this string will have Secondary colour; optional "buttonTextPrimary": "Claim your 50% discount", // this is on CTA button; optional; if missing, normal button text without offer is used "buttonTextSecondary": "Yearly subscription: cancel anytime" // this is under the CTA button on bottom sheets and in before-ad; optional, if missing default text under button is used }, "cs-CZ": { "titlePrimary": "BLACK", "titleSecondary": "FRIDAY", "buttonTextPrimary": "Nechci slevu zadarmo", "buttonTextSecondary": "Roční předplatné: zrušte kdykoliv" } } }
/paymentProviders/<providerId>/- Configuration for external payment providers, see Payment Providers for details
- Sample
"paymentProviders": { "revolut": { "platforms": ["ios", "android", "web"], // null means available for all "name": "Revolut", "icon": "<url>", "link": "https://revolut.me/{handle}?amount={amount}¤cy={currency}¬e={purpose}", "handlePrefix": "@", // optional "amountFormat": "minor|decimal", "currencies": ["USD", "CZK"], // null means no limit "regions": ["CZ", "US", "UK", "IN"], // null means no limit "androidPackageName": "com.x.y", "active": true, "order": 0 } },