Kalor (first generation)
An event-ticketing platform shipping iOS, Android and web from a single Expo codebase, on Firebase.
- Period
- Feb 2025 – Feb 2026
- Role
- Team of 4, 59% of commits
- Status
- archived
- commits
- 900
- releases
- 11
- branches
- 107
Technical lead. Owned payments, the service layer and the security rules; screens and components were shared.
Highlights
- Charges are created against the organizer's connected Stripe account with an application fee attached, so the organizer is paid directly and the platform takes its cut in the same intent.
- The webhook keys off application_fee.created rather than payment_intent.succeeded, then walks fee to charge to payment intent to metadata, because on a connected account the fee event is the reliable platform-side signal.
- With Firestore the rules are the API rather than a check behind one: eleven named predicates, a collection-group rule so a user's tickets stay authorized per document across every event, and a payments rule where the only client write permitted is cancelling your own.
- Redeem links are claimed inside a transaction: check the link, create the ticket, increment the counter and append to the guest list, or none of it.
- 2,600 lines of service layer where errors are returned rather than thrown, and nothing above it knows what a Firestore document looks like.
Retired, and the repository stays private: it carries a year of a live product's payment and identity plumbing.
One test file and no CI, against a contributing guide that required a pipeline and 80% coverage.
The problem
The first version of a product that took real money from real people, written in under a month against a deadline we could not move, and then run for a year. I was technical lead on a team of four and owned payments, the service layer and the security rules. It shipped to iOS, Android and web from one codebase, went through eleven releases, and proved the business existed. It is also the version whose shortcuts eventually made it unworkable, and the rewrite that followed is the other half of the story.
My two co-founders ran their own events and the worst part of running one was the door: paying on the spot, finding a name on a printed list, arguing about who was allowed in. Kalor was built to remove that. A host creates an event, shares a link, guests apply for entry, the host approves the ones they know, and approved guests buy a ticket in the app and get scanned in at the door.
The deadline was summer: be live for it, or wait a year to prove the concept. I was technical lead and owned payments, the service layer and the security rules, while screens and components were shared out across the team. Around 59% of the commits are mine. The repository is not public, because although the project is retired it still holds a year of the payment and identity plumbing behind a product that is live today.
Payments through Stripe Connect
Organizers need to be paid directly and the platform needs its cut, and neither of us should have to trust the other to forward money afterwards. Charges are created against the organizer's connected account with an application fee attached, so a single payment intent moves the ticket price to the organizer and our 8% to us at the same time. The fee is added on top of the ticket price rather than taken out of it, so an organizer setting a price gets that price.
The webhook is the part worth explaining. The obvious event to listen for is payment_intent.succeeded, and that is not what this listens for. It keys
off application_fee.created instead, because on a connected account the
fee event is the signal that the money actually moved on our side of the arrangement,
and it fires only for charges that went through the Connect path we care about.
The cost of that choice is that the fee event does not carry the context we need, so the handler has to walk back to it: from the fee to its charge, from the charge to its payment intent, and from the intent to the metadata we wrote when we created it, which carries the event, the user, the ticket and our own payment record. Then the ticket is created and the count of tickets sold is incremented inside a transaction.
Security rules as the actual boundary
With Firestore the client talks to the database directly, so the rules are not a secondary check behind an API. They are the API.
The rules are built out of eleven named predicates, so an individual rule reads as a sentence rather than a nest of document lookups. There is a collection-group rule for tickets, which is what lets a user query their tickets across every event in one go and still have each document authorized individually, rather than needing read access to every event those tickets belong to.
The payments rule is the tightest one, and deliberately so. Creating and deleting a
payment is denied outright to every client; those only ever happen server-side. The
single mutation a client is allowed is cancelling their own payment, expressed by
requiring that the write touches the status, that the writer owns the row and that
the resulting status is exactly canceled. Anything else a client tries to
do to a payment fails at the database.
Redeem links are one transaction or nothing
A redeem link hands someone a ticket without them applying and being approved. Claiming one has to do four things: confirm the link is still active and the user does not already have a ticket, create the ticket, increment the number redeemed, and add the user to the event's guest list. Any of those happening without the others leaves the system lying about something.
So all four run inside a single Firestore transaction with the reads before the writes, and it either all commits or none of it does. Two people opening the last redeem on the same link cannot both get a ticket, and a failure partway through cannot leave a guest on the list with no ticket to scan.
Everything goes through the service layer
Around 2,600 lines across eight files sit between the screens and Firebase, and
nothing in the app talks to Firebase directly. The rule the team agreed on is that
errors are returned and never thrown, and that try/catch is
permitted only inside this layer, so every service function returns a result that is
either a success with a payload or a typed error code. Raw SDK errors are translated
at the boundary and never reach a screen.
The screens did not know what a Firestore document looked like, which is what left the rewrite a seam to cut along.
Known gaps
Our contributing guide states that pull requests should pass a pipeline and that we were aiming at 80% coverage. There is one test file in the entire repository and no pipeline at all; a branch proposing one exists and was never merged.
Dozens of files and cloud functions each assumed an exact document shape with nothing enforcing it, so a change to the event document could break a feature nobody had thought about. The service layer gave us one place the client talked to the database from. It did not give us one place that decided what the database contained.