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);