Inspiration

Every shared meal ends the same way. One person puts their card down, and then spends two weeks sending gentle reminders to people they like. The apps that promise to fix this usually ask everyone to join a wallet first, and the money sits inside that wallet until the platform decides to release it. For a table of friends, both halves of that are wrong: nobody wants a new account with a balance, and nobody wants a third party holding the cash.

We wanted the boring, honest version. If you front the bill, you should get paid back directly into your PayPal account. The app never touches a cent. Reading receipts, splitting the bill down to the penny, and chasing the two people who forgot to pay is the only work software should actually be doing.

What it does

Globie Keeper is a group-expense app with three rules: agree before you spend, confirm only your own lines, and pay the person who actually paid.

  1. Sign in with PayPal. Identity only. The verified PayPal email becomes the address other members reimburse, and no PayPal tokens are stored on the client.
  2. Accept a lightweight agreement. The group agrees on the split rules before any money moves: a member vote picks Evenly or By line item, plus a repayment deadline and an optional monthly late fee, so the rules are never in dispute later.
  3. Photograph the receipt. Globie reads the photo (or a short video), extracts every line item, and proposes a per-person allocation.
  4. Confirm your own lines. Each member confirms only what they ate. The bill finalises when no proposed lines remain.
  5. Pay with PayPal. The fronter picks the rail in Settings: a PayPal order naming them as payee, or a PayPal.Me link with the amount filled in. Either way the money lands in their account, never ours. An overdue share carries the agreed late fee into that request.
  6. Get a receipt and a score. A signed webhook closes the loop; the bill flips to SETTLED and Globie points are awarded for fronting and for paying back quickly.

Globie points reward the behaviour we want: fronting the table earns a flat 15, and repaying first in a bill earns 10 / 5 / 3, capped at 50 points a day.

Beyond the core loop:

  • The table votes how to divide. Every member casts one vote on the bill and on the group, either Evenly or By line item. The majority wins agreement-wide, so a new bill starts in the winning mode, and the tally stays visible to everyone with a check on your own vote.
  • A late fee, agreed before the meal. The agreement can set a monthly late-fee rate and an optional cap as a share of the debt. It is prorated by the day from the share's due date, added to the payer's PayPal request only once the share is actually overdue, and snapshotted on that request so a later change to the terms cannot rewrite what someone was already asked to pay.
  • Tip the person who fronted. Any amount works: presets suggest 2% / 5% / 10% of the bill and a field lets the payer name their own figure, capped server-side at the bill total. Each tip is its own PayPal order to the same payee, so gratitude never distorts the split.
  • Help in the product, not a PDF. A Globie user manual at /help, served from GET /api/help and reachable from the home card, the Activity header, Settings and the sign-in screen; the same text is part of the assistant's prompt.
  • The Globie Fund. Donate through PayPal, or spend earned Globie points at 1,000 points = US$1. The fund is part of the Globie Earth project (globie.earth).
  • Fee coverage, opt-in and off by default. The fronter can choose Covers the PayPal fee; the payer then sees the fee and the PayPal total before tapping Pay. It is refused in live mode, because passing PayPal's fee to the payer is a surcharge under the US and Thai user agreements.
  • PayPal's pages in English. A Thai browser otherwise lands on a Thai checkout; every hosted page is pinned with locale.x=en_US, and the login host also takes country.x=US.
  • Honest currency display. Amounts convert against a cached rate and are marked ≈; after a capture the app shows PayPal's own settled rate (PayPal converted at 1 … = …) instead.

How we built it

Client. Expo SDK 58 / React Native 0.88 with Expo Router, React 19, React Query, Reanimated 4 and expo-haptics. The same codebase runs the native app and the web build that judges can open in a browser.

API. Fastify 5 on Node 22, PostgreSQL 16 with TimescaleDB hypertables for the activity ledger, and pg-boss for durable background jobs. Every money operation runs inside a transaction that also writes the ledger event.

Receipt AI. The upload endpoint samples frames from a photo or video and sends them to Globie AI, which returns strict JSON: merchant, currency, line items, tax, service charge, tip, discount and a per-line confidence. We validate and normalise that JSON before it touches the database. All money is stored as integer minor units (satang, cents), never floats. Allocation uses largest-remainder arithmetic, so sum(shares) == bill.total by construction. Nothing gets invented, and no satang goes missing. If the Globie AI key is missing, the app degrades to manual entry instead of hallucinating line items.

PayPal Orders v2. Each share becomes one order whose purchase_units[].payee is the fronter's merchant id (falling back to their verified email). The platform's own credentials create and capture the order, but the money lands in the payee's account. The no-custody promise is enforced in the order payload itself, so the UI never has to be trusted for it. Captures are reconciled against webhooks whose signature is verified through PayPal's verification endpoint, and the same settlement function handles the webhook, the return-page capture and a retry, deduplicated on the capture id. A replayed webhook cannot pay twice or award points twice.

PayPal.Me, for the personal account. Most people splitting a bill have a Personal PayPal account, which cannot take a merchant-receipt capture. So the fronter can switch mode in Settings → Getting paid and collect through paypal.me/<handle>/<amount><CUR> instead. There is no server-side confirmation for that link, because PayPal does not expose its money requests over the API. So the payer sends the amount, taps I've paid, and POST /api/shares/:id/paid settles the share through the same settleShare path with the ref p2p:<share_id>. The unique index on (provider, provider_ref) still makes a double tap harmless, and order reconciliation skips link shares because they carry no order id.

Agreement terms, computed rather than promised. The split vote and the late fee live in the database next to the agreement: one vote per member, a tally the group and bill payloads both read, and a lateFeeFor helper that prorates a monthly rate by the day from the share's due_at and caps it as a share of the debt. The fee is snapshotted onto the share when its PayPal request is issued, so the member pays the terms that were true when they were asked, not whatever the agreement says later.

Log in with PayPal. A single-use, 10-minute state is issued server-side; the callback exchanges the code, and the app receives a 60-second hand-off code instead of a session token. A PayPal-linked address refuses the email form, so a typed address cannot inherit a verified account.

The Globie agent. Seven database-backed tools: parse_receipt_media, propose_allocation, request_item_confirmation, create_payment_request, get_bill_status, send_reminder and award_points. The dispatcher authorises every call against the database rather than trusting ids in the request, and the three tools that move money or message people require a short-lived HMAC confirmation token bound to the exact canonicalised arguments (10-minute TTL). A confirmation for "remind Ben 400 THB" cannot be replayed for a different amount or member, and every call is written to an agent_events audit table. The agent is intentionally deterministic: the safety property we care about is authorisation, not improvisation.

Reminders. pg-boss sweeps create in-app nudges and dispatch remote channels only when credentials exist, respecting quiet hours (22:00–08:00), per-stage idempotency and a fallback chain of in-app → push → email → WhatsApp. A dead Expo push token is cleared and the reminder falls back to email.

Delivery. Docker Compose: db, api, web (nginx) and a reverse proxy. The same compose file runs locally and on the host behind keeper.globie.ai.

Challenges we ran into

Rounding is a policy, not division. 1,200.00 THB across three people is 400.00 each. Across seven it is 171.42857…, and whichever way you cut it somebody is short a satang. So the split cannot be a float division; it has to be a written rule. Every amount is an integer in minor units and allocation is largest-remainder, which hands the leftover satang to specific shares instead of losing them to the sequence of operations. A property-style test re-asserts the same invariant after every path that touches a bill: sum(shares) == bill.total, to the satang, including after a vote changes the mode.

Exactly-once settlement, when three things settle at once. A share can be closed by the payer returning to the app, by the webhook, or by a retry after a timeout. Those three arrive out of order and sometimes more than once. A check-then-update is a race by construction. We made the capture id the identity of the event: all three paths call the same transactional settle, the ledger row and the point award commit with it, and a unique index decides which caller wins. The loser reads alreadyPaid back, which is the honest answer, and a replayed webhook cannot award points twice.

The payee is not always a merchant. The shortest Orders v2 integration collects into the app's own merchant account, and that one line quietly turns a splitting app into a custody product: the money is ours, the payout is our problem, and "we never hold your money" becomes false. Moving the fronter into purchase_units[].payee fixes it, but only for a fronter whose account can receive a merchant capture. Most people splitting a dinner have a Personal account that cannot. That is why the product ends up with two rails, an order and a PayPal.Me link, and why the payer is told which one they are on rather than handed a button that fails at the end.

Accomplishments that we are proud of

Money and billing are sensitive topics. Our proudest achievement is delivering a working product that resolves bottlenecks for our team and everyone, preserving trust and fostering strong, positive relationships.

What we learned

Money is an integer problem before it is a UI problem. Payment integrations fail in the seams: returns, webhooks, retries. That is why the settlement path has to be idempotent by design. And "agentic" does not mean "let the AI decide"; the useful, defensible pattern is a small tool surface with server-side authorisation and an explicit human confirmation for anything irreversible.

What's next

  • Web Push (service worker) so browser users get the same reminders as the native build.
  • A dispute flow for lines a member rejects, plus rate limiting on the public API.
  • Live PayPal onboarding for fronter merchant ids, instead of the current sandbox mapping.
  • Group templates and recurring bills (rent, utilities) on top of the same agreement model.

Built With

Share this project:

Updates

Submission history