Skip to content
2025Personal project

Splibilo

A group expense system that turns shared spending into clear balances and practical settlement suggestions.

GitHub

Role
Full-stack development
Contribution
Frontend, backend, data modeling, receipt workflow
Key challenges
Balance consistency, settlement logic, receipt extraction
Tech stack
Next.js, NestJS, PostgreSQL, Prisma, Gemini

Overview

Splibilo is a group expense system for recording shared spending, splitting costs, tracking balances, and settling what is still owed.

It combines expenses, shares, and settlements into a clear view of each member’s current position, then suggests who should pay whom. It also supports equal or custom splits and receipt uploads to make adding expenses faster.

Why I built it

Splibilo started from something that happened pretty often in my friend groups. We would take turns paying for meals, tickets, or other shared expenses, and after a while it became surprisingly hard to keep track of who still owed whom.

The annoying part was that settling each expense one by one did not always make sense. A might owe B from one expense, while B also owed A from another. If we followed every expense literally, we could end up making multiple transfers even though some of those amounts could cancel each other out first.

That became more noticeable when transfer fees were involved. A few small payments could mean paying the fee several times, even though the group could have reached the same final balance with fewer transfers.

That was really why I built Splibilo. I wanted an easier way for everyone to settle up after sharing expenses, without having to work through every payment one by one.

Problem

Once I started building it, the main question became how to represent the group's current financial state.

An expense tells me who paid and how the cost was split, but that alone does not tell me what everyone should do next. I also need to account for all the other expenses in the group and any settlements that have already happened.

So the system needs to take those records and answer one question:

Who should pay whom now?

For example, if Alex pays Rp300,000 for three people:

MemberPaidShare
AlexRp300,000Rp100,000
BobRp0Rp100,000
CharlieRp0Rp100,000

Alex paid Rp200,000 more than his own share, while Bob and Charlie each still owe Rp100,000. That gives each member a different net position:

MemberNet balance
Alex+Rp200,000
Bob-Rp100,000
Charlie-Rp100,000

A positive balance means that member should receive money. A negative balance means they still need to pay.

That way, instead of tracking separate "who owes whom" relationships for every expense, I could look at each member's overall position after combining all of the group's expenses and settlements.

Deriving balances

To calculate that overall position, I keep expenses, expense shares, and settlements as the source of truth rather than storing the current balances themselves.

  1. Expenses, expense shares, and settlements
  2. Net balance per member
  3. Settlement suggestions

For each member, the balance is calculated as total paid − total owed + settlements sent − settlements received.

This separation matters because "who pays whom" is not the financial state itself. Different payment pairings can settle the same set of balances, so I treat those pairings as suggestions rather than something that needs to be stored permanently.

Keeping the records consistent

Because the balances depend on the underlying records, those records need to stay consistent when an expense changes.

Updating an expense can involve changing the expense itself, removing the previous shares, and creating new ones. I wrap those writes in a database transaction so they either all succeed or none of them do.

That way, a partially updated expense cannot leave the data in a state that would produce the wrong balance later.

Settlement matching

Once each member has a net balance, the system separates those who need to pay from those who need to receive money.

For example:

MemberNet balance
Alex+Rp150,000
Bob-Rp50,000
Charlie-Rp100,000

I then match the two sides one at a time. At each step, the payment is whichever is smaller: what the current debtor still owes or what the current creditor still needs to receive.

That can produce:

FromToAmount
BobAlexRp50,000
CharlieAlexRp100,000

The matching logic comes down to this loop:

while (i < debtors.length && j < creditors.length) {
  const debtor = debtors[i];
  const creditor = creditors[j];

  const pay = Prisma.Decimal.min(debtor.amount, creditor.amount);

  edges.push({
    fromUserId: debtor.userId,
    toUserId: creditor.userId,
    amount: pay.toNumber(),
  });

  debtor.amount = debtor.amount.sub(pay);
  creditor.amount = creditor.amount.sub(pay);

  if (debtor.amount.lte(0)) i++;
  if (creditor.amount.lte(0)) j++;
}

This gives me a practical payment plan from the group's final balances. Because it uses a greedy algorithm, it does not search every possible pairing, so I do not treat the result as a guarantee of the fewest possible transfers.

Receipt extraction

I also wanted expense entry to be less repetitive when the user already had a receipt.

Rather than sending the raw image directly to a model, I split the process into a few stages:

  1. Receipt image
  2. Image preprocessing
  3. OCR
  4. Gemini interpretation
  5. Structured fields
  6. User review

The image is first cleaned up with Sharp before Tesseract.js extracts the visible text. I then pass that OCR output to Gemini, which interprets the less structured parts of the receipt and maps them into fields the expense form can use.

I chose this staged approach partly because it keeps the model's job smaller. Sending text instead of the original image is generally cheaper, and the OCR output does not need to be perfectly structured for the model to make sense of it. With clear instructions and an expected output format, even fairly messy receipt text can still contain enough context to identify items, prices, and totals.

The extracted result is still not treated as final. The user can review and correct the fields before saving the expense.

Choosing reliability over lower model cost

I also tried lower-cost model options because inference cost still mattered.

In my tests at the time, the cheaper alternatives were less reliable at understanding the Indonesian receipt text I was working with. I kept Gemini even though it cost more because a cheaper model was not useful if I had to keep fixing the extracted data manually.

So the cost trade-off ended up happening in two places: reduce the amount of work sent to the model by doing OCR first, then use a model that was reliable enough for the interpretation step to actually save time.

Reflection

Looking back, I think the system has held up well for the scale I built it for. The basic model still makes sense, especially because the amount of expense history has not reached a point where balance reads need to be heavily optimized. However, there are a couple of things I’d revisit if the amount of data and activity grew.

Scaling balance reads

Right now, calculating a balance means reading the group's expense and settlement history and deriving each member's current position again.

For the amount of data Splibilo handles now, I prefer keeping that logic simple and deriving the balance directly from the original records.

If the history became much larger, I would probably introduce a separate database table that stores the latest balance for each member in a group. Instead of processing every past expense and settlement whenever the balance page is opened, the system could read the current balances directly from that table.

The original expenses, shares, and settlements would still remain the source of truth. The balance table would only act as a derived projection that can be rebuilt from those records if needed.

The trade-off is that reads become much cheaper, but writes become more complicated. Creating, editing, deleting, or settling an expense would also need to update the affected member balances in the same transaction so the projection stays consistent.

Concurrent settlements

The settlement flow currently calculates the outstanding debt, checks whether the requested amount is valid, and then creates the settlement.

That works normally, but there is a small concurrency gap between checking the balance and writing the settlement. If two requests arrive at nearly the same time, they could both read the same outstanding debt and both pass validation before either settlement has been recorded.

For example, if Bob still owes Alex Rp100,000, two concurrent requests for Rp100,000 could both see that amount as valid. If both were then saved, the group would end up recording more settlement than was actually owed.

If I were hardening this for heavier real-world use, I would make the balance check and settlement write part of the same protected database operation. With transaction isolation or locking, the second request would have to see the result of the first before it could be accepted.

The main thing I took away from Splibilo is that the balance and the settlement suggestion are different things. The balance represents the group's financial state, while the greedy algorithm is just one way of turning that state into a payment plan people can act on.