Open Net Worth
Open Net Worth is an open-source personal AI wealth assistant you run yourself: one ledger for your whole financial life, worked by an AI agent you control.
翻訳待ち
No updates detected yet. GitHub releases will appear here.
Expense splitting where settling up moves real money through Stripe Connect. Next.js, TypeScript, PostgreSQL, Terraform on AWS.
翻訳待ち
Loading the full README. The saved excerpt is shown below.
Squared splits group expenses like Splitwise, but settling up moves real money. Members log shared expenses with equal, exact-amount, or percentage splits; the app nets everyone's position within a group and reduces the debts to the fewest transfers that clear them; then a member pays what they owe through Stripe Connect, and the funds land in the recipient's connected account.Read the full README ↗Squared splits group expenses like Splitwise, but settling up moves real money. Members log shared expenses with equal, exact-amount, or percentage splits; the app nets everyone's position within a group and reduces the debts to the fewest transfers that clear them; then a member pays what they owe through Stripe Connect, and the funds land in the recipient's connected account. Money that moved somewhere the app cannot see, such as cash or a bank app, can be recorded instead, but the two are stored as different things and never conflated: a Stripe settlement is confirmed by a webhook, a recorded one is confirmed by the payer and says so wherever it appears.
Live demo → · sign in as [email protected] / demo1234
Seeded with three groups so the balance engine has something real to solve: a five-person trip, a shared apartment with rent split by percentage, and a small recurring group. Payments run in Stripe test mode — settling up opens a real Stripe Checkout, and card 4242 4242 4242 4242 completes it. No real money moves.
Stack: Next.js (App Router) · TypeScript · Tailwind · PostgreSQL + Drizzle · Stripe Connect · Vitest · Docker · Terraform (ECS Fargate, RDS, Secrets Manager)
flowchart TB
subgraph client [Browser]
UI[React pages<br/>groups · expenses · settle up]
end
subgraph app [Next.js on ECS Fargate]
API[Route handlers<br/>auth · authz · rate limits]
PURE[Pure domain logic<br/>splits · balances · settlement rules<br/>onboarding state machine · idempotency]
HOOK[Webhook handler<br/>signature verified on raw body]
end
DB[(PostgreSQL<br/>RDS)]
STRIPE{{Stripe Connect}}
UI -->|httpOnly JWT cookie| API
API --> PURE
API --> DB
API -->|create Checkout session<br/>destination charge| STRIPE
STRIPE -->|redirect to pay| UI
STRIPE -->|account.updated<br/>checkout.session.*| HOOK
HOOK --> DB
The parts worth reviewing are deliberately framework-free. src/lib/ holds the split arithmetic, the balance engine, the settlement rules, the Connect onboarding state machine, and the idempotency contract — all pure functions with no imports from Next.js, Drizzle, or Stripe. Route handlers do authentication, authorization, rate limiting, and persistence, then call into that core. It's testable without a server, and the logic that matters isn't entangled with the framework it happens to be deployed behind.
Data model. Seven tables: users, groups, group_members, expenses, expense_shares, settlements, and stripe_events. Every amount is an integer count of cents — no float ever touches money. Enum-shaped columns and amount signs are enforced by CHECK constraints, and Stripe object ids carry UNIQUE constraints, so the database rejects a double-recorded charge even if the application logic above it fails.
Stripe guarantees at-least-once delivery. The same event will arrive twice — after a network timeout, during a retry storm, when someone resends from the dashboard. Processing checkout.session.completed twice would mark a settlement paid twice; processing account.updated twice is harmless, but you don't get to choose which duplicates arrive.
The obvious implementation is to check a ledger of processed event ids, and it has a bug that only shows up under failure:
// Wrong — the failure mode is invisible in testing
await recordEvent(event.id); // committed
await handleEvent(event); // throws → Stripe retries
// → the retry sees the id already recorded
// → the event is skipped forever
The event is marked processed before the work succeeds. When the handler fails, Stripe's retry finds the id already recorded and skips it — the webhook is silently, permanently dropped. In a payments system that means a settlement stuck in pending that no amount of retrying will ever resolve.
The fix is that recording the event and applying its effects have to be the same transaction:
return db.transaction(async (tx) => {
const isNew = await recordEventOnce(tx, event); // INSERT ... ON CONFLICT DO NOTHING
if (!isNew) return "duplicate"; // skip the handler entirely
await handle(tx); // throws → the ledger row rolls back too
return "processed";
});
Now a duplicate delivery conflicts on the primary key and skips the handler, while a failed handler rolls back the ledger row along with everything else, so Stripe's retry finds an unrecorded event and genuinely reprocesses it.
That guarantee is the product, so it's tested as one. webhook-idempotency.ts is written against a small dependency interface rather than against Drizzle, which lets the tests drive it with a fake database that has real commit and rollback semantics — including the case that matters most: handler fails, ledger must roll back, retry must succeed. It's also verified against real Postgres with signed payloads: forged signatures rejected, first delivery processed, replays skipped, distinct events processed independently.
A group of five people with a dozen shared expenses generates a tangle of pairwise debts. Showing them raw means everyone makes several payments — and with real card payments, each one costs a processing fee and a step the user has to complete.
This is the beginning of the README. Read the rest at the original. Read the full README ↗