Skip to content

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 inviteLink and inviteLinkHash for sharing with others, this is generated server side
  • Click here to learn about Premium groups
  • premiumPurchasedBy and premiumPurchasedUntil are 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 expense or transfer. In UI we also support income which is technically expense with 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
  • inviteLinkHash is 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 offerId and 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}&currency={currency}&note={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
        }
      },