Sameer Chohan

↖ All work

Squared

Expense splitting where settling up moves real money through Stripe Connect, built around exactly-once webhook processing and a debt-simplification algorithm.

Year2026
RolePersonal project, solo architected
StackNext.js 16, TypeScript, PostgreSQL, Drizzle, Stripe Connect, Terraform, AWS ECS Fargate, Docker, Vitest
Runs onTerraform-defined AWS ECS Fargate, applied and verified 31 Aug 2026, then torn down. Public demo runs on Vercel and Neon.
LinksSource code ↗Live ↗
A

Evidence

One minute of the appBalances in a group, a settlement opened through Stripe Checkout, and the debt clearing once the webhook lands. Test mode throughout, so no real money moves.

20 captures, grouped. Open a group to read it, click any image for full size.

Serving traffic on AWS3

31 August 2026. The Terraform ran against a real account, served the app over HTTPS, and was torn down the same afternoon.

The infrastructure underneath10

A VPC, Fargate behind a load balancer, RDS with no route to the internet, and secrets that never appear in the image or the repo.

Converged, then destroyed3

How many resources got created is the weaker number. These are the two that matter: the running system matched its code, and then the meter stopped.

How it is built and verified4

The parts that run on every pull request, whether or not anything is deployed.

B

Case study

Problem

Splitwise tracks who owes what and then stops. Settling up is a checkbox you tick after moving the money somewhere else. Squared does the other half: paying someone back opens a real Stripe Checkout session that routes funds to their connected account. The app around that is ordinary on purpose, with groups, expenses split equally or by exact amount or by percentage, and running balances. Once real money moves, a duplicated webhook stops being a display bug, and that is where the work went.

Exactly-once webhooks

Stripe delivers at least once, so handlers have to be idempotent. The obvious version records the event id and then runs the handler, and it fails in a way tests do not catch: if the handler throws, the id is already written, so Stripe's retry sees a processed event and skips it. The event is gone for good and a settlement sits in pending forever.

Recording the event and applying its effects happen in one transaction instead, so a failed handler rolls back its own ledger row and the retry genuinely reprocesses. The code takes a small dependency interface rather than the ORM, which lets tests drive it with a fake database that really commits and rolls back. Against live Stripe, replaying a processed event with a valid signature returns a duplicate outcome without running the handler, and a forged signature returns 400. Same result later on the AWS deployment.

Debt simplification

Five people in a group produce a mess of pairwise debts. The app nets each person's position, then matches the largest debtor against the largest creditor until everyone is square, which takes at most n-1 transfers. The five-person trip in the demo data clears in four.

Minimizing transfer count in general is NP-hard, so greedy is a trade rather than a solution, and it is the right trade at the sizes people actually split dinners across. Underneath it, $100 split three ways does not divide into cents, so shares use largest-remainder apportionment and percentages become integer basis points before any arithmetic. A property test over 3,500 amount and group-size combinations checks that no split ever creates or loses a cent.

The race I had to close

Balance validation reads state and then writes it, which two browser tabs can walk straight through: both read that you owe $50, both create a $50 settlement. Settlement creation now recomputes balances inside the transaction that does the insert, serialized per group with a Postgres advisory lock and capped at the smaller of the debt and the credit. Stripe is called outside that transaction, because holding a database lock across someone else's network latency turns their slow day into your outage. If Stripe fails, the settlement is marked failed and the debt comes back.

Infrastructure

The AWS side is Terraform: 55 resources covering a VPC, ECS Fargate behind a load balancer, RDS in private subnets, and Secrets Manager. Security groups reference other security groups rather than address ranges, and the database subnet's route table has one entry, local, so nothing outside the VPC has a path to it.

Every pull request runs terraform plan against the real account through GitHub OIDC, with no credentials stored anywhere. The CI role is read-only and explicitly denied secret decryption, and the plan runs without refresh or locking so that deny holds. The NAT gateway is a variable rather than a default, since it costs about $32 a month and buys topology rather than access control when the security group already admits only the load balancer.

Deployed, then destroyed

On 31 August 2026 I applied it. It brought up the VPC, the load balancer, Fargate, RDS and Secrets Manager, served the app over HTTPS on an ACM certificate, ran migrations as a one-off task, seeded a database no laptop could reach, and handled a live Stripe webhook. A plan against the running stack then reported no changes, which is the result I care about more than a resource count. I destroyed it the same afternoon: one hour thirty-seven minutes, about nine cents.

What a plan cannot catch

Five pull requests had planned that configuration cleanly. The first real apply failed six times in twenty minutes. A plan checks a configuration against an API, and nothing consults string encoding rules, account entitlements, or certificate trust chains until you actually create something.

The one I would not have guessed: ACM reported DNS validation as successful and refused to issue anyway. CAA lookups follow CNAMEs, so that hostname inherited the certificate policy of the platform it pointed at, and Amazon was not on the list. A sibling hostname with a clear path to the root fixed it. The one I keep coming back to is the RDS certificate. The connection failed because RDS chains to a private Amazon root that Node does not ship, so verification was correctly rejecting a valid certificate. Turning verification off fixes that in one line, on the connection carrying the database credentials, so I committed Amazon's bundle into the image and pointed NODE_EXTRA_CA_CERTS at it.

The other four: a non-ASCII character in a security group description that aborted an apply 48 resources deep, a backup retention window past what that account's billing tier allows, a static RDS parameter declared without an apply method so the stack could never converge, and a teardown path that would have crashed halfway and left the billable resources running. All six are written up in the engineering log.

Where it runs

After the AWS run I tore it down and left the public demo on Vercel and Neon. That architecture costs roughly $42 a month sitting idle, and a demo nobody pays for does not earn a standing bill. Both hosts build from the same repository. Designing the production topology and deciding what deserves to run continuously are separate questions, and the Terraform is still in the repo, still planned on every pull request, and about six cents an hour to bring back.

Shipped

Live at squared.sameerchohan.com with seeded data, 59 Vitest cases, and five required checks gating main across 21 merged pull requests. Stripe runs in test mode, where a real Connect account moved a real $40 through a real Checkout session.

The gaps are documented rather than hidden: stateless JWTs cannot be revoked, the rate limiter is per-instance, the idempotency ledger grows without bound, it is single currency, and an abandoned Checkout hides a debt until Stripe expires the session a day later. I found that last one after the deployment and wrote it down. The repo carries a decision record and an engineering log with twenty-one bugs and how each was diagnosed.