Expense tracker

Track shared expenses between workspace users, show balances and history, remove expenses, and notify affected users.

Make it yours

Copy these instructions into your AilaFlow admin agent’s chat.

View Markdown

Create /expense_tracker

Create a process named /expense_tracker.

Before creating it, ask the admin which currency the tracker should use, for example PLN, EUR, or USD. The selected currency applies to the whole process.

Create a responsive custom Start form so users do not need to enter JSON manually.

Variables

Create one start variable:

  • $input — object containing the action and its arguments.

Create internal variables:

  • $action — string used by Branch.
  • $notification_users — string containing a user expression.
  • $notification_text — string.
  • $add_result — object.
  • $remove_result — object.
  • $summary_result — object.
  • $entries_result — object.

Actions

Support exactly:

add_expense
remove_expense
show_summary
show_entries

add_expense

{
  "action": "add_expense",
  "bill_with": "b4rtaz",
  "amount": 120.5,
  "split": "50:50",
  "description": "Dinner"
}
  • bill_with — required AilaFlow user.
  • amount — positive monetary amount.
  • split"50:50" or "0:100".
  • description — optional.

The process starter always pays the complete expense.

50:50 means the other user repays half.

0:100 means the other user repays the full amount.

remove_expense

{
  "action": "remove_expense",
  "expense_id": "expense-id"
}

Anyone may remove an active expense.

show_summary

{
  "action": "show_summary"
}

Returns the current balances of the process starter.

show_entries

{
  "action": "show_entries",
  "user": "b4rtaz",
  "page": 1
}

Returns expenses between the process starter and the specified user.

  • user — required.
  • page — optional positive integer, default 1.
  • return 50 records per page;
  • return whether another page exists.

User handling

Get the process starter with:

const startedBy = await ailaflow.getStartedBy();

Normalize every username supplied through $input:

  1. trim whitespace;
  2. lowercase it;
  3. add @ if missing.

For example:

B4RTAZ
@B4RTAZ
b4rtaz

all become:

@b4rtaz

AilaFlow usernames are lowercase.

Validate supplied users with:

await ailaflow.userExists(user);

Fail if the user does not exist.

Do not allow an expense with yourself or history lookup with yourself.

User expressions

User-expression operators must be lowercase.

Use or when addressing multiple users:

@alice or @bob

Never generate:

@alice AND @bob

For property expressions, follow AilaFlow syntax such as:

@{.team = "finance" and .access_level = "c3"}

Use only lowercase and and or.

Money and splitting

Store money as integer minor units.

For two-decimal currencies:

const amountMinor = Math.round(input.amount * 100);

Do accounting calculations using integers.

For 50:50:

const owedMinor = Math.floor(amountMinor / 2);

If the amount is odd in minor units, the payer absorbs the extra unit:

101 minor units
payer: 51
other user: 50

For 0:100:

const owedMinor = amountMinor;

Tables

Use:

#expenses
#expense_movements

Tables are dynamic; do not create them separately.

#expenses

Store one row per expense.

Columns:

_id
pair_key
payer
bill_with
amount_minor
owed_minor
currency
split
description
status
created_at
created_by
removed_at
removed_by

Example:

{
  "_id": "expense-id",
  "pair_key": "@alice:@bob",
  "payer": "@alice",
  "bill_with": "@bob",
  "amount_minor": 12050,
  "owed_minor": 6025,
  "currency": "PLN",
  "split": "50:50",
  "description": "Dinner",
  "status": "active",
  "created_at": "2026-09-15T20:00:00.000Z",
  "created_by": "@alice"
}

Omit unset removal fields.

Never physically delete expenses.

Canonical pair key

Use the same key regardless of who paid:

function createPairKey(user1, user2) {
  return [user1, user2].sort().join(':');
}

Alice/Bob and Bob/Alice therefore both produce:

@alice:@bob

#expense_movements

Create two signed movement rows for every expense.

Columns:

_id
expense_id
user
with_user
amount_minor
currency
status

Positive means:

with_user owes user

Negative means:

user owes with_user

Example:

{
  "_id": "expense-id:@alice",
  "expense_id": "expense-id",
  "user": "@alice",
  "with_user": "@bob",
  "amount_minor": 6025,
  "currency": "PLN",
  "status": "active"
}

and:

{
  "_id": "expense-id:@bob",
  "expense_id": "expense-id",
  "user": "@bob",
  "with_user": "@alice",
  "amount_minor": -6025,
  "currency": "PLN",
  "status": "active"
}

Use deterministic IDs:

expense_id + ":" + user

Main Script

Use one Script step for business logic.

Read:

const input = await ailaflow.readVariable('$input');
const startedBy = await ailaflow.getStartedBy();

Validate the action and write:

await ailaflow.writeVariable('$action', input.action);

Dispatch:

switch (input.action) {
  case 'add_expense':
    break;

  case 'remove_expense':
    break;

  case 'show_summary':
    break;

  case 'show_entries':
    break;

  default:
    throw new Error(`Unsupported action: ${input.action}`);
}

add_expense

Validate:

  • user exists;
  • user is not the starter;
  • amount is valid and positive;
  • split is 50:50 or 0:100.

Generate an expense ID.

Calculate:

amount_minor
owed_minor
pair_key

Write:

  1. one #expenses row;
  2. payer movement with +owed_minor;
  3. other-user movement with -owed_minor.

Prepare $add_result, for example:

{
  "expense_id": "expense-id",
  "payer": "@alice",
  "bill_with": "@bob",
  "amount": 120.5,
  "owed": 60.25,
  "currency": "PLN",
  "split": "50:50",
  "description": "Dinner"
}

Set:

$notification_users = "@alice or @bob"

Use lowercase or.

Prepare $notification_text, for example:

@alice added a 120.50 PLN expense with @bob in expense_tracker.

remove_expense

Read:

const expense = await ailaflow.tryReadTable('#expenses', input.expense_id);

Fail if it does not exist or is already removed.

Anyone may remove it.

Read the two movement rows using their deterministic IDs.

Rewrite them with:

status = "removed"

while preserving their other fields.

Rewrite the expense with:

status = "removed"
removed_at = current timestamp
removed_by = startedBy

Prepare $remove_result with the expense ID, participants, amount, currency, and remover.

Set:

$notification_users = "@alice or @bob"

using the original expense participants.

Prepare a message such as:

@charlie removed a 120.50 PLN expense between @alice and @bob in expense_tracker.

show_summary

Read only active movements belonging to the starter:

await ailaflow.readTablePage('#expense_movements', {
  page: 1,
  where: {
    user: { $eq: startedBy },
    status: { $eq: 'active' }
  }
});

Continue through pages until:

hasMore === false

Group rows by with_user and sum amount_minor.

Discard zero balances.

Positive total:

other user owes you

Negative total:

you owe other user

Prepare:

{
  "user": "@alice",
  "currency": "PLN",
  "total_owed_to_you": 120.5,
  "total_you_owe": 42.0,
  "net": 78.5,
  "balances": [
    {
      "user": "@bob",
      "amount": 60.25,
      "direction": "owes_you"
    },
    {
      "user": "@john",
      "amount": 42.0,
      "direction": "you_owe"
    }
  ]
}

Sort balances by username.

Do not send notifications.

show_entries

Normalize and validate $input.user.

Use page 1 when omitted. A supplied page must be a positive integer.

Calculate:

const pairKey = createPairKey(startedBy, requestedUser);

Read:

const result = await ailaflow.readTablePage('#expenses', {
  page,
  pageSize: 50,
  orderBy: 'created_at',
  ascending: false,
  where: {
    pair_key: { $eq: pairKey }
  }
});

Do not filter on status. Include active and removed history.

Prepare:

{
  "user": "@alice",
  "with_user": "@bob",
  "currency": "PLN",
  "page": 1,
  "page_size": 50,
  "has_more": true,
  "entries": [
    {
      "expense_id": "expense-id",
      "payer": "@bob",
      "bill_with": "@alice",
      "amount": 80,
      "owed": 40,
      "split": "50:50",
      "description": "Taxi",
      "status": "active",
      "created_at": "2026-09-15T21:00:00.000Z"
    }
  ]
}

Set:

has_more: result.hasMore;

Convert money back from minor units before returning.

Do not send notifications.

Branch

After Script, add a Branch using:

$action

Create exactly:

add_expense
remove_expense
show_summary
show_entries

Workflow:

Start

Script

Branch($action)

  ├─ add_expense
  │    Notification
  │    Finish($add_result)

  ├─ remove_expense
  │    Notification
  │    Finish($remove_result)

  ├─ show_summary
  │    Finish($summary_result)

  └─ show_entries
       Finish($entries_result)

Each branch has its own Finish step.

Do not add a common Finish after Branch.

Notifications

For add_expense and remove_expense, configure Notification with:

User expression:
$notification_users

Notification text:
$notification_text

The generated expression must use lowercase or:

@alice or @bob

Do not notify for show_summary or show_entries.

Start form

Create a responsive Start form with:

Add expense
Remove expense
Show summary
Show entries

For Add expense, show:

  • user;
  • amount with configured currency;
  • split selector;
  • optional description.

Explain:

50:50 — I paid and the other user owes half.
0:100 — I paid and the other user owes the full amount.

For Remove expense, show:

  • expense ID.

For Show summary, no additional fields.

For Show entries, show:

  • user;
  • page, default 1.

Hide irrelevant fields.

Validate inputs, use type="button", and call ailaflow.submitForm() inside try/catch.

The form may lowercase usernames, but the Script must normalize them again.

Finish forms

Create separate responsive Finish forms.

Add expense

Show:

  • expense added;
  • description;
  • total amount;
  • split;
  • how much the other user owes.

Remove expense

Show:

  • expense removed;
  • amount;
  • participants;
  • who removed it;
  • confirmation that balances changed.

Summary

Show:

  • currency;
  • total owed to the user;
  • total user owes;
  • net;
  • individual balances.

If empty:

You're all settled up.

Entries

Show:

  • both users;
  • currency;
  • newest entries first;
  • payer;
  • amount;
  • split;
  • owed amount;
  • description;
  • active/removed status;
  • date.

Clearly distinguish removed entries.

If empty:

No expenses found between you and @user.

At the bottom show:

Page 1
More entries available

when has_more is true, otherwise:

Page 1
No more entries

Do not load subsequent pages from the Finish form. Another /expense_tracker execution requests the next page.

Implementation rules

  • Ask for currency once while creating the process.
  • $input is the only start variable.
  • Create a custom Start form.
  • Normalize supplied usernames to lowercase.
  • Validate supplied users with userExists().
  • User expressions must use lowercase and / or.
  • Multiple notification recipients use or, for example @alice or @bob.
  • Each expense involves two users; the tracker supports any number of users overall.
  • The starter always pays newly added expenses.
  • Support only 50:50 and 0:100.
  • The payer absorbs an odd minor unit for 50:50.
  • Anyone may remove an active expense.
  • Never physically delete history.
  • Store money in integer minor units.
  • Store two signed movement rows per expense.
  • Use canonical pair_key for history.
  • Do not maintain mutable aggregate-balance rows.
  • Summary reads only the starter’s active movements and processes all matching pages.
  • History returns 50 records per page and has_more.
  • Use Branch for action-specific behavior.
  • Each action has its own Finish step and form.
  • Notify affected users only when balances change.
  • Keep Start and Finish forms simple, consistent, and responsive.
← All recipes