Payments, erasure, and other people's code
Lawly is a marketplace where people buy consulting time. Money moves through it, so the interesting problems are the ones where approximately right is the same as wrong.
I joined as a software engineering intern in May 2026 and worked mostly on payments, account erasure, and auth. In August I was promoted to engineering team lead, and since then most of what I ship is other people's code, reviewed and merged.
Charging someone twice
The problem arrived as a sandbox incident: one buyer, one purchase, three successful charges in under half a minute, on two different cards. Two cards meant it wasn't a double click or a network retry. It was a person who believed the first attempt had failed and tried again. And again.
A Stripe idempotency key derived from the request, the session, or the card would have given those three attempts three different keys. The only thing they shared was what the buyer was trying to buy. But a key built only from the purchase replays the first charge for a customer who buys the same thing twice on purpose, and they get nothing. So the key pairs the purchase with a counter of how many of it the buyer has already completed. A retry keeps the key. A genuine second purchase moves the counter.
Keys only hold within one endpoint, and a deploy between two submits can move a buyer onto a different payment path. So a second check, before any call to Stripe, refuses to open a charge when one has already succeeded, is processing, or is awaiting capture. If the check itself can't run, it refuses. Failing closed costs a buyer an error they didn't deserve. Failing open costs someone a second charge.
The tests come in matched pairs: half assert an already-charged submit is refused, half assert that a buyer who refreshed mid-checkout still gets through. A guard that only satisfies one half is a new bug wearing a safety vest.
Deleting a user without deleting their data
Our privacy policy says personal information is erased on request and payment records are retained. Deleting the user row breaks both, because the foreign keys cascade into payout installments, reviews, and consultants' private notes. So erasure anonymizes in place: the row survives and every personal field is overwritten. The replacement email isn't even a hash of the old one, because a hash of personal data is still a retained representation of it.
Making it hold without me
Every column on the user table sits in one of four lists: erased, preserved, and two smaller categories. A test asserts that the four lists together equal the table's actual columns. Someone adds a column next year without thinking about erasure, and the test tells them to classify it.
Other people's code
I've merged 162 of the team's 355 pull requests, 46%, more than anyone else. Eighty-three came from the five other engineers, and I was the assigned reviewer on 61. Over the same stretch I pushed zero commits onto anyone else's branch. That last number is the one I watch. Pushing the fix myself is faster, and it's also how the author never finds out what was wrong.
A comment fixes one pull request. A written rule fixes the ones not yet opened.
Between July 31 and August 9, migration numbers collided across unmerged branches
three times, each repaired by hand. Two of the three repairs were mine. When I
wrote AGENTS.md, the team's house rules, that became one line: check dev for
the highest migration number before generating one.
Toward the end of the internship I started turning one-off fixes into lint rules with a ceiling at the current count, then driving the count to zero. That grew into a CI gate capping database calls per route file, with an allowlist that can only shrink. A count going up is the regression the gate exists to catch. A count going down without anyone editing the allowlist is also a failure, because the stale entry silently excuses a future regression in a file that was already fixed.
The first version of the gate counted with a regex. It read a file making 13 real calls, reported 6, and passed it, and other engineers were trusting it on my say-so. I rewrote the counter against the AST and it counted 13. Calls over the ceiling have since gone from 324 to 233, mostly in other engineers' commits. I built the mechanism. The team did the ratcheting.
The largest change I drove collapsed three consultant tiers into two tracks: 208 files in one pull request. The schema changes were one commit, all additive. The track is derived from the badge column that already existed, so no tier data was rewritten. Package renames went into ordered SQL files to be run by hand, and the code maps old and new names to the same key, so it's correct whether the data change has run yet or not.
The rest produces no interesting diff: the read-only-mode runbook the 3am alert points at, and a handoff doc for an engineer whose branch the tier change landed underneath.
Merging 46% of the team's pull requests is also a cost. When nearly half the code reaches production through me, I'm the queue when I'm slow and the single point of failure when I'm wrong. That share is something to spread out, not grow.
How I test
Red first. On a duplicate-notification bug I committed a failing test as its own commit before the fix. If it had passed before the fix, it was never testing the bug.
I break my own code to check the tests notice. Once, a test passed anyway. It asserted on labels without opening the menu that contained them, so it would have stayed green against completely broken code. I rewrote it and recorded the failure mode in its docstring.
Knowing when not to ship
Late in a long session I stopped midway through moving the message send path, a route that gates rate limits and money. Earlier that day I'd caught myself writing a vacuous test. I committed the piece that stood alone and reported the rest as unfinished, because the risk was a test that would certify a bug as safe.
I was also asked to build a card form to match a mock that collected only the card number. Stripe needs the CVC, the postal code, and the cardholder name. I said so instead of building it and letting it fail in review.
What I got wrong
More than once I shipped defensive code that would itself have thrown on malformed input, and a reviewer caught it. Defensive code runs on bad input, so it most needs its own guards.
I also kept writing docs that overstated the code. Four commits exist only to make a claim match reality. In one, a comment said a table was immutable, and it wasn't. I fixed the comment rather than adding a trigger to make it true, because a trigger with no documented disable path blocks the manual repair an operator needs during an incident. I'd rather the docs be honest than the guarantee be brittle, but I'd rather have noticed sooner that I was writing the overstatements.