From 48f22bbdf76aa9ad5655865d912fcd88b7802893 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:45:30 -0300 Subject: [PATCH] refactor(billing): adopt Money value object across Wallet, Invoice and BillingPlan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1316. Promoting Money to BuildingBlocks left Billing modeling money two ways: Money on TopupRequest, but raw decimal+Currency on Wallet, WalletTransaction, Invoice, InvoiceLineItem and BillingPlan. This folds the remaining money-bearing fields into Money and reintroduces the arithmetic (Add/Subtract/Multiply/Round) trimmed from #1316, now justified by real production callers: - Add -> Wallet.Credit balance, Invoice subtotal aggregation over line items - Subtract -> Wallet.Debit balance - Multiply -> BillingPlan.TermPrice (annual fallback = monthly * 12) - Round -> InvoiceLineItem amount (quantity * unitPrice, away-from-zero) Persistence: aggregate-level amounts that already had a paired Currency column reuse it via OwnsOne (Wallet.Balance, Invoice.SubtotalAmount, BillingPlan.MonthlyBasePrice) — pure no-op remap. Sub-entity/optional amounts gain a per-row currency column (WalletTransaction.Currency, InvoiceLineItem.AmountCurrency, BillingPlan.AnnualPriceCurrency), backfilled from the owning aggregate's currency before the NOT NULL columns are enforced. Contract DTOs stay decimal (unwrapped at the mapping boundary), so there is no API or frontend impact. BuildingBlocks change (Money arithmetic) is the reintroduction pre-approved by the maintainer in the #1316 review. Verified: build clean (-warnaserror), unit (Framework 112, Billing 123, Catalog 65, Arch 51), integration 730/731 on real Postgres, backfill exercised on populated data (EUR/GBP/BRL inherited from parent), no pending model changes vs snapshot. --- src/BuildingBlocks/Core/Domain/Money.cs | 46 ++ .../DemoSeed/DemoSeeder.cs | 4 +- ...illingMoneyValueObjectAdoption.Designer.cs | 606 ++++++++++++++++++ ...1063312_BillingMoneyValueObjectAdoption.cs | 111 ++++ .../Billing/BillingDbContextModelSnapshot.cs | 209 ++++-- .../BillingPlanConfiguration.cs | 15 +- .../Configurations/InvoiceConfiguration.cs | 9 +- .../InvoiceLineItemConfiguration.cs | 7 +- .../Configurations/WalletConfiguration.cs | 9 +- .../WalletTransactionConfiguration.cs | 7 +- .../Modules.Billing/Domain/BillingPlan.cs | 19 +- .../Billing/Modules.Billing/Domain/Invoice.cs | 12 +- .../Modules.Billing/Domain/InvoiceLineItem.cs | 8 +- .../Billing/Modules.Billing/Domain/Wallet.cs | 19 +- .../Domain/WalletTransaction.cs | 4 +- .../Features/v1/Invoices/InvoiceMappings.cs | 4 +- .../GetPlanTerm/GetPlanTermQueryHandler.cs | 2 +- .../v1/Plans/GetPlans/GetPlansQueryHandler.cs | 2 +- .../Mappings/WalletMappings.cs | 4 +- .../Services/BillingService.cs | 16 +- .../Billing.Tests/Domain/BillingPlanTests.cs | 16 +- .../Domain/InvoiceLineItemTests.cs | 4 +- .../Billing.Tests/Domain/InvoiceTests.cs | 10 +- src/Tests/Billing.Tests/Domain/WalletTests.cs | 6 +- src/Tests/Framework.Tests/Core/MoneyTests.cs | 68 ++ .../Tests/Billing/BillingDomainEdgeTests.cs | 4 +- .../Tests/Billing/MonthlyInvoiceJobTests.cs | 2 +- .../Tests/Billing/WalletTopupServiceTests.cs | 2 +- 28 files changed, 1110 insertions(+), 115 deletions(-) create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260701063312_BillingMoneyValueObjectAdoption.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260701063312_BillingMoneyValueObjectAdoption.cs diff --git a/src/BuildingBlocks/Core/Domain/Money.cs b/src/BuildingBlocks/Core/Domain/Money.cs index ecc7d33b44..a182c9f6e9 100644 --- a/src/BuildingBlocks/Core/Domain/Money.cs +++ b/src/BuildingBlocks/Core/Domain/Money.cs @@ -13,4 +13,50 @@ public Money(decimal amount, string currency) } public static Money Zero(string currency = "USD") => new(0m, currency); + + public Money Add(Money other) + { + ArgumentNullException.ThrowIfNull(other); + EnsureSameCurrency(other); + return this with { Amount = Amount + other.Amount }; + } + + public Money Subtract(Money other) + { + ArgumentNullException.ThrowIfNull(other); + EnsureSameCurrency(other); + return this with { Amount = Amount - other.Amount }; + } + + public Money Multiply(decimal factor) => this with { Amount = Amount * factor }; + + public Money Round(int decimals) => + this with { Amount = Math.Round(Amount, decimals, MidpointRounding.AwayFromZero) }; + + public static Money operator +(Money left, Money right) + { + ArgumentNullException.ThrowIfNull(left); + return left.Add(right); + } + + public static Money operator -(Money left, Money right) + { + ArgumentNullException.ThrowIfNull(left); + return left.Subtract(right); + } + + public static Money operator *(Money left, decimal right) + { + ArgumentNullException.ThrowIfNull(left); + return left.Multiply(right); + } + + private void EnsureSameCurrency(Money other) + { + if (!string.Equals(Currency, other.Currency, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Cannot operate on Money with different currencies: {Currency} and {other.Currency}."); + } + } } diff --git a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs index 74ec5d9f43..350efbdf43 100644 --- a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs +++ b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs @@ -233,7 +233,7 @@ private async Task SeedTenantSubscriptionAsync(DemoTenant demo, CancellationToke // Paid plans get an issued term invoice (like real CreateTenant), written directly so no InvoiceIssuedIntegrationEvent fires // (no outbox dispatcher; seeding mustn't email). Idempotent on invoice number; free plans (term price 0) get none, as in production. - if (plan.TermPrice > 0m) + if (plan.TermPrice.Amount > 0m) { var invoiceNumber = string.Create( CultureInfo.InvariantCulture, $"SUB-{startUtc:yyyyMM}-{demo.Id.ToUpperInvariant()}"); @@ -251,7 +251,7 @@ private async Task SeedTenantSubscriptionAsync(DemoTenant demo, CancellationToke CultureInfo.InvariantCulture, $"{plan.Name} — {plan.Interval} subscription ({startUtc:yyyy-MM-dd} to {endUtc:yyyy-MM-dd})"), 1m, - plan.TermPrice); + plan.TermPrice.Amount); invoice.Issue(); billingDb.Invoices.Add(invoice); if (_logger.IsEnabled(LogLevel.Information)) diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260701063312_BillingMoneyValueObjectAdoption.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260701063312_BillingMoneyValueObjectAdoption.Designer.cs new file mode 100644 index 0000000000..70889ea6e8 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260701063312_BillingMoneyValueObjectAdoption.Designer.cs @@ -0,0 +1,606 @@ +// +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("20260701063312_BillingMoneyValueObjectAdoption")] + partial class BillingMoneyValueObjectAdoption + { + /// + 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("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + 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("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("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("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("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("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + 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("CreatedAtUtc") + .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_wallets_tenantid"); + + b.ToTable("Wallets", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.WalletTransaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + 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("ReferenceId") + .IsUnique() + .HasDatabaseName("ux_wallet_transactions_topup_reference") + .HasFilter("\"Kind\" = 0"); + + b.HasIndex("TenantId"); + + b.HasIndex("WalletId", "CreatedAtUtc"); + + b.ToTable("WalletTransactions", "billing"); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.BillingPlan", b => + { + b.OwnsOne("FSH.Framework.Core.Domain.Money", "AnnualPrice", b1 => + { + b1.Property("BillingPlanId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("AnnualPrice"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("AnnualPriceCurrency"); + + b1.HasKey("BillingPlanId"); + + b1.ToTable("Plans", "billing"); + + b1.WithOwner() + .HasForeignKey("BillingPlanId"); + }); + + b.OwnsOne("FSH.Framework.Core.Domain.Money", "MonthlyBasePrice", b1 => + { + b1.Property("BillingPlanId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("MonthlyBasePrice"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("Currency"); + + b1.HasKey("BillingPlanId"); + + b1.ToTable("Plans", "billing"); + + b1.WithOwner() + .HasForeignKey("BillingPlanId"); + }); + + b.Navigation("AnnualPrice"); + + b.Navigation("MonthlyBasePrice") + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.Invoice", b => + { + b.OwnsOne("FSH.Framework.Core.Domain.Money", "SubtotalAmount", b1 => + { + b1.Property("InvoiceId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("SubtotalAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("Currency"); + + b1.HasKey("InvoiceId"); + + b1.ToTable("Invoices", "billing"); + + b1.WithOwner() + .HasForeignKey("InvoiceId"); + }); + + b.Navigation("SubtotalAmount") + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.InvoiceLineItem", b => + { + b.HasOne("FSH.Modules.Billing.Domain.Invoice", null) + .WithMany("LineItems") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("FSH.Framework.Core.Domain.Money", "Amount", b1 => + { + b1.Property("InvoiceLineItemId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("Amount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("AmountCurrency"); + + b1.HasKey("InvoiceLineItemId"); + + b1.ToTable("InvoiceLineItems", "billing"); + + b1.WithOwner() + .HasForeignKey("InvoiceLineItemId"); + }); + + b.Navigation("Amount") + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.TopupRequest", b => + { + b.OwnsOne("FSH.Framework.Core.Domain.Money", "Amount", b1 => + { + b1.Property("TopupRequestId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("Amount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("Currency"); + + b1.HasKey("TopupRequestId"); + + b1.ToTable("TopupRequests", "billing"); + + b1.WithOwner() + .HasForeignKey("TopupRequestId"); + }); + + b.Navigation("Amount") + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.Wallet", b => + { + b.OwnsOne("FSH.Framework.Core.Domain.Money", "Balance", b1 => + { + b1.Property("WalletId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("Balance"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("Currency"); + + b1.HasKey("WalletId"); + + b1.ToTable("Wallets", "billing"); + + b1.WithOwner() + .HasForeignKey("WalletId"); + }); + + b.Navigation("Balance") + .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(); + + b.OwnsOne("FSH.Framework.Core.Domain.Money", "Amount", b1 => + { + b1.Property("WalletTransactionId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("Amount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("Currency"); + + b1.HasKey("WalletTransactionId"); + + b1.ToTable("WalletTransactions", "billing"); + + b1.WithOwner() + .HasForeignKey("WalletTransactionId"); + }); + + b.Navigation("Amount") + .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/20260701063312_BillingMoneyValueObjectAdoption.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260701063312_BillingMoneyValueObjectAdoption.cs new file mode 100644 index 0000000000..c0c46d5451 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260701063312_BillingMoneyValueObjectAdoption.cs @@ -0,0 +1,111 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Billing +{ + /// + public partial class BillingMoneyValueObjectAdoption : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // WalletTransaction.Amount and InvoiceLineItem.Amount became Money owned value objects, and + // BillingPlan.AnnualPrice became an optional Money. The amount columns are preserved; only the + // per-row currency columns are new. Currency is denormalized from the owning aggregate, so + // existing rows are backfilled from the parent's currency before the NOT NULL columns are enforced. + // (Wallet.Balance/Invoice.SubtotalAmount/BillingPlan.MonthlyBasePrice reuse their existing + // "Currency" columns via OwnsOne, so those adoptions are pure no-ops with no schema change here.) + + migrationBuilder.AddColumn( + name: "Currency", + schema: "billing", + table: "WalletTransactions", + type: "character varying(8)", + maxLength: 8, + nullable: true); + + migrationBuilder.AddColumn( + name: "AmountCurrency", + schema: "billing", + table: "InvoiceLineItems", + type: "character varying(8)", + maxLength: 8, + nullable: true); + + migrationBuilder.AddColumn( + name: "AnnualPriceCurrency", + schema: "billing", + table: "Plans", + type: "character varying(8)", + maxLength: 8, + nullable: true); + + migrationBuilder.Sql( + """ + UPDATE billing."WalletTransactions" t + SET "Currency" = w."Currency" + FROM billing."Wallets" w + WHERE t."WalletId" = w."Id"; + """); + + migrationBuilder.Sql( + """ + UPDATE billing."InvoiceLineItems" li + SET "AmountCurrency" = i."Currency" + FROM billing."Invoices" i + WHERE li."InvoiceId" = i."Id"; + """); + + migrationBuilder.Sql( + """ + UPDATE billing."Plans" + SET "AnnualPriceCurrency" = "Currency" + WHERE "AnnualPrice" IS NOT NULL; + """); + + migrationBuilder.AlterColumn( + name: "Currency", + schema: "billing", + table: "WalletTransactions", + type: "character varying(8)", + maxLength: 8, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(8)", + oldMaxLength: 8, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AmountCurrency", + schema: "billing", + table: "InvoiceLineItems", + type: "character varying(8)", + maxLength: 8, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(8)", + oldMaxLength: 8, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Currency", + schema: "billing", + table: "WalletTransactions"); + + migrationBuilder.DropColumn( + name: "AnnualPriceCurrency", + schema: "billing", + table: "Plans"); + + migrationBuilder.DropColumn( + name: "AmountCurrency", + schema: "billing", + table: "InvoiceLineItems"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs index d1214f381a..d22678f634 100644 --- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs @@ -29,18 +29,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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") @@ -54,10 +45,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("character varying(64)"); - b.Property("MonthlyBasePrice") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - b.Property("Name") .IsRequired() .HasMaxLength(128) @@ -90,11 +77,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); @@ -133,10 +115,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .HasColumnType("integer"); - b.Property("SubtotalAmount") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - b.Property("TenantId") .IsRequired() .HasMaxLength(64) @@ -166,10 +144,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("Amount") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - b.Property("Description") .IsRequired() .HasMaxLength(512) @@ -331,18 +305,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -368,10 +333,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("uuid"); - b.Property("Amount") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); @@ -409,6 +370,92 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("WalletTransactions", "billing"); }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.BillingPlan", b => + { + b.OwnsOne("FSH.Framework.Core.Domain.Money", "AnnualPrice", b1 => + { + b1.Property("BillingPlanId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("AnnualPrice"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("AnnualPriceCurrency"); + + b1.HasKey("BillingPlanId"); + + b1.ToTable("Plans", "billing"); + + b1.WithOwner() + .HasForeignKey("BillingPlanId"); + }); + + b.OwnsOne("FSH.Framework.Core.Domain.Money", "MonthlyBasePrice", b1 => + { + b1.Property("BillingPlanId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("MonthlyBasePrice"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("Currency"); + + b1.HasKey("BillingPlanId"); + + b1.ToTable("Plans", "billing"); + + b1.WithOwner() + .HasForeignKey("BillingPlanId"); + }); + + b.Navigation("AnnualPrice"); + + b.Navigation("MonthlyBasePrice") + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Billing.Domain.Invoice", b => + { + b.OwnsOne("FSH.Framework.Core.Domain.Money", "SubtotalAmount", b1 => + { + b1.Property("InvoiceId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("SubtotalAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("Currency"); + + b1.HasKey("InvoiceId"); + + b1.ToTable("Invoices", "billing"); + + b1.WithOwner() + .HasForeignKey("InvoiceId"); + }); + + b.Navigation("SubtotalAmount") + .IsRequired(); + }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.InvoiceLineItem", b => { b.HasOne("FSH.Modules.Billing.Domain.Invoice", null) @@ -416,6 +463,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("InvoiceId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.OwnsOne("FSH.Framework.Core.Domain.Money", "Amount", b1 => + { + b1.Property("InvoiceLineItemId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("Amount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("AmountCurrency"); + + b1.HasKey("InvoiceLineItemId"); + + b1.ToTable("InvoiceLineItems", "billing"); + + b1.WithOwner() + .HasForeignKey("InvoiceLineItemId"); + }); + + b.Navigation("Amount") + .IsRequired(); }); modelBuilder.Entity("FSH.Modules.Billing.Domain.TopupRequest", b => @@ -448,6 +522,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.Wallet", b => + { + b.OwnsOne("FSH.Framework.Core.Domain.Money", "Balance", b1 => + { + b1.Property("WalletId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("Balance"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("Currency"); + + b1.HasKey("WalletId"); + + b1.ToTable("Wallets", "billing"); + + b1.WithOwner() + .HasForeignKey("WalletId"); + }); + + b.Navigation("Balance") + .IsRequired(); + }); + modelBuilder.Entity("FSH.Modules.Billing.Domain.WalletTransaction", b => { b.HasOne("FSH.Modules.Billing.Domain.Wallet", null) @@ -455,6 +559,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("WalletId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.OwnsOne("FSH.Framework.Core.Domain.Money", "Amount", b1 => + { + b1.Property("WalletTransactionId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("Amount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("Currency"); + + b1.HasKey("WalletTransactionId"); + + b1.ToTable("WalletTransactions", "billing"); + + b1.WithOwner() + .HasForeignKey("WalletTransactionId"); + }); + + b.Navigation("Amount") + .IsRequired(); }); modelBuilder.Entity("FSH.Modules.Billing.Domain.Invoice", b => diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/BillingPlanConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/BillingPlanConfiguration.cs index e619df6f9e..24a66b0e95 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/BillingPlanConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/BillingPlanConfiguration.cs @@ -17,10 +17,19 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Key).IsRequired().HasMaxLength(64); builder.HasIndex(x => x.Key).IsUnique(); builder.Property(x => x.Name).IsRequired().HasMaxLength(128); - builder.Property(x => x.Currency).IsRequired().HasMaxLength(8); - builder.Property(x => x.MonthlyBasePrice).HasPrecision(18, 4); + builder.Ignore(x => x.Currency); + builder.OwnsOne(x => x.MonthlyBasePrice, m => + { + m.Property(p => p.Amount).HasColumnName("MonthlyBasePrice").HasPrecision(18, 4).IsRequired(); + m.Property(p => p.Currency).HasColumnName("Currency").HasMaxLength(8).IsRequired(); + }); + builder.Navigation(x => x.MonthlyBasePrice).IsRequired(); builder.Property(x => x.Interval).HasConversion().HasDefaultValue(Contracts.PlanInterval.Monthly); - builder.Property(x => x.AnnualPrice).HasPrecision(18, 4); + builder.OwnsOne(x => x.AnnualPrice, m => + { + m.Property(p => p.Amount).HasColumnName("AnnualPrice").HasPrecision(18, 4); + m.Property(p => p.Currency).HasColumnName("AnnualPriceCurrency").HasMaxLength(8); + }); // Overage rates map to jsonb so the plan's pricing schedule is a single column. builder.Property>("_overageRates") diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs index 062bb7c676..c442b391ff 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceConfiguration.cs @@ -13,8 +13,13 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(x => x.Id); builder.Property(x => x.TenantId).IsRequired().HasMaxLength(64); builder.Property(x => x.InvoiceNumber).IsRequired().HasMaxLength(64); - builder.Property(x => x.Currency).IsRequired().HasMaxLength(8); - builder.Property(x => x.SubtotalAmount).HasPrecision(18, 4); + builder.Ignore(x => x.Currency); + builder.OwnsOne(x => x.SubtotalAmount, m => + { + m.Property(p => p.Amount).HasColumnName("SubtotalAmount").HasPrecision(18, 4).IsRequired(); + m.Property(p => p.Currency).HasColumnName("Currency").HasMaxLength(8).IsRequired(); + }); + builder.Navigation(x => x.SubtotalAmount).IsRequired(); builder.Property(x => x.Status).HasConversion(); builder.Property(x => x.Purpose).HasConversion().HasDefaultValue(Contracts.InvoicePurpose.Usage); builder.Property(x => x.PeriodStartUtc); diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceLineItemConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceLineItemConfiguration.cs index 0bf180c86b..17703bf666 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceLineItemConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/InvoiceLineItemConfiguration.cs @@ -17,7 +17,12 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Description).IsRequired().HasMaxLength(512); builder.Property(x => x.Quantity).HasPrecision(18, 4); builder.Property(x => x.UnitPrice).HasPrecision(18, 4); - builder.Property(x => x.Amount).HasPrecision(18, 4); + builder.OwnsOne(x => x.Amount, m => + { + m.Property(p => p.Amount).HasColumnName("Amount").HasPrecision(18, 4).IsRequired(); + m.Property(p => p.Currency).HasColumnName("AmountCurrency").HasMaxLength(8).IsRequired(); + }); + builder.Navigation(x => x.Amount).IsRequired(); builder.HasIndex(x => x.InvoiceId); diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletConfiguration.cs index 11d2a1d325..28f2d532b2 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletConfiguration.cs @@ -12,8 +12,13 @@ public void Configure(EntityTypeBuilder 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.Ignore(x => x.Currency); + builder.OwnsOne(x => x.Balance, m => + { + m.Property(p => p.Amount).HasColumnName("Balance").HasPrecision(18, 4).IsRequired(); + m.Property(p => p.Currency).HasColumnName("Currency").HasMaxLength(8).IsRequired(); + }); + builder.Navigation(x => x.Balance).IsRequired(); builder.Property(x => x.Status).HasConversion(); builder.HasIndex(x => x.TenantId).IsUnique().HasDatabaseName("ux_wallets_tenantid"); diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs index 5095258d71..ab1398cb0e 100644 --- a/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs +++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/WalletTransactionConfiguration.cs @@ -14,7 +14,12 @@ public void Configure(EntityTypeBuilder builder) // 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.OwnsOne(x => x.Amount, m => + { + m.Property(p => p.Amount).HasColumnName("Amount").HasPrecision(18, 4).IsRequired(); + m.Property(p => p.Currency).HasColumnName("Currency").HasMaxLength(8).IsRequired(); + }); + builder.Navigation(x => x.Amount).IsRequired(); builder.Property(x => x.Kind).HasConversion(); builder.Property(x => x.Description).IsRequired().HasMaxLength(256); builder.Property(x => x.ReferenceId).HasMaxLength(128); diff --git a/src/Modules/Billing/Modules.Billing/Domain/BillingPlan.cs b/src/Modules/Billing/Modules.Billing/Domain/BillingPlan.cs index 625fe922aa..30dd36f210 100644 --- a/src/Modules/Billing/Modules.Billing/Domain/BillingPlan.cs +++ b/src/Modules/Billing/Modules.Billing/Domain/BillingPlan.cs @@ -18,8 +18,8 @@ public sealed class BillingPlan : BaseEntity, IGlobalEntity public string Key { get; private set; } = default!; public string Name { get; private set; } = default!; - public string Currency { get; private set; } = "USD"; - public decimal MonthlyBasePrice { get; private set; } + public Money MonthlyBasePrice { get; private set; } = default!; + public string Currency => MonthlyBasePrice.Currency; public PlanInterval Interval { get; private set; } = PlanInterval.Monthly; /// @@ -27,7 +27,7 @@ public sealed class BillingPlan : BaseEntity, IGlobalEntity /// ; null falls back to twelve times the monthly base /// price so a yearly plan can be configured without restating the discount. /// - public decimal? AnnualPrice { get; private set; } + public Money? AnnualPrice { get; private set; } public bool IsActive { get; private set; } = true; public DateTime CreatedAtUtc { get; private set; } public DateTime? UpdatedAtUtc { get; private set; } @@ -64,10 +64,9 @@ public static BillingPlan Create( Key = key.ToLowerInvariant(), #pragma warning restore CA1308 Name = name, - Currency = currency.ToUpperInvariant(), - MonthlyBasePrice = monthlyBasePrice, + MonthlyBasePrice = new Money(monthlyBasePrice, currency), Interval = interval, - AnnualPrice = annualPrice, + AnnualPrice = annualPrice is { } a ? new Money(a, currency) : null, IsActive = true, CreatedAtUtc = DateTime.UtcNow }; @@ -101,9 +100,9 @@ public void Update( } Name = name; - MonthlyBasePrice = monthlyBasePrice; + MonthlyBasePrice = new Money(monthlyBasePrice, Currency); Interval = interval; - AnnualPrice = annualPrice; + AnnualPrice = annualPrice is { } a ? new Money(a, Currency) : null; _overageRates.Clear(); if (overageRates is not null) { @@ -131,8 +130,8 @@ public decimal GetOverageRate(QuotaResource resource) => /// Price charged for a single billing term: the monthly base price for monthly plans, or the /// annual price (falling back to twelve months) for yearly plans. /// - public decimal TermPrice => + public Money TermPrice => Interval == PlanInterval.Yearly - ? AnnualPrice ?? (MonthlyBasePrice * 12m) + ? AnnualPrice ?? MonthlyBasePrice.Multiply(12m) : MonthlyBasePrice; } diff --git a/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs b/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs index d79430d6d9..39be962081 100644 --- a/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs +++ b/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs @@ -29,8 +29,8 @@ public sealed class Invoice : AggregateRoot /// End of the billed term (subscription invoices only). public DateTime? PeriodEndUtc { get; private set; } - public string Currency { get; private set; } = "USD"; - public decimal SubtotalAmount { get; private set; } + public Money SubtotalAmount { get; private set; } = default!; + public string Currency => SubtotalAmount.Currency; public InvoiceStatus Status { get; private set; } public DateTime CreatedAtUtc { get; private set; } public DateTime? IssuedAtUtc { get; private set; } @@ -84,7 +84,7 @@ public static Invoice CreateDraft( Purpose = purpose, PeriodStartUtc = periodStartUtc is { } s ? DateTime.SpecifyKind(s, DateTimeKind.Utc) : null, PeriodEndUtc = periodEndUtc is { } e ? DateTime.SpecifyKind(e, DateTimeKind.Utc) : null, - Currency = currency.ToUpperInvariant(), + SubtotalAmount = Money.Zero(currency.ToUpperInvariant()), Status = InvoiceStatus.Draft, CreatedAtUtc = DateTime.UtcNow }; @@ -113,7 +113,7 @@ public static Invoice CreateTopupDraft( public InvoiceLineItem AddLineItem(InvoiceLineItemKind kind, string description, decimal quantity, decimal unitPrice) { RequireStatus(InvoiceStatus.Draft); - var line = InvoiceLineItem.Create(Id, kind, description, quantity, unitPrice); + var line = InvoiceLineItem.Create(Id, kind, description, quantity, unitPrice, Currency); _lineItems.Add(line); RecalculateTotals(); return line; @@ -177,6 +177,8 @@ private void RequireStatus(InvoiceStatus expected) private void RecalculateTotals() { - SubtotalAmount = _lineItems.Sum(l => l.Amount); + SubtotalAmount = _lineItems.Aggregate( + Money.Zero(SubtotalAmount.Currency), + (acc, l) => acc.Add(l.Amount)); } } diff --git a/src/Modules/Billing/Modules.Billing/Domain/InvoiceLineItem.cs b/src/Modules/Billing/Modules.Billing/Domain/InvoiceLineItem.cs index 4a7552726c..7423193144 100644 --- a/src/Modules/Billing/Modules.Billing/Domain/InvoiceLineItem.cs +++ b/src/Modules/Billing/Modules.Billing/Domain/InvoiceLineItem.cs @@ -16,7 +16,7 @@ public sealed class InvoiceLineItem : BaseEntity public string Description { get; private set; } = default!; public decimal Quantity { get; private set; } public decimal UnitPrice { get; private set; } - public decimal Amount { get; private set; } + public Money Amount { get; private set; } = default!; private InvoiceLineItem() { } @@ -25,9 +25,11 @@ internal static InvoiceLineItem Create( InvoiceLineItemKind kind, string description, decimal quantity, - decimal unitPrice) + decimal unitPrice, + string currency) { ArgumentException.ThrowIfNullOrWhiteSpace(description); + ArgumentException.ThrowIfNullOrWhiteSpace(currency); if (quantity < 0) { throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity cannot be negative."); @@ -45,7 +47,7 @@ internal static InvoiceLineItem Create( Description = description, Quantity = quantity, UnitPrice = unitPrice, - Amount = Math.Round(quantity * unitPrice, 2, MidpointRounding.AwayFromZero) + Amount = new Money(quantity * unitPrice, currency).Round(2) }; } diff --git a/src/Modules/Billing/Modules.Billing/Domain/Wallet.cs b/src/Modules/Billing/Modules.Billing/Domain/Wallet.cs index 2b19715ca9..4aac34a6d8 100644 --- a/src/Modules/Billing/Modules.Billing/Domain/Wallet.cs +++ b/src/Modules/Billing/Modules.Billing/Domain/Wallet.cs @@ -8,8 +8,8 @@ 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 Money Balance { get; private set; } = default!; + public string Currency => Balance.Currency; public WalletStatus Status { get; private set; } public DateTime CreatedAtUtc { get; private set; } public DateTime? UpdatedAtUtc { get; private set; } @@ -25,8 +25,7 @@ public static Wallet Create(string tenantId, string currency) { Id = Guid.CreateVersion7(), TenantId = tenantId, - Currency = string.IsNullOrWhiteSpace(currency) ? "USD" : currency, - Balance = 0m, + Balance = Money.Zero(string.IsNullOrWhiteSpace(currency) ? "USD" : currency), Status = WalletStatus.Active, CreatedAtUtc = DateTime.UtcNow }; @@ -35,9 +34,10 @@ public static Wallet Create(string tenantId, string currency) 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); + var credit = new Money(amount, Balance.Currency); + var tx = WalletTransaction.Create(Id, TenantId, credit, kind, description, referenceId); _transactions.Add(tx); - Balance += amount; + Balance = Balance.Add(credit); UpdatedAtUtc = DateTime.UtcNow; return tx; } @@ -45,11 +45,12 @@ public WalletTransaction Credit(decimal amount, WalletTransactionKind kind, stri public WalletTransaction Debit(decimal amount, WalletTransactionKind kind, string description, string? referenceId) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(amount, 0m); - if (amount > Balance) + if (amount > Balance.Amount) throw new InvalidOperationException("Insufficient wallet balance."); - var tx = WalletTransaction.Create(Id, TenantId, -amount, kind, description, referenceId); + var debit = new Money(amount, Balance.Currency); + var tx = WalletTransaction.Create(Id, TenantId, new Money(-amount, Balance.Currency), kind, description, referenceId); _transactions.Add(tx); - Balance -= amount; + Balance = Balance.Subtract(debit); 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 index 9612f1d5ad..236a336aa9 100644 --- a/src/Modules/Billing/Modules.Billing/Domain/WalletTransaction.cs +++ b/src/Modules/Billing/Modules.Billing/Domain/WalletTransaction.cs @@ -7,7 +7,7 @@ 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 Money Amount { get; private set; } = default!; public WalletTransactionKind Kind { get; private set; } public string Description { get; private set; } = default!; public string? ReferenceId { get; private set; } @@ -16,7 +16,7 @@ public sealed class WalletTransaction : BaseEntity private WalletTransaction() { } internal static WalletTransaction Create( - Guid walletId, string tenantId, decimal amount, + Guid walletId, string tenantId, Money amount, WalletTransactionKind kind, string description, string? referenceId) => new() { diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/InvoiceMappings.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/InvoiceMappings.cs index a84e4f85bb..adecc39e1c 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/InvoiceMappings.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/InvoiceMappings.cs @@ -12,7 +12,7 @@ internal static class InvoiceMappings invoice.PeriodYear, invoice.PeriodMonth, invoice.Currency, - invoice.SubtotalAmount, + invoice.SubtotalAmount.Amount, invoice.Status, invoice.CreatedAtUtc, invoice.IssuedAtUtc, @@ -21,7 +21,7 @@ internal static class InvoiceMappings invoice.VoidedAtUtc, invoice.Notes, invoice.LineItems - .Select(l => new InvoiceLineItemDto(l.Id, l.Kind, l.Resource, l.Description, l.Quantity, l.UnitPrice, l.Amount)) + .Select(l => new InvoiceLineItemDto(l.Id, l.Kind, l.Resource, l.Description, l.Quantity, l.UnitPrice, l.Amount.Amount)) .ToList(), invoice.Purpose, invoice.PeriodStartUtc, diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs index 28b61813c9..da86c3187d 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs @@ -26,7 +26,7 @@ public async ValueTask Handle(GetPlanTermQuery query, Cancella plan.Name, plan.Interval, plan.TermMonths, - plan.TermPrice, + plan.TermPrice.Amount, plan.Currency); } } diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlans/GetPlansQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlans/GetPlansQueryHandler.cs index e3277f898b..58b3bc537c 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlans/GetPlansQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlans/GetPlansQueryHandler.cs @@ -21,7 +21,7 @@ public async ValueTask> Handle(GetPlansQuery query var plans = await plansQuery.OrderBy(p => p.Key).ToListAsync(cancellationToken).ConfigureAwait(false); return plans - .Select(p => new BillingPlanDto(p.Id, p.Key, p.Name, p.Currency, p.MonthlyBasePrice, p.OverageRates, p.IsActive, p.Interval, p.AnnualPrice)) + .Select(p => new BillingPlanDto(p.Id, p.Key, p.Name, p.Currency, p.MonthlyBasePrice.Amount, p.OverageRates, p.IsActive, p.Interval, p.AnnualPrice?.Amount)) .ToList(); } } diff --git a/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs b/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs index 385f32f484..384f5936fc 100644 --- a/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs +++ b/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs @@ -6,11 +6,11 @@ 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); + => new(t.Id, t.Amount.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.Id, w.TenantId, w.Currency, w.Balance.Amount, w.Status.ToString(), w.CreatedAtUtc, w.Transactions .OrderByDescending(t => t.CreatedAtUtc) .Take(recentCount) diff --git a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs index 652bc34d20..0a6643d140 100644 --- a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs +++ b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs @@ -109,7 +109,7 @@ public BillingService( if (_logger.IsEnabled(LogLevel.Information)) { _logger.LogInformation("[Billing] generated draft invoice {InvoiceNumber} for tenant {TenantId} period {Year}-{Month:00} total={Total} {Currency}", - invoice.InvoiceNumber, tenantId, periodYear, periodMonth, invoice.SubtotalAmount, invoice.Currency); + invoice.InvoiceNumber, tenantId, periodYear, periodMonth, invoice.SubtotalAmount.Amount, invoice.Currency); } return invoice; } @@ -211,7 +211,7 @@ public async Task CreateTopupInvoiceAsync(string tenantId, Guid topupRe { _logger.LogInformation( "[Billing] issued top-up invoice {InvoiceNumber} for tenant {TenantId} amount={Amount} {Currency}", - invoice.InvoiceNumber, tenantId, invoice.SubtotalAmount, invoice.Currency); + invoice.InvoiceNumber, tenantId, invoice.SubtotalAmount.Amount, invoice.Currency); } await _eventBus.PublishAsync(new InvoiceIssuedIntegrationEvent( @@ -222,7 +222,7 @@ await _eventBus.PublishAsync(new InvoiceIssuedIntegrationEvent( Source: "Billing", InvoiceId: invoice.Id, InvoiceNumber: invoice.InvoiceNumber, - Amount: invoice.SubtotalAmount, + Amount: invoice.SubtotalAmount.Amount, Currency: invoice.Currency, DueAtUtc: invoice.DueAtUtc, PeriodYear: invoice.PeriodYear, @@ -257,7 +257,7 @@ public async Task MarkInvoicePaidAsync(Guid invoiceId, CancellationToken cancell } wallet.Credit( - invoice.SubtotalAmount, + invoice.SubtotalAmount.Amount, WalletTransactionKind.Topup, "WhatsApp wallet top-up", topupRequest.Id.ToString()); @@ -303,7 +303,7 @@ private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken c ?? throw new NotFoundException($"Plan {planId} not found for tenant {tenantId}."); var termPrice = plan.TermPrice; - if (termPrice <= 0) + if (termPrice.Amount <= 0m) { // Free / trial plan — validity is still set, but there is nothing to bill. if (_logger.IsEnabled(LogLevel.Information)) @@ -332,7 +332,7 @@ private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken c InvoiceLineItemKind.BaseFee, $"{plan.Name} — {plan.Interval} subscription ({periodStart:yyyy-MM-dd} to {periodEnd:yyyy-MM-dd})", 1m, - termPrice); + termPrice.Amount); invoice.Issue(); _db.Invoices.Add(invoice); @@ -340,7 +340,7 @@ private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken c if (_logger.IsEnabled(LogLevel.Information)) { _logger.LogInformation("[Billing] issued subscription invoice {InvoiceNumber} for tenant {TenantId} total={Total} {Currency}", - invoice.InvoiceNumber, tenantId, invoice.SubtotalAmount, invoice.Currency); + invoice.InvoiceNumber, tenantId, invoice.SubtotalAmount.Amount, invoice.Currency); } // Notify (e.g. email the tenant) that a real bill was issued. Only fires for newly-created @@ -353,7 +353,7 @@ await _eventBus.PublishAsync(new InvoiceIssuedIntegrationEvent( Source: "Billing", InvoiceId: invoice.Id, InvoiceNumber: invoice.InvoiceNumber, - Amount: invoice.SubtotalAmount, + Amount: invoice.SubtotalAmount.Amount, Currency: invoice.Currency, DueAtUtc: invoice.DueAtUtc, PeriodYear: invoice.PeriodYear, diff --git a/src/Tests/Billing.Tests/Domain/BillingPlanTests.cs b/src/Tests/Billing.Tests/Domain/BillingPlanTests.cs index 4a53206120..47fef1340f 100644 --- a/src/Tests/Billing.Tests/Domain/BillingPlanTests.cs +++ b/src/Tests/Billing.Tests/Domain/BillingPlanTests.cs @@ -15,7 +15,7 @@ public void Create_Should_Default_To_Monthly_Interval() plan.Interval.ShouldBe(PlanInterval.Monthly); plan.TermMonths.ShouldBe(1); - plan.TermPrice.ShouldBe(30m); + plan.TermPrice.Amount.ShouldBe(30m); } [Fact] @@ -25,7 +25,7 @@ public void Yearly_Plan_Should_Use_AnnualPrice_When_Set() plan.Interval.ShouldBe(PlanInterval.Yearly); plan.TermMonths.ShouldBe(12); - plan.TermPrice.ShouldBe(300m); + plan.TermPrice.Amount.ShouldBe(300m); } [Fact] @@ -33,7 +33,7 @@ public void Yearly_Plan_Should_Fall_Back_To_Twelve_Times_Monthly() { var plan = BillingPlan.Create("pro-yr", "Pro Annual", "USD", 30m, interval: PlanInterval.Yearly); - plan.TermPrice.ShouldBe(360m); + plan.TermPrice.Amount.ShouldBe(360m); } [Fact] @@ -51,8 +51,8 @@ public void Update_Should_Replace_Interval_And_AnnualPrice() plan.Update("Pro", 30m, null, PlanInterval.Yearly, 300m); plan.Interval.ShouldBe(PlanInterval.Yearly); - plan.AnnualPrice.ShouldBe(300m); - plan.TermPrice.ShouldBe(300m); + plan.AnnualPrice!.Amount.ShouldBe(300m); + plan.TermPrice.Amount.ShouldBe(300m); } #endregion @@ -68,7 +68,7 @@ public void Create_Should_Lowercase_Key_And_Uppercase_Currency() plan.Currency.ShouldBe("USD"); plan.Name.ShouldBe("Pro Plan"); plan.IsActive.ShouldBeTrue(); - plan.MonthlyBasePrice.ShouldBe(49m); + plan.MonthlyBasePrice.Amount.ShouldBe(49m); } [Fact] @@ -110,7 +110,7 @@ public void Update_Should_Replace_Name_Price_And_OverageRates() plan.Update("Pro Max", 99m, newRates); plan.Name.ShouldBe("Pro Max"); - plan.MonthlyBasePrice.ShouldBe(99m); + plan.MonthlyBasePrice.Amount.ShouldBe(99m); plan.GetOverageRate(QuotaResource.ApiCalls).ShouldBe(0m); plan.GetOverageRate(QuotaResource.Users).ShouldBe(2m); plan.UpdatedAtUtc.ShouldNotBeNull(); @@ -184,7 +184,7 @@ public void Create_Should_Allow_Zero_Price() { var plan = BillingPlan.Create("free", "Free", "USD", 0m); - plan.MonthlyBasePrice.ShouldBe(0m); + plan.MonthlyBasePrice.Amount.ShouldBe(0m); } #endregion diff --git a/src/Tests/Billing.Tests/Domain/InvoiceLineItemTests.cs b/src/Tests/Billing.Tests/Domain/InvoiceLineItemTests.cs index 2f8ee142da..c78978b4e6 100644 --- a/src/Tests/Billing.Tests/Domain/InvoiceLineItemTests.cs +++ b/src/Tests/Billing.Tests/Domain/InvoiceLineItemTests.cs @@ -23,7 +23,7 @@ public void Create_Should_Compute_Amount_As_Quantity_Times_UnitPrice() line.Quantity.ShouldBe(100m); line.UnitPrice.ShouldBe(0.02m); - line.Amount.ShouldBe(2.00m); + line.Amount.Amount.ShouldBe(2.00m); line.InvoiceId.ShouldBe(inv.Id); line.Kind.ShouldBe(InvoiceLineItemKind.Overage); } @@ -35,7 +35,7 @@ public void Create_Should_Allow_Zero_Quantity_And_Zero_Price() var line = inv.AddLineItem(InvoiceLineItemKind.Adjustment, "credit", 0m, 0m); - line.Amount.ShouldBe(0m); + line.Amount.Amount.ShouldBe(0m); } #endregion diff --git a/src/Tests/Billing.Tests/Domain/InvoiceTests.cs b/src/Tests/Billing.Tests/Domain/InvoiceTests.cs index 5104146b21..3c6dfeae66 100644 --- a/src/Tests/Billing.Tests/Domain/InvoiceTests.cs +++ b/src/Tests/Billing.Tests/Domain/InvoiceTests.cs @@ -58,7 +58,7 @@ public void CreateDraft_Should_Start_Draft_With_Upper_Currency() inv.Status.ShouldBe(InvoiceStatus.Draft); inv.Currency.ShouldBe("USD"); - inv.SubtotalAmount.ShouldBe(0m); + inv.SubtotalAmount.Amount.ShouldBe(0m); inv.LineItems.ShouldBeEmpty(); } @@ -71,7 +71,7 @@ public void AddLineItem_Should_Append_And_Recalculate_Subtotal() inv.AddLineItem(InvoiceLineItemKind.Overage, "Overage", 2m, 10m); inv.LineItems.Count.ShouldBe(2); - inv.SubtotalAmount.ShouldBe(69m); + inv.SubtotalAmount.Amount.ShouldBe(69m); } [Fact] @@ -252,8 +252,8 @@ public void AddLineItem_Should_Round_Amount_Half_Away_From_Zero() // 0.005 * 1 = 0.005 -> rounds to 0.01 (away from zero), not banker's 0.00 var line = inv.AddLineItem(InvoiceLineItemKind.Overage, "tiny", 1m, 0.005m); - line.Amount.ShouldBe(0.01m); - inv.SubtotalAmount.ShouldBe(0.01m); + line.Amount.Amount.ShouldBe(0.01m); + inv.SubtotalAmount.Amount.ShouldBe(0.01m); } [Fact] @@ -264,7 +264,7 @@ public void AddLineItem_Should_Round_Quantity_Times_UnitPrice_To_Two_Decimals() // 3 * 0.333 = 0.999 -> 1.00 var line = inv.AddLineItem(InvoiceLineItemKind.Overage, "units", 3m, 0.333m); - line.Amount.ShouldBe(1.00m); + line.Amount.Amount.ShouldBe(1.00m); } #endregion diff --git a/src/Tests/Billing.Tests/Domain/WalletTests.cs b/src/Tests/Billing.Tests/Domain/WalletTests.cs index cad15647cb..36ef5e9ae6 100644 --- a/src/Tests/Billing.Tests/Domain/WalletTests.cs +++ b/src/Tests/Billing.Tests/Domain/WalletTests.cs @@ -13,7 +13,7 @@ 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.Balance.Amount.ShouldBe(0m); w.Status.ShouldBe(WalletStatus.Active); w.Id.ShouldNotBe(Guid.Empty); } @@ -23,8 +23,8 @@ 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); + w.Balance.Amount.ShouldBe(50m); + tx.Amount.Amount.ShouldBe(50m); tx.WalletId.ShouldBe(w.Id); tx.TenantId.ShouldBe("tenant-a"); tx.ReferenceId.ShouldBe("req-1"); diff --git a/src/Tests/Framework.Tests/Core/MoneyTests.cs b/src/Tests/Framework.Tests/Core/MoneyTests.cs index 60caddd989..5a695e9e0b 100644 --- a/src/Tests/Framework.Tests/Core/MoneyTests.cs +++ b/src/Tests/Framework.Tests/Core/MoneyTests.cs @@ -65,6 +65,74 @@ public void Ctor_Should_Throw_When_CurrencyIsNull() #endregion + #region Arithmetic + + [Fact] + public void Add_Should_SumAmounts_When_CurrenciesMatch() + { + var sum = new Money(10m, "USD").Add(new Money(2.50m, "usd")); + + sum.Amount.ShouldBe(12.50m); + sum.Currency.ShouldBe("USD"); + } + + [Fact] + public void Subtract_Should_DiffAmounts_When_CurrenciesMatch() + { + var diff = new Money(10m, "USD").Subtract(new Money(2.50m, "USD")); + + diff.Amount.ShouldBe(7.50m); + } + + [Fact] + public void Subtract_Should_AllowNegativeResult_When_RightExceedsLeft() + { + var diff = new Money(2m, "USD").Subtract(new Money(5m, "USD")); + + diff.Amount.ShouldBe(-3m); + } + + [Fact] + public void Multiply_Should_ScaleAmountAndKeepCurrency() + { + var product = new Money(3m, "USD").Multiply(4m); + + product.Amount.ShouldBe(12m); + product.Currency.ShouldBe("USD"); + } + + [Fact] + public void Round_Should_RoundAwayFromZero() + { + new Money(1.005m, "USD").Round(2).Amount.ShouldBe(1.01m); + new Money(-1.005m, "USD").Round(2).Amount.ShouldBe(-1.01m); + } + + [Fact] + public void Add_Should_Throw_When_CurrenciesDiffer() + { + Should.Throw(() => new Money(1m, "USD").Add(new Money(1m, "EUR"))); + } + + [Fact] + public void Subtract_Should_Throw_When_CurrenciesDiffer() + { + Should.Throw(() => new Money(1m, "USD").Subtract(new Money(1m, "EUR"))); + } + + [Fact] + public void Operators_Should_MirrorNamedMethods() + { + var a = new Money(10m, "USD"); + var b = new Money(4m, "USD"); + + (a + b).Amount.ShouldBe(14m); + (a - b).Amount.ShouldBe(6m); + (a * 2m).Amount.ShouldBe(20m); + } + + #endregion + #region Equality [Fact] diff --git a/src/Tests/Integration.Tests/Tests/Billing/BillingDomainEdgeTests.cs b/src/Tests/Integration.Tests/Tests/Billing/BillingDomainEdgeTests.cs index 10c9d04a28..c729d7cd97 100644 --- a/src/Tests/Integration.Tests/Tests/Billing/BillingDomainEdgeTests.cs +++ b/src/Tests/Integration.Tests/Tests/Billing/BillingDomainEdgeTests.cs @@ -202,10 +202,10 @@ await SeedDirectAsync(async db => { var inv = await db.Invoices.AsNoTracking().Include(i => i.LineItems).FirstAsync(i => i.Id == invoiceId); var overage = inv.LineItems.Single(l => l.Kind == InvoiceLineItemKind.Overage); - overage.Amount.ShouldBe(0.03m, "2.5 * 0.01 = 0.025 must round AwayFromZero to 0.03"); + overage.Amount.Amount.ShouldBe(0.03m, "2.5 * 0.01 = 0.025 must round AwayFromZero to 0.03"); // Subtotal recomputes across all lines (BaseFee 10 + overage 0.03). - inv.SubtotalAmount.ShouldBe(10.03m, "AddLineItem must recompute SubtotalAmount over every line"); + inv.SubtotalAmount.Amount.ShouldBe(10.03m, "AddLineItem must recompute SubtotalAmount over every line"); }); } diff --git a/src/Tests/Integration.Tests/Tests/Billing/MonthlyInvoiceJobTests.cs b/src/Tests/Integration.Tests/Tests/Billing/MonthlyInvoiceJobTests.cs index d4520e842e..24c528f1c0 100644 --- a/src/Tests/Integration.Tests/Tests/Billing/MonthlyInvoiceJobTests.cs +++ b/src/Tests/Integration.Tests/Tests/Billing/MonthlyInvoiceJobTests.cs @@ -57,7 +57,7 @@ public async Task RunAsync_Should_Generate_Draft_Invoice_For_Subscribed_Tenant_F invoice.PeriodYear.ShouldBe(previous.Year); invoice.PeriodMonth.ShouldBe(previous.Month); invoice.Purpose.ShouldBe(InvoicePurpose.Usage, "the monthly job produces usage invoices"); - invoice.SubtotalAmount.ShouldBe(0m, + invoice.SubtotalAmount.Amount.ShouldBe(0m, "usage invoices bill metered overage only — the base fee is billed on the subscription invoice on create/renew, and root has no overage"); } diff --git a/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs b/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs index 561c2bd43b..01565c9504 100644 --- a/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs +++ b/src/Tests/Integration.Tests/Tests/Billing/WalletTopupServiceTests.cs @@ -65,7 +65,7 @@ public async Task Topup_credits_wallet_when_invoice_marked_paid() // Assert: wallet balance equals the top-up amount. var wallet = await billing.GetOrCreateWalletAsync(tenantId, "USD"); - wallet.Balance.ShouldBe(50m); + wallet.Balance.Amount.ShouldBe(50m); // Assert: request transitioned to Completed. var reloaded = await db.TopupRequests.FindAsync(request.Id);