HoldingHands
When the product is trust, the ledger is the product.
Team4Tech Solutions · 2021 to 2023
HoldingHands is a community mutual aid platform. Members contribute a recurring monthly amount into a shared pool, and the pool funds admin authorized disbursements to members facing health, school or urgent needs.
There is no product market fit ambiguity to design around here. The product is trust infrastructure. Every dollar in and out has to be traceable, every disbursement has to be authorized by an accountable person, and none of that could be cut short by the fact that one engineer had eight weeks.
The formula that determines each member's monthly contribution belongs to the organization and is not described here. The architecture treats it as an admin configured, versioned input rather than something the system decides.
Founding engineer and sole technical hire. Owned the whole product: schema design, the Node and Express API, the React frontend, the data layer across PostgreSQL, MongoDB and Redis, all Stripe money movement, and the CI/CD and review practices the team kept using after it grew past one engineer.
Zero to production in 8 weeks as the only engineer, with every dollar in and out of a community pool traceable.
A pool balance you cannot reconstruct is a number you cannot defend
The obvious way to track a shared pool is a balance column you add to and subtract from. It is also the way you end up unable to answer the only question that matters when someone disputes a figure, which is why the number is what it is.
So the ledger is append only, and the pool balance is a derived sum rather than a stored counter. Every contribution marked paid and every disbursement marked paid writes exactly one entry. The balance can always be rebuilt from history and audited against Stripe.
Money in and money out are deliberately not symmetric. Money leaving the system is higher risk than money entering it, so it was built and tested last, with more scrutiny, and a disbursement above a configured threshold requires a second admin's approval before the transfer fires. Encoding that as a status transition guard rather than a policy document is what actually stops it being skipped when someone is in a hurry.
The polyglot storage was a fit decision, not indecision. Money needs ACID guarantees, so it lives in Postgres. Case requests genuinely differ by category, a medical case needs different fields from a tuition request, and the category set was expected to grow, so they live in MongoDB rather than a wide sparse table or an EAV pattern.
What it already handles
- Queue unavailable
Redis unreachable at enqueue time
Work delayed, never lostENQUEUEQUEUE XPENDING JOB - Large disbursement
Amount over the configured threshold
Maker checker enforced in code, not policyADMIN 1THRESHOLDADMIN 2 - Simultaneous approvals
Two admins act on one case at once
No double disbursement, structurallyBOTHLOCKONE PAYS - Approval revoked late
Admin reverses just before the transfer fires
The revocation wins the raceREVOKEDRECHECKHELD - Contribution refunded after disbursement
Member disputes money the pool already paid out
Never an automatic and wrong clawbackREFUNDOFFSETADMIN ALERT
Failure modes
- Failure
Stripe webhook spoofed or tampered with
How it is caughtSignature verification on every inbound webhook
What happens nextRejected and alerted on. A webhook is never trusted on the shape of its payload alone, because a fake payment succeeded event is exactly the attack.
- Failure
Webhook delivered twice
How it is caughtStripe event id already processed
What happens nextUnique constraint makes the duplicate a no op.
- Failure
Webhook never arrives
How it is caughtNightly reconciliation against Stripe's own records
What happens nextAny drift raises an alert and is corrected with a compensating ledger entry rather than quietly ignored.
- Failure
Contribution charge fails
How it is caughtStripe decline webhook
What happens nextGrace period and retry schedule, with the member flagged at risk and visible to admins rather than silently dropped. A mutual aid model depends on knowing who is actually contributing.
- Failure
Disbursement transfer fails on bad bank details
How it is caughtStripe transfer.failed webhook
What happens nextHeld in a distinct failed state rather than sitting in pending review. An approved but undelivered disbursement is surfaced explicitly instead of disappearing from the queue.
- Failure
Two admins approve the same case at once
How it is caughtOptimistic locking on the disbursement status
What happens nextThe second approval fails the status transition check, which structurally prevents a double disbursement rather than warning about it in the UI.
- Failure
Job queue unreachable
How it is caughtEnqueue retries exhausted
What happens nextFalls back to writing a pending job row in Postgres, which a recovery sweep picks up once the queue is healthy. A queue being briefly down should delay work, not lose it.
What it is held to
Ledger to Stripe reconciliation drift
Zero tolerance, checked nightly
The one number that can never silently drift in a trust based fund.
Automatic contribution collection success
~99.5%, remainder via dunning
Realistic given card decline base rates. The dunning path is what makes the overall rate acceptable, not first attempt success.
Urgent case review
Decision within 48h
Urgent requests are explicitly prioritized in the review queue. Health and school follow a tracked but non SLA cadence.
Dashboard endpoints, p95
Sub second after caching
Directly tied to the cache aside and indexing work.
Why I chose what I chose
Why two databases?
Money movement needs ACID transactions and relational integrity, so contributions, the ledger and disbursements live in Postgres. Case requests genuinely differ by category and the category set was expected to grow, which relationally means either a wide sparse table or an EAV pattern, both of which are worse than the problem. Document storage fits the shape the data actually has. That is polyglot persistence as a fit decision, not schema indecision.
Why is the pool balance derived rather than stored?
A stored counter can drift from the records underneath it, and the moment it does you cannot tell which one is wrong. Deriving it from an append only ledger means the balance is always reconstructable and always auditable against Stripe.
Why Express rather than NestJS, given I use NestJS elsewhere?
At eight weeks solo, Express's lower ceremony was the right trade for time to first working payment flow. There was no DI container or module scaffolding to stand up before writing a real endpoint. The cost is less enforced structure, and I paid that down with review discipline and CI rather than with a framework.
Why lean on Stripe this heavily?
As the sole engineer responsible for all money movement, minimizing the custom payment handling surface was itself the risk reduction. Stripe's webhook and idempotency primitives are well tested infrastructure that one engineer building in eight weeks should use rather than reimplement.
Why is this the simplest infrastructure in my portfolio?
A non profit with one engineer and a tight budget is exactly the case where not reaching for Kubernetes or microservices is the senior decision rather than the absence of one. One well structured Express service on Fargate, one deploy unit, one thing to operate. The right infrastructure is a function of the actual constraint, and it looks nothing like what CoinPerps needs.
What I would reconsider
- Several parts were built synchronously first, including contribution collection before it moved behind the job queue. Reasonable eight week sequencing, but retry and backoff semantics are much easier to get right before the first production failure than to retrofit after, so the queue version is what should exist from day one on a rebuild.
- Nightly rather than real time reconciliation means drift can persist up to a day in the failure case. Acceptable at launch volume, and the first thing I would move to a streaming reconciliation as transaction volume grows, since detection latency on financial drift is itself a risk surface.
- As the only engineer owning all the money movement code, there was no second reviewer on payment critical changes for the first stretch. Mitigated with deploy time smoke tests against Stripe test mode, but it was a real gap that only closed when the team grew.
Node.js · Express · React · PostgreSQL · MongoDB · Redis · BullMQ · Stripe · ECS Fargate · CI/CD