Invoice Bank QR + Silent Approval Flow — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Add a global bank QR upload to Invoice Studio that snapshots onto every issued invoice and renders on the PDF, suppress all auto-comms on upgrade-request approval, and surface a one-time celebratory toast to clinic admins when their plan is activated.
Architecture: A single QR image lives in R2 with its pointer in the existing platform_settings table (key billing_bank_qr). Each newly created subscription_invoices row snapshots the current R2 key into metadata.bankQrR2Key. The PDF renderer fetches that snapshot and embeds it as a data URI. Approval-time email/notification calls are stripped from issueInvoiceForApprovedRequest — the superadmin sends manually via the existing POST /invoices/:id/send action. On mark-paid, in addition to the existing receipt email + “Billing Receipt Ready” notification, a second notification with entityType='plan_activated' is created; the admin dashboard mount queries unread notifications, detects this entity type, fires a celebratory toast.success with confetti, then marks it read so it never fires again.
Tech Stack: Hono + Cloudflare Workers (server), Drizzle ORM + Neon Postgres, @react-pdf/renderer (PDF), React + Vite (UI), sonner (toasts), Cloudflare R2 (object storage).
Spec: docs/superpowers/specs/2026-05-18-invoice-qr-and-approval-flow-design.md
Design refinement vs spec
The spec described pushing the toast signal viametadata.loginToast=true on the notification. The notifications table has no metadata column (verified at server/src/schema/notifications.ts). We instead use the existing entityType text column with value 'plan_activated' as the discriminator. No schema change needed. The notificationEntityTypeEnum exists but is informational — the column is plain text.
File map
Server — new filesserver/src/lib/bank-qr.ts— Read/write/clear the QR pointer inplatform_settings; helper to fetch image bytes from R2.server/src/lib/bank-qr.test.ts— Unit tests for the helper (sha256 keying, pointer round-trip).
server/src/routes/billing.ts— AddGET/POST/DELETE /bank-qr+GET /bank-qr/imageendpoints. Add “Send to clinic” already exists at/invoices/:id/send, no change there. InPOST /invoices(manual creation, line 1271), snapshot QR pointer intometadata.bankQrR2Key.server/src/lib/plan-activation.ts— SplitissueInvoiceForApprovedRequestintocreateOpenInvoiceForApprovedRequest(silent — invoice only, with QR snapshot) + drop the email/notification side effects. Remove the legacyissuePlanActivationReceiptAndNotifyalias once callers are updated.server/src/routes/admin.ts(line ~561) — Update caller of the renamed function in the upgrade-request approval handler.server/src/routes/billing.ts(line ~434,PATCH /license-requests/:id) — Update caller too.server/src/routes/billing.ts(line 1457,mark-paid) — Add secondplan_activatednotification call after the existing “Billing Receipt Ready” notification.server/src/pdf/SubscriptionInvoicePdf.tsx— Accept new optionalbankQrDataUri?: stringprop and render it as a 3rd payment column.server/src/routes/billing.ts—ensureSubscriptionInvoicePdf(line 498) fetches the snapshotted QR from R2 and passes the data URI when calling the PDF renderer.
ui/src/components/superadmin/BankQrCard.tsx— Upload/preview/replace/remove component.
ui/src/components/superadmin/InvoiceStudio.tsx— MountBankQrCardabove the page header; add Mail-icon “Send to clinic” button in the history row (calls existingPOST /invoices/:id/send).ui/src/components/dashboards/AdminDashboard.tsx— AddusePlanActivationToast()hook call on mount that queries unread notifications, detectsentityType='plan_activated', firestoast.success(...), marks it read.ui/src/hooks/usePlanActivationToast.ts(new) — Encapsulate the fetch/show/dismiss logic.
Verification commands
Use these throughout:- Server typecheck:
cd server && bunx tsc --noEmit(~30s) - Server tests:
cd server && bunx vitest run src/lib/bank-qr.test.ts - UI typecheck + build:
cd ui && bun run build(~60s — verifies typecheck + bundle) - Schema validation: none needed; no migration
Task 1: Bank QR helper module + unit tests
Files:-
Create:
server/src/lib/bank-qr.ts -
Create:
server/src/lib/bank-qr.test.ts - Step 1: Write the failing test
- Step 2: Run the test to confirm it fails
cd server && bunx vitest run src/lib/bank-qr.test.ts
Expected: FAIL — Cannot find module './bank-qr'
- Step 3: Implement the helper
- Step 4: Run the test to confirm it passes
cd server && bunx vitest run src/lib/bank-qr.test.ts
Expected: PASS — 5 tests pass.
- Step 5: Typecheck
cd server && bunx tsc --noEmit
Expected: no errors.
- Step 6: Commit
Task 2: Bank QR endpoints in billing route
Files:-
Modify:
server/src/routes/billing.ts(add new endpoints after existing/invoicesblock, near line 1660) - Step 1: Add imports for the helpers
server/src/routes/billing.ts, find the existing imports block. Add:
- Step 2: Add
GET /bank-qrandGET /bank-qr/imageendpoints
server/src/routes/billing.ts (after the existing invoices routes, before the closing export default):
- Step 3: Typecheck
cd server && bunx tsc --noEmit
Expected: no errors.
- Step 4: Commit
Task 3: Snapshot QR onto new invoices + render on PDF
Files:-
Modify:
server/src/lib/plan-activation.ts -
Modify:
server/src/routes/billing.ts(manualPOST /invoicesblock at line 1271,ensureSubscriptionInvoicePdfat line 498) -
Modify:
server/src/pdf/SubscriptionInvoicePdf.tsx - Step 1: Extend the PDF renderer with optional QR
server/src/pdf/SubscriptionInvoicePdf.tsx. Update the props interface and the payment section.
Replace the SubscriptionInvoicePdfProps interface (around line 4-28) to add:
bankQrDataUri:
paymentRow block (lines ~171-184). Currently:
s = StyleSheet.create({ ... }) block (just before the closing });):
- Step 2: Snapshot QR on approval-time invoice creation
server/src/lib/plan-activation.ts. Add an import at the top:
issueInvoiceForApprovedRequest, find the db.insert(subscriptionInvoices).values({ ... }) block (around line 82-109). Replace the metadata: { ... } value with:
const qr = await readBankQrPointer(db).catch(() => null); above the insert and reference qr?.r2Key / qr?.mimeType.)
Cleaner version — replace the insert block as a whole. Find lines ~82-109 and replace with:
- Step 3: Snapshot QR on manual invoice creation
server/src/routes/billing.ts. Add the same import:
fetchBankQrDataUri if it’s not already there.)
Find the POST /invoices block at line 1271. Inside the handler, just before the db.insert(subscriptionInvoices) call (around line 1312), add:
metadata: { ... } field in the insert (currently lines 1325-1333) to include the snapshot:
- Step 4: Fetch + pass QR to renderer in
ensureSubscriptionInvoicePdf
server/src/routes/billing.ts, find ensureSubscriptionInvoicePdf (around line 498). Locate the place where it calls the React PDF renderer (search within the function for SubscriptionInvoicePdf or pdf( invocation — typically right before the r2.uploadFile for the rendered PDF, in the section after the totals are computed).
Just before the <SubscriptionInvoicePdf ... /> element is rendered (or before the pdf(...) call), add:
bankQrDataUri={bankQrDataUri ?? undefined} as a prop to the SubscriptionInvoicePdf element. (If the file structures the props as a plain object passed into pdf({...}), add bankQrDataUri to that object instead.)
To locate the exact render call, run: grep -n "SubscriptionInvoicePdf\b" server/src/routes/billing.ts — there should be one or two callsites inside ensureSubscriptionInvoicePdf.
- Step 5: Typecheck
cd server && bunx tsc --noEmit
Expected: no errors.
- Step 6: Commit
Task 4: Make approval silent — strip email + notification from issueInvoiceForApprovedRequest
Files:
-
Modify:
server/src/lib/plan-activation.ts - Step 1: Remove the email send + notification creation
server/src/lib/plan-activation.ts, after the invoice insert from Task 3, delete the rest of the function body that currently sends emails and creates notifications. The function should end immediately after the invoice insert.
Specifically, delete lines that currently look like:
issueInvoiceForApprovedRequest) should now end right after the db.insert(subscriptionInvoices).values({...}).returning() block.
- Step 2: Optionally rename + drop the alias
issueInvoiceForApprovedRequest to createOpenInvoiceForApprovedRequest (per spec). Also delete the deprecated alias at the bottom:
- Step 3: Update both callers
server/src/routes/admin.ts (line ~561), change:
grep -n "issuePlanActivationReceiptAndNotify\|issueInvoiceForApprovedRequest" server/src/routes/admin.ts to find the import statement and update it.
In server/src/routes/billing.ts (line ~434), change:
- Step 4: Remove now-unused imports from
plan-activation.ts
clinics, subscriptionInvoices, subscriptionPlans).
- Step 5: Typecheck
cd server && bunx tsc --noEmit
Expected: no errors. If you missed a caller of the old name, the typecheck will flag it.
- Step 6: Commit
Task 5: Add plan_activated notification on mark-paid
Files:
-
Modify:
server/src/routes/billing.ts(line ~1457,POST /invoices/:id/mark-paid) - Step 1: Add the second notification call
server/src/routes/billing.ts, find the mark-paid handler at line ~1457. After the existing createNotificationForClinicUsers(...) call (around line 1575) that creates the “Billing Receipt Ready” notification, add a second call for the celebratory toast. Place it inside the if (wasNotPaid && planId) activation branch so it only fires on the actual activation transition (not on idempotent re-invocations).
The cleanest location is right after the existing Plan modules sync block ends and the existing receipt notification was created. Look for:
if (wasNotPaid) body, add:
wasNotPaid guard at line 1481 already wraps the activation logic; the inner if (wasNotPaid) here is a safety belt for refactoring. If the outer guard already encloses the notification calls, drop the inner if.)
- Step 2: Typecheck
cd server && bunx tsc --noEmit
Expected: no errors.
- Step 3: Commit
Task 6: Bank QR card UI in Invoice Studio
Files:-
Create:
ui/src/components/superadmin/BankQrCard.tsx -
Modify:
ui/src/components/superadmin/InvoiceStudio.tsx - Step 1: Create the BankQrCard component
ui/src/components/superadmin/BankQrCard.tsx:
- Step 2: Mount BankQrCard in InvoiceStudio
ui/src/components/superadmin/InvoiceStudio.tsx. Add the import (with the other imports near the top):
Mail is needed for Step 3.)
Find the page header block at line 127-179 (the <div className="flex items-center gap-4 flex-wrap">...</div> for the title and clinic selector). Above that block (still inside the outer <div className="space-y-6">), insert:
- Step 3: Add “Send to clinic” button in invoice history row
handleInvoiceAction type signature at the top of the file (line 97) from:
- Step 4: UI build + typecheck
cd ui && bun run build
Expected: build succeeds; no TS errors.
- Step 5: Commit
Task 7: Plan-activation toast on admin dashboard
Files:-
Create:
ui/src/hooks/usePlanActivationToast.ts -
Modify:
ui/src/components/dashboards/AdminDashboard.tsx - Step 1: Create the hook
ui/src/hooks/usePlanActivationToast.ts:
- Step 2: Call the hook in AdminDashboard
ui/src/components/dashboards/AdminDashboard.tsx. Add the import near the other hook imports:
AdminDashboard function body (right after useChatV2Flag() around line 76), add:
- Step 3: Verify
toast.successsupports{ description, action }options
grep -n "toast.success\|description:" ui/src/lib/toast.ts | head -10
If toast.success is a thin re-export of sonner.toast.success, the options object is fine. If it’s a custom wrapper that only accepts (message: string), fall back to:
- Step 4: UI build
cd ui && bun run build
Expected: build succeeds.
- Step 5: Commit
Task 8: API docs update
Files:-
Modify:
docs/api-reference.md - Step 1: Document the new endpoints
- Step 2: Commit
Task 9: Manual end-to-end verification
These checks are run by hand against a local dev session (bun dev in ui/ + server/). The test tenant is ssh & Associates (clinic id b6d3a3f3-..., per memory). Do NOT run against prod.
- Step 1: Local dev up
cd server && bun dev (separate terminal) + cd ui && bun dev
Expected: both start without errors.
- Step 2: Upload a QR
- Log in as superadmin.
- Navigate to Invoice Studio.
- Confirm
BankQrCardis visible at the top. - Click the dropzone, pick a test PNG (~50 KB).
- Expected: success toast, preview appears, “Updated just now” caption.
- Step 3: Approve an upgrade request (silent)
- Have a clinic submit an upgrade request (or seed one).
- Approve it via the Tenants admin UI.
- Expected: invoice appears in Invoice Studio history; clinic admin sees NO email, NO bell-icon notification, NO push.
- Check
serverlogs — nosendEmailViaZeptocalls fired in the approval branch.
- Step 4: Manual send from Invoice Studio
- In the invoice history sidebar, click the Mail icon next to the new invoice.
- Expected: success toast “Invoice email sent to clinic”. Clinic admin receives the existing invoice email template.
- Step 5: Check QR rendering on the invoice PDF
- Click the Download button on the just-created invoice.
- Open the PDF.
- Expected: Payment Instructions section shows 3 columns: HBL, JazzCash/EasyPaisa, Scan to Pay with QR.
- Step 6: Mark-paid + login toast
- Click the checkmark on the invoice to mark it paid.
- Expected: clinic admin receives the receipt email (existing flow); two notifications created in the database (
Billing Receipt Ready+Your plan is now active). - Log in as the clinic admin in a fresh incognito session.
- Expected: on admin dashboard mount, a green success toast fires once: “Your plan is now active — Welcome to Pro!”. Reloading the page does NOT re-fire it.
- Step 7: Test the rejection path still fires email
- Submit another upgrade request, then reject it.
- Expected: clinic admin receives
UpgradeRequestRejectedEmail(unchanged behavior).
- Step 8: Remove QR + verify old invoices still render their snapshot
- In Invoice Studio, click Remove on the Bank QR card. Confirm.
- Re-download the invoice PDF from Step 5 (mark-paid one).
- Expected: the PAID receipt still shows the QR (snapshotted at creation;
forceRegenerate: trueon mark-paid re-fetched from the snapshot key, NOT the current pointer). - Create a new invoice (any path) and download it.
- Expected: the new invoice renders WITHOUT a QR column (other two columns expand).
- Step 9: Final typecheck + build
cd server && bunx tsc --noEmitcd ui && bun run build
- Step 10: Commit + deploy via odontox-commit-deploy skill
odontox-commit-deploy skill for release notes, staging, and Cloudflare deployment with canonical promotion. Invoke it after manual verification passes.
Self-review against spec
- Spec §Goals.1 (silent approval): Tasks 4 + 9 step 3.
- Spec §Goals.2 (bank QR on every issued invoice): Tasks 1, 2, 3 + 9 step 5.
- Spec §Goals.3 (one-time login toast): Tasks 5, 7 + 9 step 6.
- Spec §Goals.4 (rejection auto): No change touched the rejection path; verified Task 9 step 7.
- Spec §Design Part 1 (R2 layout, content-hash key): Task 1
computeQrR2Key. - Spec §Design Part 1 (snapshot at invoice creation): Task 3 steps 2 + 3 (approval path + manual path).
- Spec §Design Part 1 (PDF render): Task 3 step 1.
- Spec §Design Part 1 (endpoints): Task 2.
- Spec §Design Part 1 (UI): Task 6.
- Spec §Design Part 2 (refactor + caller updates): Task 4.
- Spec §Design Part 3 (mark-paid notification): Task 5.
- Spec §Design Part 3 (dashboard toast): Task 7.
- Spec §Risks edge cases 1–5: Risk 4 (
forceRegeneratereuses snapshot) verified by Task 9 step 8. Risk 3 (idempotency) preserved by the existingwasNotPaidguard. Risks 1, 2, 5 don’t require new code. - Design-vs-spec adjustment (no metadata column on notifications): Documented at the top of the plan;
entityType='plan_activated'used as the discriminator.
createOpenInvoiceForApprovedRequest (Task 4 rename), computeQrR2Key / readBankQrPointer / writeBankQrPointer / clearBankQrPointer / fetchBankQrDataUri / isAllowedQrMime (Task 1), usePlanActivationToast (Task 7).
