Ammad Ashraf
Back to work

AI Product Engineering

Forward a receipt, get your shop scored

For UK adults with prediabetes. Forward a supermarket receipt: AI rates every item, scores the basket, suggests swaps and tracks progress. Free once, then monthly or yearly.

Client
PrediabetesBasket
Delivered via
Nettlio
Role
Lead AI Product & Automation Engineer — full lifecycle
Period
Mar 2026 – Aug 2026
Domain
Health guidance
Product
Consumer SaaS
Platform
Mobile-first web
Market
United Kingdom

Stack

  • React
  • Node.js
  • Typescript
  • Supabase
  • PostgreSQL
  • Stripe
  • PostHog
  • Loveable
  • 5

    paid subscribers

    first 14 days live

  • 8

    health exclusions

    checked before any AI runs

  • 17

    screens shipped

    11 behind the login

The PrediabetesBasket logo and home screen

The product

What the app is

PrediabetesBasket is a UK app for adults living with prediabetes. You forward your supermarket receipt to a private email address, and minutes later the app shows what your shop actually looks like: every item traffic-lighted green, amber or red, a basket score out of 100, a healthier swap for anything flagged, and a short written takeaway. Shop again next week and it charts whether things are improving.

Everyone gets one free basket check; after that it runs on a monthly or yearly plan.

The PrediabetesBasket home screen showing the latest basket score and the private inbound email address
Home — the latest basket, and the address you forward receipts to.

1

email address is the entire input surface

No camera, no barcode scanner, no manual entry, no upload button anywhere in the app.

That one decision removed a whole category of product work — capture screens, OCR retries, camera permissions — and replaced it with a subtler problem. The user has to trust an address they cannot watch working, so a good half of the app exists to answer one question: did it arrive, and is anything happening yet?

Scope

Where this app ends and the AI begins

Two systems, one product. This study is the app; the sibling study is the engine behind it.

The analysis itself does not happen here. A separate pipeline receives the forwarded email, reads the receipt, runs it through the model and emails the finished result straight back — so the user gets their answer in their inbox whether or not they ever open the app. That pipeline is the sibling case study.

What this app adds is everything an email cannot do: history, filters, a score trend across weeks, per-item reasoning you can expand, the subscription that pays for it, and the safety and legal surfaces a UK health product needs. I built both halves; this is the one people log into.

One receipt, end to end

  1. Sign up and onboard — six steps, saved at three checkpoints

  2. Get a private inbound address — provisioned automatically, one per user

  3. Forward a supermarket receipt — no camera, no upload

  4. The pipeline takes over — sibling case study

    health exclusion → referral message, and the model is never called

  5. Analysis lands in the inbox — the product’s actual deliverable

  6. The app updates itself — no refresh needed

  7. Explore, compare, track — history, per-item reasoning, score trend

Steps four and five are the sibling study. Everything either side of them is this one.

Onboarding

Signing up, and who the app turns away

Email and password only. Google and Apple sign-in were built and then deliberately taken out — the account is tied to a per-user inbound email identity, so a social login adds an outside dependency and a consent conversation to a screen most people see exactly once.

Onboarding runs six steps: welcome, how it works, eligibility, safety screening, preferences, consent. It saves at three checkpoints rather than once at the end, so someone who gets halfway through and comes back tomorrow picks up where they left off.

The safety gate is a database column, not an if-statement

Eight health conditions mean the app should not give someone personalised food advice. Where does that rule live?

In Postgres. Whether someone has an exclusion, and whether they can receive personalised analysis, are both generated columns — derived automatically from their onboarding answers. Application code physically cannot write them; trying to is a database error.

So the gate can never quietly disagree with the answers it came from, whatever any future code path does.

The alternative

A boolean the app sets whenever the onboarding answers change.

What it cost

The rule is no longer visible in the TypeScript, so you have to know to look at the schema. Worth it — an excluded user’s receipt never reaches the model, and that is not a guarantee I wanted resting on a code review.

Two ways to be turned away, two different screens

A health exclusion

Coeliac disease, pregnancy, eating-disorder history, kidney disease and four others.

Gets a clinical message pointing them to a professional. Their receipt never reaches the model.

Just not the audience

Not a UK adult with prediabetes. No health condition involved at all.

Gets a softer, non-clinical message. One shared screen would tell this group something untrue about their own health.

Surfaces

The screens people actually use

Seventeen routes, eleven behind the login. These four are where the time goes.

The decision inside each one

  • Five items, then expand — A basket can carry forty. Receipt detail shows five collapsible rows — forty open cards is not a screen anyone reads.

  • Say where the missing items went — Non-food items are stripped before scoring. Someone looking at 38 items and a score built from 31 needs to know what happened to the other seven.

  • No trend from one point — Progress stays empty until there are two baskets. A chart with one dot is worse than an honest "not yet".

  • The screen updates itself — Home listens for database changes, so a result landing while the tab is open just appears. Polling a pipeline measured in minutes buys a stale screen or wasted requests.

Revenue

Plans, payments and the paywall

One free basket check, then a monthly or yearly plan. Checkout, the billing portal, upgrades and cancellations all run through four Stripe edge functions — the secret key never touches the frontend bundle, and Stripe’s hosted checkout owns every payment screen.

Each function authenticates the user with the public key first and only then escalates to admin privileges. Doing it the other way round would mean trusting a user id sent from the browser.

The one-line bug that switched the paywall off

The free tier also got its own flag rather than sharing the paid usage counter, which is contaminated in both directions: checkout zeroes it, so subscribing then cancelling would mint a fresh free credit, and a downgrading customer arrives carrying fifteen used receipts that look nothing like a spent free check.

Compliance

Ten tables, three analytics systems, and a lot of things deliberately switched off.

The data model is small and boring on purpose: profiles carry identity, plan state, preferences and the eight health flags; receipts and per-item analysis carry the output; separate tables track swap-ladder position, usage windows, inbound email delivery, legal notice versions and cookie consent.

Disclaimer text lives in a table, not a component — and every receipt stamps which version applied when it was analysed, so updating a disclaimer cannot retroactively change what someone was actually shown.

Making a privacy leak a compile error

src/lib/analytics.ts

// The only properties any analytics event may ever carry.
interface AllowedProperties {
  plan_selected?: string;
  receipt_method?: string;
  device_type?: string;
  traffic_source?: string;
  user_type?: string;
}

// trackEvent rebuilds the payload field by field instead of
// spreading whatever the caller passed in.

A name, an email or a basket item is now a build failure rather than something a reviewer has to spot. Autocapture, session recording, heatmaps and automatic page views are all off — there is no acceptable version of replaying a screen full of someone’s health answers. Google Analytics is injected only after consent, so rejecting cookies produces zero Google requests.

Debugging

The bug that wasn’t the bug

Reported: users are getting a "speak to your doctor" email despite having no health conditions.

What everyone assumed

The safety gate is misfiring. It is the newest and most safety-critical logic in the product, and the symptom points straight at it.

What was actually happening

The gate was working perfectly and was completely blameless.

Two unrelated problems were stacked on top of each other, and neither one was in the gate.

  1. Onboarding only saved if you picked a plan — The free-check button sat on the paywall, one step past the last question. Anyone who answered everything, saw the pricing and closed the tab saved nothing and received nothing — they looked, to the system, like someone who never finished onboarding.

  2. One email served four different situations — Incomplete onboarding, not eligible, screening pending and genuine health exclusion all got the same clinical doctor-referral wording.

Outcome

What shipped

  • 5

    paid subscribers

    first 14 days live

  • 17

    routes shipped

    11 behind the login

  • 4

    Stripe edge functions

    checkout, portal, upgrade, cancel

  • 8

    health exclusions enforced

    checked before the model is ever called

  • 2

    gates the app cannot write

    generated Postgres columns

  • 11

    analytics events

    limited to 5 allowed properties

Retrospective

What I’d take to the next build

  • Empty is not expired — The biggest bug here came from treating a missing timestamp as an elapsed one. When one value stands in for two states, check whether they deserve separate handling.

  • Put the gate in the database — A generated column cannot drift from the data it derives from. An application check can, and eventually will.

  • A bug report names a symptom, not a cause — The doctor-referral emails were real and the safety gate was innocent. The failure was an onboarding step nobody finished and one message doing four jobs.

  • Enforce privacy in the type system — An allow-listed property interface stops a data leak at compile time. A convention stops it on a good day.