Skip to content

feat: add data bundle purchase screen and flip tile live - #1144

Open
Tim4evar wants to merge 2 commits into
Miracle656:mainfrom
Tim4evar:feat/1133-data-bundle-purchase-screen
Open

Tim4evar wants to merge 2 commits into
Miracle656:mainfrom
Tim4evar:feat/1133-data-bundle-purchase-screen

Conversation

@Tim4evar

@Tim4evar Tim4evar commented Oct 19, 2026 •

Copy link
Copy Markdown

Summary

Adds a data bundle purchase screen at /data for the wallet:

  • operator selection, with bundles showing size (MB), validity (days), and naira price rather than price alone;
  • distinct empty-list and error states (empty-bundles-notice vs bundles-error);
  • spend-fee preview shown before the passkey prompt;
  • purchase through the onramp client, passing the phone number as customerId and the provider's opaque bundle id separately as bundleId (the purchase-side field tracked in wraith#305).

The Data tile in PayForGrid.tsx intentionally stays soon for now. The screen calls GET /ngn/bills/data/bundles?operator=…, which does not exist in wraith yet — it is the subject of Miracle656/wraith#267. Even once it exists, POST /ngn/bills still has no field for the selected product, so the purchase side depends on wraith#305. Until both land, the flow cannot complete and the tile must stay soon.

Shared flow: this screen was written before V296 (#1132) was available and currently duplicates a large part of airtime.tsx. Once the shared flow lands (#1132 / #1145), the plan is to rebase, remove the duplicated flow, and build on the shared implementation — which should shrink this PR substantially.

Related issue

Closes #1133

Depends on: #1145, Miracle656/wraith#267, Miracle656/wraith#305

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Docs
  • Tests
  • CI / tooling

Component

  • Wallet frontend
  • SDK
  • Contracts
  • Agent

Checklist

  • I have read CONTRIBUTING.md
  • cargo test passes (contracts)
  • npm run typecheck passes (wallet)
  • npm run build passes (wallet / agent)
  • I added or updated tests where relevant
  • I updated docs / README where relevant

Screenshots / test output

frontend/mobile/__tests__/dataScreen.test.tsx — 8 tests passing, covering the screen's states rather than rendering details. Screenshots to follow.

@Tim4evar
Tim4evar requested a review from Miracle656 as a code owner October 19, 2026 18:52
@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@Tim4evar Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@vercel

vercel Bot commented Oct 19, 2026

Copy link
Copy Markdown

@Tim4evar is attempting to deploy a commit to the miracle656's projects Team on Vercel.

A member of the Team first needs to authorize it.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The screen itself is largely right — and it calls a backend route that does not exist, while flipping the tile to live. That combination is the blocker.

1. /ngn/bills/data/bundles is not a wraith route

export async function fetchDataBundles(operator: string): Promise<DataBundle[]> {
  const { bundles } = await call<{ bundles: DataBundle[] }>(
    `/ngn/bills/data/bundles?operator=${encodeURIComponent(operator)}`,
  );

wraith's NGN router exposes exactly two bill routes:

src/api/ngn.ts:572  router.post("/bills", ...)
src/api/ngn.ts:650  router.get("/bills/:orderId", lookupLimiter, ...)

Grepping the whole of wraith/src for bundles finds nothing. So this call 404s, the bundle list is always empty, and the screen's empty-list state — which you built correctly — becomes the only state anyone ever sees.

And PayForGrid.tsx now reads:

-{ id: 'data', ... status: 'soon' },
+{ id: 'data', ... status: 'live', route: '/data' },

So the tile advertises a working feature that cannot work. The last acceptance criterion on #1133 is explicit about this: "The tile goes live only when the flow is complete."

Your tests pass because they mock fetchDataBundles. That is the right way to test a screen, and it is also why the missing route is invisible from inside this PR — mocking a boundary hides whether the boundary exists. Worth keeping in mind generally: a mocked client is a claim about a contract, and the contract needs checking somewhere.

This is not work you can finish alone, and that is not your fault. wraith has to grow the endpoint first. I'm opening an issue on wraith for it so it can be built and paid for; once it exists, this PR becomes small.

2. It copies airtime.tsx, which #1133 asked you not to do

Build on the shared flow from V296; do not copy airtime.tsx.

app/data.tsx is 914 lines and shares 272 distinct lines with airtime.tsx (844 lines). That is roughly half the file.

I want to be fair about this: V296 is #1132, it is assigned to another contributor, and it has not landed. You were asked to build on something that does not exist yet. That is our sequencing mistake for publishing both issues in the same batch, not a misread on your part.

But the consequence is real — #1132 now has a second 900-line screen to fold in, and whoever takes it inherits your divergences as well as airtime's. So the resolution is ordering, not blame:

  • If #1132 lands first, rebase onto the shared flow and this PR should shrink dramatically.
  • If it does not land soon, say so and I will decide which goes first rather than leaving you blocked.

Either way #1133 stays yours and stays assigned to you.

What is already right

  • Bundles carry sizeMb and validityDays, not just a price. The criterion existed because a column of naira amounts tells someone nothing about what they are buying, and your DataBundle type and its comment both get that.
  • An empty list is a state, not a failure — empty-bundles-notice, tested, and distinct from bundles-error. That distinction is the one most people miss.
  • previewSpendFee is wired, so the charge is shown before the passkey prompt.
  • Treating id as opaque and passing it straight through as customerId is right; inventing structure in an identifier the provider owns is how clients break on a provider change.
  • 319 lines of tests for a 914-line screen, covering the states rather than the rendering.

One request

Please fill in the PR description. It is the empty template right now, and "Closes #1133" plus two sentences would have told me the bundle endpoint was assumed rather than existing — which is the whole of blocker 1.

https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

@Miracle656

Copy link
Copy Markdown
Owner

Follow-up: I've opened Miracle656/wraith#267 for the missing endpoint, at 150 points, so the backend half can be built and paid for rather than sitting as an unowned blocker.

It specifies the route shape your client already expects — GET /ngn/bills/data/bundles?operator=… returning bundles with an opaque id, label, size, validity and naira price, and an empty list rather than an error when an operator has none, since your screen already distinguishes those two states correctly.

So the path forward for this PR:

  1. wraith#267 lands the endpoint.
  2. One bill flow, not five copies of airtime.tsx #1132 lands the shared bill flow, or we decide it is not going to land soon.
  3. You rebase, drop whatever One bill flow, not five copies of airtime.tsx #1132 replaces, and flip the tile.

Nothing here needs you to wait idly — if you want to take wraith#267 yourself, it is unassigned and you already know the contract better than anyone, having written the client for it. Say the word and it is yours.

@Tim4evar

Tim4evar commented Oct 20, 2026 •

Copy link
Copy Markdown
Author

I'll readily take on wraith#267 since I already have the client side wired up and know the expected contract, I'll get started on the backend endpoint so we can clear this blocker.

@Tim4evar
Tim4evar requested a review from Miracle656 October 20, 2026 12:26
@Tim4evar
Tim4evar force-pushed the feat/1133-data-bundle-purchase-screen branch from cefc2d7 to 6313ae7 Compare October 20, 2026 12:38
@Tim4evar

Tim4evar commented Oct 20, 2026 •

Copy link
Copy Markdown
Author

wraith#267 is now assigned to @ayo-mide55. #1132/#1145 are still open (and #1145 is currently conflicting). Should I wait for the shared BillFlow, or rebase onto #1145 once it is green? The Data tile stays soon either way.

@ayo-mide55 the client in this PR expects GET /ngn/bills/data/bundles?operator=… returning { bundles: [...] }, with each bundle carrying an opaque id, label, sizeMb, validityDays and naira price, and an empty list (not an error) when the operator has none. The id is passed through to POST /ngn/bills as customerId, so it should stay opaque.

The Data tile stays 'soon' until wraith#267 ships the bundle catalogue endpoint, so the app does not advertise a flow whose list can only be empty.
@Tim4evar
Tim4evar force-pushed the feat/1133-data-bundle-purchase-screen branch from 6313ae7 to 3d1c264 Compare October 21, 2026 07:13
@Miracle656

Copy link
Copy Markdown
Owner

Both good questions, and thank you for going and telling @ayo-mide55 the contract directly — that is exactly the right instinct. One answer, one correction, and one thing neither of you could have known.

Wait for #1145

Build on the shared BillFlow. I checked the overlap before answering rather than guessing:

frontend/mobile/app/data.tsx      914 lines   (this PR)
frontend/mobile/app/airtime.tsx   844 lines   (on the branch already)

and the two share continueFromForm, loadRate, pollRef, PREFIXES, phoneValid, phoneDigits, stepNumber, createStyles, mainnetOnly and amountStableCoin. data.tsx is essentially airtime forked and edited, which is understandable — there was nothing else to build on when you started.

But that is precisely what #1145 exists to stop. If I merge this first, the extraction has three call sites to unify instead of two, and the third one is 900 lines that landed after #1145 was branched. We would be creating the duplication in the same week someone is removing it.

The cost of waiting is close to zero here, which is what decides it: the tile stays soon either way, and the screen cannot work at all until wraith#267 ships. So nothing shippable is blocked by waiting, and quite a lot of someone else's work is saved.

I will get #1145 reviewed and moving rather than leaving you waiting on a conflicting branch indefinitely. Once it is in, rebase onto the shared BillFlow and this becomes a much smaller PR — which is also a much easier one for me to review properly.

The customerId mapping is wrong, and it matters

The id is passed through to POST /ngn/bills as customerId, so it should stay opaque.

customerId is not the product. From src/linq/bills.ts:

/** Who the bill is for: a phone number for airtime, a meter number for electricity. */
customerId: string;

It is the subscriber identifier, and it is the field the phone number has to go in. Putting a bundle id there would overwrite the phone number, so Linq would be told to send data to a bundle id — and it would also put a product code into a field that otherwise carries personal data, which is a boundary I would rather keep clean.

Good news: the opacity requirement you asked for is still right. It is just a different field.

That field does not exist yet

This is the part worth flagging loudly, because it is nobody's mistake and it would have cost you both a round trip. I grepped the whole bills payload: customerRef, billCategory, provider, customerId, amountNgn, amountStableCoin, rate, coin, refundAddress. There is nowhere to put a selected bundle.

wraith#267 adds the listing endpoint — GET /ngn/bills/data/bundles?operator=… — and I wrote it against the shape this PR already expects, so your contract description matches it exactly. But #267 does not touch POST /ngn/bills, so even once @ayo-mide55 lands it, you could list bundles and still not buy one.

I am filing the purchase-side field as its own issue rather than widening #267 under someone who has already started, and I will link it here. Assume for now that the bundle id travels in a new field alongside customerId, not instead of it.

So the order is: #1145 lands, the bundles endpoint lands, the purchase field lands, then this rebases onto BillFlow and the tile comes off soon. Your side is the furthest along of the four.

@Miracle656

Copy link
Copy Markdown
Owner

Filed the purchase-side field as wraith#305, and left a note on wraith#267 so @ayo-mide55 knows the listing id has to match what #305 accepts. All three sides are now working to the same contract.

Miracle656 added a commit that referenced this pull request Oct 21, 2026
Found while answering veil #1144. wraith #267 adds the endpoint that LISTS
data bundles; nothing lets a client say which one was bought.

POST /ngn/bills accepts nine fields and none identifies a product. The PR
author had assumed the bundle id travels in `customerId` -- but that is the
subscriber ("a phone number for airtime, a meter number for electricity"),
so it would have overwritten the phone number and put a product code into a
field that otherwise carries personal data.

Two contributors were each building a half and the halves did not meet.
Corrected on #1144, noted for the #267 assignee, filed as its own issue
rather than widening #267 under someone who had already started.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
customerId is the phone number the bundle is for. Passing the bundle id there would credit the wrong thing and overwrite the subscriber; the product id now travels in bundleId, matching wraith#305's purchase-side field.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Data bundles: the backend sells them, the app says "soon"

2 participants