Oracle3
Open-source trading engine and MCP server for prediction markets. Fee-aware no-arbitrage trading across related contracts, live on Kalshi, Polymarket and Solana or on paper.
Track shared expenses with friends and settle balances with real bank transfers through Stripe.
FinanceTypeScript · HCLGitHub @sameerchohanOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Expense splitting where settling up moves real money through Stripe Connect. Next.js, TypeScript, PostgreSQL, Terraform on AWS.
Translation pending
Taken from the repository README
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.
Squared reduces this in two stages, in balances.ts. First, collapse everything into one net position per person: for each expense the payer is credited the full amount and every participant is debited their share, then settlements move credit back. Positions always sum to zero, and that's asserted rather than assumed. Second, repeatedly match the largest debtor with the largest creditor for min(debt, credit). Each match zeroes out at least one person, so n participants need at most n − 1 transfers instead of the pairwise tangle.
A concrete case from the test suite — three roommates, three expenses:
Alice paid rent $1500 → net: Alice +$950
Bob paid groceries $90 Bob −$460
Carol paid utilities $60 Carol −$490
Pairwise view: Bob→Alice, Carol→Alice, Bob→Carol, Carol→Bob …
Simplified: Carol → Alice $490.00
Bob → Alice $460.00 (2 transfers, no Bob↔Carol payment)
Greedy matching is optimal for the number of people who must pay, though minimizing transfer count in the general case is NP-hard (it's partition in disguise). For group sizes that exist in reality — under twenty — this is the right trade: predictable, O(n log n) per match with tiny n, and easy to explain to a user who wants to know why they're paying the person they are.
The rounding underneath it is its own small correctness problem. Splitting $100 three ways can't be done in cents, so splits.ts uses largest-remainder apportionment: floor every share, then hand out the leftover cents to the largest truncated remainders. Percentages are converted to integer basis points before any arithmetic. The property test checks 3,500 amount-and-group-size combinations to confirm no split ever creates or destroys a cent and no participant is more than one cent from the mean.
Balance validation reads the current state and then writes a settlement, which is a read-then-write race. If Bob owes Alice $50 and opens checkout twice in two tabs, both requests can read "Bob owes $50", both can approve a $50 settlement, and Alice gets paid twice.
Settlement creation therefore recomputes balances inside the transaction that inserts the settlement, serialized per group with pg_advisory_xact_lock(hashtext(group_id)). The second request blocks until the first commits, then recomputes against the updated balance and correctly rejects. The amount is also capped at min(payer debt, recipient credit), which preserves the group's zero-sum invariant: no settlement can leave a payer net-positive or a recipient net-negative.
Stripe is called outside that transaction — holding a database transaction (and the group's lock) open across a third party's network latency is how you turn a payment provider's slow day into your outage. That leaves a window where the row exists but the charge doesn't, so a Stripe failure marks the settlement failed, which returns the debt to the group's balances rather than freezing it behind a pending row no webhook will ever resolve.
A Connect account isn't binary. It has capabilities that Stripe enables independently, after review, sometimes revoking them later. Routing funds to an account that can't receive them fails at checkout, in front of a user who has already committed to paying.
stripe-status.ts reduces the account to not_started → pending → active/restricted, driven by account.updated webhooks. The subtle case: a brand-new account has every capability disabled, so checking capabilities first labels every user who just started onboarding as "restricted." details_submitted has to be consulted before drawing that conclusion. Only active users can receive settlements, and the UI says which person isn't ready rather than presenting a button that fails.
A reset token has to work once and then stop working. The usual way to promise that is a password_reset_tokens row, written on request, read on use, marked spent, and swept afterwards.
password-reset.ts gets the same guarantee from the data already on hand: the token is a one-hour JWT signed with a key derived from the user's current password hash. Completing a reset writes a new hash, which changes the derived key, which invalidates every token ever issued for that account — the one just spent, and any older link still sitting in a mailbox. Changing a password the ordinary way invalidates them too, for free. bcrypt hashes embed a per-user salt, so a token minted for one account cannot be replayed against another even if the two share a password.
The signature can't be checked before knowing whose hash to check it with, so the subject is read unverified first to select the candidate row — and rejected there unless it is a well-formed uuid, because handing a malformed one to Postgres turns a bad link into a 500 rather than a 400.
What this gives up is early revocation: a token cannot be cancelled before it expires. An hour is short enough that a table is not worth the migration.
git clone https://github.com/sameerchohan/squared.git && cd squared
npm install
cp .env.example .env # then set JWT_SECRET: openssl rand -hex 32
docker compose up -d # Postgres 16
npm run db:migrate
npm run dev # http://localhost:3000
Seed a realistic dataset — five people across three groups with equal, exact, and percentage splits, plus settlement history:
node scripts/seed.mjs --reset # sign in as [email protected] / demo1234
npm test # 72 Vitest cases
npm run db:generate # regenerate migrations after editing src/db/schema.ts
docker build -t squared . # production image (311 MB, non-root)
Settlements need Stripe test keys in .env plus a webhook forwarder:
stripe listen --forward-to localhost:3000/api/stripe/webhook
Use card 4242 4242 4242 4242 at checkout. Both parties need to complete Express onboarding before a settlement between them is allowed.
Password reset emails go through Resend, configured with RESEND_API_KEY and EMAIL_FROM. Leave both unset locally: the reset link is written to the server log instead of being sent, which is enough to walk through the whole flow without a verified sending domain.
There are two deployment targets, for two different reasons.
The live demo runs on Vercel with Neon Postgres, at no cost. That is deliberate: this architecture on AWS costs about $42/month idle, and an expense-splitting demo does not justify a permanent meter. A recruiter clicking the link cannot tell what is underneath it, so the money buys nothing.
The production-grade path is Terraform in infra/ — Fargate behind an ALB, RDS private and encrypted with TLS enforced, secrets injected from Secrets Manager at task start, security groups referencing each other rather than CIDR ranges, a deployment circuit breaker with automatic rollback, and a budget alarm. It is validated on every pull request: CI runs terraform plan against the real AWS account through GitHub OIDC, with no long-lived credentials stored anywhere.
That plan-in-CI is the ongoing proof the infrastructure code is sound — stronger than a screenshot, because it cannot go stale.
It has also been applied. On 31 August 2026 the stack was deployed to the real account: VPC, ALB, Fargate, RDS, Secrets Manager. It served the application over HTTPS on an ACM certificate, ran its migrations as a one-off task, seeded a database it could not reach from a laptop, processed a Stripe webhook, and was destroyed the same afternoon for about $0.09. terraform plan against the running stack reported No changes.
That exercise found six failures a clean plan had never surfaced — a certificate authority policy inherited through a CNAME, a non-ASCII character in a security group description, an account entitlement on backup retention, a missing root CA for RDS, a parameter that could never converge, and a teardown path that would have crashed. They are entries 16 to 21 in the engineering log. A plan validates a configuration against an API; it cannot validate string encoding, billing entitlements, or trust chains, because none of those are consulted until something is created.
infra/README.md has the runbook, the cost breakdown, and the reasoning behind each trade, including why the NAT gateway is a variable rather than a default.
Two documents cover the parts a README shouldn't carry:
Balances are recomputed from every expense on each read. Fine for groups of a dozen people and a few hundred expenses, and it keeps the calculation obviously correct — there's no cached total to drift. At a scale where groups accumulate years of history, this becomes a materialized balance per member, updated transactionally as expenses and settlements land, with the full recomputation kept as a reconciliation job that verifies the cache rather than trusting it.
Webhook handling is synchronous. The handler does its database work inside the request, and Stripe's retries are the only queue. That's a deliberate simplification: fewer moving parts, and idempotency makes retries safe. Under real volume, the endpoint should verify, enqueue, and return, with workers consuming from SQS and a dead-letter queue catching events that fail repeatedly — the current design silently depends on Stripe eventually giving up.
Sessions are stateless JWTs, which means they can't be revoked. A stolen token stays valid until it expires, and "sign out everywhere" is unimplementable. The fix is short-lived access tokens with refresh tokens tracked server-side, so revocation has somewhere to write.
The rate limiter is in-process. Limits are per-container, so n tasks means n times the intended allowance, and a restart forgets everything. Redis moves the counters somewhere shared; WAF rules in front of the load balancer handle the volumetric case before it reaches application code at all.
The idempotency ledger grows without bound. Every Stripe event id is kept forever. Since Stripe stops retrying after a few days, rows older than that window serve no purpose and should be aged out — partitioned by month and dropped, rather than accumulating indefinitely in a table on the hot path of every webhook.
Tasks run in public subnets, and RDS is single-AZ. Both are cost decisions, and the first is the more interesting one: a NAT gateway costs ~$32/month and buys network topology rather than access control, since the tasks' security group already admits nothing but the load balancer. Flipping use_nat_gateway in the Terraform moves them into private subnets — the topology to run with real money, where a compromised task can't be addressed directly even if a security group is later misconfigured, and outbound traffic leaves from a stable IP an upstream provider can allowlist. Multi-AZ RDS is the other change real users would justify. Both paths are in the code because the trade-off, not the default, is the point.
The demo and the production path are different deployments. The live site runs on Vercel with a serverless Postgres; the Terraform describes a containerised deployment on Fargate. That split is honest about cost, but it does mean the two are not identical environments — connection pooling in particular behaves differently, which is why the app uses a pooled connection string and transaction-scoped advisory locks that survive a transaction pooler. A single target would be simpler.
An abandoned checkout hides a debt for up to 24 hours. Settlements are inserted as pending before Stripe is called — that ordering is what makes the concurrent-settlement guard work — and pending settlements count against the balance. If someone opens Checkout and walks away, the debt disappears from the group until Stripe fires checkout.session.expired, which by default is a day later. A short expires_at on the session would shrink the window; sweeping stale pending settlements on read would close it.
Everything is USD. Currency is assumed rather than stored. Supporting more means a currency column on groups, per-currency balances, and a decision about what "settling up" means across currencies — which is a product question before it's a schema question.
Stripe's processing fee is currently absorbed by the platform. ~2.9% + 30¢ per settlement, which is fine for a demo and a slow bleed for anything real. The options and the trade-offs between them are laid out in issue #6.