Pedro Wiezel
back to blog

in defense of the tip jar

why I chose voluntary tips for EXIF.ER, and how three buttons became a small StoreKit project

I wanted to add a small thank-you to EXIF.ER. Three buttons in the Info and Credits screen, priced as a coffee, a snack, or lunch. Somewhere along the way, this acquired a transaction listener, duplicate detection, a migration for a stored boolean, and tests for what happens when the store returns absolutely nothing. There is a respectable amount of machinery behind buying someone an imaginary croissant.

I made EXIF.ER for my own photo backups. It renames photos and videos using their capture dates, and the tip jar shipped in the 3.0 update. A tip adds a thank-you and some hearts to the Info and Credits screen. All the renaming features are free.

paying for a chore

Sometimes I have a batch of files to organize. Then I leave the app alone. A subscription would give me another thing to remember to cancel after finishing a chore, which is a peculiar improvement to my backup routine.

I would like to be paid for the work. Bugs survive the version where I was sure I'd caught them, and keeping up with iOS takes time. But when I imagine charging for this app, I keep coming back to someone with a folder much like mine, wanting to sort out their photos and get on with something else.

I couldn't bring myself to build a counter that stops a batch at the eleventh photo until someone pays. I'd have to decide where to interrupt a task I'd spent all this time trying to make less annoying. So the invitation lives in Info and Credits, where someone can leave a tip if the app has been useful.

EXIF.ER tip jar with coffee, snack and lunch options, one red heart, and the message Thank you for tipping.
Three tip options and a thank-you

when the store returns nothing

The tips are consumable products, with their identifiers and translated names in TipTier. I sort the returned products so coffee always comes before snack and lunch. For prices, the buttons use StoreKit's Product.displayPrice, already formatted for the storefront's locale.

Drawing the buttons is straightforward once those products arrive. They might also fail to load, or the store might send back an empty list. The TipJar service keeps track of that with a small enum:

enum State: Equatable {
    case loading
    case unavailable
    case ready([TipOffer])
}

A failed or empty response leaves a message and a retry button in the screen. Once the offers arrive, the interface gets TipOffer values with the text and emoji for each tile; the StoreKit products stay in the service. While a purchase is open, the other tip buttons are disabled.

That also gave me a way to prepare screenshots without waiting for the store. The debug harness supplies offers, including their prices, to the same views the app uses. I could check the Portuguese buttons even when StoreKit wasn't answering.

keeping count

The thank-you needs to work even when a purchase finishes later. EXIF.ER listens to Transaction.updates and sends those updates to the same handler as a successful purchase() result. The handler checks verification, records the tip, then calls finish().

Two ways in meant I had to guard against counting the same transaction twice. The recording method keeps the IDs it has seen during that launch, and ignores unknown products and revoked transactions. Each accepted tip adds a heart, up to five.

The count lives in UserDefaults on that installation. An earlier version stored only whether someone had tipped, so there is a migration that turns the old “yes” into one heart.

I test the recording method with plain transaction IDs and flags. Send the same ID twice, check that it adds one heart; send a revoked transaction, check that it adds none. Other tests cover the saved count and the old boolean. For loading, a supplied closure lets the tests return nothing or throw an error, then try again. These tests exercise my code. Buying a tip through the store still needs a separate check.

room for the name

Three prices in a row look tidy until one translated name wraps and pushes its price down. The tile uses lineLimit(2, reservesSpace: true) to give every name two lines, even when it only needs one. At accessibility text sizes, AnyLayout stacks the buttons vertically and the names can take as much room as they need.

I had thought of the tip jar as a small addition to the app, then found myself adjusting the space above a price. It is easy to spend less care on a screen I rarely open myself.

Version 3.0 is now on the App Store. As of September 10, 2026, no one has left a tip yet. I’m still hoping for that coffee.