v0.0.0
OpenAPI 3.1.0

Spendy2

Spendy2 is an end-to-end encrypted personal finance dashboard that does two things:

  • It stores details of your transactions in an encrypted database.
  • The frontend decrypts this database to provide a dashboard view and easy edit access.

Most importantly, it does NOT handle the actual import of data. Parsing transactions from a pdf statement, bank API, or any other source and adding it to the database will need to be handled by you.

Server:https://spendy2.com
Client Libraries

Auth

​

Spendy2 uses OPAQUE auth to prevent sending your password to the server. This requires two requests for both user registration and login, and each of these operations have /start and /finish endpoints that must be called in quick succession.

Register (start)

​

Create a new user with the supplied username. The corresponding /finish request must be called for the user to be successfully created.

Body

required
application/json

Responses

  • application/json
  • application/json
Request Example for post/api/auth/register/start
curl https://spendy2.com/api/auth/register/start \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
  "username": "",
  "registrationRequest": ""
}'
{
  "registrationResponse": "string"
}

Register (finish)

​

Complete user creation.

Body

required
application/json

Responses

  • The user was created

  • application/json
Request Example for post/api/auth/register/finish
curl https://spendy2.com/api/auth/register/finish \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
  "username": "",
  "registrationRecord": ""
}'
No Body

Login (start)

​

Log in with a given username. /finish must be called to complete the login process.

Body

required
application/json

Responses

  • application/json
  • application/json
  • application/json
Request Example for post/api/auth/login/start
curl https://spendy2.com/api/auth/login/start \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
  "username": "",
  "startLoginRequest": ""
}'
{
  "loginResponse": "string"
}

Login (finish)

​

Completes the log in and returns a bearer token.

Body

required
application/json

Responses

  • application/json
  • application/json
Request Example for post/api/auth/login/finish
curl https://spendy2.com/api/auth/login/finish \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
  "username": "",
  "finishLoginRequest": ""
}'
{
  "token": "string"
}

Log out

​

Ends the current session.

Responses

  • Logged out

  • application/json
Request Example for post/api/auth/logout
curl https://spendy2.com/api/auth/logout \
  --request POST \
  --header 'Authorization: Bearer YOUR_SECRET_TOKEN'
No Body

Database

​

All of the user data is stored in a single encrypted, compressed SQLite database with the following table schema:

CREATE TABLE currencies (
  -- This table stores the currencies used in the database, along with how many decimals each
  -- currency is to store. Common currencies are already added to this table (think USD, EUR, etc).
  -- Exotic currencies will need to be added (think BTC).

  -- Currency code. Example: USD, EUR, BTC, etc.
  code              TEXT NOT NULL PRIMARY KEY CHECK (code <> ''),

  -- Number of stored decimals. Any amount that uses this currency will be multiplied by these many
  -- decimal places. For example, this value is 2 for USD (two decimal places). The amount $16.79
  -- will be stored as 1679.
  -- Note that if this value is updated, all existing amounts must also be updated.
  stored_decimals   INTEGER NOT NULL CHECK (stored_decimals >= 0)
) STRICT;

CREATE TABLE accounts (
  -- Account details. All transactions must be linked to an account in this table.

  -- Account name.
  name              TEXT NOT NULL COLLATE NOCASE PRIMARY KEY CHECK (name <> ''),

  -- The currency that this account's amounts are shown in. If a transaction linked to this account
  -- is made in a different currency, it will not be taken into account if it is not converted into
  -- this currency. If this value is NULL, conversions are not required and the UI will show a
  -- dropdown to see spends in all currencies that transactions are made in.
  display_currency  TEXT REFERENCES currencies(code),

  -- Account type. Example: "Bank" or "Card"
  type              TEXT NOT NULL CHECK (type <> ''),

  -- Account identifier. Usually masked. Example: "••4821"
  identifier        TEXT CHECK (identifier <> '')
) STRICT;

CREATE TABLE sources (
  -- Every transaction is required to be linked to a "source"
  -- In the case of file imports, hash can store the file hash, while remarks can store the file
  -- name or description.
  -- In other cases, it is up to the importer to fill the various details. The sources table exists
  -- to easily remove bulk transactions that may be incorrectly imported. A popular option is to
  -- set hash to sha256(date) - assuming that no better alternative exists.
  hash              BLOB NOT NULL PRIMARY KEY CHECK (length(hash) = 32),
  remarks           TEXT NOT NULL CHECK (remarks <> ''),
  import_timestamp  INTEGER NOT NULL DEFAULT (unixepoch())
) STRICT;

CREATE TABLE categories (
  -- Stores the various categories that transactions can be categorized under.

  -- Category name.
  name              TEXT NOT NULL COLLATE NOCASE PRIMARY KEY CHECK (name <> ''),

  -- A category can be listed under an existing category.
  parent            TEXT COLLATE NOCASE REFERENCES categories(name) ON UPDATE CASCADE ON DELETE SET NULL,
  CONSTRAINT no_self_parent CHECK (name <> parent)
) STRICT;

CREATE TABLE transactions (
  -- Store all transactions made.

  -- A transaction id. Lowercase uuid4. Importers can generate this per transaction.
  id                TEXT NOT NULL PRIMARY KEY CHECK (id GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-4[0-9a-f][0-9a-f][0-9a-f]-[89ab][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'),

  source            BLOB NOT NULL REFERENCES sources(hash) ON UPDATE CASCADE ON DELETE CASCADE,
  account_name      TEXT NOT NULL COLLATE NOCASE REFERENCES accounts(name) ON UPDATE CASCADE ON DELETE CASCADE,

  -- Indicates if the transaction is made from/to an existing account
  transfer_account  TEXT COLLATE NOCASE REFERENCES accounts(name) ON UPDATE CASCADE ON DELETE SET NULL,

  currency          TEXT NOT NULL REFERENCES currencies(code),

  -- Amount. This is the actual amount multiplied the pow(10, currency's stored decimal places).
  amount            INTEGER NOT NULL,

  counterparty      TEXT NOT NULL CHECK (counterparty <> ''),
  category          TEXT COLLATE NOCASE REFERENCES categories(name) ON UPDATE CASCADE ON DELETE SET NULL,

  -- It is quite possible that two sources list the same transaction. In that case, set one of the
  -- transactions as duplicate of the other.
  duplicate_of      TEXT REFERENCES transactions(id) ON UPDATE CASCADE ON DELETE SET NULL,

  timestamp         INTEGER,

  -- Local date, not utc, not timezone aware.
  naive_date        TEXT NOT NULL CHECK (naive_date IS date(naive_date)),
  
  -- if timestamp is NULL, transactions are ordered by (naive_date, sort_key).
  -- Can be set to 0 if not required
  sort_key          INTEGER NOT NULL,

  remarks           TEXT CHECK (remarks <> ''),
  CONSTRAINT no_self_duplicate CHECK (id <> duplicate_of),
  CONSTRAINT no_self_transfer CHECK (account_name <> transfer_account)
) STRICT;

CREATE TABLE conversions (
  -- A transaction's amount in other currencies, at most one per currency.
  transaction_id    TEXT NOT NULL REFERENCES transactions(id) ON UPDATE CASCADE ON DELETE CASCADE,
  currency          TEXT NOT NULL REFERENCES currencies(code),
  amount            INTEGER NOT NULL, -- in the currency's stored_decimals
  PRIMARY KEY (transaction_id, currency)
) STRICT;

Upload the database

​

Upload a new version of the database.

Headers

  • If the user has an existing database, this header is required. Pass the ETag from the corresponding GET call for the value of this header.

  • If the user does NOT have an existing database, this header is required. Pass * for the value of this header.

Body

required
application/json

Responses

  • application/json
  • application/json
  • application/json
  • application/json
Request Example for put/api/users/me/database
curl https://spendy2.com/api/users/me/database \
  --request PUT \
  --header 'content-length: ' \
  --header 'Content-Type: application/octet-stream' \
  --header 'Authorization: Bearer YOUR_SECRET_TOKEN' \
  --data-binary '@filename'
No Body

Download the database

​

Downloads the stored database. With If-None-Match set to its ETag, responds 304 if it hasn't changed.

Headers

Responses

  • application/octet-stream
  • application/json
  • application/json
Request Example for get/api/users/me/database
curl https://spendy2.com/api/users/me/database \
  --header 'Authorization: Bearer YOUR_SECRET_TOKEN'
@filename