Real project
Community Split — House bills without the drama
An app for people who share a home: it splits bills to the exact cent, closes the month and turns a web of debts into the fewest possible Pix transfers.

- Pix transfers at most to settle a house of N people
- N − 1
- split modes: equal, by shares and by amounts
- 3
- lost to rounding — everything in whole cents
- R$ 0,00
- automated tests across API and interface
- 85
- Year
- 2026
- Role
- Product & full-stack
- Client
- Projeto próprio
- Stack
- Next.js
- React
- TypeScript
- Fastify
- PostgreSQL
- Drizzle ORM
- Zod
- Tailwind CSS
- Radix UI
- Docker
- MCP
Anyone who has shared a house knows the scene: Ana buys groceries, Bê pays for the internet, Carlão covers electricity — and by the end of the month nobody knows who owes what to whom. Asking a friend for R$ 15 is awkward, the spreadsheet goes stale and the dishes pile up.
Community Split is the house's neutral referee. Each housemate logs what they paid, the system splits it fairly, consolidates balances and says exactly which Pix transfers to make — QR Code and copy-paste code included. As a bonus, chores become a points leaderboard.
I built the product end to end: business rules, API, database, interface and deployment. Below is a real housemate journey, with screenshots taken from the running app, and what happens behind each screen.
1. Joining the house
The journey starts with an invite link sent over WhatsApp. Each house has a short code (e.g. REP-4921). The invitee creates an account — name, email, password and, optionally, the Pix key where they want to be paid — and sends a request to join.
Nobody gets in alone: the request stays pending until an admin approves it. A leaked link can't drop a stranger into the house's finances, and the admin can rotate the code at any time, invalidating the old one.
- Passwords of 8 to 128 characters with upper and lower case, a number and a symbol; stored with scrypt and a random salt.
- Session in HttpOnly cookies: a short access token plus a 30-day refresh token, stored only as a SHA-256 hash and rotated on every use.
- Per-house roles (admin or housemate): one account can live in several houses.



2. Logging an expense (and splitting it right)
Logging a bill takes seconds: title, amount, category, date and who's in. The interesting part is the split, with three modes — equal, by shares (weights) and by hand-set amounts.
What separates a serious app from a calculator is the cent. R$ 100 divided by 3 is 33.333…, and someone has to take the leftover cent. Community Split converts everything to whole cents, divides, and hands out the remainder deterministically: in equal mode, to the first participants by ID order; in share mode, using the largest-remainder method. The result always adds up to the exact total — never R$ 99.99, never R$ 100.01.
In the screenshot below, the R$ 250 barbecue was split into 8 shares: whoever brought guests weighs more. Lucas, with 3 shares, pays R$ 93.75; a single share pays R$ 31.25.
- Amounts with more than two decimal places are rejected before any maths happens.
- In amount mode, the values entered must add up exactly to the total.
- A “pending” bill (not yet paid to the supplier) creates no debt between housemates; it only counts once someone records the payment.
- Only the author or an admin can edit an expense, and every edit bumps a version: if two people edit at once, the second gets a 409 and a prompt to refresh.
// R$ 100,00 ÷ 3 → [33,34, 33,33, 33,33]
const base = Math.floor(totalCentavos / participantes.length);
const centavosRestantes = totalCentavos % participantes.length;
const idsComCentavoExtra = new Set(
participantes
.map(({ membroId }) => membroId)
.sort(compararMembroId)
.slice(0, centavosRestantes),
);
return participantes.map(({ membroId }) => ({
membroId,
valorAtribuido: (base + (idsComCentavoExtra.has(membroId) ? 1 : 0)) / 100,
}));

3. Closing the month
If balances changed with every new bill, nobody would know when to pay: you send the Pix and, five minutes later, someone logs another expense. The fix was to bring a ritual houses already have into the app: closing the month.
While a month is open, expenses show up and can be corrected, but they don't create Pix transfers. When the admin closes the month, its bills are frozen and count towards balances. Only then are settlements suggested.
- Months close in order; empty months along the way close together, and an earlier month with open expenses blocks the close.
- Pending bills block the close — or are moved to the 1st of the next month, if the admin chooses.
- A closed month won't accept new, edited, paid or deleted expenses; only a category change, which doesn't touch amounts.
- Only the last closed month can be reopened, and only if no Pix was confirmed after it closed (that Pix may have used those balances).
- Closing and reopening lock the house row with SELECT … FOR UPDATE; expense changes take the same lock in shared mode, so nothing slips through mid-operation.

4. Fewer Pix: simplifying debts
In October, the house had 4 bills paid by 3 different people and split between 4 housemates. If everyone paid their share of each bill to whoever paid it, that would be up to 12 transfers. Community Split does it in 3.
The trick is to look at each person's net balance: what they paid minus what they consumed. Ana paid R$ 570 and consumed R$ 232.50, so she is owed +R$ 337.50. Dani paid nothing and consumed R$ 232.50: she owes −R$ 232.50. Add up every balance and the result is always zero — money only changes hands.
With balances in hand, a greedy two-pointer algorithm sorts debtors and creditors from largest to smallest and matches the two at the top: it transfers the smaller of the two amounts, zeroes one of them and moves on. Every step zeroes at least one person, so a house of N people never needs more than N − 1 Pix transfers.
credores.sort((a, b) => b.saldoEmCentavos - a.saldoEmCentavos);
devedores.sort((a, b) => b.saldoEmCentavos - a.saldoEmCentavos);
while (devedorIndex < devedores.length && credorIndex < credores.length) {
const devedor = devedores[devedorIndex];
const credor = credores[credorIndex];
const valorCentavos = Math.min(devedor.saldoEmCentavos, credor.saldoEmCentavos);
transacoes.push({ devedor, credor, valor: valorCentavos / 100 });
devedor.saldoEmCentavos -= valorCentavos;
credor.saldoEmCentavos -= valorCentavos;
if (devedor.saldoEmCentavos === 0) devedorIndex++;
if (credor.saldoEmCentavos === 0) credorIndex++;
}
5. Paying with Pix and confirming
On the settlements screen, Dani sees only what matters to her: “pay R$ 232.50 to Ana”. When she clicks Pay with Pix, the app builds the BR Code — the Central Bank's EMV standard behind every Pix QR Code — with Ana's key and the exact amount. Dani scans it with her bank app or uses the copy-paste code.
A BR Code is a sequence of ID + length + value fields (TLV), ending with a CRC16-CCITT checksum the bank uses to validate it. Generating it without a library meant reading the spec: names without accents, a city of up to 15 characters and a checksum computed over the payload itself.
Money never goes through the app. That's why settling takes two steps: the payer reports the Pix and the payee confirms it. Only the creditor's confirmation updates balances — and the debtor can cancel until that happens.
const emvField = (id: string, value: string) =>
`${id}${value.length.toString().padStart(2, "0")}${value}`;
let payload =
emvField("00", "01") + // formato do payload
emvField("26", emvField("00", "br.gov.bcb.pix") + emvField("01", chave)) +
emvField("53", "986") + // moeda: real
emvField("54", valor.toFixed(2)) + // valor exato
emvField("58", "BR") + emvField("59", nome) + emvField("60", cidade);
return `${payload}6304${crc16(`${payload}6304`)}`;


6. Chores that become a scoreboard
Money isn't the only source of friction. Every chore has an owner, a deadline, a frequency (daily to one-off) and a point weight — from 10 for something quick to 30 for a deep clean. When it's done, the points go to the owner and the monthly leaderboard updates instantly.
The idea is to swap nagging for recognition: instead of calling out whoever skipped the dishes, the house sees who pitched in the most. Only the owner or an admin can mark a chore as done, and every completion is kept in the history.

Behind the screens
The front end is a Next.js app with React, Tailwind and Radix components (shadcn/ui style), Recharts charts and Framer Motion micro-interactions. It never talks to the API directly: a proxy under /api forwards calls to the backend over the internal network, rewriting cookies for the public domain. The browser only knows one origin — no CORS, no token exposed to JavaScript.
The API is Fastify with TypeScript. Every input goes through Zod schemas, the database is PostgreSQL via Drizzle ORM, and migrations run on their own before the server starts. OpenAPI/Swagger docs are generated from the routes.
Business rules live in pure functions (splitting, simplification, month closing, permissions, Pix), separate from database access. That keeps the core easy to test: 58 backend tests — unit and integration against a real Postgres, including concurrent payment and edit races — plus 27 on the front end.
An extra I enjoyed building: the API is also an MCP (Model Context Protocol) server, with OAuth 2.1 and PKCE. AI assistants such as Claude can list houses, log expenses, pay bills and complete chores on the user's behalf, limited to the scopes the user granted. Everything runs in Docker, deployed with Dokploy.
- Money is always whole cents; reais only at the edge, for display.
- Concurrency handled in the database: row locks on financial operations and optimistic versioning on edits.
- Domain errors map to the right HTTP status (400, 403, 404, 409) with end-user messages in Portuguese.