diff --git a/clients/admin/src/api/wallet.ts b/clients/admin/src/api/wallet.ts new file mode 100644 index 0000000000..c2e46a4d38 --- /dev/null +++ b/clients/admin/src/api/wallet.ts @@ -0,0 +1,70 @@ +import { apiFetch } from "@/lib/api-client"; +import type { PagedResponse } from "@/lib/api-types"; + +// ─── shared enums ──────────────────────────────────────────────────── + +export type TopupRequestStatus = + | "Pending" + | "Approved" + | "Rejected" + | "Completed" + | (string & {}); + +// ─── top-up requests ───────────────────────────────────────────────── + +export type TopupRequestDto = { + id: string; + tenantId: string; + amount: number; + currency: string; + note?: string | null; + status: TopupRequestStatus; + invoiceId?: string | null; + requestedBy?: string | null; + decisionNote?: string | null; + createdAtUtc: string; + decidedAtUtc?: string | null; + completedAtUtc?: string | null; +}; + +export type ListTopupRequestsParams = { + tenantId?: string; + status?: TopupRequestStatus; + pageNumber?: number; + pageSize?: number; +}; + +export function listTopupRequests( + params: ListTopupRequestsParams = {}, +): Promise> { + const query = new URLSearchParams(); + if (params.tenantId) query.set("tenantId", params.tenantId); + if (params.status) query.set("status", params.status); + query.set("pageNumber", String(params.pageNumber ?? 1)); + query.set("pageSize", String(params.pageSize ?? 20)); + return apiFetch>( + `/api/v1/billing/wallet/topup-requests?${query.toString()}`, + ); +} + +/** Approve a pending request — generates an invoice and returns its id. */ +export function approveTopupRequest(id: string, note?: string): Promise { + return apiFetch( + `/api/v1/billing/wallet/topup-requests/${encodeURIComponent(id)}/approve`, + { + method: "POST", + body: JSON.stringify({ note: note?.trim() ? note.trim() : null }), + }, + ); +} + +/** Reject a pending request — returns the request id. */ +export function rejectTopupRequest(id: string, reason?: string): Promise { + return apiFetch( + `/api/v1/billing/wallet/topup-requests/${encodeURIComponent(id)}/reject`, + { + method: "POST", + body: JSON.stringify({ reason: reason?.trim() ? reason.trim() : null }), + }, + ); +} diff --git a/clients/admin/src/pages/billing/layout.tsx b/clients/admin/src/pages/billing/layout.tsx index 08762e5475..45ac563ebc 100644 --- a/clients/admin/src/pages/billing/layout.tsx +++ b/clients/admin/src/pages/billing/layout.tsx @@ -8,6 +8,7 @@ type Tab = { to: string; label: string }; const TABS: Tab[] = [ { to: "/billing/plans", label: "Plans" }, { to: "/billing/invoices", label: "Invoices" }, + { to: "/billing/topups", label: "Top-ups" }, ]; /** diff --git a/clients/admin/src/pages/billing/topups-list.tsx b/clients/admin/src/pages/billing/topups-list.tsx new file mode 100644 index 0000000000..d5cf7149a5 --- /dev/null +++ b/clients/admin/src/pages/billing/topups-list.tsx @@ -0,0 +1,472 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + useMutation, + useQuery, + useQueryClient, + keepPreviousData, +} from "@tanstack/react-query"; +import { + Check, + ChevronLeft, + ChevronRight, + Filter, + Receipt, + Wallet, + X, +} from "lucide-react"; +import { toast } from "sonner"; +import { + approveTopupRequest, + listTopupRequests, + rejectTopupRequest, + type TopupRequestDto, + type TopupRequestStatus, +} from "@/api/wallet"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Select } from "@/components/list"; +import { KpiTile } from "@/components/kpi-tile"; +import { ApiRequestError } from "@/lib/api-client"; +import { useAuth } from "@/auth/use-auth"; +import { BillingPermissions } from "@/lib/permissions"; + +const PAGE_SIZE = 20; + +const STATUSES: TopupRequestStatus[] = ["Pending", "Approved", "Rejected", "Completed"]; + +// ─── helpers ───────────────────────────────────────────────────────── + +function formatMoney(amount: number, currency: string) { + try { + return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount); + } catch { + return `${amount.toFixed(2)} ${currency}`; + } +} + +const dateShort = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "2-digit", + year: "numeric", +}); + +function formatDate(iso?: string | null) { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : dateShort.format(d); +} + +function statusVariant(status: TopupRequestStatus): React.ComponentProps["variant"] { + switch (status) { + case "Completed": + return "success"; + case "Approved": + return "info"; + case "Pending": + return "warning"; + case "Rejected": + return "danger"; + default: + return "default"; + } +} + +function describe(err: unknown, fallback: string): string { + if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message; + if (err instanceof Error) return err.message; + return fallback; +} + +type ActionTarget = { request: TopupRequestDto; mode: "approve" | "reject" }; + +// ─── component ─────────────────────────────────────────────────────── + +export function TopupsListPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { user: currentUser } = useAuth(); + // Approve / reject mutate the request and generate invoices — gated by Billing.Manage. + const canManageBilling = (currentUser?.permissions ?? []).includes(BillingPermissions.Manage); + + const [pageNumber, setPageNumber] = useState(1); + const [tenantFilter, setTenantFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("Pending"); + + const filters = useMemo( + () => ({ + tenantId: tenantFilter.trim() || undefined, + status: statusFilter || undefined, + }), + [tenantFilter, statusFilter], + ); + + const query = useQuery({ + queryKey: ["billing", "topup-requests", { pageNumber, ...filters }], + queryFn: () => listTopupRequests({ pageNumber, pageSize: PAGE_SIZE, ...filters }), + placeholderData: keepPreviousData, + }); + + const data = query.data; + const items = useMemo(() => data?.items ?? [], [data]); + + const totals = useMemo(() => { + let pendingCount = 0; + let requested = 0; + const firstCurrency = items[0]?.currency ?? "USD"; + for (const req of items) { + requested += req.amount; + if (req.status === "Pending") pendingCount += 1; + } + return { pendingCount, requested, currency: firstCurrency }; + }, [items]); + + const filtersDirty = !!tenantFilter || statusFilter !== "Pending"; + + const clearFilters = () => { + setTenantFilter(""); + setStatusFilter("Pending"); + setPageNumber(1); + }; + + // ── approve / reject mutations ───────────────────────────────────── + + const [action, setAction] = useState(null); + const [decisionNote, setDecisionNote] = useState(""); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["billing", "topup-requests"] }); + + const closeAction = () => { + setAction(null); + setDecisionNote(""); + }; + + // Pass id + note/reason through mutate(arg); never close over `action`/`decisionNote` + // state, which could be stale between render and execute-time. + const approveMutation = useMutation({ + mutationFn: (vars: { id: string; note?: string }) => + approveTopupRequest(vars.id, vars.note), + onSuccess: (invoiceId) => { + toast.success("Invoice generated", { + description: "A draft invoice was created for this top-up.", + action: invoiceId + ? { + label: "View invoice", + onClick: () => navigate(`/billing/invoices/${invoiceId}`), + } + : undefined, + }); + invalidate(); + closeAction(); + }, + onError: (err) => + toast.error("Approve failed", { description: describe(err, "Could not approve the request.") }), + }); + + const rejectMutation = useMutation({ + mutationFn: (vars: { id: string; reason?: string }) => + rejectTopupRequest(vars.id, vars.reason), + onSuccess: () => { + toast.success("Request rejected"); + invalidate(); + closeAction(); + }, + onError: (err) => + toast.error("Reject failed", { description: describe(err, "Could not reject the request.") }), + }); + + const actionPending = approveMutation.isPending || rejectMutation.isPending; + + const confirmAction = () => { + if (!action) return; + if (action.mode === "approve") { + approveMutation.mutate({ id: action.request.id, note: decisionNote }); + } else { + rejectMutation.mutate({ id: action.request.id, reason: decisionNote }); + } + }; + + return ( +
+ {/* KPI strip — page-scope (current page, not all-time) */} +
+ : data?.items.length ?? 0} + subtitle={data ? `${data.totalCount.toLocaleString()} total` : "loading…"} + /> + : totals.pendingCount} + subtitle="awaiting decision (this page)" + /> + + ) : ( + formatMoney(totals.requested, totals.currency) + ) + } + subtitle="this page" + /> +
+ + {/* Filter panel */} + + +
+ + + Filters + + + Review wallet top-up requests across every tenant. Approve to generate an invoice. + +
+ {filtersDirty && ( + + )} +
+ +
+ + { + setTenantFilter(e.target.value); + setPageNumber(1); + }} + autoComplete="off" + /> +
+
+ + setDecisionNote(e.target.value)} + disabled={actionPending} + autoComplete="off" + /> +
+
+ ) : null + } + /> + + ); +} diff --git a/clients/admin/src/routes.tsx b/clients/admin/src/routes.tsx index 9e6840464b..164059f5a2 100644 --- a/clients/admin/src/routes.tsx +++ b/clients/admin/src/routes.tsx @@ -37,6 +37,7 @@ const BillingLayout = lazyNamed(() => import("@/pages/billing/layout"), "Billing const PlansListPage = lazyNamed(() => import("@/pages/billing/plans-list"), "PlansListPage"); const InvoicesListPage = lazyNamed(() => import("@/pages/billing/invoices-list"), "InvoicesListPage"); const InvoiceDetailPage = lazyNamed(() => import("@/pages/billing/invoice-detail"), "InvoiceDetailPage"); +const TopupsListPage = lazyNamed(() => import("@/pages/billing/topups-list"), "TopupsListPage"); const AuditsListPage = lazyNamed(() => import("@/pages/audits/list"), "AuditsListPage"); const HealthPage = lazyNamed(() => import("@/pages/health/page"), "HealthPage"); const ImpersonationListPage = lazyNamed(() => import("@/pages/impersonation/list"), "ImpersonationListPage"); @@ -165,6 +166,7 @@ export const router = createBrowserRouter([ { path: "plans", element: }, { path: "invoices", element: }, { path: "invoices/:invoiceId", element: }, + { path: "topups", element: }, ], }, diff --git a/clients/admin/tests/billing/topups.spec.ts b/clients/admin/tests/billing/topups.spec.ts new file mode 100644 index 0000000000..e539dae22a --- /dev/null +++ b/clients/admin/tests/billing/topups.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from "@playwright/test"; +import type { Route } from "@playwright/test"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; +import { installAdminShellMocks, ADMIN_PERMS, paged } from "../helpers/shell-mocks"; + +// A TopupRequestDto matching src/api/wallet.ts, awaiting an operator decision. +const PENDING_REQUEST = { + id: "tr-9", + tenantId: "acme", + amount: 500, + currency: "USD", + note: "campaign budget", + status: "Pending", + invoiceId: null, + requestedBy: "alice@acme.com", + decisionNote: null, + createdAtUtc: "2026-06-20T10:00:00Z", + decidedAtUtc: null, + completedAtUtc: null, +}; + +test.beforeEach(async ({ page }) => { + // ADMIN_PERMS carries Permissions.Billing.View + Permissions.Billing.Manage, + // so the Approve/Reject row actions render. + await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); + await installAdminShellMocks(page); +}); + +test.describe("billing top-ups list", () => { + test("renders a pending request with an Approve action", async ({ page }) => { + await page.route("**/api/v1/billing/wallet/topup-requests?*", async (route: Route) => { + if (route.request().method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(paged([PENDING_REQUEST])), + }); + }); + + await page.goto("/billing/topups"); + + // The list card + the request row (amount + tenant + Approve button). + await expect(page.getByText("Top-up requests", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("$500.00").first()).toBeVisible(); + await expect(page.getByText(/tenant\s+acme/i)).toBeVisible(); + await expect(page.getByRole("button", { name: "Approve", exact: true })).toBeVisible(); + }); + + test("approving a request POSTs to /approve and shows the invoice toast", async ({ page }) => { + await page.route("**/api/v1/billing/wallet/topup-requests?*", async (route: Route) => { + if (route.request().method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(paged([PENDING_REQUEST])), + }); + }); + + // POST /topup-requests/{id}/approve → returns the generated invoice id. + let approvedId: string | null = null; + await page.route( + "**/api/v1/billing/wallet/topup-requests/*/approve", + async (route: Route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + approvedId = route.request().url(); + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify("inv-new-1"), + }); + }, + ); + + await page.goto("/billing/topups"); + await expect(page.getByText("$500.00").first()).toBeVisible({ timeout: 10_000 }); + + // Open the confirm dialog from the row action, then confirm. + await page.getByRole("button", { name: "Approve", exact: true }).click(); + await page + .getByRole("button", { name: /approve & generate invoice/i }) + .click(); + + // The approve endpoint fired for the right request. + await expect.poll(() => approvedId).toContain("/topup-requests/tr-9/approve"); + + // Success toast + the "View invoice" action linking to the new invoice. + await expect(page.getByText(/invoice generated/i)).toBeVisible(); + await expect(page.getByText(/view invoice/i)).toBeVisible(); + }); +}); diff --git a/clients/dashboard/src/api/wallet.ts b/clients/dashboard/src/api/wallet.ts new file mode 100644 index 0000000000..e03e929f7a --- /dev/null +++ b/clients/dashboard/src/api/wallet.ts @@ -0,0 +1,91 @@ +import { apiFetch } from "@/lib/api-client"; +import type { PagedResult } from "@/api/billing"; + +// Wallet transaction kinds. The backend serializes enums as STRINGS +// (see project_api_string_enums); mirror them as a string union with an +// open `(string & {})` fallback so unknown future members don't break the type. +export type WalletTransactionKind = + | "Credit" + | "Debit" + | "Adjustment" + | (string & {}); + +export type WalletStatus = "Active" | "Suspended" | "Closed" | (string & {}); + +export type WalletTransactionDto = { + id: string; + amount: number; + kind: WalletTransactionKind; + description: string; + referenceId?: string | null; + createdAtUtc: string; +}; + +export type WalletDto = { + id: string; + tenantId: string; + currency: string; + balance: number; + status: WalletStatus; + createdAtUtc: string; + recentTransactions: WalletTransactionDto[]; +}; + +export type TopupRequestStatus = + | "Pending" + | "Invoiced" + | "Completed" + | "Rejected" + | "Cancelled" + | (string & {}); + +export type TopupRequestDto = { + id: string; + tenantId: string; + amount: number; + currency: string; + note?: string | null; + status: TopupRequestStatus; + invoiceId?: string | null; + requestedBy?: string | null; + decisionNote?: string | null; + createdAtUtc: string; + decidedAtUtc?: string | null; + completedAtUtc?: string | null; +}; + +export type CreateTopupRequestInput = { + amount: number; + note?: string; +}; + +export type TopupRequestSearchParams = { + status?: TopupRequestStatus; + pageNumber?: number; + pageSize?: number; +}; + +/** The current tenant's prepaid WhatsApp wallet (balance + recent ledger). */ +export function getMyWallet() { + return apiFetch("/api/v1/billing/wallet/me"); +} + +/** Submit a top-up request for the current tenant. Returns the new request id. */ +export function createTopupRequest(input: CreateTopupRequestInput) { + return apiFetch("/api/v1/billing/wallet/topup-requests", { + method: "POST", + body: JSON.stringify(input), + }); +} + +/** Paged list of the current tenant's own top-up requests, newest first. */ +export function getMyTopupRequests(params: TopupRequestSearchParams = {}) { + const query = new URLSearchParams(); + if (params.status) query.set("status", params.status); + if (params.pageNumber) query.set("pageNumber", String(params.pageNumber)); + if (params.pageSize) query.set("pageSize", String(params.pageSize)); + const suffix = query.toString() ? `?${query.toString()}` : ""; + return apiFetch>( + `/api/v1/billing/wallet/topup-requests/me${suffix}`, + ); +} diff --git a/clients/dashboard/src/components/layout/nav-data.ts b/clients/dashboard/src/components/layout/nav-data.ts index 5eda502fa3..ba8883aea0 100644 --- a/clients/dashboard/src/components/layout/nav-data.ts +++ b/clients/dashboard/src/components/layout/nav-data.ts @@ -16,6 +16,7 @@ import { Trash2, Users, UsersRound, + Wallet, Wifi, } from "lucide-react"; import { ALL_TRASH_PERMISSIONS } from "@/lib/trash-permissions"; @@ -74,6 +75,7 @@ export const sections: NavSection[] = [ // Live activity is SSE-backed; the stream is auth-only (no permission), so no gate. { to: "/activity", label: "Live activity", icon: Activity }, { to: "/subscription", label: "Subscription", icon: CreditCard, perm: "Permissions.Billing.View" }, + { to: "/wallet", label: "WhatsApp wallet", icon: Wallet, perm: "Permissions.Billing.View" }, { to: "/invoices", label: "Invoices", icon: Receipt, perm: "Permissions.Billing.View" }, ], }, diff --git a/clients/dashboard/src/pages/wallet.tsx b/clients/dashboard/src/pages/wallet.tsx new file mode 100644 index 0000000000..a62b71d3f0 --- /dev/null +++ b/clients/dashboard/src/pages/wallet.tsx @@ -0,0 +1,410 @@ +import { useState, type FormEvent } from "react"; +import { Link } from "react-router-dom"; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { AlertTriangle, Receipt, Send, Wallet } from "lucide-react"; +import { toast } from "sonner"; +import { + createTopupRequest, + getMyTopupRequests, + getMyWallet, + type CreateTopupRequestInput, + type TopupRequestDto, + type TopupRequestStatus, +} from "@/api/wallet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + EntityEmpty, + EntityListCard, + EntityListHeader, + EntityListLoading, + EntityListRow, + EntityPageHeader, + EntityPager, + EntityStatusBadge, + ErrorBand, + Field, + ToneIconTile, + type EntityStatusTone, +} from "@/components/list"; +import { cn } from "@/lib/cn"; +import { describe, formatDate, formatMoney } from "@/lib/list-helpers"; + +const PAGE_SIZE = 20; + +// Below this balance the wallet card surfaces a low-balance hint nudging the +// tenant to top up. A balance of 0 (or negative, defensively) always warns. +const LOW_BALANCE_THRESHOLD = 10; + +const WALLET_QUERY_KEY = ["billing", "wallet", "me"] as const; +const TOPUP_REQUESTS_QUERY_KEY = ["billing", "topup-requests", "me"] as const; + +const DESKTOP_GRID = "grid-cols-[1fr_140px_130px_150px]"; + +// ──────────────────────────────────────────────────────────────────── +// Pure helpers — module scope so they're not re-allocated each render. +// ──────────────────────────────────────────────────────────────────── + +function statusTone(status: TopupRequestStatus): EntityStatusTone { + switch (status) { + case "Pending": + return "warning"; + case "Invoiced": + return "info"; + case "Completed": + return "success"; + case "Rejected": + return "danger"; + case "Cancelled": + default: + return "default"; + } +} + +// ──────────────────────────────────────────────────────────────────── +// Page +// ──────────────────────────────────────────────────────────────────── + +export function WalletPage() { + const [pageNumber, setPageNumber] = useState(1); + + const walletQuery = useQuery({ + queryKey: WALLET_QUERY_KEY, + queryFn: getMyWallet, + staleTime: 30_000, + }); + + const requestsQuery = useQuery({ + queryKey: [...TOPUP_REQUESTS_QUERY_KEY, { pageNumber, pageSize: PAGE_SIZE }], + queryFn: () => getMyTopupRequests({ pageNumber, pageSize: PAGE_SIZE }), + staleTime: 30_000, + placeholderData: keepPreviousData, + }); + + const wallet = walletQuery.data; + const requests = requestsQuery.data?.items ?? []; + const totalPages = requestsQuery.data?.totalPages ?? 1; + + const walletError = + walletQuery.error != null ? describe(walletQuery.error) : null; + const requestsError = + requestsQuery.error != null ? describe(requestsQuery.error) : null; + + return ( +
+ + + {walletError && } + +
+ + +
+ +
+

+ My top-up requests +

+ + {requestsError && } + + {requestsQuery.isLoading ? ( + + ) : requests.length === 0 ? ( + + ) : ( +
+ {/* Mobile: card list */} +
+ {requests.map((request) => ( + + ))} +
+ + {/* Desktop: table */} + + + Requested + Amount + Status + Invoice + + {requests.map((request, i) => ( + + ))} + + + 1} + hasNext={pageNumber < totalPages} + onPrev={() => setPageNumber((p) => Math.max(1, p - 1))} + onNext={() => setPageNumber((p) => p + 1)} + /> +
+ )} +
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Balance card +// ──────────────────────────────────────────────────────────────────── + +function BalanceCard({ + loading, + balance, + currency, +}: { + loading: boolean; + balance: number | null; + currency: string; +}) { + const isLow = balance !== null && balance <= LOW_BALANCE_THRESHOLD; + + return ( +
+
+ + Current balance +
+ + {loading ? ( +
+ ) : ( +
+ + {formatMoney(balance ?? 0, currency)} + +
+ )} + + {!loading && isLow && ( +
+ + + {balance !== null && balance <= 0 + ? "Your wallet is empty. Request a top-up to keep sending WhatsApp messages." + : "Your balance is running low. Consider requesting a top-up soon."} + +
+ )} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Request top-up form — plain controlled inputs (dashboard convention). +// ──────────────────────────────────────────────────────────────────── + +function TopupRequestForm({ currency }: { currency: string }) { + const queryClient = useQueryClient(); + const [amount, setAmount] = useState(""); + const [note, setNote] = useState(""); + + const mutation = useMutation({ + mutationFn: (input: CreateTopupRequestInput) => createTopupRequest(input), + onSuccess: () => { + toast.success("Top-up requested", { + description: "We'll raise an invoice to credit your wallet shortly.", + }); + queryClient.invalidateQueries({ queryKey: WALLET_QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: TOPUP_REQUESTS_QUERY_KEY }); + setAmount(""); + setNote(""); + }, + onError: (err) => + toast.error("Top-up request failed", { description: describe(err) }), + }); + + const amountNum = Number(amount); + const valid = amount.trim().length > 0 && !Number.isNaN(amountNum) && amountNum > 0; + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!valid) return; + // Pass per-call data through mutate(arg) — never via state the mutationFn + // closes over (execute-time race; see project_react_mutation_closure_race). + mutation.mutate({ + amount: amountNum, + note: note.trim() ? note.trim() : undefined, + }); + }; + + return ( +
+
+ + Request a top-up +
+ +
+ + setAmount(e.target.value)} + placeholder="100.00" + className="tabular-nums" + required + /> + + + +