Engineering
The refund we never meant to give: closing a free-download loophole in gallery proofing
A photo client could trade a delivered photo back and forth in a way that settled to free. The fix was one identifier, and a clear lesson about modelling state by its lifetime.
The short version
- Bookatu's photographer galleries give each client a set number of included photos plus a price per extra photo.
- Unlocking a photo to full resolution is permanent. The download never expires.
- The bug: a client could trade a delivered photo back and forth in a way that settled to free, because the allowance was measured against state that could move backwards.
- The fix was to charge the allowance against the permanent unlocked count. One number, modelled by its real lifetime.
The feature
Bookatu is one booking platform with a tailored mode for each kind of business. The photographer vertical adds private client galleries. A photographer uploads full-resolution files. The platform generates a low-resolution preview at 1080px with a baked-in watermark and stripped EXIF, and that preview is all the client ever sees until they settle up. The client opens a secure long-lived magic link, hearts their favourites, and pays. Each gallery carries a package: a number of included photos, and a price per extra photo beyond that. Money goes straight to the photographer's own bank through Stripe Connect at 0% booking commission, so the studio reconciles its own tax. Platforms in this space compete on exactly this shape, an a la carte set of downloads layered on top of an included count, as the proofing-gallery roundups make clear.
When a client confirms and the included or paid set is settled, the full resolution unlocks for their picks. That unlock is permanent. It has to be. Someone who paid for a photo in March should still be able to download it in December. The studio sees who has settled on a delivery board.
The bug
The number we showed the client, and the number we charged, came from one function. It answered a simple question. Given what this client has hearted, how much of the included allowance is left, and how much do they owe right now.
The first version got that sum wrong in a quiet way: it spent the allowance against state that could move backwards. Stripped of the specifics, the effect was that a client could trade a delivered photo back and forth in a way that settled to free, and repeating the trade unlocked the whole gallery for nothing. No error, no alarm, just a meter that politely wound itself back down.
Why it happened
The mistake was not arithmetic. It was a state model that mixed two different lifetimes. A favourite is a toggle. It moves up and down as the client browses and changes their mind. An unlock is a one-way door. Once it opens it never closes. We were spending a permanent allowance against a number that could go down. Any quantity you treat as spent has to be charged against state that only moves in one direction.
If unlocking is forever, the thing it costs must be measured against something that is also forever.
The fix
Charge the allowance against every photo this client has ever unlocked, however it was unlocked, included or paid. That count only ever goes up, so the allowance can only ever be spent, never refunded.
One identifier changed: the meter now reads from the permanent unlocked count instead of a reversible one. The exploit closes because giving a delivered photo back no longer lowers the number the allowance is charged against. Delivered photos still count, so the next picks become payable, which is exactly right. The same query still handles the honest cases: buying more photos later works at any time, the already-unlocked ones are never re-charged, and any unused included slots are applied first.
This is the mirror image of idempotency in payment systems. Stripe keys money movement on a stable idempotency key so the same intent can be replayed without charging twice, and teams like Airbnb describe building their whole payments path around that guarantee. We wanted the opposite property for value: the intent 'give me my included photos' must be impossible to replay for new value. The route to both is the same. Anchor the cost to state that moves in one direction. The literature calls this monotonic state, a value that only ever increases, and that one constraint makes a whole class of reasoning safe.
How we locked it in
We did not just patch the line and move on. We wrote the abuse down as a test, because a comment is not a guardrail. The integration tests replay the trade against a small included package and assert the client ends up owing the full extra price rather than zero, and that once the allowance is spent every further pick stays payable no matter how the favourites churn. If anyone ever swaps the meter back to a reversible count, those tests go red before the change ships.
The diff is tiny. The lesson is not. When you charge for something permanent, the meter has to read from permanent state. Get the lifetime of each value right and the arithmetic takes care of itself.
Sources
- 17 Best Online Proofing Galleries for Photographers (Aftershoot): https://aftershoot.com/blog/best-online-proofing-galleries/
- Stripe API: Idempotent requests: https://docs.stripe.com/api/idempotent_requests
- Avoiding double payments in a distributed payments system (Airbnb Tech Blog): https://medium.com/airbnb-engineering/avoiding-double-payments-in-a-distributed-payments-system-2981f6b070bb
- The Essence of Monotonic State (Pilkiewicz and Pottier, INRIA): http://gallium.inria.fr/~fpottier/publis/pilkiewicz-pottier-monotonicity.pdf
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs