Skip to main content

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 via metadata.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 files
  • server/src/lib/bank-qr.ts — Read/write/clear the QR pointer in platform_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 — modified files
  • server/src/routes/billing.ts — Add GET/POST/DELETE /bank-qr + GET /bank-qr/image endpoints. Add “Send to clinic” already exists at /invoices/:id/send, no change there. In POST /invoices (manual creation, line 1271), snapshot QR pointer into metadata.bankQrR2Key.
  • server/src/lib/plan-activation.ts — Split issueInvoiceForApprovedRequest into createOpenInvoiceForApprovedRequest (silent — invoice only, with QR snapshot) + drop the email/notification side effects. Remove the legacy issuePlanActivationReceiptAndNotify alias 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 second plan_activated notification call after the existing “Billing Receipt Ready” notification.
  • server/src/pdf/SubscriptionInvoicePdf.tsx — Accept new optional bankQrDataUri?: string prop and render it as a 3rd payment column.
  • server/src/routes/billing.tsensureSubscriptionInvoicePdf (line 498) fetches the snapshotted QR from R2 and passes the data URI when calling the PDF renderer.
UI — new files
  • ui/src/components/superadmin/BankQrCard.tsx — Upload/preview/replace/remove component.
UI — modified files
  • ui/src/components/superadmin/InvoiceStudio.tsx — Mount BankQrCard above the page header; add Mail-icon “Send to clinic” button in the history row (calls existing POST /invoices/:id/send).
  • ui/src/components/dashboards/AdminDashboard.tsx — Add usePlanActivationToast() hook call on mount that queries unread notifications, detects entityType='plan_activated', fires toast.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
// server/src/lib/bank-qr.test.ts
import { describe, it, expect } from 'vitest';
import { computeQrR2Key, parseQrPointer } from './bank-qr';

describe('bank-qr helpers', () => {
  it('computeQrR2Key derives a stable content-hash key from bytes + mime', async () => {
    const bytes = new Uint8Array([1, 2, 3, 4]);
    const k1 = await computeQrR2Key(bytes, 'image/png');
    const k2 = await computeQrR2Key(bytes, 'image/png');
    expect(k1).toBe(k2);
    expect(k1).toMatch(/^platform\/billing\/bank-qr\/[a-f0-9]{64}\.png$/);
  });

  it('computeQrR2Key gives different keys for different bytes', async () => {
    const a = await computeQrR2Key(new Uint8Array([1]), 'image/png');
    const b = await computeQrR2Key(new Uint8Array([2]), 'image/png');
    expect(a).not.toBe(b);
  });

  it('computeQrR2Key uses .jpg extension for image/jpeg', async () => {
    const k = await computeQrR2Key(new Uint8Array([1]), 'image/jpeg');
    expect(k).toMatch(/\.jpg$/);
  });

  it('parseQrPointer round-trips a stored JSON value', () => {
    const stored = JSON.stringify({
      r2Key: 'platform/billing/bank-qr/abc.png',
      mimeType: 'image/png',
      uploadedAt: '2026-05-18T10:00:00.000Z',
      uploadedByUserId: 'u_1',
    });
    const parsed = parseQrPointer(stored);
    expect(parsed?.r2Key).toBe('platform/billing/bank-qr/abc.png');
    expect(parsed?.mimeType).toBe('image/png');
    expect(parsed?.uploadedByUserId).toBe('u_1');
  });

  it('parseQrPointer returns null for missing or invalid input', () => {
    expect(parseQrPointer(null)).toBeNull();
    expect(parseQrPointer('')).toBeNull();
    expect(parseQrPointer('not json')).toBeNull();
    expect(parseQrPointer('{}')).toBeNull();
  });
});
  • Step 2: Run the test to confirm it fails
Run: cd server && bunx vitest run src/lib/bank-qr.test.ts Expected: FAIL — Cannot find module './bank-qr'
  • Step 3: Implement the helper
// server/src/lib/bank-qr.ts
import { eq } from 'drizzle-orm';
import { platformSettings } from '../schema/platform_settings';
import { getR2Service, type R2Bucket } from './r2';

const SETTING_KEY = 'billing_bank_qr';

export interface BankQrPointer {
  r2Key: string;
  mimeType: string;
  uploadedAt: string;
  uploadedByUserId: string | null;
}

const ALLOWED_MIME: Record<string, string> = {
  'image/png': 'png',
  'image/jpeg': 'jpg',
  'image/jpg': 'jpg',
};

export function isAllowedQrMime(mime: string | null | undefined): boolean {
  return !!mime && mime.toLowerCase() in ALLOWED_MIME;
}

export async function computeQrR2Key(bytes: Uint8Array, mimeType: string): Promise<string> {
  const ext = ALLOWED_MIME[mimeType.toLowerCase()] || 'png';
  const digestBuf = await crypto.subtle.digest('SHA-256', bytes);
  const hex = Array.from(new Uint8Array(digestBuf))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
  return `platform/billing/bank-qr/${hex}.${ext}`;
}

export function parseQrPointer(value: string | null | undefined): BankQrPointer | null {
  if (!value || typeof value !== 'string') return null;
  try {
    const parsed = JSON.parse(value);
    if (!parsed || typeof parsed !== 'object') return null;
    if (typeof parsed.r2Key !== 'string' || !parsed.r2Key) return null;
    if (typeof parsed.mimeType !== 'string' || !parsed.mimeType) return null;
    return {
      r2Key: parsed.r2Key,
      mimeType: parsed.mimeType,
      uploadedAt: parsed.uploadedAt || new Date(0).toISOString(),
      uploadedByUserId: parsed.uploadedByUserId ?? null,
    };
  } catch {
    return null;
  }
}

export async function readBankQrPointer(db: any): Promise<BankQrPointer | null> {
  const [row] = await db.select({ value: platformSettings.settingValue })
    .from(platformSettings)
    .where(eq(platformSettings.settingKey, SETTING_KEY))
    .limit(1);
  return parseQrPointer(row?.value);
}

export async function writeBankQrPointer(db: any, pointer: BankQrPointer): Promise<void> {
  const serialized = JSON.stringify(pointer);
  const [existing] = await db.select({ id: platformSettings.id })
    .from(platformSettings)
    .where(eq(platformSettings.settingKey, SETTING_KEY))
    .limit(1);
  if (existing) {
    await db.update(platformSettings)
      .set({ settingValue: serialized, updatedAt: new Date() })
      .where(eq(platformSettings.id, existing.id));
  } else {
    await db.insert(platformSettings).values({
      id: crypto.randomUUID(),
      settingKey: SETTING_KEY,
      settingValue: serialized,
      description: 'OdontoX bank QR shown on subscription invoice PDFs (Approach A — global, snapshotted).',
    });
  }
}

export async function clearBankQrPointer(db: any): Promise<void> {
  const [existing] = await db.select({ id: platformSettings.id })
    .from(platformSettings)
    .where(eq(platformSettings.settingKey, SETTING_KEY))
    .limit(1);
  if (existing) {
    await db.update(platformSettings)
      .set({ settingValue: null, updatedAt: new Date() })
      .where(eq(platformSettings.id, existing.id));
  }
}

export async function fetchBankQrDataUri(
  env: { R2_STORAGE?: R2Bucket },
  r2Key: string,
  mimeType: string,
): Promise<string | null> {
  const r2 = getR2Service(env);
  if (!r2) return null;
  const file = await r2.getFile(r2Key);
  if (!file) return null;
  const buf = await file.arrayBuffer();
  const b64 = Buffer.from(buf).toString('base64');
  return `data:${mimeType};base64,${b64}`;
}
  • Step 4: Run the test to confirm it passes
Run: cd server && bunx vitest run src/lib/bank-qr.test.ts Expected: PASS — 5 tests pass.
  • Step 5: Typecheck
Run: cd server && bunx tsc --noEmit Expected: no errors.
  • Step 6: Commit
git add server/src/lib/bank-qr.ts server/src/lib/bank-qr.test.ts
git commit -m "feat(billing): bank-qr helper for invoice studio QR storage"

Task 2: Bank QR endpoints in billing route

Files:
  • Modify: server/src/routes/billing.ts (add new endpoints after existing /invoices block, near line 1660)
  • Step 1: Add imports for the helpers
At the top of server/src/routes/billing.ts, find the existing imports block. Add:
import {
  readBankQrPointer,
  writeBankQrPointer,
  clearBankQrPointer,
  computeQrR2Key,
  fetchBankQrDataUri,
  isAllowedQrMime,
} from '../lib/bank-qr';
  • Step 2: Add GET /bank-qr and GET /bank-qr/image endpoints
Append to server/src/routes/billing.ts (after the existing invoices routes, before the closing export default):
// ============ BANK QR (superadmin only) ============

billingRoute.get('/bank-qr', async (c) => {
  try {
    const user = c.get('user') as any;
    if (user.role !== 'superadmin') throw new AppError('Only superadmins can view the bank QR', 403);
    const db = getReadDb();
    const pointer = await readBankQrPointer(db);
    if (!pointer) {
      return c.json({ url: null, uploadedAt: null, uploadedByUserId: null });
    }
    return c.json({
      url: `/api/v1/protected/billing/bank-qr/image?v=${encodeURIComponent(pointer.r2Key)}`,
      uploadedAt: pointer.uploadedAt,
      uploadedByUserId: pointer.uploadedByUserId,
      mimeType: pointer.mimeType,
    });
  } catch (error) {
    return handleError(error, c);
  }
});

billingRoute.get('/bank-qr/image', async (c) => {
  try {
    const user = c.get('user') as any;
    if (user.role !== 'superadmin' && user.role !== 'admin') {
      throw new AppError('Not authorized', 403);
    }
    const db = getReadDb();
    const pointer = await readBankQrPointer(db);
    if (!pointer) throw new AppError('No QR set', 404);
    const dataUri = await fetchBankQrDataUri(c.env as any, pointer.r2Key, pointer.mimeType);
    if (!dataUri) throw new AppError('QR object missing in storage', 404);
    const base64 = dataUri.split(',')[1] || '';
    const bytes = Buffer.from(base64, 'base64');
    return new Response(bytes, {
      headers: {
        'Content-Type': pointer.mimeType,
        'Cache-Control': 'private, max-age=300',
      },
    });
  } catch (error) {
    return handleError(error, c);
  }
});

billingRoute.post('/bank-qr', async (c) => {
  try {
    const user = c.get('user') as any;
    if (user.role !== 'superadmin') throw new AppError('Only superadmins can upload the bank QR', 403);

    const form = await c.req.formData();
    const file = form.get('file');
    if (!(file instanceof File)) throw new AppError('Missing file', 400);
    if (file.size > 1024 * 1024) throw new AppError('QR must be ≤ 1 MB', 400);
    if (!isAllowedQrMime(file.type)) throw new AppError('QR must be PNG or JPEG', 400);

    const bytes = new Uint8Array(await file.arrayBuffer());
    const r2Key = await computeQrR2Key(bytes, file.type);

    const r2 = getR2Service(c.env as any);
    if (!r2) throw new AppError('R2 storage not configured', 500);
    await r2.uploadFile(r2Key, bytes, file.type, {
      uploadedByUserId: user.id || 'unknown',
    });

    const db = getReadDb();
    const pointer = {
      r2Key,
      mimeType: file.type,
      uploadedAt: new Date().toISOString(),
      uploadedByUserId: user.id || null,
    };
    await writeBankQrPointer(db, pointer);

    return c.json({
      success: true,
      url: `/api/v1/protected/billing/bank-qr/image?v=${encodeURIComponent(r2Key)}`,
      uploadedAt: pointer.uploadedAt,
      uploadedByUserId: pointer.uploadedByUserId,
      mimeType: pointer.mimeType,
    });
  } catch (error) {
    return handleError(error, c);
  }
});

billingRoute.delete('/bank-qr', async (c) => {
  try {
    const user = c.get('user') as any;
    if (user.role !== 'superadmin') throw new AppError('Only superadmins can clear the bank QR', 403);
    const db = getReadDb();
    await clearBankQrPointer(db);
    return c.json({ success: true });
  } catch (error) {
    return handleError(error, c);
  }
});
  • Step 3: Typecheck
Run: cd server && bunx tsc --noEmit Expected: no errors.
  • Step 4: Commit
git add server/src/routes/billing.ts
git commit -m "feat(billing): GET/POST/DELETE /bank-qr endpoints"

Task 3: Snapshot QR onto new invoices + render on PDF

Files:
  • Modify: server/src/lib/plan-activation.ts
  • Modify: server/src/routes/billing.ts (manual POST /invoices block at line 1271, ensureSubscriptionInvoicePdf at line 498)
  • Modify: server/src/pdf/SubscriptionInvoicePdf.tsx
  • Step 1: Extend the PDF renderer with optional QR
Edit server/src/pdf/SubscriptionInvoicePdf.tsx. Update the props interface and the payment section. Replace the SubscriptionInvoicePdfProps interface (around line 4-28) to add:
interface SubscriptionInvoicePdfProps {
  invoiceNumber: string;
  status: 'paid' | 'open' | 'draft' | 'void' | 'uncollectible';
  clinicName: string;
  clinicEmail?: string;
  clinicPhone?: string;
  clinicAddress?: string;
  taxId?: string;
  date: string;
  dueDate?: string;
  lineItems: Array<{
    description: string;
    period?: string;
    quantity: number;
    unitAmount: number;
    totalAmount: number;
  }>;
  subtotal: number;
  discountAmount: number;
  taxRate: number;
  taxAmount: number;
  totalAmount: number;
  notes?: string;
  isReceipt?: boolean;
  bankQrDataUri?: string; // NEW — data URI of the bank QR PNG/JPG
}
Update the function signature destructure (around line 40-45) to pull bankQrDataUri:
  const {
    invoiceNumber, status, clinicName, clinicEmail, clinicPhone,
    clinicAddress, taxId, date, dueDate, lineItems,
    subtotal, discountAmount, taxRate, taxAmount, totalAmount,
    notes, isReceipt = false, bankQrDataUri,
  } = props;
Replace the paymentRow block (lines ~171-184). Currently:
            <View style={s.paymentRow}>
              <View style={s.paymentBox}>
                <Text style={s.paymentTitle}>Bank Transfer (HBL)</Text>
                <Text style={s.paymentDetail}>Account Title: OdontoX</Text>
                <Text style={s.paymentDetail}>Account #: 1234-5678-9012-34</Text>
                <Text style={s.paymentDetail}>IBAN: PK21HABB0012345678901234</Text>
              </View>
              <View style={s.paymentBox}>
                <Text style={s.paymentTitle}>JazzCash / EasyPaisa</Text>
                <Text style={s.paymentDetail}>Title: OdontoX</Text>
                <Text style={s.paymentDetail}>Number: 0300-1234567</Text>
              </View>
            </View>
Becomes:
            <View style={s.paymentRow}>
              <View style={s.paymentBox}>
                <Text style={s.paymentTitle}>Bank Transfer (HBL)</Text>
                <Text style={s.paymentDetail}>Account Title: OdontoX</Text>
                <Text style={s.paymentDetail}>Account #: 1234-5678-9012-34</Text>
                <Text style={s.paymentDetail}>IBAN: PK21HABB0012345678901234</Text>
              </View>
              <View style={s.paymentBox}>
                <Text style={s.paymentTitle}>JazzCash / EasyPaisa</Text>
                <Text style={s.paymentDetail}>Title: OdontoX</Text>
                <Text style={s.paymentDetail}>Number: 0300-1234567</Text>
              </View>
              {bankQrDataUri ? (
                <View style={s.paymentBox}>
                  <Text style={s.paymentTitle}>Scan to Pay</Text>
                  <Image src={bankQrDataUri} style={s.qrImage} />
                </View>
              ) : null}
            </View>
Add a new style at the bottom of the s = StyleSheet.create({ ... }) block (just before the closing });):
  qrImage: { width: 96, height: 96, alignSelf: 'center', marginTop: 4 },
  • Step 2: Snapshot QR on approval-time invoice creation
Edit server/src/lib/plan-activation.ts. Add an import at the top:
import { readBankQrPointer } from './bank-qr';
Inside issueInvoiceForApprovedRequest, find the db.insert(subscriptionInvoices).values({ ... }) block (around line 82-109). Replace the metadata: { ... } value with:
    metadata: {
      source,
      planId: plan.id,
      planName: plan.planName,
      approvedByUserId: activatedByUserId || null,
      approvedAt: now.toISOString(),
      bankQrR2Key: (await readBankQrPointer(db).catch(() => null))?.r2Key ?? null,
      bankQrMime: (await readBankQrPointer(db).catch(() => null))?.mimeType ?? null,
    },
(Note: two reads is fine here — Drizzle/Neon caches the query within the same request; but if you prefer one read, hoist into a 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:
  const bankQr = await readBankQrPointer(db).catch(() => null);
  const [invoice] = await db.insert(subscriptionInvoices).values({
    id: crypto.randomUUID(),
    clinicId: clinic.id,
    invoiceNumber,
    amount: amount.toFixed(2),
    currency: 'PKR',
    status: 'open',
    periodStart: now,
    periodEnd,
    dueDate,
    paidAt: null,
    lineItems: [
      {
        description: `${plan.planName} subscription activation`,
        quantity: 1,
        unitAmount: amount.toFixed(2),
        totalAmount: amount.toFixed(2),
      },
    ],
    metadata: {
      source,
      planId: plan.id,
      planName: plan.planName,
      approvedByUserId: activatedByUserId || null,
      approvedAt: now.toISOString(),
      bankQrR2Key: bankQr?.r2Key ?? null,
      bankQrMime: bankQr?.mimeType ?? null,
    },
    updatedAt: now,
  }).returning();
  • Step 3: Snapshot QR on manual invoice creation
Edit server/src/routes/billing.ts. Add the same import:
import { readBankQrPointer, fetchBankQrDataUri } from '../lib/bank-qr';
(Note: re-use the import line you added in Task 2 — just append 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:
    const bankQr = await readBankQrPointer(db).catch(() => null);
Then update the metadata: { ... } field in the insert (currently lines 1325-1333) to include the snapshot:
        metadata: {
          subtotal: Number.isFinite(subtotal) ? subtotal : computedSubtotal,
          discountType: discountType || null,
          discountValue: Number(discountValue || 0),
          discountAmount: computedDiscountAmount,
          taxRate: computedTaxRate,
          taxAmount: computedTaxAmount,
          notes: notes || null,
          bankQrR2Key: bankQr?.r2Key ?? null,
          bankQrMime: bankQr?.mimeType ?? null,
        },
  • Step 4: Fetch + pass QR to renderer in ensureSubscriptionInvoicePdf
In 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:
  const qrR2Key = metadata.bankQrR2Key as string | undefined;
  const qrMime = (metadata.bankQrMime as string | undefined) || 'image/png';
  const bankQrDataUri = qrR2Key
    ? await fetchBankQrDataUri(env as any, qrR2Key, qrMime).catch(() => null)
    : null;
Then pass 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
Run: cd server && bunx tsc --noEmit Expected: no errors.
  • Step 6: Commit
git add server/src/lib/plan-activation.ts server/src/routes/billing.ts server/src/pdf/SubscriptionInvoicePdf.tsx
git commit -m "feat(billing): snapshot bank QR onto invoices, render on PDF"

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
In 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:
  const admins = await db.select({ ... })
    .from(users).where(and(eq(users.clinicId, clinic.id), eq(users.role, 'admin'), eq(users.isActive, true)));

  const emailSubject = `Invoice ${invoiceNumber}${plan.planName} for ${clinic.name}`;
  // ...all the email sending...

  await Promise.all(uniqueAdmins.map(async (admin: any) => {
    // ...SubscriptionInvoiceEmail render + sendEmailViaZepto...
  }));

  await createNotificationForClinicUsers(clinic.id, 'admin', {
    type: 'payment',
    title: 'Invoice Ready for Payment',
    // ...
  });
The function (already named issueInvoiceForApprovedRequest) should now end right after the db.insert(subscriptionInvoices).values({...}).returning() block.
  • Step 2: Optionally rename + drop the alias
Rename issueInvoiceForApprovedRequest to createOpenInvoiceForApprovedRequest (per spec). Also delete the deprecated alias at the bottom:
// DELETE these lines:
/**
 * @deprecated Use issueInvoiceForApprovedRequest instead.
 */
export const issuePlanActivationReceiptAndNotify = issueInvoiceForApprovedRequest;
  • Step 3: Update both callers
Find and update both callers to use the new name: In server/src/routes/admin.ts (line ~561), change:
      await issuePlanActivationReceiptAndNotify({
to:
      await createOpenInvoiceForApprovedRequest({
And update the import at the top of that file. Search: 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:
        await issueInvoiceForApprovedRequest({
to:
        await createOpenInvoiceForApprovedRequest({
And update the import in that file too.
  • Step 4: Remove now-unused imports from plan-activation.ts
After stripping the email + notification code, these imports become unused. Remove them:
// DELETE these lines from the top of plan-activation.ts:
import { sendEmailViaZepto } from './email';
import { createNotificationForClinicUsers } from './notifications';
import { render } from '@react-email/render';
// Also delete the `import { users }` if `users` is no longer referenced
Keep imports that are still in use (clinics, subscriptionInvoices, subscriptionPlans).
  • Step 5: Typecheck
Run: 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
git add server/src/lib/plan-activation.ts server/src/routes/admin.ts server/src/routes/billing.ts
git commit -m "feat(billing): silent approval — no auto-email/notification on upgrade approval"

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
In 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:
    await createNotificationForClinicUsers(
      latestInvoice.clinicId,
      'admin',
      {
        type: 'payment',
        title: 'Billing Receipt Ready',
        // ...
      }
    );
Immediately after that block, inside the same if (wasNotPaid) body, add:
    // One-time celebratory toast — surfaced by AdminDashboard via entityType='plan_activated'
    if (wasNotPaid) {
      const meta = (latestInvoice.metadata || {}) as Record<string, any>;
      const activatedPlanName = (meta.planName as string) || 'your new plan';
      await createNotificationForClinicUsers(
        latestInvoice.clinicId,
        'admin',
        {
          type: 'payment',
          title: 'Your plan is now active',
          message: `Welcome to ${activatedPlanName}! All features are unlocked. Enjoy OdontoX.`,
          actionUrl: '/dashboard/settings?tab=billing',
          entityType: 'plan_activated', // discriminator used by the frontend toast
          entityId: latestInvoice.id,
          performedByUserId: user.id,
          clinicId: latestInvoice.clinicId,
          priority: 'high',
        }
      );
    }
(Note: the outer 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
Run: cd server && bunx tsc --noEmit Expected: no errors.
  • Step 3: Commit
git add server/src/routes/billing.ts
git commit -m "feat(billing): plan_activated notification on mark-paid for login toast"

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
Create ui/src/components/superadmin/BankQrCard.tsx:
import { useState, useEffect, useCallback, useRef } from 'react';
import { Upload, Trash2, Loader2, QrCode } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { getAuthHeaders } from '@/lib/serverComm';
import { getApiBaseUrl } from '@/lib/api-url';
import { toast } from '@/lib/toast';

interface BankQrState {
  url: string | null;
  uploadedAt: string | null;
  uploadedByUserId: string | null;
}

const MAX_BYTES = 1024 * 1024;
const ALLOWED = ['image/png', 'image/jpeg', 'image/jpg'];

function formatRelative(iso: string | null): string {
  if (!iso) return '';
  const ms = Date.now() - new Date(iso).getTime();
  const mins = Math.floor(ms / 60000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins}m ago`;
  const hrs = Math.floor(mins / 60);
  if (hrs < 24) return `${hrs}h ago`;
  const days = Math.floor(hrs / 24);
  return `${days}d ago`;
}

export function BankQrCard() {
  const [state, setState] = useState<BankQrState>({ url: null, uploadedAt: null, uploadedByUserId: null });
  const [loading, setLoading] = useState(true);
  const [busy, setBusy] = useState(false);
  const fileRef = useRef<HTMLInputElement | null>(null);

  const load = useCallback(async () => {
    setLoading(true);
    try {
      const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/billing/bank-qr`, {
        headers: getAuthHeaders(),
      });
      if (!res.ok) throw new Error(`Server returned ${res.status}`);
      const data = await res.json();
      setState({
        url: data.url ?? null,
        uploadedAt: data.uploadedAt ?? null,
        uploadedByUserId: data.uploadedByUserId ?? null,
      });
    } catch (e) {
      toast.error(e instanceof Error ? e.message : 'Failed to load bank QR');
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { void load(); }, [load]);

  const handleUpload = async (file: File) => {
    if (file.size > MAX_BYTES) {
      toast.error('QR must be 1 MB or smaller');
      return;
    }
    if (!ALLOWED.includes(file.type)) {
      toast.error('QR must be PNG or JPEG');
      return;
    }
    setBusy(true);
    try {
      const form = new FormData();
      form.append('file', file);
      const headers = { ...getAuthHeaders() } as Record<string, string>;
      // Don't set Content-Type — browser sets multipart boundary automatically
      delete headers['Content-Type'];
      const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/billing/bank-qr`, {
        method: 'POST',
        headers,
        body: form,
      });
      const payload = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(payload?.message || payload?.error || `Server returned ${res.status}`);
      toast.success('Bank QR updated. Future invoices will use this QR.');
      await load();
    } catch (e) {
      toast.error(e instanceof Error ? e.message : 'Upload failed');
    } finally {
      setBusy(false);
      if (fileRef.current) fileRef.current.value = '';
    }
  };

  const handleRemove = async () => {
    if (!confirm('Remove the bank QR? Past invoices keep their snapshot; future invoices will render without a QR until you upload a new one.')) {
      return;
    }
    setBusy(true);
    try {
      const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/billing/bank-qr`, {
        method: 'DELETE',
        headers: getAuthHeaders(),
      });
      if (!res.ok) throw new Error(`Server returned ${res.status}`);
      toast.success('Bank QR removed');
      await load();
    } catch (e) {
      toast.error(e instanceof Error ? e.message : 'Remove failed');
    } finally {
      setBusy(false);
    }
  };

  return (
    <Card>
      <CardHeader className="pb-3">
        <CardTitle className="flex items-center gap-2 text-base">
          <QrCode className="h-4 w-4" /> Bank QR
        </CardTitle>
        <CardDescription>
          One QR is rendered on every invoice PDF you generate from now on. PNG or JPEG, ≤ 1 MB.
        </CardDescription>
      </CardHeader>
      <CardContent>
        {loading ? (
          <div className="flex items-center justify-center py-6">
            <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
          </div>
        ) : state.url ? (
          <div className="flex items-start gap-4">
            <img
              src={`${getApiBaseUrl()}${state.url}`}
              alt="Bank QR preview"
              className="h-32 w-32 rounded border bg-white object-contain"
            />
            <div className="flex-1 space-y-2">
              <div className="text-xs text-muted-foreground">
                Updated {formatRelative(state.uploadedAt)}.
              </div>
              <div className="flex gap-2">
                <Button
                  size="sm"
                  variant="outline"
                  disabled={busy}
                  onClick={() => fileRef.current?.click()}
                >
                  <Upload className="h-3 w-3 mr-1" /> Replace
                </Button>
                <Button
                  size="sm"
                  variant="ghost"
                  className="text-destructive"
                  disabled={busy}
                  onClick={handleRemove}
                >
                  <Trash2 className="h-3 w-3 mr-1" /> Remove
                </Button>
              </div>
            </div>
          </div>
        ) : (
          <button
            type="button"
            disabled={busy}
            onClick={() => fileRef.current?.click()}
            className="w-full border-2 border-dashed rounded-lg py-8 flex flex-col items-center gap-2 hover:bg-muted/30 transition-colors disabled:opacity-50"
          >
            {busy ? (
              <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
            ) : (
              <Upload className="h-6 w-6 text-muted-foreground" />
            )}
            <span className="text-sm font-medium">Upload bank QR</span>
            <span className="text-xs text-muted-foreground">PNG or JPEG · ≤ 1 MB</span>
          </button>
        )}
        <input
          ref={fileRef}
          type="file"
          accept="image/png,image/jpeg"
          className="hidden"
          onChange={(e) => {
            const f = e.target.files?.[0];
            if (f) void handleUpload(f);
          }}
        />
      </CardContent>
    </Card>
  );
}

export default BankQrCard;
  • Step 2: Mount BankQrCard in InvoiceStudio
Edit ui/src/components/superadmin/InvoiceStudio.tsx. Add the import (with the other imports near the top):
import BankQrCard from './BankQrCard';
import { Mail } from 'lucide-react';
(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:
      {/* Bank QR (global) */}
      <BankQrCard />
  • Step 3: Add “Send to clinic” button in invoice history row
In the same file, find the per-row action buttons (around line 265-303). The current buttons are: Download, Mark-as-paid, Publish-to-portal. Between Mark-as-paid and Publish-to-portal, insert:
                          {item.invoice.status !== 'paid' && (
                            <Button
                              variant="ghost"
                              size="icon"
                              className="h-5 w-5"
                              title="Send invoice email to clinic"
                              onClick={() => handleInvoiceAction(item.invoice, 'send' as any)}
                              disabled={actionLoading[`${item.invoice.id}:send`]}
                            >
                              {actionLoading[`${item.invoice.id}:send`]
                                ? <Loader2 className="h-3 w-3 animate-spin" />
                                : <Mail className="h-3 w-3" />}
                            </Button>
                          )}
And extend the handleInvoiceAction type signature at the top of the file (line 97) from:
  const handleInvoiceAction = async (invoice: any, action: 'mark-paid' | 'portal') => {
to:
  const handleInvoiceAction = async (invoice: any, action: 'mark-paid' | 'portal' | 'send') => {
Then update the toast on success (line 111) to handle the new action:
      toast.success(
        action === 'mark-paid' ? 'Invoice marked as paid'
        : action === 'send' ? 'Invoice email sent to clinic'
        : 'Published to clinic portal'
      );
  • Step 4: UI build + typecheck
Run: cd ui && bun run build Expected: build succeeds; no TS errors.
  • Step 5: Commit
git add ui/src/components/superadmin/BankQrCard.tsx ui/src/components/superadmin/InvoiceStudio.tsx
git commit -m "feat(invoice-studio): bank QR upload card + send-to-clinic button"

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
Create ui/src/hooks/usePlanActivationToast.ts:
import { useEffect, useRef } from 'react';
import { toast } from '@/lib/toast';
import { getAuthHeaders } from '@/lib/serverComm';
import { getApiBaseUrl } from '@/lib/api-url';

interface NotificationRow {
  id: string;
  type: string;
  title: string;
  message: string;
  entityType?: string | null;
  entityId?: string | null;
  actionUrl?: string | null;
  isRead?: boolean;
}

/**
 * Detects unread `entityType='plan_activated'` notifications and shows a
 * celebratory toast once. Marks the notification read so it never fires again.
 */
export function usePlanActivationToast(userRole: string) {
  const firedRef = useRef(false);

  useEffect(() => {
    if (userRole !== 'admin') return;
    if (firedRef.current) return;

    let cancelled = false;

    (async () => {
      try {
        const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/notifications`, {
          headers: getAuthHeaders(),
        });
        if (!res.ok) return;
        const payload = await res.json();
        const list: NotificationRow[] = Array.isArray(payload)
          ? payload
          : Array.isArray(payload?.notifications) ? payload.notifications : [];

        const matches = list.filter(
          (n) => !n.isRead && n.entityType === 'plan_activated'
        );
        if (cancelled || matches.length === 0) return;

        firedRef.current = true;

        for (const n of matches) {
          toast.success(n.title || 'Your plan is now active', {
            description: n.message,
            duration: 12_000,
            ...(n.actionUrl
              ? { action: { label: 'View receipt', onClick: () => { window.location.href = n.actionUrl!; } } }
              : {}),
          });

          // Mark read so it doesn't fire again
          fetch(`${getApiBaseUrl()}/api/v1/protected/notifications/${n.id}/read`, {
            method: 'PUT',
            headers: getAuthHeaders(),
          }).catch(() => { /* non-fatal */ });
        }
      } catch {
        /* non-fatal */
      }
    })();

    return () => { cancelled = true; };
  }, [userRole]);
}
  • Step 2: Call the hook in AdminDashboard
Edit ui/src/components/dashboards/AdminDashboard.tsx. Add the import near the other hook imports:
import { usePlanActivationToast } from '@/hooks/usePlanActivationToast';
Inside the AdminDashboard function body (right after useChatV2Flag() around line 76), add:
  usePlanActivationToast(user.role);
  • Step 3: Verify toast.success supports { description, action } options
Run: 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:
toast.success(`${n.title || 'Plan active'}${n.message}`);
and skip the action button. Check the actual signature before shipping.
  • Step 4: UI build
Run: cd ui && bun run build Expected: build succeeds.
  • Step 5: Commit
git add ui/src/hooks/usePlanActivationToast.ts ui/src/components/dashboards/AdminDashboard.tsx
git commit -m "feat(admin): one-time plan-activation toast on dashboard mount"

Task 8: API docs update

Files:
  • Modify: docs/api-reference.md
  • Step 1: Document the new endpoints
Add a section under the Billing / superadmin endpoints area:
### Bank QR (superadmin)

- `GET /api/v1/protected/billing/bank-qr` — Returns current bank QR pointer: `{ url, uploadedAt, uploadedByUserId, mimeType }` or `{ url: null }`.
- `GET /api/v1/protected/billing/bank-qr/image` — Streams the QR image bytes. Cached privately for 5 minutes. Available to superadmin and admin roles.
- `POST /api/v1/protected/billing/bank-qr` (multipart `file`) — Upload a new bank QR. PNG or JPEG, ≤ 1 MB. Replaces the current pointer. Old R2 objects are retained (referenced by historical invoices).
- `DELETE /api/v1/protected/billing/bank-qr` — Clear the current pointer. Past invoices keep their snapshot.

### Notification entity types

- `entityType='plan_activated'` — One-time celebratory toast for clinic admins after their plan is activated by mark-paid. Auto-rendered by `AdminDashboard` via `usePlanActivationToast`.
  • Step 2: Commit
git add docs/api-reference.md
git commit -m "docs(api): document bank-qr endpoints + plan_activated entityType"

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
Run: cd server && bun dev (separate terminal) + cd ui && bun dev Expected: both start without errors.
  • Step 2: Upload a QR
  1. Log in as superadmin.
  2. Navigate to Invoice Studio.
  3. Confirm BankQrCard is visible at the top.
  4. Click the dropzone, pick a test PNG (~50 KB).
  5. Expected: success toast, preview appears, “Updated just now” caption.
  • Step 3: Approve an upgrade request (silent)
  1. Have a clinic submit an upgrade request (or seed one).
  2. Approve it via the Tenants admin UI.
  3. Expected: invoice appears in Invoice Studio history; clinic admin sees NO email, NO bell-icon notification, NO push.
  4. Check server logs — no sendEmailViaZepto calls fired in the approval branch.
  • Step 4: Manual send from Invoice Studio
  1. In the invoice history sidebar, click the Mail icon next to the new invoice.
  2. 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
  1. Click the Download button on the just-created invoice.
  2. Open the PDF.
  3. Expected: Payment Instructions section shows 3 columns: HBL, JazzCash/EasyPaisa, Scan to Pay with QR.
  • Step 6: Mark-paid + login toast
  1. Click the checkmark on the invoice to mark it paid.
  2. Expected: clinic admin receives the receipt email (existing flow); two notifications created in the database (Billing Receipt Ready + Your plan is now active).
  3. Log in as the clinic admin in a fresh incognito session.
  4. 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
  1. Submit another upgrade request, then reject it.
  2. Expected: clinic admin receives UpgradeRequestRejectedEmail (unchanged behavior).
  • Step 8: Remove QR + verify old invoices still render their snapshot
  1. In Invoice Studio, click Remove on the Bank QR card. Confirm.
  2. Re-download the invoice PDF from Step 5 (mark-paid one).
  3. Expected: the PAID receipt still shows the QR (snapshotted at creation; forceRegenerate: true on mark-paid re-fetched from the snapshot key, NOT the current pointer).
  4. Create a new invoice (any path) and download it.
  5. Expected: the new invoice renders WITHOUT a QR column (other two columns expand).
  • Step 9: Final typecheck + build
Run in parallel:
  • cd server && bunx tsc --noEmit
  • cd ui && bun run build
Both should succeed.
  • Step 10: Commit + deploy via odontox-commit-deploy skill
Per CLAUDE memory: every logical unit of work goes through the 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 (forceRegenerate reuses snapshot) verified by Task 9 step 8. Risk 3 (idempotency) preserved by the existing wasNotPaid guard. 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.
No placeholders. No unresolved TODOs. Function names consistent: createOpenInvoiceForApprovedRequest (Task 4 rename), computeQrR2Key / readBankQrPointer / writeBankQrPointer / clearBankQrPointer / fetchBankQrDataUri / isAllowedQrMime (Task 1), usePlanActivationToast (Task 7).