From c80b918be9b239ae0f22d136bf91144d017483c3 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 00:22:49 +0530 Subject: [PATCH 01/15] docs(plan): WhatsApp wallet + topup Phase 1 implementation plan --- .../plans/2026-06-26-whatsapp-wallet-topup.md | 1393 +++++++++++++++++ 1 file changed, 1393 insertions(+) create mode 100644 superpowers/plans/2026-06-26-whatsapp-wallet-topup.md diff --git a/superpowers/plans/2026-06-26-whatsapp-wallet-topup.md b/superpowers/plans/2026-06-26-whatsapp-wallet-topup.md new file mode 100644 index 0000000000..e27436db24 --- /dev/null +++ b/superpowers/plans/2026-06-26-whatsapp-wallet-topup.md @@ -0,0 +1,1393 @@ +# WhatsApp Wallet + Top-up Request — Phase 1 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:** Let a tenant (clinic) request a prepaid wallet top-up from the dashboard; let an operator see the request in the admin app, generate an invoice for it, and have the wallet auto-credited when that invoice is marked paid (payments collected manually, offline). + +**Architecture:** We are the WhatsApp BSP (Meta bills us; clinics pay us). This phase builds the **funding** half of a prepaid money wallet inside the existing **Billing** module: three new tenant-scoped entities (`Wallet`, `WalletTransaction` ledger, `TopupRequest`), CQRS commands/queries + endpoints, an admin review/approve UI, a dashboard request UI, and one EF migration. Crediting rides the **existing** invoice mark-paid flow — when a `Topup`-purpose invoice transitions to Paid, `BillingService` writes a `+credit` ledger row and completes the request. The **metering** half (debiting the wallet per message) is explicitly out of scope (Phase 2, needs the Meta send integration which does not exist yet). + +**Tech Stack:** .NET 10, EF Core 10 (Npgsql), Mediator 3.x (source-gen), FluentValidation 12.x, ASP.NET Minimal APIs + Asp.Versioning, Finbuckle multitenancy, xUnit + Shouldly + Testcontainers (backend); React 19 + Vite 7 + TS, TanStack Query v5, React Router 7 (both frontends). + +## Global Constraints + +- **Module boundary:** all backend work lives in `src/Modules/Billing/` (runtime `Modules.Billing` + public `Modules.Billing.Contracts`). Do **not** touch `src/BuildingBlocks`. +- **Mediator handlers** are `public sealed`, return `ValueTask`, and `.ConfigureAwait(false)` every await. `ArgumentNullException.ThrowIfNull(...)` guard at the top of every handler. +- **Every command handler + every paginated query handler needs a `{Name}Validator`** (enforced by `Architecture.Tests`). +- **`BillingDbContext` is NOT tenant-filtered** (it is a plain `DbContext`, not `BaseDbContext`). Every billing entity carries an explicit `string TenantId`, and every handler that reads/writes it **must root-gate**: resolve `callerTenantId` from `IMultiTenantContextAccessor`, treat `callerTenantId == MultitenancyConstants.Root.Id` as root, and scope all other callers to their own tenant. (Mirror `GetInvoicesQueryHandler`.) This is a hard security rule from the prior cross-tenant audit. +- **Enum ordering footgun:** EF writes enums via `HasConversion()`. A property whose value equals the CLR default `0` and that also has `HasDefaultValue` may be omitted on INSERT. Order new enums so the natural initial member is `0` **and** always set it explicitly in the factory. +- **Money:** decimals use `HasPrecision(18, 4)`. Currency strings `HasMaxLength(8)`. +- **TenantId columns:** `IsRequired().HasMaxLength(64)`. +- **Structured logging only** (message templates / `[LoggerMessage]`), never log PII (e.g. emails) into messages. +- **CancellationToken** propagated into every EF/IO call; `= default` on public service methods. +- **Frontend:** admin uses `react-hook-form + zod`; **dashboard uses plain controlled `useState` inputs** (rhf+zod is admin-only). Pass per-call data via `mutate(arg)`, never via state the mutation callback closes over. +- **String enums over the wire:** the API serializes enums as strings globally; frontends mirror them as string unions (e.g. `"Pending" | "Invoiced" | ...`), not numeric consts. +- **Docs travel with the change** (AGENTS rule #10): a changelog entry + docs-repo page update are part of "done" (Task 12). + +--- + +## File Structure + +**Backend — new files (`src/Modules/Billing/`)** + +Contracts (`Modules.Billing.Contracts/`): +- `WalletEnums.cs` — `WalletStatus`, `TopupRequestStatus` (or fold into existing `BillingEnums.cs`; this plan adds them to `BillingEnums.cs` to match the module's single-enum-file convention). +- `Dtos/WalletDto.cs`, `Dtos/WalletTransactionDto.cs`, `Dtos/TopupRequestDto.cs` +- `v1/Wallets/GetMyWalletQuery.cs` +- `v1/Wallets/CreateTopupRequestCommand.cs` +- `v1/Wallets/GetMyTopupRequestsQuery.cs` +- `v1/Wallets/GetTopupRequestsQuery.cs` (admin, cross-tenant) +- `v1/Wallets/ApproveTopupRequestCommand.cs` +- `v1/Wallets/RejectTopupRequestCommand.cs` +- `Events/WalletCreditedIntegrationEvent.cs` (optional notification hook; built in Task 11) + +Runtime (`Modules.Billing/`): +- `Domain/Wallet.cs`, `Domain/WalletTransaction.cs`, `Domain/TopupRequest.cs` +- `Data/Configurations/WalletConfiguration.cs`, `WalletTransactionConfiguration.cs`, `TopupRequestConfiguration.cs` +- `Features/v1/Wallets/GetMyWallet/{Query Handler,Endpoint}.cs` +- `Features/v1/Wallets/CreateTopupRequest/{Handler,Validator,Endpoint}.cs` +- `Features/v1/Wallets/GetMyTopupRequests/{Handler,Validator,Endpoint}.cs` +- `Features/v1/Wallets/GetTopupRequests/{Handler,Validator,Endpoint}.cs` +- `Features/v1/Wallets/ApproveTopupRequest/{Handler,Validator,Endpoint}.cs` +- `Features/v1/Wallets/RejectTopupRequest/{Handler,Validator,Endpoint}.cs` +- `Mappings/` — extend existing `ToDto()` mapping file(s) or add `WalletMappings.cs`. + +**Backend — modified files** +- `Modules.Billing.Contracts/BillingEnums.cs` — add `InvoicePurpose.Topup = 2` + the two new enums. +- `Modules.Billing/Domain/Invoice.cs` — add a `CreateTopupDraft(...)` factory (or extend `CreateDraft`). +- `Modules.Billing/Data/BillingDbContext.cs` — 3 new `DbSet`s. +- `Modules.Billing/Data/Configurations/InvoiceConfiguration.cs` — filter the period-unique index to exclude `Topup`. +- `Modules.Billing/Services/BillingService.cs` + `Services/IBillingService.cs` — `GetOrCreateWalletAsync`, `CreateTopupInvoiceAsync`, and the wallet-credit branch inside `MarkInvoicePaidAsync`. +- `Modules.Billing/BillingModule.cs` — register the 6 new endpoints in `MapEndpoints`. +- `src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/` — one new migration + updated snapshot. + +**Frontend — admin (`clients/admin/`)** +- `src/api/wallet.ts` (new) — types + `listTopupRequests`, `approveTopupRequest`, `rejectTopupRequest`. +- `src/pages/billing/topups-list.tsx` (new) +- `src/pages/billing/layout.tsx`, `src/routes.tsx`, `src/lib/permissions.ts` (already has Billing perms), `src/components/layout/nav-items.ts` — wire route + nav tab. + +**Frontend — dashboard (`clients/dashboard/`)** +- `src/api/wallet.ts` (new) — types + `getMyWallet`, `createTopupRequest`, `getMyTopupRequests`. +- `src/pages/wallet.tsx` (new) +- `src/routes.tsx`, `src/components/layout/nav-data.ts` — wire route + nav item. + +**Tests** +- `src/Tests/Billing.Tests/...` (unit: domain + validators) and `src/Tests/Integration.Tests/...` (lifecycle + cross-tenant isolation), matching existing Billing test locations. + +--- + +## Data model (reference for all tasks) + +``` +Wallet (1 per tenant) + Id Guid (v7) + TenantId string(64) + Currency string(8) default "USD" + Balance decimal(18,4) -- = Σ WalletTransaction.Amount, kept denormalized for fast reads + Status WalletStatus (Active=0, Frozen=1) + CreatedAtUtc DateTime + UpdatedAtUtc DateTime? + UNIQUE(TenantId) + +WalletTransaction (append-only ledger) + Id Guid (v7) + WalletId Guid (FK -> Wallet, cascade) + TenantId string(64) -- denormalized for root-gating queries + Amount decimal(18,4) -- signed: +credit, -debit + Kind WalletTransactionKind (Topup=0, MessageCharge=1, Adjustment=2) + Description string(256) + ReferenceId string(128)? -- e.g. TopupRequest Id or future message id + CreatedAtUtc DateTime + INDEX(WalletId, CreatedAtUtc) + INDEX(TenantId) + +TopupRequest + Id Guid (v7) + TenantId string(64) + Amount decimal(18,4) + Currency string(8) + Note string(512)? + Status TopupRequestStatus (Pending=0, Invoiced=1, Completed=2, Rejected=3, Cancelled=4) + InvoiceId Guid? -- set on approve + RequestedBy string(64)? -- user id + DecisionNote string(512)? -- reject reason / approve note + CreatedAtUtc DateTime + DecidedAtUtc DateTime? + CompletedAtUtc DateTime? + INDEX(TenantId, Status) + INDEX(InvoiceId) +``` + +**Lifecycle:** dashboard `CreateTopupRequest` → `Pending`. Admin `ApproveTopupRequest` → `BillingService.CreateTopupInvoiceAsync` makes a `Topup`-purpose invoice, **issues** it (existing `InvoiceIssued` email fires to the clinic), links `InvoiceId`, request → `Invoiced`. Clinic pays offline. Operator opens the **existing** invoice-detail page, clicks **Mark paid** → `BillingService.MarkInvoicePaidAsync` detects `Purpose == Topup`, writes a `+Topup` `WalletTransaction`, bumps `Wallet.Balance`, flips the request → `Completed`. Admin `RejectTopupRequest` → `Rejected` (only from `Pending`). + +--- + +### Task 1: Enums + `InvoicePurpose.Topup` + +**Files:** +- Modify: `src/Modules/Billing/Modules.Billing.Contracts/BillingEnums.cs` +- Test: `src/Tests/Billing.Tests/Domain/EnumOrderingTests.cs` (create) + +**Interfaces:** +- Produces: `InvoicePurpose.Topup` (= 2); `WalletStatus { Active=0, Frozen=1 }`; `WalletTransactionKind { Topup=0, MessageCharge=1, Adjustment=2 }`; `TopupRequestStatus { Pending=0, Invoiced=1, Completed=2, Rejected=3, Cancelled=4 }`. + +- [ ] **Step 1: Write the failing test** — pins the byte values so a future reorder (the EF default-omit footgun) breaks a test, not production. + +`src/Tests/Billing.Tests/Domain/EnumOrderingTests.cs`: +```csharp +using FSH.Modules.Billing.Contracts; +using Shouldly; +using Xunit; + +namespace FSH.Modules.Billing.Tests.Domain; + +public sealed class EnumOrderingTests +{ + [Fact] + public void InvoicePurpose_Topup_is_two() + => ((int)InvoicePurpose.Topup).ShouldBe(2); + + [Fact] + public void TopupRequestStatus_Pending_is_default_zero() + => ((int)TopupRequestStatus.Pending).ShouldBe(0); + + [Fact] + public void WalletStatus_Active_is_default_zero() + => ((int)WalletStatus.Active).ShouldBe(0); + + [Fact] + public void WalletTransactionKind_Topup_is_default_zero() + => ((int)WalletTransactionKind.Topup).ShouldBe(0); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter EnumOrderingTests` +Expected: FAIL — compile error, `Topup`/`WalletStatus`/etc. do not exist. + +- [ ] **Step 3: Edit `BillingEnums.cs`** — add `Topup` to `InvoicePurpose` and append the three new enums. Keep the existing footgun comment. + +```csharp +public enum InvoicePurpose +{ + // Usage=0 doubles as the column default (rows backfill to Usage). Do NOT reorder. + Usage = 0, + Subscription = 1, + Topup = 2 +} + +public enum WalletStatus +{ + Active = 0, + Frozen = 1 +} + +public enum WalletTransactionKind +{ + Topup = 0, + MessageCharge = 1, + Adjustment = 2 +} + +public enum TopupRequestStatus +{ + Pending = 0, + Invoiced = 1, + Completed = 2, + Rejected = 3, + Cancelled = 4 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter EnumOrderingTests` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Modules/Billing/Modules.Billing.Contracts/BillingEnums.cs src/Tests/Billing.Tests/Domain/EnumOrderingTests.cs +git commit -m "feat(billing): add Topup invoice purpose + wallet/topup enums" +``` + +--- + +### Task 2: `Wallet` + `WalletTransaction` domain entities + +**Files:** +- Create: `src/Modules/Billing/Modules.Billing/Domain/Wallet.cs` +- Create: `src/Modules/Billing/Modules.Billing/Domain/WalletTransaction.cs` +- Test: `src/Tests/Billing.Tests/Domain/WalletTests.cs` (create) + +**Interfaces:** +- Consumes: `WalletStatus`, `WalletTransactionKind` (Task 1); base types `AggregateRoot` / `BaseEntity` (existing, used by `Invoice`/`InvoiceLineItem`). +- Produces: + - `Wallet.Create(string tenantId, string currency) : Wallet` + - `Wallet.Credit(decimal amount, WalletTransactionKind kind, string description, string? referenceId) : WalletTransaction` — guards `amount > 0`, adds to `Balance`, returns the ledger row (Id set). + - `Wallet.Debit(decimal amount, WalletTransactionKind kind, string description, string? referenceId) : WalletTransaction` — guards `0 < amount <= Balance`, subtracts. + - Properties: `Guid Id`, `string TenantId`, `string Currency`, `decimal Balance`, `WalletStatus Status`, `DateTime CreatedAtUtc`, `DateTime? UpdatedAtUtc`, `IReadOnlyList Transactions`. + - `WalletTransaction`: `Guid Id`, `Guid WalletId`, `string TenantId`, `decimal Amount`, `WalletTransactionKind Kind`, `string Description`, `string? ReferenceId`, `DateTime CreatedAtUtc`. + +- [ ] **Step 1: Write the failing test** + +`src/Tests/Billing.Tests/Domain/WalletTests.cs`: +```csharp +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Domain; +using Shouldly; +using Xunit; + +namespace FSH.Modules.Billing.Tests.Domain; + +public sealed class WalletTests +{ + [Fact] + public void Create_starts_active_with_zero_balance() + { + var w = Wallet.Create("tenant-a", "USD"); + w.TenantId.ShouldBe("tenant-a"); + w.Currency.ShouldBe("USD"); + w.Balance.ShouldBe(0m); + w.Status.ShouldBe(WalletStatus.Active); + w.Id.ShouldNotBe(Guid.Empty); + } + + [Fact] + public void Credit_increases_balance_and_returns_ledger_row() + { + var w = Wallet.Create("tenant-a", "USD"); + var tx = w.Credit(50m, WalletTransactionKind.Topup, "Top-up", "req-1"); + w.Balance.ShouldBe(50m); + tx.Amount.ShouldBe(50m); + tx.WalletId.ShouldBe(w.Id); + tx.TenantId.ShouldBe("tenant-a"); + tx.ReferenceId.ShouldBe("req-1"); + } + + [Fact] + public void Credit_rejects_non_positive_amount() + => Should.Throw( + () => Wallet.Create("t", "USD").Credit(0m, WalletTransactionKind.Topup, "x", null)); + + [Fact] + public void Debit_beyond_balance_throws() + { + var w = Wallet.Create("tenant-a", "USD"); + w.Credit(10m, WalletTransactionKind.Topup, "Top-up", null); + Should.Throw( + () => w.Debit(25m, WalletTransactionKind.MessageCharge, "msg", null)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter WalletTests` +Expected: FAIL — `Wallet` does not exist. + +- [ ] **Step 3: Create `WalletTransaction.cs`** + +```csharp +using FSH.Framework.Domain; +using FSH.Modules.Billing.Contracts; + +namespace FSH.Modules.Billing.Domain; + +public sealed class WalletTransaction : BaseEntity +{ + public Guid WalletId { get; private set; } + public string TenantId { get; private set; } = default!; + public decimal Amount { get; private set; } + public WalletTransactionKind Kind { get; private set; } + public string Description { get; private set; } = default!; + public string? ReferenceId { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + + private WalletTransaction() { } + + internal static WalletTransaction Create( + Guid walletId, string tenantId, decimal amount, + WalletTransactionKind kind, string description, string? referenceId) + => new() + { + Id = Guid.CreateVersion7(), + WalletId = walletId, + TenantId = tenantId, + Amount = amount, + Kind = kind, + Description = description, + ReferenceId = referenceId, + CreatedAtUtc = DateTime.UtcNow + }; +} +``` +> Note: confirm the base-class namespace by opening `Invoice.cs`/`InvoiceLineItem.cs` — use the **same** `using` for `AggregateRoot<>`/`BaseEntity<>` they use. Adjust the `using FSH.Framework.Domain;` line to match. + +- [ ] **Step 4: Create `Wallet.cs`** + +```csharp +using FSH.Framework.Domain; +using FSH.Modules.Billing.Contracts; + +namespace FSH.Modules.Billing.Domain; + +public sealed class Wallet : AggregateRoot +{ + private readonly List _transactions = new(); + + public string TenantId { get; private set; } = default!; + public string Currency { get; private set; } = "USD"; + public decimal Balance { get; private set; } + public WalletStatus Status { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + public DateTime? UpdatedAtUtc { get; private set; } + + public IReadOnlyList Transactions => _transactions; + + private Wallet() { } + + public static Wallet Create(string tenantId, string currency) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + return new Wallet + { + Id = Guid.CreateVersion7(), + TenantId = tenantId, + Currency = string.IsNullOrWhiteSpace(currency) ? "USD" : currency, + Balance = 0m, + Status = WalletStatus.Active, + CreatedAtUtc = DateTime.UtcNow + }; + } + + public WalletTransaction Credit(decimal amount, WalletTransactionKind kind, string description, string? referenceId) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(amount, 0m); + var tx = WalletTransaction.Create(Id, TenantId, amount, kind, description, referenceId); + _transactions.Add(tx); + Balance += amount; + UpdatedAtUtc = DateTime.UtcNow; + return tx; + } + + public WalletTransaction Debit(decimal amount, WalletTransactionKind kind, string description, string? referenceId) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(amount, 0m); + if (amount > Balance) + throw new InvalidOperationException("Insufficient wallet balance."); + var tx = WalletTransaction.Create(Id, TenantId, -amount, kind, description, referenceId); + _transactions.Add(tx); + Balance -= amount; + UpdatedAtUtc = DateTime.UtcNow; + return tx; + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter WalletTests` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/Modules/Billing/Modules.Billing/Domain/Wallet.cs src/Modules/Billing/Modules.Billing/Domain/WalletTransaction.cs src/Tests/Billing.Tests/Domain/WalletTests.cs +git commit -m "feat(billing): add Wallet aggregate + WalletTransaction ledger" +``` + +--- + +### Task 3: `TopupRequest` domain entity + +**Files:** +- Create: `src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs` +- Test: `src/Tests/Billing.Tests/Domain/TopupRequestTests.cs` (create) + +**Interfaces:** +- Consumes: `TopupRequestStatus` (Task 1). +- Produces: + - `TopupRequest.Create(string tenantId, decimal amount, string currency, string? note, string? requestedBy) : TopupRequest` (Status=Pending). + - `MarkInvoiced(Guid invoiceId, string? note)` — only from `Pending`, sets `InvoiceId`, `Status=Invoiced`, `DecidedAtUtc`. + - `MarkCompleted()` — only from `Invoiced`, sets `Status=Completed`, `CompletedAtUtc`. + - `Reject(string? reason)` — only from `Pending`. + - Properties: `Guid Id`, `string TenantId`, `decimal Amount`, `string Currency`, `string? Note`, `TopupRequestStatus Status`, `Guid? InvoiceId`, `string? RequestedBy`, `string? DecisionNote`, `DateTime CreatedAtUtc`, `DateTime? DecidedAtUtc`, `DateTime? CompletedAtUtc`. + +- [ ] **Step 1: Write the failing test** + +`src/Tests/Billing.Tests/Domain/TopupRequestTests.cs`: +```csharp +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Domain; +using Shouldly; +using Xunit; + +namespace FSH.Modules.Billing.Tests.Domain; + +public sealed class TopupRequestTests +{ + [Fact] + public void Create_starts_pending() + { + var r = TopupRequest.Create("tenant-a", 50m, "USD", "need credit", "user-1"); + r.Status.ShouldBe(TopupRequestStatus.Pending); + r.Amount.ShouldBe(50m); + r.InvoiceId.ShouldBeNull(); + } + + [Fact] + public void MarkInvoiced_from_pending_links_invoice() + { + var r = TopupRequest.Create("tenant-a", 50m, "USD", null, null); + var inv = Guid.CreateVersion7(); + r.MarkInvoiced(inv, "approved"); + r.Status.ShouldBe(TopupRequestStatus.Invoiced); + r.InvoiceId.ShouldBe(inv); + r.DecidedAtUtc.ShouldNotBeNull(); + } + + [Fact] + public void MarkCompleted_requires_invoiced() + { + var r = TopupRequest.Create("tenant-a", 50m, "USD", null, null); + Should.Throw(() => r.MarkCompleted()); + } + + [Fact] + public void Reject_from_invoiced_throws() + { + var r = TopupRequest.Create("tenant-a", 50m, "USD", null, null); + r.MarkInvoiced(Guid.CreateVersion7(), null); + Should.Throw(() => r.Reject("late")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter TopupRequestTests` +Expected: FAIL — `TopupRequest` does not exist. + +- [ ] **Step 3: Create `TopupRequest.cs`** + +```csharp +using FSH.Framework.Domain; +using FSH.Modules.Billing.Contracts; + +namespace FSH.Modules.Billing.Domain; + +public sealed class TopupRequest : AggregateRoot +{ + public string TenantId { get; private set; } = default!; + public decimal Amount { get; private set; } + public string Currency { get; private set; } = "USD"; + public string? Note { get; private set; } + public TopupRequestStatus Status { get; private set; } + public Guid? InvoiceId { get; private set; } + public string? RequestedBy { get; private set; } + public string? DecisionNote { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + public DateTime? DecidedAtUtc { get; private set; } + public DateTime? CompletedAtUtc { get; private set; } + + private TopupRequest() { } + + public static TopupRequest Create(string tenantId, decimal amount, string currency, string? note, string? requestedBy) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(amount, 0m); + return new TopupRequest + { + Id = Guid.CreateVersion7(), + TenantId = tenantId, + Amount = amount, + Currency = string.IsNullOrWhiteSpace(currency) ? "USD" : currency, + Note = note, + RequestedBy = requestedBy, + Status = TopupRequestStatus.Pending, + CreatedAtUtc = DateTime.UtcNow + }; + } + + public void MarkInvoiced(Guid invoiceId, string? note) + { + Require(TopupRequestStatus.Pending); + InvoiceId = invoiceId; + DecisionNote = note; + Status = TopupRequestStatus.Invoiced; + DecidedAtUtc = DateTime.UtcNow; + } + + public void MarkCompleted() + { + Require(TopupRequestStatus.Invoiced); + Status = TopupRequestStatus.Completed; + CompletedAtUtc = DateTime.UtcNow; + } + + public void Reject(string? reason) + { + Require(TopupRequestStatus.Pending); + DecisionNote = reason; + Status = TopupRequestStatus.Rejected; + DecidedAtUtc = DateTime.UtcNow; + } + + private void Require(TopupRequestStatus expected) + { + if (Status != expected) + throw new InvalidOperationException($"Top-up request must be {expected} (was {Status})."); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter TopupRequestTests` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs src/Tests/Billing.Tests/Domain/TopupRequestTests.cs +git commit -m "feat(billing): add TopupRequest aggregate with status transitions" +``` + +--- + +### Task 4: EF configurations, DbSets, invoice-index filter, migration + +**Files:** +- Create: `src/Modules/Billing/Modules.Billing/Data/Configurations/WalletConfiguration.cs` +- Create: `src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs` +- Create: `src/Modules/Billing/Modules.Billing/Data/Configurations/TopupRequestConfiguration.cs` +- Modify: `src/Modules/Billing/Modules.Billing/Data/BillingDbContext.cs` +- Modify: `src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs` +- Create: migration files under `src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/` + +**Interfaces:** +- Consumes: `Wallet`, `WalletTransaction`, `TopupRequest` (Tasks 2–3). +- Produces: `BillingDbContext.Wallets`, `.WalletTransactions`, `.TopupRequests` DbSets; DB tables `billing.Wallets`, `billing.WalletTransactions`, `billing.TopupRequests`; the invoice period-unique index now filtered to `Purpose <> 2`. + +- [ ] **Step 1: Add DbSets to `BillingDbContext.cs`** + +```csharp +public DbSet Wallets => Set(); +public DbSet WalletTransactions => Set(); +public DbSet TopupRequests => Set(); +``` +(Add `using FSH.Modules.Billing.Domain;` if not present.) + +- [ ] **Step 2: Create `WalletConfiguration.cs`** + +```csharp +using FSH.Modules.Billing.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Billing.Data.Configurations; + +public sealed class WalletConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("Wallets"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired().HasMaxLength(64); + builder.Property(x => x.Currency).IsRequired().HasMaxLength(8); + builder.Property(x => x.Balance).HasPrecision(18, 4); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => x.TenantId).IsUnique().HasDatabaseName("ux_wallets_tenantid"); + + builder.HasMany(x => x.Transactions) + .WithOne() + .HasForeignKey(t => t.WalletId) + .OnDelete(DeleteBehavior.Cascade); + builder.Metadata.FindNavigation(nameof(Wallet.Transactions))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.Ignore(x => x.DomainEvents); + } +} +``` + +- [ ] **Step 3: Create `WalletTransactionConfiguration.cs`** + +```csharp +using FSH.Modules.Billing.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Billing.Data.Configurations; + +public sealed class WalletTransactionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("WalletTransactions"); + builder.HasKey(x => x.Id); + // Child reached only via Wallet.Transactions nav — pin Id generation or EF marks Modified, not Added. + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.TenantId).IsRequired().HasMaxLength(64); + builder.Property(x => x.Amount).HasPrecision(18, 4); + builder.Property(x => x.Kind).HasConversion(); + builder.Property(x => x.Description).IsRequired().HasMaxLength(256); + builder.Property(x => x.ReferenceId).HasMaxLength(128); + builder.HasIndex(x => new { x.WalletId, x.CreatedAtUtc }); + builder.HasIndex(x => x.TenantId); + } +} +``` +> The `ValueGeneratedNever()` on `Id` is required because `WalletTransaction` is only ever added through the `Wallet.Transactions` collection (known EF footgun in this repo). + +- [ ] **Step 4: Create `TopupRequestConfiguration.cs`** + +```csharp +using FSH.Modules.Billing.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Billing.Data.Configurations; + +public sealed class TopupRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("TopupRequests"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired().HasMaxLength(64); + builder.Property(x => x.Amount).HasPrecision(18, 4); + builder.Property(x => x.Currency).IsRequired().HasMaxLength(8); + builder.Property(x => x.Note).HasMaxLength(512); + builder.Property(x => x.DecisionNote).HasMaxLength(512); + builder.Property(x => x.RequestedBy).HasMaxLength(64); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.Status }); + builder.HasIndex(x => x.InvoiceId); + builder.Ignore(x => x.DomainEvents); + } +} +``` + +- [ ] **Step 5: Filter the invoice period-unique index** in `InvoiceConfiguration.cs` so multiple Topup invoices per period are allowed. Replace the existing `ux_invoices_tenant_period_purpose` index definition with: + +```csharp +// Recurring invoices are unique per tenant/period/purpose; Topup invoices (Purpose=2) are +// ad-hoc and may repeat within a period, so exclude them from the uniqueness filter. +builder.HasIndex(x => new { x.TenantId, x.PeriodYear, x.PeriodMonth, x.Purpose }) + .IsUnique() + .HasFilter($"\"Purpose\" <> {(int)Contracts.InvoicePurpose.Topup}") + .HasDatabaseName("ux_invoices_tenant_period_purpose"); +``` + +- [ ] **Step 6: Build to validate the model compiles** + +Run: `dotnet build src/Modules/Billing/Modules.Billing/Modules.Billing.csproj` +Expected: Build succeeded, 0 warnings. + +- [ ] **Step 7: Add the migration** (full solution build first — `migrations remove`/`add` footgun). + +Run: +```bash +dotnet build src/FSH.Starter.slnx +dotnet ef migrations add WhatsAppWalletTopup \ + --context BillingDbContext \ + --project src/Host/FSH.Starter.Migrations.PostgreSQL/FSH.Starter.Migrations.PostgreSQL.csproj \ + --startup-project src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj \ + --output-dir Billing +``` +Expected: new `*_WhatsAppWalletTopup.cs` + `.Designer.cs` under `Billing/`, updated `BillingDbContextModelSnapshot.cs`. + +- [ ] **Step 8: Eyeball the migration** — confirm it `CreateTable`s `Wallets`, `WalletTransactions`, `TopupRequests` in schema `billing`, **and** drops+recreates `ux_invoices_tenant_period_purpose` with the `"Purpose" <> 2` filter. No unintended column drops elsewhere. + +- [ ] **Step 9: Apply + smoke-test the migration** (Docker required). + +Run: `dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply` +Expected: applies cleanly, no errors. + +- [ ] **Step 10: Commit** + +```bash +git add src/Modules/Billing/Modules.Billing/Data src/Host/FSH.Starter.Migrations.PostgreSQL/Billing +git commit -m "feat(billing): persist wallet/ledger/topup tables + filter invoice unique index" +``` + +--- + +### Task 5: DTOs + mappings + +**Files:** +- Create: `src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletDto.cs` +- Create: `src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletTransactionDto.cs` +- Create: `src/Modules/Billing/Modules.Billing.Contracts/Dtos/TopupRequestDto.cs` +- Create: `src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs` +- Test: `src/Tests/Billing.Tests/Mappings/WalletMappingTests.cs` (create) + +**Interfaces:** +- Produces: + - `WalletDto(Guid Id, string TenantId, string Currency, decimal Balance, string Status, DateTime CreatedAtUtc, IReadOnlyList RecentTransactions)` + - `WalletTransactionDto(Guid Id, decimal Amount, string Kind, string Description, string? ReferenceId, DateTime CreatedAtUtc)` + - `TopupRequestDto(Guid Id, string TenantId, decimal Amount, string Currency, string? Note, string Status, Guid? InvoiceId, string? RequestedBy, string? DecisionNote, DateTime CreatedAtUtc, DateTime? DecidedAtUtc, DateTime? CompletedAtUtc)` + - Extension methods `Wallet.ToDto(int recentCount = 10)`, `TopupRequest.ToDto()`, `WalletTransaction.ToDto()`. +- Note: `Status`/`Kind` are emitted as **strings** (`.ToString()`) to match the API's global string-enum policy. + +- [ ] **Step 1: Write the failing test** + +`src/Tests/Billing.Tests/Mappings/WalletMappingTests.cs`: +```csharp +using FSH.Modules.Billing.Domain; +using FSH.Modules.Billing.Mappings; +using FSH.Modules.Billing.Contracts; +using Shouldly; +using Xunit; + +namespace FSH.Modules.Billing.Tests.Mappings; + +public sealed class WalletMappingTests +{ + [Fact] + public void Wallet_ToDto_emits_string_status_and_balance() + { + var w = Wallet.Create("tenant-a", "USD"); + w.Credit(50m, WalletTransactionKind.Topup, "Top-up", "req-1"); + var dto = w.ToDto(); + dto.Balance.ShouldBe(50m); + dto.Status.ShouldBe("Active"); + dto.RecentTransactions.Count.ShouldBe(1); + dto.RecentTransactions[0].Kind.ShouldBe("Topup"); + } + + [Fact] + public void TopupRequest_ToDto_emits_string_status() + { + var r = TopupRequest.Create("tenant-a", 25m, "USD", "note", "u1"); + r.ToDto().Status.ShouldBe("Pending"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter WalletMappingTests` +Expected: FAIL — DTOs/mappings missing. + +- [ ] **Step 3: Create the three DTO records** (each in its own file under `Contracts/Dtos/`), matching the Produces signatures above. Example `WalletDto.cs`: + +```csharp +namespace FSH.Modules.Billing.Contracts.Dtos; + +public sealed record WalletDto( + Guid Id, + string TenantId, + string Currency, + decimal Balance, + string Status, + DateTime CreatedAtUtc, + IReadOnlyList RecentTransactions); +``` + +- [ ] **Step 4: Create `WalletMappings.cs`** + +```csharp +using FSH.Modules.Billing.Contracts.Dtos; +using FSH.Modules.Billing.Domain; + +namespace FSH.Modules.Billing.Mappings; + +public static class WalletMappings +{ + public static WalletTransactionDto ToDto(this WalletTransaction t) + => new(t.Id, t.Amount, t.Kind.ToString(), t.Description, t.ReferenceId, t.CreatedAtUtc); + + public static WalletDto ToDto(this Wallet w, int recentCount = 10) + => new( + w.Id, w.TenantId, w.Currency, w.Balance, w.Status.ToString(), w.CreatedAtUtc, + w.Transactions + .OrderByDescending(t => t.CreatedAtUtc) + .Take(recentCount) + .Select(t => t.ToDto()) + .ToList()); + + public static TopupRequestDto ToDto(this TopupRequest r) + => new( + r.Id, r.TenantId, r.Amount, r.Currency, r.Note, r.Status.ToString(), + r.InvoiceId, r.RequestedBy, r.DecisionNote, r.CreatedAtUtc, r.DecidedAtUtc, r.CompletedAtUtc); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter WalletMappingTests` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/Modules/Billing/Modules.Billing.Contracts/Dtos src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs src/Tests/Billing.Tests/Mappings/WalletMappingTests.cs +git commit -m "feat(billing): wallet/topup DTOs + string-enum mappings" +``` + +--- + +### Task 6: `BillingService` wallet methods + credit-on-paid + +**Files:** +- Modify: `src/Modules/Billing/Modules.Billing/Services/IBillingService.cs` +- Modify: `src/Modules/Billing/Modules.Billing/Services/BillingService.cs` +- Test: `src/Tests/Integration.Tests/Billing/WalletTopupServiceTests.cs` (create) — Testcontainers-backed. + +**Interfaces:** +- Consumes: `BillingDbContext`, `Wallet`, `TopupRequest`, `Invoice.CreateDraft`/issue (existing), `InvoiceLineItemKind.Adjustment`. +- Produces (on `IBillingService`): + - `Task GetOrCreateWalletAsync(string tenantId, string currency, CancellationToken ct = default)` + - `Task CreateTopupInvoiceAsync(string tenantId, Guid topupRequestId, CancellationToken ct = default)` — loads the `Pending` request, creates a `Topup`-purpose Draft invoice with one `Adjustment` line item (`"WhatsApp wallet top-up"`, qty 1, unit = amount), **issues** it (fires existing `InvoiceIssuedIntegrationEvent`), calls `request.MarkInvoiced(invoice.Id, note)`, saves, returns the invoice. + - **Inside existing `MarkInvoicePaidAsync`:** after the invoice is set Paid, if `invoice.Purpose == InvoicePurpose.Topup`, find the `TopupRequest` by `InvoiceId`, `GetOrCreateWalletAsync`, `wallet.Credit(invoice.SubtotalAmount, WalletTransactionKind.Topup, "WhatsApp wallet top-up", request.Id.ToString())`, `request.MarkCompleted()`, persist — all in the same `SaveChangesAsync`. + +- [ ] **Step 1: Write the failing integration test** + +`src/Tests/Integration.Tests/Billing/WalletTopupServiceTests.cs` (follow the existing Billing integration-test harness: same base class/fixture as the current invoice integration tests — open a sibling file to copy the `[Collection]`/fixture attributes and the tenant-context setup): +```csharp +// Pattern note: set the Finbuckle tenant context INLINE in this method (AsyncLocal is lost across awaited helpers). +[Fact] +public async Task Topup_credits_wallet_when_invoice_marked_paid() +{ + // arrange: resolve IBillingService + BillingDbContext from the test host; set tenant context to a seeded tenant inline. + var request = TopupRequest.Create(TenantId, 50m, "USD", "need credit", "user-1"); + Db.TopupRequests.Add(request); + await Db.SaveChangesAsync(); + + // act + var invoice = await Billing.CreateTopupInvoiceAsync(TenantId, request.Id); + invoice.Purpose.ShouldBe(InvoicePurpose.Topup); + invoice.Status.ShouldBe(InvoiceStatus.Issued); + + await Billing.MarkInvoicePaidAsync(invoice.Id); + + // assert + var wallet = await Billing.GetOrCreateWalletAsync(TenantId, "USD"); + wallet.Balance.ShouldBe(50m); + var reloaded = await Db.TopupRequests.FindAsync(request.Id); + reloaded!.Status.ShouldBe(TopupRequestStatus.Completed); +} +``` + +- [ ] **Step 2: Run test to verify it fails** (Docker required) + +Run: `dotnet test src/Tests/Integration.Tests/Integration.Tests.csproj --filter WalletTopupServiceTests` +Expected: FAIL — `CreateTopupInvoiceAsync`/`GetOrCreateWalletAsync` not defined. + +- [ ] **Step 3: Add the three signatures to `IBillingService.cs`** (per the Produces block). + +- [ ] **Step 4: Implement in `BillingService.cs`.** Add (using the existing `_db`, `_eventBus`, `_timeProvider` fields — confirm their names by opening the file): + +```csharp +public async Task GetOrCreateWalletAsync(string tenantId, string currency, CancellationToken ct = default) +{ + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var wallet = await _db.Wallets + .Include(w => w.Transactions) + .FirstOrDefaultAsync(w => w.TenantId == tenantId, ct) + .ConfigureAwait(false); + if (wallet is null) + { + wallet = Wallet.Create(tenantId, currency); + _db.Wallets.Add(wallet); + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + } + return wallet; +} + +public async Task CreateTopupInvoiceAsync(string tenantId, Guid topupRequestId, CancellationToken ct = default) +{ + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var request = await _db.TopupRequests + .FirstOrDefaultAsync(r => r.Id == topupRequestId && r.TenantId == tenantId, ct) + .ConfigureAwait(false) + ?? throw new NotFoundException($"Top-up request {topupRequestId} not found."); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var invoice = Invoice.CreateTopupDraft(tenantId, request.Currency, now, request.Amount, + $"WhatsApp wallet top-up ({request.Amount:0.##} {request.Currency})"); + invoice.Issue(); + _db.Invoices.Add(invoice); + request.MarkInvoiced(invoice.Id, request.Note); + + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + + await _eventBus.PublishAsync(new InvoiceIssuedIntegrationEvent( + Id: Guid.NewGuid(), + OccurredOnUtc: now, + TenantId: tenantId, + CorrelationId: Guid.NewGuid().ToString(), + Source: "Billing", + InvoiceId: invoice.Id, + InvoiceNumber: invoice.InvoiceNumber, + Amount: invoice.SubtotalAmount, + Currency: invoice.Currency, + DueAtUtc: invoice.DueAtUtc, + PeriodYear: invoice.PeriodYear, + PeriodMonth: invoice.PeriodMonth), ct).ConfigureAwait(false); + + return invoice; +} +``` +And inside the existing `MarkInvoicePaidAsync`, after the invoice is marked paid and **before** the final `SaveChangesAsync`, add the topup branch: +```csharp +if (invoice.Purpose == InvoicePurpose.Topup) +{ + var request = await _db.TopupRequests + .FirstOrDefaultAsync(r => r.InvoiceId == invoice.Id, ct).ConfigureAwait(false); + if (request is { Status: TopupRequestStatus.Invoiced }) + { + var wallet = await _db.Wallets + .FirstOrDefaultAsync(w => w.TenantId == invoice.TenantId, ct).ConfigureAwait(false); + if (wallet is null) + { + wallet = Wallet.Create(invoice.TenantId, invoice.Currency); + _db.Wallets.Add(wallet); + } + wallet.Credit(invoice.SubtotalAmount, WalletTransactionKind.Topup, + "WhatsApp wallet top-up", request.Id.ToString()); + request.MarkCompleted(); + } +} +``` +> If `MarkInvoicePaidAsync` currently loads the invoice with a narrow projection, ensure it loads the tracked `Invoice` entity (with `Purpose`, `TenantId`, `SubtotalAmount`) so this branch works in the same unit of work. + +- [ ] **Step 5: Add `Invoice.CreateTopupDraft` factory** in `Invoice.cs` (mirror `CreateDraft`; sets `Purpose = InvoicePurpose.Topup`, `PeriodYear`/`PeriodMonth` from `now`, adds one `Adjustment` line item via the existing line-item path, sets `SubtotalAmount = amount`). Confirm the existing private line-item add helper name and reuse it. + +- [ ] **Step 6: Run test to verify it passes** (Docker required) + +Run: `dotnet test src/Tests/Integration.Tests/Integration.Tests.csproj --filter WalletTopupServiceTests` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/Modules/Billing/Modules.Billing/Services src/Modules/Billing/Modules.Billing/Domain/Invoice.cs src/Tests/Integration.Tests/Billing/WalletTopupServiceTests.cs +git commit -m "feat(billing): topup invoice generation + wallet credit on invoice paid" +``` + +--- + +### Task 7: Tenant-facing commands/queries + endpoints (dashboard side) + +**Files:** +- Create contracts: `v1/Wallets/GetMyWalletQuery.cs`, `v1/Wallets/CreateTopupRequestCommand.cs`, `v1/Wallets/GetMyTopupRequestsQuery.cs` (under `Modules.Billing.Contracts/`) +- Create handlers/validators/endpoints under `Modules.Billing/Features/v1/Wallets/{GetMyWallet,CreateTopupRequest,GetMyTopupRequests}/` +- Modify: `src/Modules/Billing/Modules.Billing/BillingModule.cs` (register 3 endpoints) +- Test: `src/Tests/Billing.Tests/Validators/CreateTopupRequestValidatorTests.cs` + `src/Tests/Integration.Tests/Billing/WalletEndpointsTests.cs` + +**Interfaces:** +- Consumes: `IBillingService`, `BillingDbContext`, `IMultiTenantContextAccessor`, `ICurrentUser` (for `RequestedBy`; confirm the accessor used elsewhere), `WalletMappings`. +- Produces routes (all under `api/v{version}/billing`, `RequirePermission(BillingPermissions.View)`): + - `GET /wallet/me` → `WalletDto` + - `POST /wallet/topup-requests` (body `{ amount, note }`) → `Guid` + - `GET /wallet/topup-requests/me?status=&pageNumber=&pageSize=` → `PagedResponse` + +- [ ] **Step 1: Write the failing validator test** + +`src/Tests/Billing.Tests/Validators/CreateTopupRequestValidatorTests.cs`: +```csharp +using FSH.Modules.Billing.Features.v1.Wallets.CreateTopupRequest; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using Shouldly; +using Xunit; + +namespace FSH.Modules.Billing.Tests.Validators; + +public sealed class CreateTopupRequestValidatorTests +{ + private readonly CreateTopupRequestCommandValidator _v = new(); + + [Theory] + [InlineData(0)] + [InlineData(-5)] + [InlineData(1_000_001)] + public void Rejects_out_of_range(decimal amount) + => _v.Validate(new CreateTopupRequestCommand(amount, null)).IsValid.ShouldBeFalse(); + + [Fact] + public void Accepts_valid_amount() + => _v.Validate(new CreateTopupRequestCommand(50m, "need credit")).IsValid.ShouldBeTrue(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter CreateTopupRequestValidatorTests` +Expected: FAIL — types missing. + +- [ ] **Step 3: Create the contracts.** + +`v1/Wallets/CreateTopupRequestCommand.cs`: +```csharp +using Mediator; + +namespace FSH.Modules.Billing.Contracts.v1.Wallets; + +public sealed record CreateTopupRequestCommand(decimal Amount, string? Note) : ICommand; +``` +`v1/Wallets/GetMyWalletQuery.cs`: +```csharp +using FSH.Modules.Billing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Billing.Contracts.v1.Wallets; + +public sealed record GetMyWalletQuery : IQuery; +``` +`v1/Wallets/GetMyTopupRequestsQuery.cs`: +```csharp +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Billing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Billing.Contracts.v1.Wallets; + +public sealed record GetMyTopupRequestsQuery( + TopupRequestStatus? Status = null, + int PageNumber = 1, + int PageSize = 20) : IQuery>; +``` + +- [ ] **Step 4: Create the validator** `Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandValidator.cs`: +```csharp +using FluentValidation; +using FSH.Modules.Billing.Contracts.v1.Wallets; + +namespace FSH.Modules.Billing.Features.v1.Wallets.CreateTopupRequest; + +public sealed class CreateTopupRequestCommandValidator : AbstractValidator +{ + public CreateTopupRequestCommandValidator() + { + RuleFor(x => x.Amount).GreaterThan(0m).LessThanOrEqualTo(1_000_000m); + RuleFor(x => x.Note).MaximumLength(512); + } +} +``` +Add a `GetMyTopupRequestsQueryValidator` (paginated query → validator required): +```csharp +using FluentValidation; +using FSH.Modules.Billing.Contracts.v1.Wallets; + +namespace FSH.Modules.Billing.Features.v1.Wallets.GetMyTopupRequests; + +public sealed class GetMyTopupRequestsQueryValidator : AbstractValidator +{ + public GetMyTopupRequestsQueryValidator() + { + RuleFor(x => x.PageNumber).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100); + } +} +``` + +- [ ] **Step 5: Run validator test to verify it passes** + +Run: `dotnet test src/Tests/Billing.Tests/Billing.Tests.csproj --filter CreateTopupRequestValidatorTests` +Expected: PASS. + +- [ ] **Step 6: Create the handlers** (root-gate to caller's own tenant — non-root callers only ever see their own data; root has no implicit tenant here so `GetMyWallet`/create resolve the caller's tenant id). + +`CreateTopupRequestCommandHandler.cs`: +```csharp +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Domain; +using Mediator; + +namespace FSH.Modules.Billing.Features.v1.Wallets.CreateTopupRequest; + +public sealed class CreateTopupRequestCommandHandler( + BillingDbContext db, + IMultiTenantContextAccessor tenantAccessor, + ICurrentUser currentUser) // confirm the ICurrentUser type/namespace used in this module + : ICommandHandler +{ + public async ValueTask Handle(CreateTopupRequestCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new UnauthorizedException("Tenant context is required."); + var currency = tenantAccessor.MultiTenantContext?.TenantInfo is { } t ? "USD" : "USD"; // wallet currency default; refine later + var request = TopupRequest.Create(tenantId, command.Amount, currency, command.Note, currentUser.GetUserId()?.ToString()); + db.TopupRequests.Add(request); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return request.Id; + } +} +``` +`GetMyWalletQueryHandler.cs` — resolve tenant, `await billing.GetOrCreateWalletAsync(tenantId, "USD", ct)`, return `wallet.ToDto()`. +`GetMyTopupRequestsQueryHandler.cs` — resolve tenant, query `db.TopupRequests.AsNoTracking().Where(r => r.TenantId == tenantId)` + optional status filter + paginate (mirror `GetInvoicesQueryHandler` pagination), map `ToDto()`. + +- [ ] **Step 7: Create the three endpoints** mirroring `GetInvoicesEndpoint`/`MarkInvoicePaidEndpoint` (use `RequirePermission(BillingPermissions.View)`; `.WithIdempotency()` on the POST). Register them in `BillingModule.MapEndpoints`: +```csharp +group.MapGetMyWalletEndpoint(); // GET /wallet/me +group.MapCreateTopupRequestEndpoint(); // POST /wallet/topup-requests +group.MapGetMyTopupRequestsEndpoint(); // GET /wallet/topup-requests/me +``` + +- [ ] **Step 8: Write the integration test** `WalletEndpointsTests.cs`: as a tenant user, POST a topup request → 200 + id; GET `/wallet/topup-requests/me` shows it `Pending`; GET `/wallet/me` shows balance 0. **Cross-tenant:** a request created by tenant A is **not** visible in tenant B's `/wallet/topup-requests/me`. + +- [ ] **Step 9: Run the integration test** (Docker required) + +Run: `dotnet test src/Tests/Integration.Tests/Integration.Tests.csproj --filter WalletEndpointsTests` +Expected: PASS. + +- [ ] **Step 10: Commit** + +```bash +git add src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests src/Modules/Billing/Modules.Billing/BillingModule.cs src/Tests +git commit -m "feat(billing): tenant wallet + topup-request endpoints" +``` + +--- + +### Task 8: Operator commands + endpoints (admin side) + +**Files:** +- Create contracts: `v1/Wallets/GetTopupRequestsQuery.cs`, `v1/Wallets/ApproveTopupRequestCommand.cs`, `v1/Wallets/RejectTopupRequestCommand.cs` +- Create handlers/validators/endpoints under `Features/v1/Wallets/{GetTopupRequests,ApproveTopupRequest,RejectTopupRequest}/` +- Modify: `BillingModule.cs` (register 3 endpoints) +- Test: `src/Tests/Integration.Tests/Billing/TopupApprovalTests.cs` + +**Interfaces:** +- Consumes: `IBillingService.CreateTopupInvoiceAsync` (Task 6), root-gating pattern. +- Produces routes under `api/v{version}/billing`: + - `GET /wallet/topup-requests?tenantId=&status=&pageNumber=&pageSize=` → `PagedResponse` — `RequirePermission(BillingPermissions.View)`, **root-only cross-tenant** (mirror `GetInvoicesQueryHandler`: non-root scoped to own tenant). + - `POST /wallet/topup-requests/{id}/approve` (body `{ note? }`) → `Guid` (the created invoice id) — `RequirePermission(BillingPermissions.Manage)`, `.WithIdempotency()`. + - `POST /wallet/topup-requests/{id}/reject` (body `{ reason? }`) → `Guid` — `RequirePermission(BillingPermissions.Manage)`, `.WithIdempotency()`. + +- [ ] **Step 1: Write the failing integration test** + +`TopupApprovalTests.cs` (root operator): +```csharp +[Fact] +public async Task Approve_generates_topup_invoice_and_marks_request_invoiced() +{ + // arrange: seed a Pending TopupRequest for TenantA (inline tenant context for the seed). + // act: as ROOT, POST /api/v1/billing/wallet/topup-requests/{id}/approve + // assert: 200 + invoiceId; request now Invoiced with InvoiceId set; an Issued Topup invoice exists for TenantA. +} + +[Fact] +public async Task NonRoot_cannot_see_other_tenants_requests() +{ + // arrange: Pending request for TenantA. + // act: as TenantB user, GET /api/v1/billing/wallet/topup-requests + // assert: TenantA's request is absent. +} +``` + +- [ ] **Step 2: Run test to verify it fails** (Docker) + +Run: `dotnet test src/Tests/Integration.Tests/Integration.Tests.csproj --filter TopupApprovalTests` +Expected: FAIL — endpoints missing. + +- [ ] **Step 3: Create contracts** `GetTopupRequestsQuery(string? TenantId, TopupRequestStatus? Status, int PageNumber=1, int PageSize=20) : IQuery>`; `ApproveTopupRequestCommand(Guid Id, string? Note) : ICommand`; `RejectTopupRequestCommand(Guid Id, string? Reason) : ICommand`. + +- [ ] **Step 4: Create validators** — `GetTopupRequestsQueryValidator` (PageNumber>0, PageSize 1..100), `ApproveTopupRequestCommandValidator` (`RuleFor(x=>x.Id).NotEmpty()`), `RejectTopupRequestCommandValidator` (`Id` NotEmpty, `Reason` MaxLength 512). + +- [ ] **Step 5: Create handlers.** + - `GetTopupRequestsQueryHandler` — root-gate (copy the `callerTenantId`/`isRoot`/`tenantFilter` block from `GetInvoicesQueryHandler`), filter by `tenantFilter`/`Status`, paginate, `ToDto()`. + - `ApproveTopupRequestCommandHandler` — resolve caller tenant; if root, the request's own `TenantId` is the scope (load request by id, use `request.TenantId`); else require `request.TenantId == callerTenantId`. Call `await billing.CreateTopupInvoiceAsync(request.TenantId, command.Id, ct)`; return `invoice.Id`. + - `RejectTopupRequestCommandHandler` — load request (same root-gate), `request.Reject(reason)`, save, return `request.Id`. + +- [ ] **Step 6: Create endpoints + register** in `BillingModule.MapEndpoints`: +```csharp +group.MapGetTopupRequestsEndpoint(); // GET /wallet/topup-requests +group.MapApproveTopupRequestEndpoint(); // POST /wallet/topup-requests/{id}/approve +group.MapRejectTopupRequestEndpoint(); // POST /wallet/topup-requests/{id}/reject +``` + +- [ ] **Step 7: Run test to verify it passes** (Docker) + +Run: `dotnet test src/Tests/Integration.Tests/Integration.Tests.csproj --filter TopupApprovalTests` +Expected: PASS. + +- [ ] **Step 8: Full backend gate** + +Run: `dotnet build src/FSH.Starter.slnx && dotnet test src/Tests/Architecture.Tests/Architecture.Tests.csproj` +Expected: build green (TreatWarningsAsErrors), architecture tests pass (every command/paginated-query handler has a validator; module boundaries intact). + +- [ ] **Step 9: Commit** + +```bash +git add src/Modules/Billing src/Tests +git commit -m "feat(billing): operator list/approve/reject topup-request endpoints" +``` + +--- + +### Task 9: Dashboard UI — wallet + request top-up + +**Files:** +- Create: `clients/dashboard/src/api/wallet.ts` +- Create: `clients/dashboard/src/pages/wallet.tsx` +- Modify: `clients/dashboard/src/routes.tsx`, `clients/dashboard/src/components/layout/nav-data.ts` + +**Interfaces:** +- Consumes: `apiFetch`, `PagedResult` (existing in `clients/dashboard/src/api/billing.ts` — import the type or re-declare consistently). +- Produces (in `api/wallet.ts`): + - types `WalletDto`, `WalletTransactionDto`, `TopupRequestStatus = "Pending" | "Invoiced" | "Completed" | "Rejected" | "Cancelled" | (string & {})`, `TopupRequestDto`. + - `getMyWallet(): Promise` → `GET /api/v1/billing/wallet/me` + - `createTopupRequest(input: { amount: number; note?: string }): Promise` → `POST /api/v1/billing/wallet/topup-requests` + - `getMyTopupRequests(params?: { status?: TopupRequestStatus; pageNumber?: number; pageSize?: number }): Promise>` → `GET /api/v1/billing/wallet/topup-requests/me` + +- [ ] **Step 1: Create `api/wallet.ts`** — mirror `clients/dashboard/src/api/billing.ts` exactly (same `apiFetch` import, `URLSearchParams` query building, string-union enums). + +- [ ] **Step 2: Create `pages/wallet.tsx`** — plain controlled `useState` form (dashboard convention, no rhf/zod). Structure: + - `useQuery(["billing","wallet","me"], getMyWallet)` → balance card (big `Balance` + `Currency`, low-balance hint). + - "Request top-up" card: controlled `amount` (number string) + `note` (textarea); inline `valid = Number(amount) > 0`; `useMutation({ mutationFn: createTopupRequest })` — pass the value via `mutate({ amount: Number(amount), note })` (never via closed-over state); `onSuccess` → toast + `invalidateQueries(["billing","topup-requests","me"])` + reset fields. + - `useQuery(["billing","topup-requests","me", {page}], () => getMyTopupRequests({pageNumber}))` → list with status badges (`Pending`=amber, `Invoiced`=blue, `Completed`=green, `Rejected`=red) and amount; if `invoiceId` present, link to `/invoices/{invoiceId}`. + - Use the same page-header/card/skeleton components the existing `pages/invoices.tsx` imports. + +- [ ] **Step 3: Register route + nav.** In `routes.tsx`: +```tsx +const WalletPage = lazyNamed(() => import("@/pages/wallet"), "WalletPage"); +// ... +{ path: "/wallet", element: }, +``` +In `components/layout/nav-data.ts`, add under the "operations" group (after Subscription): +```ts +{ to: "/wallet", label: "WhatsApp wallet", icon: Wallet, perm: "Permissions.Billing.View" }, +``` +(Import `Wallet` from `lucide-react`.) + +- [ ] **Step 4: Typecheck + build the dashboard** + +Run: `cd clients/dashboard && npm run build` +Expected: type-checks and builds with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add clients/dashboard/src/api/wallet.ts clients/dashboard/src/pages/wallet.tsx clients/dashboard/src/routes.tsx clients/dashboard/src/components/layout/nav-data.ts +git commit -m "feat(dashboard): WhatsApp wallet page + request top-up" +``` + +--- + +### Task 10: Admin UI — top-up requests review/approve + +**Files:** +- Create: `clients/admin/src/api/wallet.ts` +- Create: `clients/admin/src/pages/billing/topups-list.tsx` +- Modify: `clients/admin/src/pages/billing/layout.tsx` (add tab), `clients/admin/src/routes.tsx`, `clients/admin/src/components/layout/nav-items.ts` (Billing nav already present; no change needed unless adding a sub-item) + +**Interfaces:** +- Consumes: `apiFetch`, `PagedResponse` (`@/lib/api-types`), `BillingPermissions` (`@/lib/permissions`), the existing invoice-detail route `/billing/invoices/:invoiceId` (approve links the operator there to mark paid). +- Produces (in `api/wallet.ts`): + - types `TopupRequestStatus` union + `TopupRequestDto` (same shape as dashboard). + - `listTopupRequests(params: { tenantId?: string; status?: TopupRequestStatus; pageNumber?: number; pageSize?: number }): Promise>` → `GET /api/v1/billing/wallet/topup-requests` + - `approveTopupRequest(id: string, note?: string): Promise` → `POST /api/v1/billing/wallet/topup-requests/{id}/approve` + - `rejectTopupRequest(id: string, reason?: string): Promise` → `POST /api/v1/billing/wallet/topup-requests/{id}/reject` + +- [ ] **Step 1: Create `api/wallet.ts`** — mirror `clients/admin/src/api/billing.ts` (same imports/helpers/string-union enums). + +- [ ] **Step 2: Create `pages/billing/topups-list.tsx`** — clone the structure of `pages/billing/invoices-list.tsx`: + - filters: `tenantId` (Input), `status` (Select, default `Pending`), pagination state. + - `useQuery(["billing","topup-requests",{...}], () => listTopupRequests({...}), { placeholderData: keepPreviousData })`. + - KPI tiles: pending count, total requested on page. + - rows: tenant id, amount+currency, status badge, created date; if `invoiceId`, a link to `/billing/invoices/{invoiceId}`. + - per-row actions gated by `canManageBilling = currentUser.permissions.includes(BillingPermissions.Manage)`: + - **Approve** → `useMutation({ mutationFn: (id) => approveTopupRequest(id, note) })`; on success toast "Invoice generated", `invalidateQueries(["billing","topup-requests"])`, and offer a link to the new invoice id returned. + - **Reject** → confirm + reason; `useMutation` → `rejectTopupRequest`. (Avoid native `confirm()` dialogs per browser-automation note; use the app's existing dialog/AlertDialog component as invoices/other admin pages do.) + - Pass per-call data via `mutate(arg)`. + +- [ ] **Step 3: Register route + tab.** In `routes.tsx` billing children: +```tsx +const TopupsListPage = lazyNamed(() => import("@/pages/billing/topups-list"), "TopupsListPage"); +// ... +{ path: "topups", element: }, +``` +Add a "Top-ups" tab to `pages/billing/layout.tsx` alongside Invoices/Plans (follow the existing tab list pattern in that file). + +- [ ] **Step 4: Typecheck + build the admin app** + +Run: `cd clients/admin && npm run build` +Expected: type-checks and builds with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add clients/admin/src/api/wallet.ts clients/admin/src/pages/billing/topups-list.tsx clients/admin/src/pages/billing/layout.tsx clients/admin/src/routes.tsx +git commit -m "feat(admin): top-up requests review/approve/reject page" +``` + +--- + +### Task 11: Frontend E2E (Playwright, route-mocked) + +**Files:** +- Create: `clients/admin/tests/billing/topups.spec.ts` +- Create: `clients/dashboard/tests/wallet.spec.ts` + +**Interfaces:** +- Consumes: the existing Playwright harness (JWT-seeded localStorage + route mocking). Copy the setup from a sibling billing spec (`clients/admin/tests/billing/*.spec.ts`, `clients/dashboard/tests/invoices.spec.ts`). + +- [ ] **Step 1: Dashboard spec** — mock `GET /api/v1/billing/wallet/me` (balance) + `GET .../topup-requests/me` (one Pending) + `POST .../topup-requests` (200 + id). Assert: balance renders; submitting the form fires the POST with the typed amount; the list refetch shows the request. **Mock every on-open endpoint** the page calls (an unmocked call 401s → auto-logout flake). + +- [ ] **Step 2: Admin spec** — seed an operator JWT with `Permissions.Billing.Manage`; mock `GET .../wallet/topup-requests` (one Pending) + `POST .../{id}/approve` (200 + invoiceId). Assert: the request renders; clicking Approve fires the POST and shows the success toast / invoice link. + +- [ ] **Step 3: Run both suites** + +Run: `cd clients/admin && npx playwright test billing/topups` then `cd clients/dashboard && npx playwright test wallet` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add clients/admin/tests/billing/topups.spec.ts clients/dashboard/tests/wallet.spec.ts +git commit -m "test(e2e): wallet topup request + admin approval flows" +``` + +--- + +### Task 12: Docs + changelog (definition of done) + +**Files:** +- Modify (docs repo `C:\Users\mukesh\repos\fullstackhero\docs`): a Billing/WhatsApp wallet page describing the top-up lifecycle + the BSP model. +- Create: `src/content/docs/changelog/.md` (in the docs repo's changelog dir; match the existing entry format). + +**Interfaces:** none (documentation). + +- [ ] **Step 1: Add changelog entry** in the docs repo describing: prepaid WhatsApp wallet, dashboard top-up request, admin approve→invoice→mark-paid→auto-credit, manual offline payment. Note Phase 2 (metering) is not yet shipped. + +- [ ] **Step 2: Add/Update the docs page** — explain Model B (we are the BSP, Meta bills us, clinics top up with us), the wallet money model, and the operator workflow. Cross-link from the Billing docs. + +- [ ] **Step 3: Commit (in the docs repo)** + +```bash +git -C C:/Users/mukesh/repos/fullstackhero/docs add . +git -C C:/Users/mukesh/repos/fullstackhero/docs commit -m "docs: WhatsApp prepaid wallet + top-up lifecycle" +``` + +--- + +## Out of scope (Phase 2 — do NOT build here) + +- **Metering / debit:** decrementing the wallet per WhatsApp template send (per Meta category pricing + markup). Requires the Meta Cloud API send integration, which does not exist yet. `Wallet.Debit` + `WalletTransactionKind.MessageCharge` are built now so the ledger is ready, but nothing calls `Debit` in Phase 1. +- **Low-balance notifications / auto-block** at zero balance. +- **Fixed top-up packages** (UI presets) — Phase 1 uses arbitrary amount with a min/max validator. +- **Per-category credit pricing display** ("messages remaining") — Phase 1 shows a money balance. +- **Embedded Signup / WABA onboarding** under our Meta Tech-Provider account. + +--- + +## Self-Review + +**Spec coverage** (against the agreed design): tenant requests top-up → Tasks 7, 9. Admin sees requests → Tasks 8, 10. Admin generates invoice + approves → Tasks 6, 8, 10. Manual payment → reuses existing invoice mark-paid UI. Wallet credited on paid → Task 6. Money-amount wallet → Tasks 2, 4, 5. Credit only on paid → Task 6 (branch inside `MarkInvoicePaidAsync`). Cross-tenant isolation → Tasks 7, 8 (root-gating + tests). Migration → Task 4. Docs/changelog → Task 12. No gaps. + +**Placeholder scan:** every code step shows real code; the two intentional "confirm the exact name in the existing file" notes (base-class `using`, `ICurrentUser` accessor, `_db`/`_eventBus`/`_timeProvider` field names, the private line-item add helper) are verification instructions, not deferred work — the implementer confirms a name, not designs a behavior. + +**Type consistency:** `TopupRequestStatus` members (`Pending/Invoiced/Completed/Rejected/Cancelled`) are identical across enum (Task 1), domain transitions (Task 3), DTO string output (Task 5), and both frontends (Tasks 9, 10). `WalletTransactionKind.Topup` used consistently in credit calls (Tasks 2, 6). `CreateTopupInvoiceAsync`/`GetOrCreateWalletAsync`/`MarkInvoicePaidAsync` signatures match between Task 6 (definition) and Tasks 7–8 (callers). Route paths (`/wallet/me`, `/wallet/topup-requests`, `/wallet/topup-requests/me`, `/wallet/topup-requests/{id}/approve|reject`) match between endpoint registration (Tasks 7–8) and frontend API modules (Tasks 9–10). From 68d77a2038248cda5f27611db6ee98e443ba76fb Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 00:26:22 +0530 Subject: [PATCH 02/15] feat(billing): add Topup invoice purpose + wallet/topup enums --- .../Modules.Billing.Contracts/BillingEnums.cs | 25 ++++++++++++++++++- .../Billing.Tests/Domain/EnumOrderingTests.cs | 24 ++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/Tests/Billing.Tests/Domain/EnumOrderingTests.cs diff --git a/src/Modules/Billing/Modules.Billing.Contracts/BillingEnums.cs b/src/Modules/Billing/Modules.Billing.Contracts/BillingEnums.cs index 310a11003c..c380acb34b 100644 --- a/src/Modules/Billing/Modules.Billing.Contracts/BillingEnums.cs +++ b/src/Modules/Billing/Modules.Billing.Contracts/BillingEnums.cs @@ -33,5 +33,28 @@ public enum InvoicePurpose // Usage=0 doubles as the column default (rows backfill to Usage; Subscription=1 always written // explicitly). Do NOT reorder — making Subscription 0 reintroduces the EF default-omitted bug. Usage = 0, - Subscription = 1 + Subscription = 1, + Topup = 2 +} + +public enum WalletStatus +{ + Active = 0, + Frozen = 1 +} + +public enum WalletTransactionKind +{ + Topup = 0, + MessageCharge = 1, + Adjustment = 2 +} + +public enum TopupRequestStatus +{ + Pending = 0, + Invoiced = 1, + Completed = 2, + Rejected = 3, + Cancelled = 4 } diff --git a/src/Tests/Billing.Tests/Domain/EnumOrderingTests.cs b/src/Tests/Billing.Tests/Domain/EnumOrderingTests.cs new file mode 100644 index 0000000000..4b0ff6f89f --- /dev/null +++ b/src/Tests/Billing.Tests/Domain/EnumOrderingTests.cs @@ -0,0 +1,24 @@ +using FSH.Modules.Billing.Contracts; +using Shouldly; +using Xunit; + +namespace Billing.Tests.Domain; + +public sealed class EnumOrderingTests +{ + [Fact] + public void InvoicePurpose_Topup_is_two() + => ((int)InvoicePurpose.Topup).ShouldBe(2); + + [Fact] + public void TopupRequestStatus_Pending_is_default_zero() + => ((int)TopupRequestStatus.Pending).ShouldBe(0); + + [Fact] + public void WalletStatus_Active_is_default_zero() + => ((int)WalletStatus.Active).ShouldBe(0); + + [Fact] + public void WalletTransactionKind_Topup_is_default_zero() + => ((int)WalletTransactionKind.Topup).ShouldBe(0); +} From c2eb03ad308d9f3f2ffddbf12421250e8bc8637f Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 00:29:25 +0530 Subject: [PATCH 03/15] feat(billing): add Wallet aggregate + WalletTransaction ledger Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Billing/Modules.Billing/Domain/Wallet.cs | 56 +++++++++++++++++++ .../Domain/WalletTransaction.cs | 32 +++++++++++ src/Tests/Billing.Tests/Domain/WalletTests.cs | 46 +++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 src/Modules/Billing/Modules.Billing/Domain/Wallet.cs create mode 100644 src/Modules/Billing/Modules.Billing/Domain/WalletTransaction.cs create mode 100644 src/Tests/Billing.Tests/Domain/WalletTests.cs diff --git a/src/Modules/Billing/Modules.Billing/Domain/Wallet.cs b/src/Modules/Billing/Modules.Billing/Domain/Wallet.cs new file mode 100644 index 0000000000..2b19715ca9 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Domain/Wallet.cs @@ -0,0 +1,56 @@ +using FSH.Framework.Core.Domain; +using FSH.Modules.Billing.Contracts; + +namespace FSH.Modules.Billing.Domain; + +public sealed class Wallet : AggregateRoot +{ + private readonly List _transactions = new(); + + public string TenantId { get; private set; } = default!; + public string Currency { get; private set; } = "USD"; + public decimal Balance { get; private set; } + public WalletStatus Status { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + public DateTime? UpdatedAtUtc { get; private set; } + + public IReadOnlyList Transactions => _transactions; + + private Wallet() { } + + public static Wallet Create(string tenantId, string currency) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + return new Wallet + { + Id = Guid.CreateVersion7(), + TenantId = tenantId, + Currency = string.IsNullOrWhiteSpace(currency) ? "USD" : currency, + Balance = 0m, + Status = WalletStatus.Active, + CreatedAtUtc = DateTime.UtcNow + }; + } + + public WalletTransaction Credit(decimal amount, WalletTransactionKind kind, string description, string? referenceId) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(amount, 0m); + var tx = WalletTransaction.Create(Id, TenantId, amount, kind, description, referenceId); + _transactions.Add(tx); + Balance += amount; + UpdatedAtUtc = DateTime.UtcNow; + return tx; + } + + public WalletTransaction Debit(decimal amount, WalletTransactionKind kind, string description, string? referenceId) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(amount, 0m); + if (amount > Balance) + throw new InvalidOperationException("Insufficient wallet balance."); + var tx = WalletTransaction.Create(Id, TenantId, -amount, kind, description, referenceId); + _transactions.Add(tx); + Balance -= amount; + UpdatedAtUtc = DateTime.UtcNow; + return tx; + } +} diff --git a/src/Modules/Billing/Modules.Billing/Domain/WalletTransaction.cs b/src/Modules/Billing/Modules.Billing/Domain/WalletTransaction.cs new file mode 100644 index 0000000000..9612f1d5ad --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Domain/WalletTransaction.cs @@ -0,0 +1,32 @@ +using FSH.Framework.Core.Domain; +using FSH.Modules.Billing.Contracts; + +namespace FSH.Modules.Billing.Domain; + +public sealed class WalletTransaction : BaseEntity +{ + public Guid WalletId { get; private set; } + public string TenantId { get; private set; } = default!; + public decimal Amount { get; private set; } + public WalletTransactionKind Kind { get; private set; } + public string Description { get; private set; } = default!; + public string? ReferenceId { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + + private WalletTransaction() { } + + internal static WalletTransaction Create( + Guid walletId, string tenantId, decimal amount, + WalletTransactionKind kind, string description, string? referenceId) + => new() + { + Id = Guid.CreateVersion7(), + WalletId = walletId, + TenantId = tenantId, + Amount = amount, + Kind = kind, + Description = description, + ReferenceId = referenceId, + CreatedAtUtc = DateTime.UtcNow + }; +} diff --git a/src/Tests/Billing.Tests/Domain/WalletTests.cs b/src/Tests/Billing.Tests/Domain/WalletTests.cs new file mode 100644 index 0000000000..cad15647cb --- /dev/null +++ b/src/Tests/Billing.Tests/Domain/WalletTests.cs @@ -0,0 +1,46 @@ +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Domain; +using Shouldly; +using Xunit; + +namespace Billing.Tests.Domain; + +public sealed class WalletTests +{ + [Fact] + public void Create_starts_active_with_zero_balance() + { + var w = Wallet.Create("tenant-a", "USD"); + w.TenantId.ShouldBe("tenant-a"); + w.Currency.ShouldBe("USD"); + w.Balance.ShouldBe(0m); + w.Status.ShouldBe(WalletStatus.Active); + w.Id.ShouldNotBe(Guid.Empty); + } + + [Fact] + public void Credit_increases_balance_and_returns_ledger_row() + { + var w = Wallet.Create("tenant-a", "USD"); + var tx = w.Credit(50m, WalletTransactionKind.Topup, "Top-up", "req-1"); + w.Balance.ShouldBe(50m); + tx.Amount.ShouldBe(50m); + tx.WalletId.ShouldBe(w.Id); + tx.TenantId.ShouldBe("tenant-a"); + tx.ReferenceId.ShouldBe("req-1"); + } + + [Fact] + public void Credit_rejects_non_positive_amount() + => Should.Throw( + () => Wallet.Create("t", "USD").Credit(0m, WalletTransactionKind.Topup, "x", null)); + + [Fact] + public void Debit_beyond_balance_throws() + { + var w = Wallet.Create("tenant-a", "USD"); + w.Credit(10m, WalletTransactionKind.Topup, "Top-up", null); + Should.Throw( + () => w.Debit(25m, WalletTransactionKind.MessageCharge, "msg", null)); + } +} From a438778cff957247640af6128bc6905278274e23 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 00:32:28 +0530 Subject: [PATCH 04/15] feat(billing): add TopupRequest aggregate with status transitions --- .../Modules.Billing/Domain/TopupRequest.cs | 68 +++++++++++++++++++ .../Billing.Tests/Domain/TopupRequestTests.cs | 44 ++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs create mode 100644 src/Tests/Billing.Tests/Domain/TopupRequestTests.cs diff --git a/src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs b/src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs new file mode 100644 index 0000000000..0149cf0876 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs @@ -0,0 +1,68 @@ +using FSH.Framework.Core.Domain; +using FSH.Modules.Billing.Contracts; + +namespace FSH.Modules.Billing.Domain; + +public sealed class TopupRequest : AggregateRoot +{ + public string TenantId { get; private set; } = default!; + public decimal Amount { get; private set; } + public string Currency { get; private set; } = "USD"; + public string? Note { get; private set; } + public TopupRequestStatus Status { get; private set; } + public Guid? InvoiceId { get; private set; } + public string? RequestedBy { get; private set; } + public string? DecisionNote { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + public DateTime? DecidedAtUtc { get; private set; } + public DateTime? CompletedAtUtc { get; private set; } + + private TopupRequest() { } + + public static TopupRequest Create(string tenantId, decimal amount, string currency, string? note, string? requestedBy) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(amount, 0m); + return new TopupRequest + { + Id = Guid.CreateVersion7(), + TenantId = tenantId, + Amount = amount, + Currency = string.IsNullOrWhiteSpace(currency) ? "USD" : currency, + Note = note, + RequestedBy = requestedBy, + Status = TopupRequestStatus.Pending, + CreatedAtUtc = DateTime.UtcNow + }; + } + + public void MarkInvoiced(Guid invoiceId, string? note) + { + Require(TopupRequestStatus.Pending); + InvoiceId = invoiceId; + DecisionNote = note; + Status = TopupRequestStatus.Invoiced; + DecidedAtUtc = DateTime.UtcNow; + } + + public void MarkCompleted() + { + Require(TopupRequestStatus.Invoiced); + Status = TopupRequestStatus.Completed; + CompletedAtUtc = DateTime.UtcNow; + } + + public void Reject(string? reason) + { + Require(TopupRequestStatus.Pending); + DecisionNote = reason; + Status = TopupRequestStatus.Rejected; + DecidedAtUtc = DateTime.UtcNow; + } + + private void Require(TopupRequestStatus expected) + { + if (Status != expected) + throw new InvalidOperationException($"Top-up request must be {expected} (was {Status})."); + } +} diff --git a/src/Tests/Billing.Tests/Domain/TopupRequestTests.cs b/src/Tests/Billing.Tests/Domain/TopupRequestTests.cs new file mode 100644 index 0000000000..115f9c2fb6 --- /dev/null +++ b/src/Tests/Billing.Tests/Domain/TopupRequestTests.cs @@ -0,0 +1,44 @@ +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Domain; +using Shouldly; +using Xunit; + +namespace Billing.Tests.Domain; + +public sealed class TopupRequestTests +{ + [Fact] + public void Create_starts_pending() + { + var r = TopupRequest.Create("tenant-a", 50m, "USD", "need credit", "user-1"); + r.Status.ShouldBe(TopupRequestStatus.Pending); + r.Amount.ShouldBe(50m); + r.InvoiceId.ShouldBeNull(); + } + + [Fact] + public void MarkInvoiced_from_pending_links_invoice() + { + var r = TopupRequest.Create("tenant-a", 50m, "USD", null, null); + var inv = Guid.CreateVersion7(); + r.MarkInvoiced(inv, "approved"); + r.Status.ShouldBe(TopupRequestStatus.Invoiced); + r.InvoiceId.ShouldBe(inv); + r.DecidedAtUtc.ShouldNotBeNull(); + } + + [Fact] + public void MarkCompleted_requires_invoiced() + { + var r = TopupRequest.Create("tenant-a", 50m, "USD", null, null); + Should.Throw(() => r.MarkCompleted()); + } + + [Fact] + public void Reject_from_invoiced_throws() + { + var r = TopupRequest.Create("tenant-a", 50m, "USD", null, null); + r.MarkInvoiced(Guid.CreateVersion7(), null); + Should.Throw(() => r.Reject("late")); + } +} From a5ccded28a8ea819c43ec6815451f34886a76985 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 00:42:30 +0530 Subject: [PATCH 05/15] feat(billing): persist wallet/ledger/topup tables + filter invoice unique index Co-Authored-By: Claude Sonnet 4.6 --- ...0625190639_WhatsAppWalletTopup.Designer.cs | 449 ++++++++++++++++++ .../20260625190639_WhatsAppWalletTopup.cs | 154 ++++++ .../Billing/BillingDbContextModelSnapshot.cs | 152 +++++- .../Modules.Billing/Data/BillingDbContext.cs | 3 + .../Configurations/InvoiceConfiguration.cs | 3 + .../TopupRequestConfiguration.cs | 25 + .../Configurations/WalletConfiguration.cs | 29 ++ .../WalletTransactionConfiguration.cs | 24 + 8 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260625190639_WhatsAppWalletTopup.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260625190639_WhatsAppWalletTopup.cs create mode 100644 src/Modules/Billing/Modules.Billing/Data/Configurations/TopupRequestConfiguration.cs create mode 100644 src/Modules/Billing/Modules.Billing/Data/Configurations/WalletConfiguration.cs create mode 100644 src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260625190639_WhatsAppWalletTopup.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260625190639_WhatsAppWalletTopup.Designer.cs new file mode 100644 index 0000000000..7196681190 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260625190639_WhatsAppWalletTopup.Designer.cs @@ -0,0 +1,449 @@ +// +using System; +using FSH.Modules.Billing.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Billing +{ + [DbContext(typeof(BillingDbContext))] + [Migration("20260625190639_WhatsAppWalletTopup")] + partial class WhatsAppWalletTopup + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("billing") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.BillingPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnnualPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Interval") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MonthlyBasePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("_overageRates") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("OverageRates") + .HasDefaultValueSql("'{}'::jsonb"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Plans", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("DueAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PaidAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PeriodEndUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PeriodMonth") + .HasColumnType("integer"); + + b.Property("PeriodStartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PeriodYear") + .HasColumnType("integer"); + + b.Property("Purpose") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubtotalAmount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("VoidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TenantId", "PeriodYear", "PeriodMonth", "Purpose") + .IsUnique() + .HasDatabaseName("ux_invoices_tenant_period_purpose") + .HasFilter("\"Purpose\" <> 2"); + + b.ToTable("Invoices", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.InvoiceLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("InvoiceId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Resource") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLineItems", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("StartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ux_subscriptions_tenantid_active") + .HasFilter("\"Status\" = 0"); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("Subscriptions", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.TopupRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DecisionNote") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("InvoiceId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RequestedBy") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("TopupRequests", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.UsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LimitUnits") + .HasColumnType("bigint"); + + b.Property("PeriodMonth") + .HasColumnType("integer"); + + b.Property("PeriodYear") + .HasColumnType("integer"); + + b.Property("Resource") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UsedUnits") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PeriodYear", "PeriodMonth", "Resource") + .IsUnique() + .HasDatabaseName("ux_usage_snapshots_tenant_period_resource"); + + b.ToTable("UsageSnapshots", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Balance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ux_wallets_tenantid"); + + b.ToTable("Wallets", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.WalletTransaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WalletId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("WalletId", "CreatedAtUtc"); + + b.ToTable("WalletTransactions", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.InvoiceLineItem", b => + { + b.HasOne("FSH.Modules.Billing.Domain.Invoice", null) + .WithMany("LineItems") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.WalletTransaction", b => + { + b.HasOne("FSH.Modules.Billing.Domain.Wallet", null) + .WithMany("Transactions") + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.Invoice", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.Wallet", b => + { + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260625190639_WhatsAppWalletTopup.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260625190639_WhatsAppWalletTopup.cs new file mode 100644 index 0000000000..9ee2c790a3 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260625190639_WhatsAppWalletTopup.cs @@ -0,0 +1,154 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Billing +{ + /// + public partial class WhatsAppWalletTopup : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ux_invoices_tenant_period_purpose", + schema: "billing", + table: "Invoices"); + + migrationBuilder.CreateTable( + name: "TopupRequests", + schema: "billing", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Amount = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Currency = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + Note = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + Status = table.Column(type: "integer", nullable: false), + InvoiceId = table.Column(type: "uuid", nullable: true), + RequestedBy = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + DecisionNote = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + DecidedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CompletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TopupRequests", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Wallets", + schema: "billing", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Currency = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + Balance = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Wallets", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "WalletTransactions", + schema: "billing", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WalletId = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Amount = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Kind = table.Column(type: "integer", nullable: false), + Description = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ReferenceId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WalletTransactions", x => x.Id); + table.ForeignKey( + name: "FK_WalletTransactions_Wallets_WalletId", + column: x => x.WalletId, + principalSchema: "billing", + principalTable: "Wallets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ux_invoices_tenant_period_purpose", + schema: "billing", + table: "Invoices", + columns: new[] { "TenantId", "PeriodYear", "PeriodMonth", "Purpose" }, + unique: true, + filter: "\"Purpose\" <> 2"); + + migrationBuilder.CreateIndex( + name: "IX_TopupRequests_InvoiceId", + schema: "billing", + table: "TopupRequests", + column: "InvoiceId"); + + migrationBuilder.CreateIndex( + name: "IX_TopupRequests_TenantId_Status", + schema: "billing", + table: "TopupRequests", + columns: new[] { "TenantId", "Status" }); + + migrationBuilder.CreateIndex( + name: "ux_wallets_tenantid", + schema: "billing", + table: "Wallets", + column: "TenantId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_WalletTransactions_TenantId", + schema: "billing", + table: "WalletTransactions", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_WalletTransactions_WalletId_CreatedAtUtc", + schema: "billing", + table: "WalletTransactions", + columns: new[] { "WalletId", "CreatedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TopupRequests", + schema: "billing"); + + migrationBuilder.DropTable( + name: "WalletTransactions", + schema: "billing"); + + migrationBuilder.DropTable( + name: "Wallets", + schema: "billing"); + + migrationBuilder.DropIndex( + name: "ux_invoices_tenant_period_purpose", + schema: "billing", + table: "Invoices"); + + migrationBuilder.CreateIndex( + name: "ux_invoices_tenant_period_purpose", + schema: "billing", + table: "Invoices", + columns: new[] { "TenantId", "PeriodYear", "PeriodMonth", "Purpose" }, + unique: true); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs index aec6849e79..647189b106 100644 --- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs @@ -154,7 +154,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TenantId", "PeriodYear", "PeriodMonth", "Purpose") .IsUnique() - .HasDatabaseName("ux_invoices_tenant_period_purpose"); + .HasDatabaseName("ux_invoices_tenant_period_purpose") + .HasFilter("\"Purpose\" <> 2"); b.ToTable("Invoices", "billing"); }); @@ -239,6 +240,62 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Subscriptions", "billing"); }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.TopupRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DecisionNote") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("InvoiceId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RequestedBy") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("TopupRequests", "billing"); + }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.UsageSnapshot", b => { b.Property("Id") @@ -277,6 +334,85 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UsageSnapshots", "billing"); }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Balance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ux_wallets_tenantid"); + + b.ToTable("Wallets", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.WalletTransaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WalletId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("WalletId", "CreatedAtUtc"); + + b.ToTable("WalletTransactions", "billing"); + }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.InvoiceLineItem", b => { b.HasOne("FSH.Modules.Billing.Domain.Invoice", null) @@ -286,10 +422,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.WalletTransaction", b => + { + b.HasOne("FSH.Modules.Billing.Domain.Wallet", null) + .WithMany("Transactions") + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.Invoice", b => { b.Navigation("LineItems"); }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.Wallet", b => + { + b.Navigation("Transactions"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Modules/Billing/Modules.Billing/Data/BillingDbContext.cs b/src/Modules/Billing/Modules.Billing/Data/BillingDbContext.cs index 340161abce..f966ed2edf 100644 --- a/src/Modules/Billing/Modules.Billing/Data/BillingDbContext.cs +++ b/src/Modules/Billing/Modules.Billing/Data/BillingDbContext.cs @@ -20,6 +20,9 @@ public BillingDbContext(DbContextOptions options) : base(optio public DbSet Invoices => Set(); public DbSet InvoiceLineItems => Set(); public DbSet UsageSnapshots => Set(); + public DbSet Wallets => Set(); + public DbSet WalletTransactions => Set(); + public DbSet TopupRequests => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs index f665c59ce0..062bb7c676 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs @@ -23,8 +23,11 @@ public void Configure(EntityTypeBuilder builder) // One invoice per tenant per month *per purpose* — subscription (term base fee) and usage // (metered overage) are separate streams that may both fall in the same calendar month. + // Recurring invoices are unique per tenant/period/purpose; Topup invoices (Purpose=2) are + // ad-hoc and may repeat within a period, so exclude them from the uniqueness filter. builder.HasIndex(x => new { x.TenantId, x.PeriodYear, x.PeriodMonth, x.Purpose }) .IsUnique() + .HasFilter($"\"Purpose\" <> {(int)Contracts.InvoicePurpose.Topup}") .HasDatabaseName("ux_invoices_tenant_period_purpose"); builder.HasIndex(x => x.Status); builder.HasIndex(x => x.InvoiceNumber).IsUnique(); diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/TopupRequestConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/TopupRequestConfiguration.cs new file mode 100644 index 0000000000..3f79e25d97 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/TopupRequestConfiguration.cs @@ -0,0 +1,25 @@ +using FSH.Modules.Billing.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Billing.Data.Configurations; + +public sealed class TopupRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("TopupRequests"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired().HasMaxLength(64); + builder.Property(x => x.Amount).HasPrecision(18, 4); + builder.Property(x => x.Currency).IsRequired().HasMaxLength(8); + builder.Property(x => x.Note).HasMaxLength(512); + builder.Property(x => x.DecisionNote).HasMaxLength(512); + builder.Property(x => x.RequestedBy).HasMaxLength(64); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.Status }); + builder.HasIndex(x => x.InvoiceId); + builder.Ignore(x => x.DomainEvents); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletConfiguration.cs new file mode 100644 index 0000000000..11d2a1d325 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletConfiguration.cs @@ -0,0 +1,29 @@ +using FSH.Modules.Billing.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Billing.Data.Configurations; + +public sealed class WalletConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("Wallets"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired().HasMaxLength(64); + builder.Property(x => x.Currency).IsRequired().HasMaxLength(8); + builder.Property(x => x.Balance).HasPrecision(18, 4); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => x.TenantId).IsUnique().HasDatabaseName("ux_wallets_tenantid"); + + builder.HasMany(x => x.Transactions) + .WithOne() + .HasForeignKey(t => t.WalletId) + .OnDelete(DeleteBehavior.Cascade); + builder.Metadata.FindNavigation(nameof(Wallet.Transactions))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.Ignore(x => x.DomainEvents); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs new file mode 100644 index 0000000000..3910b95190 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs @@ -0,0 +1,24 @@ +using FSH.Modules.Billing.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Billing.Data.Configurations; + +public sealed class WalletTransactionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("WalletTransactions"); + builder.HasKey(x => x.Id); + // Child reached only via Wallet.Transactions nav — pin Id generation or EF marks Modified, not Added. + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.TenantId).IsRequired().HasMaxLength(64); + builder.Property(x => x.Amount).HasPrecision(18, 4); + builder.Property(x => x.Kind).HasConversion(); + builder.Property(x => x.Description).IsRequired().HasMaxLength(256); + builder.Property(x => x.ReferenceId).HasMaxLength(128); + builder.HasIndex(x => new { x.WalletId, x.CreatedAtUtc }); + builder.HasIndex(x => x.TenantId); + } +} From c621434c64865c9ae6dd4713a2b0f3b30fa2186e Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 00:48:27 +0530 Subject: [PATCH 06/15] feat(billing): wallet/topup DTOs + string-enum mappings --- .../Dtos/TopupRequestDto.cs | 15 ++++++++++ .../Dtos/WalletDto.cs | 10 +++++++ .../Dtos/WalletTransactionDto.cs | 9 ++++++ .../Mappings/WalletMappings.cs | 24 +++++++++++++++ .../Modules.Billing/Modules.Billing.csproj | 4 +++ .../Mappings/WalletMappingTests.cs | 29 +++++++++++++++++++ 6 files changed, 91 insertions(+) create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/Dtos/TopupRequestDto.cs create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletDto.cs create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletTransactionDto.cs create mode 100644 src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs create mode 100644 src/Tests/Billing.Tests/Mappings/WalletMappingTests.cs diff --git a/src/Modules/Billing/Modules.Billing.Contracts/Dtos/TopupRequestDto.cs b/src/Modules/Billing/Modules.Billing.Contracts/Dtos/TopupRequestDto.cs new file mode 100644 index 0000000000..acedd3e4de --- /dev/null +++ b/src/Modules/Billing/Modules.Billing.Contracts/Dtos/TopupRequestDto.cs @@ -0,0 +1,15 @@ +namespace FSH.Modules.Billing.Contracts.Dtos; + +public sealed record TopupRequestDto( + Guid Id, + string TenantId, + decimal Amount, + string Currency, + string? Note, + string Status, + Guid? InvoiceId, + string? RequestedBy, + string? DecisionNote, + DateTime CreatedAtUtc, + DateTime? DecidedAtUtc, + DateTime? CompletedAtUtc); diff --git a/src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletDto.cs b/src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletDto.cs new file mode 100644 index 0000000000..f9c30a56b3 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletDto.cs @@ -0,0 +1,10 @@ +namespace FSH.Modules.Billing.Contracts.Dtos; + +public sealed record WalletDto( + Guid Id, + string TenantId, + string Currency, + decimal Balance, + string Status, + DateTime CreatedAtUtc, + IReadOnlyList RecentTransactions); diff --git a/src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletTransactionDto.cs b/src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletTransactionDto.cs new file mode 100644 index 0000000000..c1b3125822 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing.Contracts/Dtos/WalletTransactionDto.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Billing.Contracts.Dtos; + +public sealed record WalletTransactionDto( + Guid Id, + decimal Amount, + string Kind, + string Description, + string? ReferenceId, + DateTime CreatedAtUtc); diff --git a/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs b/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs new file mode 100644 index 0000000000..0f3092e3af --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs @@ -0,0 +1,24 @@ +using FSH.Modules.Billing.Contracts.Dtos; +using FSH.Modules.Billing.Domain; + +namespace FSH.Modules.Billing.Mappings; + +internal static class WalletMappings +{ + public static WalletTransactionDto ToDto(this WalletTransaction t) + => new(t.Id, t.Amount, t.Kind.ToString(), t.Description, t.ReferenceId, t.CreatedAtUtc); + + public static WalletDto ToDto(this Wallet w, int recentCount = 10) + => new( + w.Id, w.TenantId, w.Currency, w.Balance, w.Status.ToString(), w.CreatedAtUtc, + w.Transactions + .OrderByDescending(t => t.CreatedAtUtc) + .Take(recentCount) + .Select(t => t.ToDto()) + .ToList()); + + public static TopupRequestDto ToDto(this TopupRequest r) + => new( + r.Id, r.TenantId, r.Amount, r.Currency, r.Note, r.Status.ToString(), + r.InvoiceId, r.RequestedBy, r.DecisionNote, r.CreatedAtUtc, r.DecidedAtUtc, r.CompletedAtUtc); +} diff --git a/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj b/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj index a25417b6fb..75f90198a1 100644 --- a/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj +++ b/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj @@ -6,6 +6,10 @@ $(NoWarn);CA1031;CA1711;CA1812;CA1859;S3267 + + + + diff --git a/src/Tests/Billing.Tests/Mappings/WalletMappingTests.cs b/src/Tests/Billing.Tests/Mappings/WalletMappingTests.cs new file mode 100644 index 0000000000..ad613f2ef1 --- /dev/null +++ b/src/Tests/Billing.Tests/Mappings/WalletMappingTests.cs @@ -0,0 +1,29 @@ +using FSH.Modules.Billing.Domain; +using FSH.Modules.Billing.Mappings; +using FSH.Modules.Billing.Contracts; +using Shouldly; +using Xunit; + +namespace Billing.Tests.Mappings; + +public sealed class WalletMappingTests +{ + [Fact] + public void Wallet_ToDto_emits_string_status_and_balance() + { + var w = Wallet.Create("tenant-a", "USD"); + w.Credit(50m, WalletTransactionKind.Topup, "Top-up", "req-1"); + var dto = w.ToDto(); + dto.Balance.ShouldBe(50m); + dto.Status.ShouldBe("Active"); + dto.RecentTransactions.Count.ShouldBe(1); + dto.RecentTransactions[0].Kind.ShouldBe("Topup"); + } + + [Fact] + public void TopupRequest_ToDto_emits_string_status() + { + var r = TopupRequest.Create("tenant-a", 25m, "USD", "note", "u1"); + r.ToDto().Status.ShouldBe("Pending"); + } +} From c7235db6b0a2d4261af91b5cba27585c7b8c4bbd Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 01:02:53 +0530 Subject: [PATCH 07/15] feat(billing): wallet top-up invoice generation and credit-on-paid 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 --- .../Billing/Modules.Billing/Domain/Invoice.cs | 20 +++ .../Services/BillingService.cs | 110 ++++++++++++++++ .../Services/IBillingService.cs | 13 ++ .../Tests/Billing/WalletTopupServiceTests.cs | 119 ++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs diff --git a/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs b/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs index 92a1397b43..d79430d6d9 100644 --- a/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs +++ b/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs @@ -90,6 +90,26 @@ public static Invoice CreateDraft( }; } + /// + /// Convenience factory for top-up invoices: creates a Draft with InvoicePurpose.Topup + /// and immediately adds a single line item so that + /// is set before the invoice is issued. + /// + public static Invoice CreateTopupDraft( + string tenantId, + string invoiceNumber, + int periodYear, + int periodMonth, + string currency, + decimal amount, + string lineItemDescription) + { + var invoice = CreateDraft(tenantId, invoiceNumber, periodYear, periodMonth, currency, + InvoicePurpose.Topup, periodStartUtc: null, periodEndUtc: null); + invoice.AddLineItem(InvoiceLineItemKind.Adjustment, lineItemDescription, 1m, amount); + return invoice; + } + public InvoiceLineItem AddLineItem(InvoiceLineItemKind kind, string description, decimal quantity, decimal unitPrice) { RequireStatus(InvoiceStatus.Draft); diff --git a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs index f3131b9ee6..bbf5cef349 100644 --- a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs +++ b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs @@ -164,10 +164,107 @@ public async Task IssueInvoiceAsync(Guid invoiceId, DateTime? dueAtUtc, Cancella await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + public async Task GetOrCreateWalletAsync(string tenantId, string currency, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var wallet = await _db.Wallets + .FirstOrDefaultAsync(w => w.TenantId == tenantId, cancellationToken) + .ConfigureAwait(false); + if (wallet is null) + { + wallet = Wallet.Create(tenantId, currency); + _db.Wallets.Add(wallet); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + return wallet; + } + + public async Task CreateTopupInvoiceAsync(string tenantId, Guid topupRequestId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var request = await _db.TopupRequests + .FirstOrDefaultAsync(r => r.Id == topupRequestId && r.TenantId == tenantId, cancellationToken) + .ConfigureAwait(false) + ?? throw new NotFoundException($"Top-up request {topupRequestId} not found for tenant {tenantId}."); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var invoiceNumber = BuildTopupInvoiceNumber(tenantId, now, topupRequestId); + + var invoice = Invoice.CreateTopupDraft( + tenantId, + invoiceNumber, + now.Year, + now.Month, + request.Currency, + request.Amount, + $"WhatsApp wallet top-up ({request.Amount:0.##} {request.Currency})"); + + invoice.Issue(); + _db.Invoices.Add(invoice); + request.MarkInvoiced(invoice.Id, request.Note); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "[Billing] issued top-up invoice {InvoiceNumber} for tenant {TenantId} amount={Amount} {Currency}", + invoice.InvoiceNumber, tenantId, invoice.SubtotalAmount, invoice.Currency); + } + + await _eventBus.PublishAsync(new InvoiceIssuedIntegrationEvent( + Id: Guid.NewGuid(), + OccurredOnUtc: now, + TenantId: tenantId, + CorrelationId: Guid.NewGuid().ToString(), + Source: "Billing", + InvoiceId: invoice.Id, + InvoiceNumber: invoice.InvoiceNumber, + Amount: invoice.SubtotalAmount, + Currency: invoice.Currency, + DueAtUtc: invoice.DueAtUtc, + PeriodYear: invoice.PeriodYear, + PeriodMonth: invoice.PeriodMonth), cancellationToken).ConfigureAwait(false); + + return invoice; + } + public async Task MarkInvoicePaidAsync(Guid invoiceId, CancellationToken cancellationToken = default) { var invoice = await LoadInvoiceAsync(invoiceId, cancellationToken).ConfigureAwait(false); invoice.MarkPaid(); + + // When a top-up invoice is paid, credit the tenant's wallet and complete the request — + // all in the same SaveChanges so the credit + status flip are atomic. + if (invoice.Purpose == InvoicePurpose.Topup) + { + var topupRequest = await _db.TopupRequests + .FirstOrDefaultAsync(r => r.InvoiceId == invoice.Id, cancellationToken) + .ConfigureAwait(false); + + if (topupRequest is { Status: TopupRequestStatus.Invoiced }) + { + var wallet = await _db.Wallets + .FirstOrDefaultAsync(w => w.TenantId == invoice.TenantId, cancellationToken) + .ConfigureAwait(false); + + if (wallet is null) + { + wallet = Wallet.Create(invoice.TenantId, invoice.Currency); + _db.Wallets.Add(wallet); + } + + wallet.Credit( + invoice.SubtotalAmount, + WalletTransactionKind.Topup, + "WhatsApp wallet top-up", + topupRequest.Id.ToString()); + + topupRequest.MarkCompleted(); + } + } + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } @@ -270,6 +367,19 @@ private static string BuildUsageInvoiceNumber(string tenantId, int periodYear, i private static string BuildSubscriptionInvoiceNumber(string tenantId, DateTime periodStartUtc) => $"SUB-{periodStartUtc:yyyyMM}-{TenantToken(tenantId)}"; + /// + /// Generates a collision-safe invoice number for a top-up. + /// Format: TOP-{yyyyMM}-{tenantToken}-{requestSuffix} + /// where requestSuffix is 8 hex chars from the last 4 bytes of . + /// Each has a unique , so two top-ups for the same + /// tenant in the same month produce distinct numbers and never collide on the unique InvoiceNumber index. + /// + private static string BuildTopupInvoiceNumber(string tenantId, DateTime now, Guid topupRequestId) + { + var suffix = Convert.ToHexString(topupRequestId.ToByteArray(), 12, 4); + return $"TOP-{now:yyyyMM}-{TenantToken(tenantId)}-{suffix}"; + } + // Stable, collision-resistant token from the full tenant id; a naive prefix truncation would // collide for shared-prefix tenants and clash on the unique InvoiceNumber index. private static string TenantToken(string tenantId) diff --git a/src/Modules/Billing/Modules.Billing/Services/IBillingService.cs b/src/Modules/Billing/Modules.Billing/Services/IBillingService.cs index c2d49e7e03..18293b8039 100644 --- a/src/Modules/Billing/Modules.Billing/Services/IBillingService.cs +++ b/src/Modules/Billing/Modules.Billing/Services/IBillingService.cs @@ -8,6 +8,19 @@ namespace FSH.Modules.Billing.Services; /// public interface IBillingService { + /// + /// Returns the wallet for , creating one if none exists. + /// The wallet is the single balance ledger per tenant for prepaid credit (e.g. WhatsApp). + /// + Task GetOrCreateWalletAsync(string tenantId, string currency, CancellationToken cancellationToken = default); + + /// + /// Creates and issues a Topup-purpose invoice for the pending , + /// fires InvoiceIssuedIntegrationEvent, calls request.MarkInvoiced, and saves — + /// all in one unit of work. + /// + Task CreateTopupInvoiceAsync(string tenantId, Guid topupRequestId, CancellationToken cancellationToken = default); + /// /// Generates a Draft invoice for the tenant/period by snapshotting usage and pricing it against /// the tenant's active subscription plan. Returns null if the tenant has no active subscription diff --git a/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs b/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs new file mode 100644 index 0000000000..e28f4657d6 --- /dev/null +++ b/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs @@ -0,0 +1,119 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Domain; +using FSH.Modules.Billing.Services; +using Integration.Tests.Infrastructure; + +namespace Integration.Tests.Tests.Billing; + +/// +/// Integration tests for the WhatsApp wallet top-up flow: +/// CreateTopupInvoiceAsync → issues a Topup-purpose invoice and marks the request as Invoiced. +/// MarkInvoicePaidAsync → credits the wallet and marks the request as Completed (same UoW). +/// GetOrCreateWalletAsync → idempotent wallet retrieval. +/// Two top-ups in the same month succeed without an invoice-number/unique-index collision. +/// +/// Each test uses a unique synthetic tenant id so wallet state never bleeds between runs. +/// The Finbuckle context is always set INLINE (AsyncLocal; lost across awaited helpers). +/// +[Collection(FshCollectionDefinition.Name)] +public sealed class WalletTopupServiceTests +{ + private readonly FshWebApplicationFactory _factory; + + public WalletTopupServiceTests(FshWebApplicationFactory factory) + { + _factory = factory; + } + + // ── Happy-path ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Topup_credits_wallet_when_invoice_marked_paid() + { + var tenantId = UniqueTestTenantId(); + + using var scope = _factory.Services.CreateScope(); + + // Set Finbuckle context INLINE — AsyncLocal propagates downward but NOT back up after await, + // so the setter must be called directly in this method before any service calls. + var tenantStore = scope.ServiceProvider.GetRequiredService>(); + var tenant = await tenantStore.GetAsync(TestConstants.RootTenantId); + scope.ServiceProvider.GetRequiredService().MultiTenantContext = + new MultiTenantContext(tenant); + + var billing = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Arrange: seed a pending top-up request directly. + var request = TopupRequest.Create(tenantId, 50m, "USD", "need credit", "user-1"); + db.TopupRequests.Add(request); + await db.SaveChangesAsync(); + + // Act: create the top-up invoice. + var invoice = await billing.CreateTopupInvoiceAsync(tenantId, request.Id); + + invoice.Purpose.ShouldBe(InvoicePurpose.Topup); + invoice.Status.ShouldBe(InvoiceStatus.Issued); + + // Act: mark it paid — should credit the wallet in the same UoW. + await billing.MarkInvoicePaidAsync(invoice.Id); + + // Assert: wallet balance equals the top-up amount. + var wallet = await billing.GetOrCreateWalletAsync(tenantId, "USD"); + wallet.Balance.ShouldBe(50m); + + // Assert: request transitioned to Completed. + var reloaded = await db.TopupRequests.FindAsync(request.Id); + reloaded!.Status.ShouldBe(TopupRequestStatus.Completed); + } + + // ── Invoice-number uniqueness ──────────────────────────────────────────── + + [Fact] + public async Task Two_topups_in_same_month_both_succeed_with_unique_invoice_numbers() + { + var tenantId = UniqueTestTenantId(); + + using var scope = _factory.Services.CreateScope(); + + // Set context inline (AsyncLocal does not propagate back after awaited helpers). + var tenantStore2 = scope.ServiceProvider.GetRequiredService>(); + var tenant2 = await tenantStore2.GetAsync(TestConstants.RootTenantId); + scope.ServiceProvider.GetRequiredService().MultiTenantContext = + new MultiTenantContext(tenant2); + + var billing = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Arrange: two pending requests for the same synthetic tenant in the current month. + var req1 = TopupRequest.Create(tenantId, 25m, "USD", "first", "user-1"); + var req2 = TopupRequest.Create(tenantId, 75m, "USD", "second", "user-1"); + db.TopupRequests.AddRange(req1, req2); + await db.SaveChangesAsync(); + + // Act: create both top-up invoices. Should not throw despite same month/tenant. + var inv1 = await billing.CreateTopupInvoiceAsync(tenantId, req1.Id); + var inv2 = await billing.CreateTopupInvoiceAsync(tenantId, req2.Id); + + // Assert: both invoices are issued with Topup purpose and distinct numbers. + inv1.Status.ShouldBe(InvoiceStatus.Issued); + inv2.Status.ShouldBe(InvoiceStatus.Issued); + inv1.Purpose.ShouldBe(InvoicePurpose.Topup); + inv2.Purpose.ShouldBe(InvoicePurpose.Topup); + inv1.InvoiceNumber.ShouldNotBe(inv2.InvoiceNumber, + "two top-ups in the same month must have distinct invoice numbers"); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// + /// Unique per-test tenant id — keeps wallet state isolated between tests. + /// Does not need to be a real tenant in the store; BillingDbContext is not tenant-filtered. + /// + private static string UniqueTestTenantId() => + $"wt-{Guid.NewGuid().ToString("N")[..12]}"; +} From 642bddc9fa37430aad2570fb8abda5ff1ba16c3a Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 01:13:11 +0530 Subject: [PATCH 08/15] fix(billing): guard topup-invoice to Pending requests + eager-load wallet ledger Co-Authored-By: Claude Sonnet 4.6 --- .../Services/BillingService.cs | 5 ++-- .../Tests/Billing/WalletTopupServiceTests.cs | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs index bbf5cef349..08ff030257 100644 --- a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs +++ b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs @@ -168,6 +168,7 @@ public async Task GetOrCreateWalletAsync(string tenantId, string currenc { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); var wallet = await _db.Wallets + .Include(w => w.Transactions) .FirstOrDefaultAsync(w => w.TenantId == tenantId, cancellationToken) .ConfigureAwait(false); if (wallet is null) @@ -184,9 +185,9 @@ public async Task CreateTopupInvoiceAsync(string tenantId, Guid topupRe ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); var request = await _db.TopupRequests - .FirstOrDefaultAsync(r => r.Id == topupRequestId && r.TenantId == tenantId, cancellationToken) + .FirstOrDefaultAsync(r => r.Id == topupRequestId && r.TenantId == tenantId && r.Status == TopupRequestStatus.Pending, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {topupRequestId} not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Top-up request {topupRequestId} not found or not pending."); var now = _timeProvider.GetUtcNow().UtcDateTime; var invoiceNumber = BuildTopupInvoiceNumber(tenantId, now, topupRequestId); diff --git a/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs b/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs index e28f4657d6..561c2bd43b 100644 --- a/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs +++ b/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs @@ -1,5 +1,6 @@ using Finbuckle.MultiTenant; using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Contracts; using FSH.Modules.Billing.Data; @@ -108,6 +109,34 @@ public async Task Two_topups_in_same_month_both_succeed_with_unique_invoice_numb "two top-ups in the same month must have distinct invoice numbers"); } + // ── Fix 1 guard ────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateTopupInvoice_throws_NotFoundException_when_request_already_invoiced() + { + var tenantId = UniqueTestTenantId(); + + using var scope = _factory.Services.CreateScope(); + + var tenantStore = scope.ServiceProvider.GetRequiredService>(); + var tenant = await tenantStore.GetAsync(TestConstants.RootTenantId); + scope.ServiceProvider.GetRequiredService().MultiTenantContext = + new MultiTenantContext(tenant); + + var billing = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Arrange: seed a Pending request and advance it to Invoiced via the happy path. + var request = TopupRequest.Create(tenantId, 10m, "USD", "advance to invoiced", "user-1"); + db.TopupRequests.Add(request); + await db.SaveChangesAsync(); + await billing.CreateTopupInvoiceAsync(tenantId, request.Id); + + // Act + Assert: a second call for the same (now Invoiced) request must 404, not 500. + await Should.ThrowAsync( + () => billing.CreateTopupInvoiceAsync(tenantId, request.Id)); + } + // ── Helpers ────────────────────────────────────────────────────────────── /// From 64c93092818f9d2dd78c3439404fcd781ccd25f4 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 01:26:10 +0530 Subject: [PATCH 09/15] feat(billing): tenant wallet + topup-request endpoints 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 --- .../v1/Wallets/CreateTopupRequestCommand.cs | 5 + .../v1/Wallets/GetMyTopupRequestsQuery.cs | 10 + .../v1/Wallets/GetMyWalletQuery.cs | 6 + .../Billing/Modules.Billing/BillingModule.cs | 7 + .../CreateTopupRequestCommandHandler.cs | 32 +++ .../CreateTopupRequestCommandValidator.cs | 13 + .../CreateTopupRequestEndpoint.cs | 24 ++ .../GetMyTopupRequestsEndpoint.cs | 26 ++ .../GetMyTopupRequestsQueryHandler.cs | 51 ++++ .../GetMyTopupRequestsQueryValidator.cs | 13 + .../GetMyWallet/GetMyWalletEndpoint.cs | 22 ++ .../GetMyWallet/GetMyWalletQueryHandler.cs | 28 ++ .../CreateTopupRequestValidatorTests.cs | 22 ++ .../Tests/Billing/WalletEndpointsTests.cs | 267 ++++++++++++++++++ 14 files changed, 526 insertions(+) create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/CreateTopupRequestCommand.cs create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetMyTopupRequestsQuery.cs create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetMyWalletQuery.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandValidator.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestEndpoint.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsEndpoint.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryValidator.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletEndpoint.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs create mode 100644 src/Tests/Billing.Tests/Validators/CreateTopupRequestValidatorTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Billing/WalletEndpointsTests.cs diff --git a/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/CreateTopupRequestCommand.cs b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/CreateTopupRequestCommand.cs new file mode 100644 index 0000000000..14401c05ba --- /dev/null +++ b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/CreateTopupRequestCommand.cs @@ -0,0 +1,5 @@ +using Mediator; + +namespace FSH.Modules.Billing.Contracts.v1.Wallets; + +public sealed record CreateTopupRequestCommand(decimal Amount, string? Note) : ICommand; diff --git a/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetMyTopupRequestsQuery.cs b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetMyTopupRequestsQuery.cs new file mode 100644 index 0000000000..eae43a1b5c --- /dev/null +++ b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetMyTopupRequestsQuery.cs @@ -0,0 +1,10 @@ +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Billing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Billing.Contracts.v1.Wallets; + +public sealed record GetMyTopupRequestsQuery( + TopupRequestStatus? Status = null, + int PageNumber = 1, + int PageSize = 20) : IQuery>; diff --git a/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetMyWalletQuery.cs b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetMyWalletQuery.cs new file mode 100644 index 0000000000..5b22421c18 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetMyWalletQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Billing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Billing.Contracts.v1.Wallets; + +public sealed record GetMyWalletQuery : IQuery; diff --git a/src/Modules/Billing/Modules.Billing/BillingModule.cs b/src/Modules/Billing/Modules.Billing/BillingModule.cs index dfff5f50a7..83584df798 100644 --- a/src/Modules/Billing/Modules.Billing/BillingModule.cs +++ b/src/Modules/Billing/Modules.Billing/BillingModule.cs @@ -18,6 +18,9 @@ using FSH.Modules.Billing.Features.v1.Subscriptions.GetSubscription; using FSH.Modules.Billing.Features.v1.Usage.CaptureUsageSnapshots; using FSH.Modules.Billing.Features.v1.Usage.GetUsageSnapshots; +using FSH.Modules.Billing.Features.v1.Wallets.CreateTopupRequest; +using FSH.Modules.Billing.Features.v1.Wallets.GetMyTopupRequests; +using FSH.Modules.Billing.Features.v1.Wallets.GetMyWallet; using FSH.Modules.Billing.Services; using Hangfire; using Hangfire.Common; @@ -96,6 +99,10 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) group.MapGetUsageSnapshotsEndpoint(); group.MapCaptureUsageSnapshotsEndpoint(); + group.MapGetMyWalletEndpoint(); + group.MapCreateTopupRequestEndpoint(); + group.MapGetMyTopupRequestsEndpoint(); + var jobManager = endpoints.ServiceProvider.GetService(); if (jobManager is not null) { diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs new file mode 100644 index 0000000000..b5621e5b6f --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs @@ -0,0 +1,32 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Domain; +using Mediator; + +namespace FSH.Modules.Billing.Features.v1.Wallets.CreateTopupRequest; + +public sealed class CreateTopupRequestCommandHandler( + BillingDbContext db, + IMultiTenantContextAccessor tenantAccessor, + ICurrentUser currentUser) + : ICommandHandler +{ + public async ValueTask Handle(CreateTopupRequestCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. + var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new UnauthorizedException("Tenant context is required."); + + var requestedBy = currentUser.IsAuthenticated() ? currentUser.GetUserId().ToString() : null; + var request = TopupRequest.Create(tenantId, command.Amount, "USD", command.Note, requestedBy); + db.TopupRequests.Add(request); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return request.Id; + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandValidator.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandValidator.cs new file mode 100644 index 0000000000..960d4f17ef --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using FSH.Modules.Billing.Contracts.v1.Wallets; + +namespace FSH.Modules.Billing.Features.v1.Wallets.CreateTopupRequest; + +public sealed class CreateTopupRequestCommandValidator : AbstractValidator +{ + public CreateTopupRequestCommandValidator() + { + RuleFor(x => x.Amount).GreaterThan(0m).LessThanOrEqualTo(1_000_000m); + RuleFor(x => x.Note).MaximumLength(512); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestEndpoint.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestEndpoint.cs new file mode 100644 index 0000000000..7310877f87 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestEndpoint.cs @@ -0,0 +1,24 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Web.Idempotency; +using FSH.Modules.Billing.Contracts.Authorization; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Billing.Features.v1.Wallets.CreateTopupRequest; + +public static class CreateTopupRequestEndpoint +{ + internal static RouteHandlerBuilder MapCreateTopupRequestEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/wallet/topup-requests", + async (CreateTopupRequestCommand command, IMediator mediator, CancellationToken ct) => + Results.Ok(await mediator.Send(command, ct))) + .WithName("CreateTopupRequest") + .WithSummary("Submit a wallet top-up request for the current tenant") + .RequirePermission(BillingPermissions.View) + .WithIdempotency(); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsEndpoint.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsEndpoint.cs new file mode 100644 index 0000000000..ca3dcb6484 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Contracts.Authorization; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Billing.Features.v1.Wallets.GetMyTopupRequests; + +public static class GetMyTopupRequestsEndpoint +{ + internal static RouteHandlerBuilder MapGetMyTopupRequestsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/wallet/topup-requests/me", + (TopupRequestStatus? status, int pageNumber, int pageSize, IMediator mediator, CancellationToken ct) => + mediator.Send(new GetMyTopupRequestsQuery( + status, + pageNumber <= 0 ? 1 : pageNumber, + pageSize <= 0 ? 20 : Math.Min(pageSize, 100)), ct)) + .WithName("GetMyTopupRequests") + .WithSummary("List top-up requests for the current tenant") + .RequirePermission(BillingPermissions.View); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs new file mode 100644 index 0000000000..d87440550b --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs @@ -0,0 +1,51 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Billing.Contracts.Dtos; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Mappings; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Billing.Features.v1.Wallets.GetMyTopupRequests; + +public sealed class GetMyTopupRequestsQueryHandler( + BillingDbContext dbContext, + IMultiTenantContextAccessor tenantAccessor) + : IQueryHandler> +{ + public async ValueTask> Handle(GetMyTopupRequestsQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. + var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new UnauthorizedException("Tenant context is required."); + + var q = dbContext.TopupRequests.AsNoTracking() + .Where(r => r.TenantId == tenantId); + + if (query.Status is not null) + { + q = q.Where(r => r.Status == query.Status); + } + + var total = await q.LongCountAsync(cancellationToken).ConfigureAwait(false); + var items = await q + .OrderByDescending(r => r.CreatedAtUtc) + .Skip((query.PageNumber - 1) * query.PageSize) + .Take(query.PageSize) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return new PagedResponse + { + Items = items.Select(r => r.ToDto()).ToList(), + PageNumber = query.PageNumber, + PageSize = query.PageSize, + TotalCount = total, + TotalPages = (int)Math.Ceiling(total / (double)query.PageSize) + }; + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryValidator.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryValidator.cs new file mode 100644 index 0000000000..9a41397d8a --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using FSH.Modules.Billing.Contracts.v1.Wallets; + +namespace FSH.Modules.Billing.Features.v1.Wallets.GetMyTopupRequests; + +public sealed class GetMyTopupRequestsQueryValidator : AbstractValidator +{ + public GetMyTopupRequestsQueryValidator() + { + RuleFor(x => x.PageNumber).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletEndpoint.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletEndpoint.cs new file mode 100644 index 0000000000..ea533ef0fd --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Billing.Contracts.Authorization; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Billing.Features.v1.Wallets.GetMyWallet; + +public static class GetMyWalletEndpoint +{ + internal static RouteHandlerBuilder MapGetMyWalletEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/wallet/me", + async (IMediator mediator, CancellationToken ct) => + Results.Ok(await mediator.Send(new GetMyWalletQuery(), ct))) + .WithName("GetMyWallet") + .WithSummary("Get the wallet for the current tenant") + .RequirePermission(BillingPermissions.View); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs new file mode 100644 index 0000000000..37512d4582 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs @@ -0,0 +1,28 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Billing.Contracts.Dtos; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using FSH.Modules.Billing.Mappings; +using FSH.Modules.Billing.Services; +using Mediator; + +namespace FSH.Modules.Billing.Features.v1.Wallets.GetMyWallet; + +public sealed class GetMyWalletQueryHandler( + IBillingService billingService, + IMultiTenantContextAccessor tenantAccessor) + : IQueryHandler +{ + public async ValueTask Handle(GetMyWalletQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. + var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new UnauthorizedException("Tenant context is required."); + + var wallet = await billingService.GetOrCreateWalletAsync(tenantId, "USD", cancellationToken).ConfigureAwait(false); + return wallet.ToDto(); + } +} diff --git a/src/Tests/Billing.Tests/Validators/CreateTopupRequestValidatorTests.cs b/src/Tests/Billing.Tests/Validators/CreateTopupRequestValidatorTests.cs new file mode 100644 index 0000000000..455c0a7fef --- /dev/null +++ b/src/Tests/Billing.Tests/Validators/CreateTopupRequestValidatorTests.cs @@ -0,0 +1,22 @@ +using FSH.Modules.Billing.Features.v1.Wallets.CreateTopupRequest; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using Shouldly; +using Xunit; + +namespace Billing.Tests.Validators; + +public sealed class CreateTopupRequestValidatorTests +{ + private readonly CreateTopupRequestCommandValidator _v = new(); + + [Theory] + [InlineData(0)] + [InlineData(-5)] + [InlineData(1_000_001)] + public void Rejects_out_of_range(decimal amount) + => _v.Validate(new CreateTopupRequestCommand(amount, null)).IsValid.ShouldBeFalse(); + + [Fact] + public void Accepts_valid_amount() + => _v.Validate(new CreateTopupRequestCommand(50m, "need credit")).IsValid.ShouldBeTrue(); +} diff --git a/src/Tests/Integration.Tests/Tests/Billing/WalletEndpointsTests.cs b/src/Tests/Integration.Tests/Tests/Billing/WalletEndpointsTests.cs new file mode 100644 index 0000000000..87ba8a29b3 --- /dev/null +++ b/src/Tests/Integration.Tests/Tests/Billing/WalletEndpointsTests.cs @@ -0,0 +1,267 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Contracts.Dtos; +using Integration.Tests.Infrastructure; +using Integration.Tests.Infrastructure.Extensions; + +namespace Integration.Tests.Tests.Billing; + +/// +/// Integration tests for the tenant-facing wallet and top-up request HTTP endpoints: +/// GET /api/v1/billing/wallet/me +/// POST /api/v1/billing/wallet/topup-requests +/// GET /api/v1/billing/wallet/topup-requests/me +/// +/// Cross-tenant isolation: a request created under Tenant A must NOT appear in Tenant B's +/// /wallet/topup-requests/me response. +/// +[Collection(FshCollectionDefinition.Name)] +public sealed class WalletEndpointsTests +{ + private const string BillingBasePath = "/api/v1/billing"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly FshWebApplicationFactory _factory; + private readonly AuthHelper _auth; + + public WalletEndpointsTests(FshWebApplicationFactory factory) + { + _factory = factory; + _auth = new AuthHelper(factory); + } + + // ─── Happy-path: create a topup request ─────────────────────────── + + [Fact] + public async Task CreateTopupRequest_Should_Return200_And_NewGuid() + { + using var client = await _auth.CreateRootAdminClientAsync(); + + using var response = await client.PostAsJsonAsync( + $"{BillingBasePath}/wallet/topup-requests", + new { amount = 100m, note = "integration test topup" }); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var id = await response.DeserializeAsync(); + id.ShouldNotBe(Guid.Empty); + } + + // ─── Happy-path: list topup requests ────────────────────────────── + + [Fact] + public async Task GetMyTopupRequests_Should_Include_JustCreated_Request_As_Pending() + { + using var client = await _auth.CreateRootAdminClientAsync(); + + // Create a topup request. + using var createResp = await client.PostAsJsonAsync( + $"{BillingBasePath}/wallet/topup-requests", + new { amount = 75m, note = "list-check" }); + createResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestId = await createResp.DeserializeAsync(); + + // Retrieve the list and assert the new request is present with Pending status. + using var listResp = await client.GetAsync( + $"{BillingBasePath}/wallet/topup-requests/me?pageNumber=1&pageSize=50"); + listResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var page = await ParseAsync>(listResp); + + page.Items.ShouldContain(r => r.Id == requestId, + "the freshly created top-up request must appear in the tenant's list"); + var dto = page.Items.First(r => r.Id == requestId); + dto.Status.ShouldBe("Pending", "a newly created top-up request must have Pending status"); + } + + // ─── Happy-path: wallet shows balance 0 (no credits yet) ────────── + + [Fact] + public async Task GetMyWallet_Should_Return_WalletDto_With_Zero_Balance_When_No_Credits() + { + using var client = await _auth.CreateRootAdminClientAsync(); + + using var response = await client.GetAsync($"{BillingBasePath}/wallet/me"); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var wallet = await ParseAsync(response); + + wallet.ShouldNotBeNull(); + wallet.TenantId.ShouldBe(TestConstants.RootTenantId, + "the wallet tenant must match the caller's tenant"); + wallet.Currency.ShouldBe("USD"); + // Balance starts at 0 (no credits have been applied in this test run). + wallet.Balance.ShouldBeGreaterThanOrEqualTo(0m, + "wallet balance must be non-negative"); + } + + // ─── Unauthenticated access is rejected ─────────────────────────── + + [Fact] + public async Task CreateTopupRequest_Should_Return401_When_Unauthenticated() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); + + using var response = await client.PostAsJsonAsync( + $"{BillingBasePath}/wallet/topup-requests", + new { amount = 50m, note = "unauth" }); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetMyTopupRequests_Should_Return401_When_Unauthenticated() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); + + using var response = await client.GetAsync( + $"{BillingBasePath}/wallet/topup-requests/me?pageNumber=1&pageSize=20"); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetMyWallet_Should_Return401_When_Unauthenticated() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); + + using var response = await client.GetAsync($"{BillingBasePath}/wallet/me"); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + // ─── Cross-tenant isolation ──────────────────────────────────────── + + [Fact] + public async Task GetMyTopupRequests_Must_NotLeak_Requests_From_Other_Tenant() + { + // Arrange — root (Tenant A) creates a topup request. + using var rootClient = await _auth.CreateRootAdminClientAsync(); + + using var createResp = await rootClient.PostAsJsonAsync( + $"{BillingBasePath}/wallet/topup-requests", + new { amount = 200m, note = "tenant-a-request" }); + createResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var rootRequestId = await createResp.DeserializeAsync(); + + // Arrange — provision a fresh Tenant B. + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + var otherTenantId = $"wallet-iso-{uniqueId}"; + var otherAdminEmail = $"wallet-admin-{uniqueId}@tenant.com"; + + await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); + await WaitForProvisioningAsync(rootClient, otherTenantId); + + using var otherClient = await CreateTenantAdminClientWithRetryAsync( + otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); + + // Act — Tenant B lists its own topup requests. + using var otherListResp = await otherClient.GetAsync( + $"{BillingBasePath}/wallet/topup-requests/me?pageNumber=1&pageSize=100"); + otherListResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var otherPage = await ParseAsync>(otherListResp); + + // Assert — Tenant A's request must NOT appear in Tenant B's list. + otherPage.Items.ShouldNotContain(r => r.Id == rootRequestId, + "Tenant B must not see Tenant A's top-up request"); + + // Sanity — Tenant A can still see its own request. + using var rootListResp = await rootClient.GetAsync( + $"{BillingBasePath}/wallet/topup-requests/me?pageNumber=1&pageSize=100"); + rootListResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var rootPage = await ParseAsync>(rootListResp); + rootPage.Items.ShouldContain(r => r.Id == rootRequestId, + "Tenant A (root) must still be able to read its own request"); + } + + // ─── helpers ────────────────────────────────────────────────────── + + private static async Task ParseAsync(HttpResponseMessage response) + { + var json = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Expected success status, got {(int)response.StatusCode} {response.StatusCode}. Body: {json}"); + } + return JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(T).Name}. Body: {json}"); + } + + private static async Task CreateTenantAsync(HttpClient rootClient, string tenantId, string adminEmail) + { + var response = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new + { + id = tenantId, + name = $"Tenant {tenantId}", + connectionString = (string?)null, + adminEmail, + adminPassword = TestConstants.DefaultPassword, + issuer = $"{tenantId}.issuer" + }); + var body = await response.Content.ReadAsStringAsync(); + response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); + } + + private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) + { + for (int i = 0; i < maxRetries; i++) + { + var statusResponse = await client.GetAsync( + $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); + + if (statusResponse.IsSuccessStatusCode) + { + var content = await statusResponse.Content.ReadAsStringAsync(); + if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Tenant {tenantId} provisioning failed: {content}"); + } + } + + await Task.Delay(1000); + } + + var finalResponse = await client.GetAsync( + $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); + var finalContent = finalResponse.IsSuccessStatusCode + ? await finalResponse.Content.ReadAsStringAsync() + : $"HTTP {finalResponse.StatusCode}"; + + throw new TimeoutException( + $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); + } + + private async Task CreateTenantAdminClientWithRetryAsync( + string email, string password, string tenant, int maxRetries = 30) + { + for (int i = 0; i < maxRetries; i++) + { + try + { + return await _auth.CreateAuthenticatedClientAsync(email, password, tenant); + } + catch (HttpRequestException) when (i < maxRetries - 1) + { + await Task.Delay(1000); + } + } + + return await _auth.CreateAuthenticatedClientAsync(email, password, tenant); + } +} From ca101a068f8e5cf3f654eea296054880e69680aa Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 01:40:31 +0530 Subject: [PATCH 10/15] feat(billing): operator list/approve/reject topup-request endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .../v1/Wallets/ApproveTopupRequestCommand.cs | 9 + .../v1/Wallets/GetTopupRequestsQuery.cs | 16 ++ .../v1/Wallets/RejectTopupRequestCommand.cs | 8 + .../Billing/Modules.Billing/BillingModule.cs | 7 + .../ApproveTopupRequestCommandHandler.cs | 42 +++ .../ApproveTopupRequestCommandValidator.cs | 12 + .../ApproveTopupRequestEndpoint.cs | 26 ++ .../GetTopupRequestsEndpoint.cs | 28 ++ .../GetTopupRequestsQueryHandler.cs | 56 ++++ .../GetTopupRequestsQueryValidator.cs | 13 + .../RejectTopupRequestCommandHandler.cs | 38 +++ .../RejectTopupRequestCommandValidator.cs | 13 + .../RejectTopupRequestEndpoint.cs | 26 ++ .../EndpointConventionTests.cs | 4 +- .../TopupOperatorValidatorsTests.cs | 138 +++++++++ .../Tests/Billing/TopupApprovalTests.cs | 272 ++++++++++++++++++ 16 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/ApproveTopupRequestCommand.cs create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetTopupRequestsQuery.cs create mode 100644 src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/RejectTopupRequestCommand.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandValidator.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestEndpoint.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsEndpoint.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryValidator.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandValidator.cs create mode 100644 src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestEndpoint.cs create mode 100644 src/Tests/Billing.Tests/Validators/TopupOperatorValidatorsTests.cs create mode 100644 src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs diff --git a/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/ApproveTopupRequestCommand.cs b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/ApproveTopupRequestCommand.cs new file mode 100644 index 0000000000..344741d258 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/ApproveTopupRequestCommand.cs @@ -0,0 +1,9 @@ +using Mediator; + +namespace FSH.Modules.Billing.Contracts.v1.Wallets; + +/// +/// Operator command — approves a Pending top-up request, creates and issues a Topup-purpose +/// invoice, and transitions the request to Invoiced. Returns the created invoice id. +/// +public sealed record ApproveTopupRequestCommand(Guid Id, string? Note) : ICommand; diff --git a/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetTopupRequestsQuery.cs b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetTopupRequestsQuery.cs new file mode 100644 index 0000000000..eab63f27a4 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/GetTopupRequestsQuery.cs @@ -0,0 +1,16 @@ +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Billing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Billing.Contracts.v1.Wallets; + +/// +/// Operator query — lists top-up requests across all tenants with optional filters. +/// Root callers get a cross-tenant view (optionally narrowed via ); +/// non-root callers are automatically scoped to their own tenant. +/// +public sealed record GetTopupRequestsQuery( + string? TenantId = null, + TopupRequestStatus? Status = null, + int PageNumber = 1, + int PageSize = 20) : IQuery>; diff --git a/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/RejectTopupRequestCommand.cs b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/RejectTopupRequestCommand.cs new file mode 100644 index 0000000000..cbcb135e8f --- /dev/null +++ b/src/Modules/Billing/Modules.Billing.Contracts/v1/Wallets/RejectTopupRequestCommand.cs @@ -0,0 +1,8 @@ +using Mediator; + +namespace FSH.Modules.Billing.Contracts.v1.Wallets; + +/// +/// Operator command — rejects a Pending top-up request. Returns the request id. +/// +public sealed record RejectTopupRequestCommand(Guid Id, string? Reason) : ICommand; diff --git a/src/Modules/Billing/Modules.Billing/BillingModule.cs b/src/Modules/Billing/Modules.Billing/BillingModule.cs index 83584df798..f43e1cb9b9 100644 --- a/src/Modules/Billing/Modules.Billing/BillingModule.cs +++ b/src/Modules/Billing/Modules.Billing/BillingModule.cs @@ -18,9 +18,12 @@ using FSH.Modules.Billing.Features.v1.Subscriptions.GetSubscription; using FSH.Modules.Billing.Features.v1.Usage.CaptureUsageSnapshots; using FSH.Modules.Billing.Features.v1.Usage.GetUsageSnapshots; +using FSH.Modules.Billing.Features.v1.Wallets.ApproveTopupRequest; using FSH.Modules.Billing.Features.v1.Wallets.CreateTopupRequest; using FSH.Modules.Billing.Features.v1.Wallets.GetMyTopupRequests; using FSH.Modules.Billing.Features.v1.Wallets.GetMyWallet; +using FSH.Modules.Billing.Features.v1.Wallets.GetTopupRequests; +using FSH.Modules.Billing.Features.v1.Wallets.RejectTopupRequest; using FSH.Modules.Billing.Services; using Hangfire; using Hangfire.Common; @@ -103,6 +106,10 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) group.MapCreateTopupRequestEndpoint(); group.MapGetMyTopupRequestsEndpoint(); + group.MapGetTopupRequestsEndpoint(); + group.MapApproveTopupRequestEndpoint(); + group.MapRejectTopupRequestEndpoint(); + var jobManager = endpoints.ServiceProvider.GetService(); if (jobManager is not null) { diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs new file mode 100644 index 0000000000..d1b92dca6e --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs @@ -0,0 +1,42 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Services; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Billing.Features.v1.Wallets.ApproveTopupRequest; + +public sealed class ApproveTopupRequestCommandHandler( + BillingDbContext db, + IBillingService billing, + IMultiTenantContextAccessor tenantAccessor) + : ICommandHandler +{ + public async ValueTask Handle(ApproveTopupRequestCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new UnauthorizedException("Tenant context is required."); + var isRoot = callerTenantId == MultitenancyConstants.Root.Id; + + var request = await db.TopupRequests + .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken) + .ConfigureAwait(false) + ?? throw new NotFoundException($"Top-up request {command.Id} not found."); + + if (!isRoot && request.TenantId != callerTenantId) + { + throw new UnauthorizedException("You can only approve top-up requests for your own tenant."); + } + + // For root, operate on the request's own tenant; for non-root, callerTenantId equals request.TenantId. + var invoice = await billing.CreateTopupInvoiceAsync(request.TenantId, command.Id, cancellationToken) + .ConfigureAwait(false); + + return invoice.Id; + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandValidator.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandValidator.cs new file mode 100644 index 0000000000..e7e7894228 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using FSH.Modules.Billing.Contracts.v1.Wallets; + +namespace FSH.Modules.Billing.Features.v1.Wallets.ApproveTopupRequest; + +public sealed class ApproveTopupRequestCommandValidator : AbstractValidator +{ + public ApproveTopupRequestCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestEndpoint.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestEndpoint.cs new file mode 100644 index 0000000000..fd0fb5bf17 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Web.Idempotency; +using FSH.Modules.Billing.Contracts.Authorization; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Billing.Features.v1.Wallets.ApproveTopupRequest; + +public static class ApproveTopupRequestEndpoint +{ + public sealed record ApproveTopupRequestBody(string? Note); + + internal static RouteHandlerBuilder MapApproveTopupRequestEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/wallet/topup-requests/{id:guid}/approve", + async (Guid id, ApproveTopupRequestBody? body, IMediator mediator, CancellationToken ct) => + Results.Ok(await mediator.Send(new ApproveTopupRequestCommand(id, body?.Note), ct))) + .WithName("ApproveTopupRequest") + .WithSummary("Approve a pending top-up request and issue the invoice") + .RequirePermission(BillingPermissions.Manage) + .WithIdempotency(); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsEndpoint.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsEndpoint.cs new file mode 100644 index 0000000000..68d5591bfd --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsEndpoint.cs @@ -0,0 +1,28 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Contracts.Authorization; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Billing.Features.v1.Wallets.GetTopupRequests; + +public static class GetTopupRequestsEndpoint +{ + internal static RouteHandlerBuilder MapGetTopupRequestsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/wallet/topup-requests", + (string? tenantId, TopupRequestStatus? status, int pageNumber, int pageSize, + IMediator mediator, CancellationToken ct) => + mediator.Send(new GetTopupRequestsQuery( + tenantId, + status, + pageNumber <= 0 ? 1 : pageNumber, + pageSize <= 0 ? 20 : Math.Min(pageSize, 100)), ct)) + .WithName("GetTopupRequests") + .WithSummary("List top-up requests across all tenants (operator admin)") + .RequirePermission(BillingPermissions.View); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs new file mode 100644 index 0000000000..51adf97f43 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs @@ -0,0 +1,56 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Billing.Contracts.Dtos; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Mappings; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Billing.Features.v1.Wallets.GetTopupRequests; + +public sealed class GetTopupRequestsQueryHandler( + BillingDbContext dbContext, + IMultiTenantContextAccessor tenantAccessor) + : IQueryHandler> +{ + public async ValueTask> Handle(GetTopupRequestsQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + // BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally + // narrowed via query.TenantId); every other caller is forced to its own tenant. + var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new UnauthorizedException("Tenant context is required."); + var isRoot = callerTenantId == MultitenancyConstants.Root.Id; + var tenantFilter = isRoot ? query.TenantId : callerTenantId; + + var q = dbContext.TopupRequests.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(tenantFilter)) + { + q = q.Where(r => r.TenantId == tenantFilter); + } + if (query.Status is not null) + { + q = q.Where(r => r.Status == query.Status); + } + + var total = await q.LongCountAsync(cancellationToken).ConfigureAwait(false); + var items = await q + .OrderByDescending(r => r.CreatedAtUtc) + .Skip((query.PageNumber - 1) * query.PageSize) + .Take(query.PageSize) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return new PagedResponse + { + Items = items.Select(r => r.ToDto()).ToList(), + PageNumber = query.PageNumber, + PageSize = query.PageSize, + TotalCount = total, + TotalPages = (int)Math.Ceiling(total / (double)query.PageSize) + }; + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryValidator.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryValidator.cs new file mode 100644 index 0000000000..57e34e7010 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using FSH.Modules.Billing.Contracts.v1.Wallets; + +namespace FSH.Modules.Billing.Features.v1.Wallets.GetTopupRequests; + +public sealed class GetTopupRequestsQueryValidator : AbstractValidator +{ + public GetTopupRequestsQueryValidator() + { + RuleFor(x => x.PageNumber).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs new file mode 100644 index 0000000000..15442fa51d --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs @@ -0,0 +1,38 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using FSH.Modules.Billing.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Billing.Features.v1.Wallets.RejectTopupRequest; + +public sealed class RejectTopupRequestCommandHandler( + BillingDbContext db, + IMultiTenantContextAccessor tenantAccessor) + : ICommandHandler +{ + public async ValueTask Handle(RejectTopupRequestCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new UnauthorizedException("Tenant context is required."); + var isRoot = callerTenantId == MultitenancyConstants.Root.Id; + + var request = await db.TopupRequests + .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken) + .ConfigureAwait(false) + ?? throw new NotFoundException($"Top-up request {command.Id} not found."); + + if (!isRoot && request.TenantId != callerTenantId) + { + throw new UnauthorizedException("You can only reject top-up requests for your own tenant."); + } + + request.Reject(command.Reason); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return request.Id; + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandValidator.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandValidator.cs new file mode 100644 index 0000000000..02dff234e5 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using FSH.Modules.Billing.Contracts.v1.Wallets; + +namespace FSH.Modules.Billing.Features.v1.Wallets.RejectTopupRequest; + +public sealed class RejectTopupRequestCommandValidator : AbstractValidator +{ + public RejectTopupRequestCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Reason).MaximumLength(512); + } +} diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestEndpoint.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestEndpoint.cs new file mode 100644 index 0000000000..51297a5f0e --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Web.Idempotency; +using FSH.Modules.Billing.Contracts.Authorization; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Billing.Features.v1.Wallets.RejectTopupRequest; + +public static class RejectTopupRequestEndpoint +{ + public sealed record RejectTopupRequestBody(string? Reason); + + internal static RouteHandlerBuilder MapRejectTopupRequestEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/wallet/topup-requests/{id:guid}/reject", + async (Guid id, RejectTopupRequestBody? body, IMediator mediator, CancellationToken ct) => + Results.Ok(await mediator.Send(new RejectTopupRequestCommand(id, body?.Reason), ct))) + .WithName("RejectTopupRequest") + .WithSummary("Reject a pending top-up request") + .RequirePermission(BillingPermissions.Manage) + .WithIdempotency(); + } +} diff --git a/src/Tests/Architecture.Tests/EndpointConventionTests.cs b/src/Tests/Architecture.Tests/EndpointConventionTests.cs index b7869cb1a4..3ce1719596 100644 --- a/src/Tests/Architecture.Tests/EndpointConventionTests.cs +++ b/src/Tests/Architecture.Tests/EndpointConventionTests.cs @@ -277,7 +277,9 @@ public void Endpoint_Names_Should_Follow_Convention() name.StartsWith("Send", StringComparison.Ordinal) || name.StartsWith("Discover", StringComparison.Ordinal) || name.StartsWith("Pin", StringComparison.Ordinal) || - name.StartsWith("Unpin", StringComparison.Ordinal); + name.StartsWith("Unpin", StringComparison.Ordinal) || + name.StartsWith("Approve", StringComparison.Ordinal) || + name.StartsWith("Reject", StringComparison.Ordinal); if (!hasVerb) { diff --git a/src/Tests/Billing.Tests/Validators/TopupOperatorValidatorsTests.cs b/src/Tests/Billing.Tests/Validators/TopupOperatorValidatorsTests.cs new file mode 100644 index 0000000000..ead28fbbc5 --- /dev/null +++ b/src/Tests/Billing.Tests/Validators/TopupOperatorValidatorsTests.cs @@ -0,0 +1,138 @@ +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Contracts.v1.Wallets; +using FSH.Modules.Billing.Features.v1.Wallets.ApproveTopupRequest; +using FSH.Modules.Billing.Features.v1.Wallets.GetTopupRequests; +using FSH.Modules.Billing.Features.v1.Wallets.RejectTopupRequest; + +namespace Billing.Tests.Validators; + +public sealed class TopupOperatorValidatorsTests +{ + #region GetTopupRequestsQueryValidator + + [Fact] + public void GetTopupRequests_Should_Pass_When_Valid() + { + var validator = new GetTopupRequestsQueryValidator(); + + var result = validator.Validate(new GetTopupRequestsQuery(null, null, 1, 20)); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void GetTopupRequests_Should_Pass_With_Optional_Filters() + { + var validator = new GetTopupRequestsQueryValidator(); + + var result = validator.Validate(new GetTopupRequestsQuery("tenant-1", TopupRequestStatus.Pending, 2, 50)); + + result.IsValid.ShouldBeTrue(); + } + + [Theory] + [InlineData(0, 20)] // PageNumber must be > 0 + [InlineData(-1, 20)] // PageNumber negative + [InlineData(1, 0)] // PageSize must be >= 1 + [InlineData(1, 101)] // PageSize must be <= 100 + public void GetTopupRequests_Should_Fail_When_Pagination_Invalid(int pageNumber, int pageSize) + { + var validator = new GetTopupRequestsQueryValidator(); + + var result = validator.Validate(new GetTopupRequestsQuery(null, null, pageNumber, pageSize)); + + result.IsValid.ShouldBeFalse(); + } + + #endregion + + #region ApproveTopupRequestCommandValidator + + [Fact] + public void ApproveTopupRequest_Should_Pass_When_Id_Provided() + { + var validator = new ApproveTopupRequestCommandValidator(); + + var result = validator.Validate(new ApproveTopupRequestCommand(Guid.NewGuid(), null)); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void ApproveTopupRequest_Should_Pass_When_Note_Provided() + { + var validator = new ApproveTopupRequestCommandValidator(); + + var result = validator.Validate(new ApproveTopupRequestCommand(Guid.NewGuid(), "approved by ops")); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void ApproveTopupRequest_Should_Fail_When_Id_Empty() + { + var validator = new ApproveTopupRequestCommandValidator(); + + var result = validator.Validate(new ApproveTopupRequestCommand(Guid.Empty, null)); + + result.IsValid.ShouldBeFalse(); + } + + #endregion + + #region RejectTopupRequestCommandValidator + + [Fact] + public void RejectTopupRequest_Should_Pass_When_Valid() + { + var validator = new RejectTopupRequestCommandValidator(); + + var result = validator.Validate(new RejectTopupRequestCommand(Guid.NewGuid(), "insufficient funds provided")); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void RejectTopupRequest_Should_Pass_When_Reason_Is_Null() + { + var validator = new RejectTopupRequestCommandValidator(); + + var result = validator.Validate(new RejectTopupRequestCommand(Guid.NewGuid(), null)); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void RejectTopupRequest_Should_Fail_When_Id_Empty() + { + var validator = new RejectTopupRequestCommandValidator(); + + var result = validator.Validate(new RejectTopupRequestCommand(Guid.Empty, null)); + + result.IsValid.ShouldBeFalse(); + } + + [Fact] + public void RejectTopupRequest_Should_Fail_When_Reason_Exceeds_512_Chars() + { + var validator = new RejectTopupRequestCommandValidator(); + var longReason = new string('x', 513); + + var result = validator.Validate(new RejectTopupRequestCommand(Guid.NewGuid(), longReason)); + + result.IsValid.ShouldBeFalse(); + } + + [Fact] + public void RejectTopupRequest_Should_Pass_When_Reason_Is_Exactly_512_Chars() + { + var validator = new RejectTopupRequestCommandValidator(); + var maxReason = new string('x', 512); + + var result = validator.Validate(new RejectTopupRequestCommand(Guid.NewGuid(), maxReason)); + + result.IsValid.ShouldBeTrue(); + } + + #endregion +} diff --git a/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs b/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs new file mode 100644 index 0000000000..5c6f249eda --- /dev/null +++ b/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs @@ -0,0 +1,272 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Billing.Contracts; +using FSH.Modules.Billing.Contracts.Dtos; +using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Domain; +using Integration.Tests.Infrastructure; +using Integration.Tests.Infrastructure.Extensions; + +namespace Integration.Tests.Tests.Billing; + +/// +/// Integration tests for the operator-facing top-up request endpoints: +/// GET /api/v1/billing/wallet/topup-requests (cross-tenant for root, own-tenant for others) +/// POST /api/v1/billing/wallet/topup-requests/{id}/approve +/// POST /api/v1/billing/wallet/topup-requests/{id}/reject +/// +[Collection(FshCollectionDefinition.Name)] +public sealed class TopupApprovalTests +{ + private const string BillingBasePath = "/api/v1/billing"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly FshWebApplicationFactory _factory; + private readonly AuthHelper _auth; + + public TopupApprovalTests(FshWebApplicationFactory factory) + { + _factory = factory; + _auth = new AuthHelper(factory); + } + + // ─── Approve: happy path ────────────────────────────────────────── + + [Fact] + public async Task Approve_generates_topup_invoice_and_marks_request_invoiced() + { + // Arrange: seed a Pending TopupRequest for root tenant (inline tenant context per pattern). + var requestId = await SeedPendingTopupRequestAsync(TestConstants.RootTenantId, 250m); + + // Act: as ROOT, approve the request. + using var rootClient = await _auth.CreateRootAdminClientAsync(); + using var approveResp = await rootClient.PostAsJsonAsync( + $"{BillingBasePath}/wallet/topup-requests/{requestId}/approve", + new { note = "approved in integration test" }); + + // Assert: 200 + a non-empty invoiceId. + approveResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var invoiceId = await approveResp.DeserializeAsync(); + invoiceId.ShouldNotBe(Guid.Empty); + + // Assert: the request is now Invoiced with InvoiceId set. + await InspectDirectAsync(TestConstants.RootTenantId, async db => + { + var request = await db.TopupRequests.FindAsync(requestId); + request.ShouldNotBeNull(); + request!.Status.ShouldBe(TopupRequestStatus.Invoiced, "request must be Invoiced after approval"); + request.InvoiceId.ShouldBe(invoiceId, "request.InvoiceId must match the returned invoice id"); + }); + + // Assert: an Issued Topup invoice exists for root tenant. + await InspectDirectAsync(TestConstants.RootTenantId, async db => + { + var invoice = await db.Invoices.FindAsync(invoiceId); + invoice.ShouldNotBeNull(); + invoice!.TenantId.ShouldBe(TestConstants.RootTenantId); + invoice.Purpose.ShouldBe(InvoicePurpose.Topup); + invoice.Status.ShouldBe(InvoiceStatus.Issued); + }); + } + + // ─── Reject: happy path ─────────────────────────────────────────── + + [Fact] + public async Task Reject_marks_request_rejected_and_returns_request_id() + { + // Arrange: seed a Pending request. + var requestId = await SeedPendingTopupRequestAsync(TestConstants.RootTenantId, 100m); + + // Act: as ROOT, reject the request. + using var rootClient = await _auth.CreateRootAdminClientAsync(); + using var rejectResp = await rootClient.PostAsJsonAsync( + $"{BillingBasePath}/wallet/topup-requests/{requestId}/reject", + new { reason = "rejected in integration test" }); + + rejectResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var returnedId = await rejectResp.DeserializeAsync(); + returnedId.ShouldBe(requestId); + + // Assert: request is now Rejected. + await InspectDirectAsync(TestConstants.RootTenantId, async db => + { + var request = await db.TopupRequests.FindAsync(requestId); + request.ShouldNotBeNull(); + request!.Status.ShouldBe(TopupRequestStatus.Rejected); + request.DecisionNote.ShouldBe("rejected in integration test"); + }); + } + + // ─── Cross-tenant: non-root cannot see other tenants' requests ──── + + [Fact] + public async Task NonRoot_cannot_see_other_tenants_requests() + { + // Arrange: seed a Pending request for root tenant (TenantA). + var rootRequestId = await SeedPendingTopupRequestAsync(TestConstants.RootTenantId, 150m); + + // Arrange: provision a fresh TenantB. + using var rootClient = await _auth.CreateRootAdminClientAsync(); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + var tenantBId = $"topup-iso-{uniqueId}"; + var tenantBAdminEmail = $"topup-admin-{uniqueId}@tenant.com"; + await CreateTenantAsync(rootClient, tenantBId, tenantBAdminEmail); + await WaitForProvisioningAsync(rootClient, tenantBId); + + using var tenantBClient = await CreateTenantAdminClientWithRetryAsync( + tenantBAdminEmail, TestConstants.DefaultPassword, tenantBId); + + // Act: as TenantB admin, GET /wallet/topup-requests (admin endpoint, own-tenant scoped). + using var listResp = await tenantBClient.GetAsync( + $"{BillingBasePath}/wallet/topup-requests?pageNumber=1&pageSize=100"); + listResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var page = await ParseAsync>(listResp); + + // Assert: root's request does NOT appear. + page.Items.ShouldNotContain(r => r.Id == rootRequestId, + "a non-root tenant must not see another tenant's top-up requests"); + + // Sanity: root CAN see the request via the same endpoint. + using var rootListResp = await rootClient.GetAsync( + $"{BillingBasePath}/wallet/topup-requests?pageNumber=1&pageSize=100"); + rootListResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var rootPage = await ParseAsync>(rootListResp); + rootPage.Items.ShouldContain(r => r.Id == rootRequestId, + "root must be able to see its own request via the admin list endpoint"); + } + + // ─── List: root cross-tenant filter by tenantId ─────────────────── + + [Fact] + public async Task Root_can_filter_topup_requests_by_tenantId() + { + // Arrange: seed a request for root. + var requestId = await SeedPendingTopupRequestAsync(TestConstants.RootTenantId, 75m); + + using var rootClient = await _auth.CreateRootAdminClientAsync(); + + // Act: filter by root tenantId. + using var filteredResp = await rootClient.GetAsync( + $"{BillingBasePath}/wallet/topup-requests?tenantId={TestConstants.RootTenantId}&pageNumber=1&pageSize=100"); + filteredResp.StatusCode.ShouldBe(HttpStatusCode.OK); + var page = await ParseAsync>(filteredResp); + + // Assert: the seeded request is present and all items belong to root. + page.Items.ShouldContain(r => r.Id == requestId); + page.Items.ShouldAllBe(r => r.TenantId == TestConstants.RootTenantId); + } + + // ─── helpers ────────────────────────────────────────────────────── + + private async Task SeedPendingTopupRequestAsync(string tenantId, decimal amount) + { + Guid id = Guid.Empty; + await SeedDirectAsync(tenantId, async db => + { + var request = TopupRequest.Create(tenantId, amount, "USD", "integration-test-seed", "test-user"); + db.TopupRequests.Add(request); + await db.SaveChangesAsync(); + id = request.Id; + }); + return id; + } + + private async Task SeedDirectAsync(string tenantId, Func action) + { + using var scope = _factory.Services.CreateScope(); + + // Finbuckle context must be set INLINE (AsyncLocal; lost across awaited helpers). + var tenantStore = scope.ServiceProvider.GetRequiredService>(); + + // BillingDbContext is not tenant-filtered so we only need a valid tenant in the store. + // Fall back to root context for synthetic/cross-tenant seeding (root is always present). + var tenant = await tenantStore.GetAsync(tenantId) + ?? await tenantStore.GetAsync(TestConstants.RootTenantId); + scope.ServiceProvider.GetRequiredService().MultiTenantContext = + new MultiTenantContext(tenant); + + var db = scope.ServiceProvider.GetRequiredService(); + await action(db); + } + + private async Task InspectDirectAsync(string tenantId, Func action) + => await SeedDirectAsync(tenantId, action); + + private static async Task ParseAsync(HttpResponseMessage response) + { + var json = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Expected success, got {(int)response.StatusCode} {response.StatusCode}. Body: {json}"); + } + return JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException( + $"Failed to deserialize to {typeof(T).Name}. Body: {json}"); + } + + private static async Task CreateTenantAsync(HttpClient rootClient, string tenantId, string adminEmail) + { + var response = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new + { + id = tenantId, + name = $"Tenant {tenantId}", + connectionString = (string?)null, + adminEmail, + adminPassword = TestConstants.DefaultPassword, + issuer = $"{tenantId}.issuer" + }); + var body = await response.Content.ReadAsStringAsync(); + response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); + } + + private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) + { + for (int i = 0; i < maxRetries; i++) + { + var statusResponse = await client.GetAsync( + $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); + + if (statusResponse.IsSuccessStatusCode) + { + var content = await statusResponse.Content.ReadAsStringAsync(); + if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) + return; + if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); + } + + await Task.Delay(1000); + } + + throw new TimeoutException($"Tenant {tenantId} provisioning did not complete within {maxRetries}s."); + } + + private async Task CreateTenantAdminClientWithRetryAsync( + string email, string password, string tenant, int maxRetries = 30) + { + for (int i = 0; i < maxRetries; i++) + { + try + { + return await _auth.CreateAuthenticatedClientAsync(email, password, tenant); + } + catch (HttpRequestException) when (i < maxRetries - 1) + { + await Task.Delay(1000); + } + } + + return await _auth.CreateAuthenticatedClientAsync(email, password, tenant); + } +} From 708c701b0aca0c6f1e01cd764bc07746e96e78b3 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 01:50:48 +0530 Subject: [PATCH 11/15] fix(billing): reject of non-pending topup returns 4xx, not 500 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 --- .../RejectTopupRequestCommandHandler.cs | 10 +++++++ .../Tests/Billing/TopupApprovalTests.cs | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs index 15442fa51d..34ff4ea4c4 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs @@ -1,6 +1,8 @@ +using System.Net; using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Billing.Contracts; using FSH.Modules.Billing.Contracts.v1.Wallets; using FSH.Modules.Billing.Data; using Mediator; @@ -31,6 +33,14 @@ public async ValueTask Handle(RejectTopupRequestCommand command, Cancellat throw new UnauthorizedException("You can only reject top-up requests for your own tenant."); } + if (request.Status != TopupRequestStatus.Pending) + { + throw new CustomException( + $"Top-up request {command.Id} cannot be rejected because it is {request.Status} (only Pending requests can be rejected).", + (IEnumerable?)null, + HttpStatusCode.Conflict); + } + request.Reject(command.Reason); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return request.Id; diff --git a/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs b/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs index 5c6f249eda..32cf80a608 100644 --- a/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs +++ b/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs @@ -107,6 +107,32 @@ await InspectDirectAsync(TestConstants.RootTenantId, async db => }); } + // ─── Reject: double-reject on non-Pending returns 409 ──────────── + + [Fact] + public async Task Reject_of_already_rejected_request_returns_409_Conflict() + { + // Arrange: seed a Pending request and perform a successful first rejection. + var requestId = await SeedPendingTopupRequestAsync(TestConstants.RootTenantId, 120m); + + using var rootClient = await _auth.CreateRootAdminClientAsync(); + + // First reject — must succeed. + using var firstRejectResp = await rootClient.PostAsJsonAsync( + $"{BillingBasePath}/wallet/topup-requests/{requestId}/reject", + new { reason = "first rejection" }); + firstRejectResp.StatusCode.ShouldBe(HttpStatusCode.OK, "first rejection must succeed"); + + // Act: attempt a second rejection on the now-Rejected request. + using var secondRejectResp = await rootClient.PostAsJsonAsync( + $"{BillingBasePath}/wallet/topup-requests/{requestId}/reject", + new { reason = "duplicate rejection" }); + + // Assert: 409 Conflict — not 500. + secondRejectResp.StatusCode.ShouldBe(HttpStatusCode.Conflict, + "rejecting a non-Pending request must return 409 Conflict, not 500"); + } + // ─── Cross-tenant: non-root cannot see other tenants' requests ──── [Fact] From 1b212aa2f390c2a2f864f962e81a117d197c4820 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 26 Jun 2026 01:55:50 +0530 Subject: [PATCH 12/15] feat(dashboard): WhatsApp wallet page + request top-up Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/dashboard/src/api/wallet.ts | 91 ++++ .../src/components/layout/nav-data.ts | 2 + clients/dashboard/src/pages/wallet.tsx | 410 ++++++++++++++++++ clients/dashboard/src/routes.tsx | 2 + 4 files changed, 505 insertions(+) create mode 100644 clients/dashboard/src/api/wallet.ts create mode 100644 clients/dashboard/src/pages/wallet.tsx 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 + /> + + + +