← Back

Eight weeks on a payments codebase

2026 · NEXT.JS / FASTIFY / POSTGRES / STRIPE · INTERNSHIP

Lawly is a marketplace where people buy consulting time from consultants. Money moves through it, which means the interesting problems are the ones where being approximately right is the same as being wrong.

I joined as a software engineering intern in summer 2026 and worked mostly on payments, account erasure, and auth. What follows is about three things I learned there, none of which are about writing code faster.

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.

That last detail is the whole thing. Two different cards means it wasn't a double click and it wasn't a network retry. It was a person who believed the first attempt had failed, so they re-entered their payment details and tried again. And again.

Stripe supports idempotency keys, which make a repeated request replay the first result instead of creating a second charge. The question is what you derive the key from, and most of the obvious answers don't work here. A key derived from the request body, the session, the card, or a timestamp would have produced three different keys for those three attempts and charged all three times. The only thing those attempts had in common was what the buyer was trying to buy.

So the key is derived from the purchase itself. Which immediately creates the opposite problem: if the key is purely a hash of what's being bought, then a customer who legitimately buys the same thing twice gets the first charge replayed and receives nothing for their money. Same failure, opposite direction, and arguably worse because it's silent.

The fix was to pair the purchase identity with a counter of how many of that purchase the buyer has already completed. Retry inside the same attempt, same key, replay. Genuine second purchase, the counter has moved, new key, real charge.

One layer wasn't enough

Idempotency keys stop Stripe from charging twice for the same request. They don't stop us from asking, and they only hold within one endpoint's key namespace. A deploy that lands between two submits can move a buyer from one payment path to another, which hands the second submit a clean namespace, and both charge.

So I added a second, independent layer: a server-side check that refuses to open a new charge when one has already succeeded, is processing, or is authorized and awaiting capture. It runs before any call to Stripe, so the refusal happens before money can move rather than after.

And if the check itself can't run, it refuses. Failing closed on an unavailable lookup means a buyer occasionally sees an error they didn't deserve. Failing open means someone occasionally gets charged twice. Those are not the same size of mistake.

The constraint that made it hard

A guard like this is easy to write and easy to write wrong, because the dangerous failure isn't the one it was built to stop.

It must not refuse a submit that hasn't charged anything. A buyer who clicks Pay, gets impatient, and refreshes has a live payment intent whose client secret their browser just threw away. Refuse there and you haven't prevented a double charge, you've bricked checkout for someone with money in their hand.

So the tests come in matched pairs. Half assert that an already-charged submit is refused before any Stripe call is made. The other half assert that a not-yet- charged submit still gets through: a re-submit while the first intent is awaiting payment, a legitimate repeat purchase after the first was delivered, a logged-out buyer with no customer record attached.

Both halves have to hold. A guard that only satisfies one of them isn't a guard, it's a new bug wearing a safety vest.

Deleting a user without deleting their data

The account deletion work had a similar shape: two promises that look like they contradict each other.

Our published privacy policy says personal information is erased on request. It also says payment and tax records are retained, which it has to, because those are legal obligations.

Actually removing the user row doesn't satisfy either promise cleanly. Most of the foreign keys pointing at it cascade, so deleting the row takes unpaid payout installments, reviews other people can still see, and consultants' own private notes about their clients along with it. That isn't erasure, it's collateral damage to other people's records.

So erasure anonymizes in place. The row survives, the financial and third-party records that hang off it survive, and every field that constitutes personal data is overwritten. The replacement email is deliberately not derived from the old one, not even hashed, because a hash of personal data is still a retained representation of it.

Making it hold after I leave

The part I'm most pleased with isn't the anonymization itself. It's the mechanism that keeps it correct.

Every column on the user table is classified into one of four lists: erased, preserved, and two smaller categories. A test asserts that the union of those four lists equals the table's actual column list at runtime.

That assertion is the entire point of the file. Someone adds a column six months from now and doesn't think about erasure, the test fails and tells them to classify it. Without it, the new field silently survives anonymization and nobody finds out until it matters.

One judgment call inside that work I still like: the payout account handle is preserved, but the email address attached to it is cleared. A provider account handle is a pointer to an account, not a fact about a person. An email address is personal data on its own, and since erasure already clears the login email, the name, and the phone number, leaving a second live address in a different table would quietly undo all of that.

Guards that fail in both directions

In my last few weeks I spent time converting one-off fixes into things CI enforces, which taught me something I didn't expect about how guardrails go bad.

The pattern was: add a lint rule as a warning with a ceiling set at the current count, drive the count to zero over a few commits, then promote the rule to an error and delete the ceiling. Same shape for float-based money parsing, for raw color literals, for off-scale type tokens.

The thing worth writing down came out of the ceiling itself. A count going up is the regression the guard exists to catch. But a count going down without someone editing the allowlist is also a failure, because a stale entry silently excuses a future regression on a file that had already been fixed.

A guard that only checks one direction ratchets the wrong way. It gets more permissive every time someone does the right thing.

How I test

Three habits, in rough order of how much they've changed my work.

Red first, in the git record. On a duplicate-notification bug I committed a failing test before the fix, as its own commit. It's in the history: a commit titled "Added a failing test for duplicate reminders," then the fix. If the test had passed before the fix, it was never testing the bug.

I break my own code to check the tests notice. After writing a set of tests, I go back and deliberately introduce the failure each one is supposed to catch, then confirm it goes red. On one pass, a test I'd written passed anyway. It asserted on labels without opening the menu that contained them, so both assertions were vacuously true and would have stayed green against completely broken code. I rewrote it and recorded the failure mode in the file's docstring. That's the thing I'd never have found by reading it, and it's why I do this now instead of trusting a green suite.

Seeded proofs. For destructive operations I seed a complete known fixture, run the real operation, and assert field by field on what changed and what stayed byte-identical, printing before and after so a human can audit the run rather than take my word for it. I made these tests rather than scripts on purpose: a proof that lives in the suite runs on every push, and a script in a scripts folder runs once and then rots.

Knowing when not to ship

Two of these matter more to me than any of the code above.

I was midway through a large extraction on the message send path at the end of a long session. The remaining work was a couple hundred lines moving off a route that gates rate limits and money, plus integration tests, one of which needed to genuinely race at the rate-limit cap. Earlier that same day I'd caught myself writing a test that passed vacuously.

So I stopped, committed the piece that stood alone and compiled clean, and reported the rest as unfinished. The risk wasn't writing a bug. It was writing a test that would pass against the bug and certify it as safe.

Separately, I was asked to build a card payment form to match a design mock that collected only the card number. It can't work. Stripe needs the CVC, the postal code for address verification, and the cardholder name, and no amount of implementing the mock faithfully changes that. I said so instead of building it and letting it fail in review.

What I got wrong

The null-guard reflex took me too long. On more than one occasion I shipped defensive code that would itself have thrown on malformed input, and a reviewer caught it. Defensive code is by definition the code that runs on bad input, which makes it the code that most needs its own guards. That took a couple of review comments to actually stick.

I also found that a running theme in my own commit history is correcting documentation that overstated what the code did. Four separate commits exist whose entire purpose was making a claim match reality. In one case I found a comment asserting a table was immutable, verified that it wasn't, and considered adding a database trigger to make the claim true. I rejected it and fixed the comment instead, because a trigger with no documented disable path blocks exactly the manual repair an operator needs during an incident. A bad hour becomes a worse one.

I'd rather the docs be honest than the guarantee be brittle. But I'd rather have noticed sooner that I was the one writing the overstatements.