Skip to content

feat(billing): WhatsApp prepaid wallet + top-up requests (Phase 1)#1315

Merged
iammukeshm merged 15 commits into
mainfrom
feat/whatsapp-wallet-topup
Jun 25, 2026
Merged

feat(billing): WhatsApp prepaid wallet + top-up requests (Phase 1)#1315
iammukeshm merged 15 commits into
mainfrom
feat/whatsapp-wallet-topup

Conversation

@iammukeshm

Copy link
Copy Markdown
Member

What

Adds the funding half of a prepaid WhatsApp messaging wallet (Phase 1). We act as the WhatsApp BSP (Meta bills us; clinics top up a prepaid money wallet with us).

Lifecycle: clinic submits a top-up request (dashboard) → operator sees it (admin → Billing → Top-ups) → Approve generates + issues an invoice (clinic emailed) → clinic pays offline → operator marks the invoice Paid → wallet is auto-credited via an append-only ledger and the request is Completed. Operators can also Reject a pending request.

Backend (Billing module)

  • New entities: Wallet (1/tenant), WalletTransaction (append-only signed ledger; balance = Σ ledger), TopupRequest (state machine Pending→Invoiced→Completed / Pending→Rejected).
  • New InvoicePurpose.Topup; the invoice period-unique index is filtered to exclude Topup so multiple top-ups per month are allowed.
  • Crediting rides the existing invoice mark-paid flow — credit happens only on invoice Paid, in the same unit of work; exactly-once is enforced by a unique partial index on the ledger's ReferenceId for Topup rows.
  • Tenant endpoints: GET /wallet/me, POST /wallet/topup-requests, GET /wallet/topup-requests/me. Operator endpoints: GET /wallet/topup-requests (root cross-tenant), POST .../{id}/approve, POST .../{id}/reject. Permissions: Billing.View to view/request, Billing.Manage to approve/reject.
  • All handlers root-gated (BillingDbContext isn't tenant-filtered); one EF migration.

Frontend

  • Dashboard: WhatsApp wallet page — balance + request-top-up form + my-requests list.
  • Admin: Billing → Top-ups review page — list, Approve (→ invoice), Reject.

Tests

  • Unit (domain + validators + mappings), Testcontainers integration (credit-on-paid lifecycle, approval, cross-tenant isolation, same-month uniqueness, exactly-once), Playwright E2E (dashboard request + admin approval). Backend build clean (TreatWarningsAsErrors); Architecture.Tests green.

Notes

  • Phase 2 (not in this PR): metering — debiting the wallet per WhatsApp message send (needs the Meta Cloud API send integration). The Debit/MessageCharge paths exist but are unused.
  • Docs: a wallet page + changelog entry are prepared on branch docs/whatsapp-wallet-topup in the separate docs repo (not yet pushed).
  • Built subagent-driven with per-task + whole-branch review.

🤖 Generated with Claude Code

iammukeshm and others added 15 commits June 26, 2026 00:22
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ique index

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds GetOrCreateWalletAsync, CreateTopupInvoiceAsync to IBillingService/BillingService.
MarkInvoicePaidAsync now credits the tenant wallet and completes the TopupRequest in the
same unit of work when Purpose == Topup. Invoice.CreateTopupDraft convenience factory
added. Invoice number scheme: TOP-{yyyyMM}-{tenantToken}-{requestSuffix} ensures
collision-safety for multiple top-ups in the same month.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…llet ledger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the three dashboard-facing wallet endpoints under
api/v1/billing: GET /wallet/me, POST /wallet/topup-requests,
GET /wallet/topup-requests/me. Handlers root-gate to caller
tenant via IMultiTenantContextAccessor; BillingDbContext is
not EF-filtered so every handler explicitly filters by
callerTenantId. Validators enforce Amount bounds and
pagination invariants. Integration tests confirm happy-path
and cross-tenant isolation (Tenant B cannot see Tenant A's
requests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds three admin-facing endpoints under api/v1/billing/wallet/topup-requests:
- GET  (root cross-tenant list, non-root scoped to own tenant)
- POST /{id}/approve  (creates+issues Topup invoice via IBillingService)
- POST /{id}/reject   (transitions request to Rejected)

Root-gating mirrors GetInvoicesQueryHandler. Approve/reject require
BillingPermissions.Manage and use .WithIdempotency(). All handlers are
public sealed with ValueTask<T> and ConfigureAwait(false). Three validators
added (Architecture.Tests requires validator per command/paginated-query).
Architecture.Tests extended with Approve/Reject as valid endpoint verb prefixes.
4 integration tests (approve happy-path, reject happy-path, cross-tenant isolation,
root tenantId filter) + 12 unit validator tests — all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add an explicit Pending-state guard in RejectTopupRequestCommandHandler
that throws CustomException(HttpStatusCode.Conflict) before delegating
to the domain Reject() method.  Without the guard the domain's
InvalidOperationException fell through to the global 500 branch.

Add integration test Reject_of_already_rejected_request_returns_409_Conflict
to TopupApprovalTests that asserts a second reject on the same request
returns 409 Conflict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Top-up wallet credit on invoice-paid was exactly-once only via an
in-process status check; two concurrent MarkInvoicePaid calls on the same
top-up invoice could both credit and commit. Add a unique partial index on
WalletTransactions(ReferenceId) filtered to Kind=Topup so a second ledger
row for the same top-up request cannot be inserted -- the concurrent second
transaction violates the constraint and rolls back, leaving exactly one
credit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@iammukeshm iammukeshm merged commit 6dc92c6 into main Jun 25, 2026
12 checks passed
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.

1 participant