From 94127a55442faf8734684d28e01f2b93df7f7998 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 13:07:54 -0300
Subject: [PATCH 1/3] refactor(core): promote Money value object to
BuildingBlocks and enrich it
Money lived only in Catalog while Billing modeled monetary amounts as a raw
decimal plus a parallel currency string, with no invariant binding the two.
Promote Money to BuildingBlocks/Core (FSH.Framework.Core.Domain) as a shared
domain primitive and enrich it with same-currency-guarded arithmetic
(Add/Subtract/Multiply), away-from-zero rounding, and matching operators.
Money now allows signed amounts for ledger semantics; non-negative rules stay
at the use-site validators (product price, top-up amount).
Adopt the shared Money where it is a clean single-value fit:
- Catalog.Product.Price: namespace move only, schema unchanged.
- Billing.TopupRequest.Amount: folds the parallel Currency column into Money
via OwnsOne, preserving the existing "Amount" and "Currency" columns.
Multi-amount, single-currency aggregates (BillingPlan, Invoice, Wallet) keep a
single Currency field, to avoid denormalizing currency across owned columns.
The TopupRequest migration is a no-op: OwnsOne preserves the columns, so only
the EF model snapshot changes.
---
src/BuildingBlocks/Core/Domain/Money.cs | 62 +++
...2_TopupRequestMoneyValueObject.Designer.cs | 475 ++++++++++++++++++
...0626155932_TopupRequestMoneyValueObject.cs | 24 +
.../Billing/BillingDbContextModelSnapshot.cs | 39 +-
.../TopupRequestConfiguration.cs | 8 +-
.../Modules.Billing/Domain/TopupRequest.cs | 6 +-
.../Mappings/WalletMappings.cs | 2 +-
.../Services/BillingService.cs | 6 +-
.../Modules.Catalog/Data/CatalogSeedData.cs | 1 +
.../Catalog/Modules.Catalog/Domain/Money.cs | 20 -
.../ChangeProductPriceCommandHandler.cs | 1 +
.../CreateProductCommandHandler.cs | 1 +
.../Billing.Tests/Domain/TopupRequestTests.cs | 3 +-
src/Tests/Catalog.Tests/Domain/MoneyTests.cs | 133 -----
src/Tests/Framework.Tests/Core/MoneyTests.cs | 174 +++++++
15 files changed, 782 insertions(+), 173 deletions(-)
create mode 100644 src/BuildingBlocks/Core/Domain/Money.cs
create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260626155932_TopupRequestMoneyValueObject.Designer.cs
create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260626155932_TopupRequestMoneyValueObject.cs
delete mode 100644 src/Modules/Catalog/Modules.Catalog/Domain/Money.cs
delete mode 100644 src/Tests/Catalog.Tests/Domain/MoneyTests.cs
create mode 100644 src/Tests/Framework.Tests/Core/MoneyTests.cs
diff --git a/src/BuildingBlocks/Core/Domain/Money.cs b/src/BuildingBlocks/Core/Domain/Money.cs
new file mode 100644
index 0000000000..a182c9f6e9
--- /dev/null
+++ b/src/BuildingBlocks/Core/Domain/Money.cs
@@ -0,0 +1,62 @@
+namespace FSH.Framework.Core.Domain;
+
+public sealed record Money
+{
+ public decimal Amount { get; init; }
+ public string Currency { get; init; }
+
+ public Money(decimal amount, string currency)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(currency);
+ Amount = amount;
+ Currency = currency.ToUpperInvariant();
+ }
+
+ 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.Migrations.PostgreSQL/Billing/20260626155932_TopupRequestMoneyValueObject.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260626155932_TopupRequestMoneyValueObject.Designer.cs
new file mode 100644
index 0000000000..09a01ad10e
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260626155932_TopupRequestMoneyValueObject.Designer.cs
@@ -0,0 +1,475 @@
+//
+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("20260626155932_TopupRequestMoneyValueObject")]
+ partial class TopupRequestMoneyValueObject
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("billing")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FSH.Modules.Billing.Domain.BillingPlan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AnnualPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("Interval")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("MonthlyBasePrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("_overageRates")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("OverageRates")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("Plans", "billing");
+ });
+
+ modelBuilder.Entity("FSH.Modules.Billing.Domain.Invoice", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("DueAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InvoiceNumber")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("IssuedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Notes")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("PaidAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PeriodEndUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PeriodMonth")
+ .HasColumnType("integer");
+
+ b.Property("PeriodStartUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PeriodYear")
+ .HasColumnType("integer");
+
+ b.Property("Purpose")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("SubtotalAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("VoidedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InvoiceNumber")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.HasIndex("TenantId", "PeriodYear", "PeriodMonth", "Purpose")
+ .IsUnique()
+ .HasDatabaseName("ux_invoices_tenant_period_purpose")
+ .HasFilter("\"Purpose\" <> 2");
+
+ b.ToTable("Invoices", "billing");
+ });
+
+ modelBuilder.Entity("FSH.Modules.Billing.Domain.InvoiceLineItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Amount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("InvoiceId")
+ .HasColumnType("uuid");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("Quantity")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Resource")
+ .HasColumnType("integer");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InvoiceId");
+
+ b.ToTable("InvoiceLineItems", "billing");
+ });
+
+ modelBuilder.Entity("FSH.Modules.Billing.Domain.Subscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EndUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PlanId")
+ .HasColumnType("uuid");
+
+ b.Property("StartUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId")
+ .IsUnique()
+ .HasDatabaseName("ux_subscriptions_tenantid_active")
+ .HasFilter("\"Status\" = 0");
+
+ b.HasIndex("TenantId", "Status");
+
+ b.ToTable("Subscriptions", "billing");
+ });
+
+ modelBuilder.Entity("FSH.Modules.Billing.Domain.TopupRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("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("Balance")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId")
+ .IsUnique()
+ .HasDatabaseName("ux_wallets_tenantid");
+
+ b.ToTable("Wallets", "billing");
+ });
+
+ modelBuilder.Entity("FSH.Modules.Billing.Domain.WalletTransaction", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Amount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("ReferenceId")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("WalletId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("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.InvoiceLineItem", b =>
+ {
+ b.HasOne("FSH.Modules.Billing.Domain.Invoice", null)
+ .WithMany("LineItems")
+ .HasForeignKey("InvoiceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .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.WalletTransaction", b =>
+ {
+ b.HasOne("FSH.Modules.Billing.Domain.Wallet", null)
+ .WithMany("Transactions")
+ .HasForeignKey("WalletId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("FSH.Modules.Billing.Domain.Invoice", b =>
+ {
+ b.Navigation("LineItems");
+ });
+
+ modelBuilder.Entity("FSH.Modules.Billing.Domain.Wallet", b =>
+ {
+ b.Navigation("Transactions");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260626155932_TopupRequestMoneyValueObject.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260626155932_TopupRequestMoneyValueObject.cs
new file mode 100644
index 0000000000..93622a277d
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/20260626155932_TopupRequestMoneyValueObject.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FSH.Starter.Migrations.PostgreSQL.Billing
+{
+ ///
+ public partial class TopupRequestMoneyValueObject : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // No-op: TopupRequest.Amount became a Money owned value object, but the OwnsOne
+ // mapping keeps the same "Amount" (numeric(18,4)) and "Currency" (varchar(8)) columns,
+ // so the relational schema is unchanged. This migration only carries the model snapshot.
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ // No-op: see Up — the value-object refactor introduced no schema change to revert.
+ }
+ }
+}
diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs
index 0591a62a04..d1214f381a 100644
--- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs
+++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Billing/BillingDbContextModelSnapshot.cs
@@ -246,21 +246,12 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
- b.Property("Amount")
- .HasPrecision(18, 4)
- .HasColumnType("numeric(18,4)");
-
b.Property("CompletedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
- b.Property("Currency")
- .IsRequired()
- .HasMaxLength(8)
- .HasColumnType("character varying(8)");
-
b.Property("DecidedAtUtc")
.HasColumnType("timestamp with time zone");
@@ -427,6 +418,36 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.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.WalletTransaction", b =>
{
b.HasOne("FSH.Modules.Billing.Domain.Wallet", null)
diff --git a/src/Modules/Billing/Modules.Billing/Data/Configurations/TopupRequestConfiguration.cs b/src/Modules/Billing/Modules.Billing/Data/Configurations/TopupRequestConfiguration.cs
index 3f79e25d97..8548eb4d96 100644
--- a/src/Modules/Billing/Modules.Billing/Data/Configurations/TopupRequestConfiguration.cs
+++ b/src/Modules/Billing/Modules.Billing/Data/Configurations/TopupRequestConfiguration.cs
@@ -12,8 +12,12 @@ public void Configure(EntityTypeBuilder builder)
builder.ToTable("TopupRequests");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired().HasMaxLength(64);
- builder.Property(x => x.Amount).HasPrecision(18, 4);
- builder.Property(x => x.Currency).IsRequired().HasMaxLength(8);
+ builder.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.Note).HasMaxLength(512);
builder.Property(x => x.DecisionNote).HasMaxLength(512);
builder.Property(x => x.RequestedBy).HasMaxLength(64);
diff --git a/src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs b/src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs
index 0149cf0876..f608692c7d 100644
--- a/src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs
+++ b/src/Modules/Billing/Modules.Billing/Domain/TopupRequest.cs
@@ -6,8 +6,7 @@ namespace FSH.Modules.Billing.Domain;
public sealed class TopupRequest : AggregateRoot
{
public string TenantId { get; private set; } = default!;
- public decimal Amount { get; private set; }
- public string Currency { get; private set; } = "USD";
+ public Money Amount { get; private set; } = default!;
public string? Note { get; private set; }
public TopupRequestStatus Status { get; private set; }
public Guid? InvoiceId { get; private set; }
@@ -27,8 +26,7 @@ public static TopupRequest Create(string tenantId, decimal amount, string curren
{
Id = Guid.CreateVersion7(),
TenantId = tenantId,
- Amount = amount,
- Currency = string.IsNullOrWhiteSpace(currency) ? "USD" : currency,
+ Amount = new Money(amount, string.IsNullOrWhiteSpace(currency) ? "USD" : currency),
Note = note,
RequestedBy = requestedBy,
Status = TopupRequestStatus.Pending,
diff --git a/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs b/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs
index 0f3092e3af..385f32f484 100644
--- a/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs
+++ b/src/Modules/Billing/Modules.Billing/Mappings/WalletMappings.cs
@@ -19,6 +19,6 @@ public static WalletDto ToDto(this Wallet w, int recentCount = 10)
public static TopupRequestDto ToDto(this TopupRequest r)
=> new(
- r.Id, r.TenantId, r.Amount, r.Currency, r.Note, r.Status.ToString(),
+ r.Id, r.TenantId, r.Amount.Amount, r.Amount.Currency, r.Note, r.Status.ToString(),
r.InvoiceId, r.RequestedBy, r.DecisionNote, r.CreatedAtUtc, r.DecidedAtUtc, r.CompletedAtUtc);
}
diff --git a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs
index 08ff030257..652bc34d20 100644
--- a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs
+++ b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs
@@ -197,9 +197,9 @@ public async Task CreateTopupInvoiceAsync(string tenantId, Guid topupRe
invoiceNumber,
now.Year,
now.Month,
- request.Currency,
- request.Amount,
- $"WhatsApp wallet top-up ({request.Amount:0.##} {request.Currency})");
+ request.Amount.Currency,
+ request.Amount.Amount,
+ $"WhatsApp wallet top-up ({request.Amount.Amount:0.##} {request.Amount.Currency})");
invoice.Issue();
_db.Invoices.Add(invoice);
diff --git a/src/Modules/Catalog/Modules.Catalog/Data/CatalogSeedData.cs b/src/Modules/Catalog/Modules.Catalog/Data/CatalogSeedData.cs
index 5876d8804d..fa8be69e88 100644
--- a/src/Modules/Catalog/Modules.Catalog/Data/CatalogSeedData.cs
+++ b/src/Modules/Catalog/Modules.Catalog/Data/CatalogSeedData.cs
@@ -1,3 +1,4 @@
+using FSH.Framework.Core.Domain;
using FSH.Modules.Catalog.Domain;
namespace FSH.Modules.Catalog.Data;
diff --git a/src/Modules/Catalog/Modules.Catalog/Domain/Money.cs b/src/Modules/Catalog/Modules.Catalog/Domain/Money.cs
deleted file mode 100644
index 6cc4659c62..0000000000
--- a/src/Modules/Catalog/Modules.Catalog/Domain/Money.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace FSH.Modules.Catalog.Domain;
-
-public sealed record Money
-{
- public decimal Amount { get; init; }
- public string Currency { get; init; }
-
- public Money(decimal amount, string currency)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(currency);
- if (amount < 0)
- {
- throw new ArgumentOutOfRangeException(nameof(amount), "Amount cannot be negative.");
- }
- Amount = amount;
- Currency = currency.ToUpperInvariant();
- }
-
- public static Money Zero(string currency = "USD") => new(0m, currency);
-}
diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs
index 27369979d7..8a101e9155 100644
--- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs
+++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs
@@ -1,3 +1,4 @@
+using FSH.Framework.Core.Domain;
using FSH.Framework.Core.Exceptions;
using FSH.Modules.Catalog.Contracts.v1.Products;
using FSH.Modules.Catalog.Data;
diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs
index f63d9dfa8d..d3e75608a1 100644
--- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs
+++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs
@@ -1,4 +1,5 @@
using System.Net;
+using FSH.Framework.Core.Domain;
using FSH.Framework.Core.Exceptions;
using FSH.Modules.Catalog.Contracts.v1.Products;
using FSH.Modules.Catalog.Data;
diff --git a/src/Tests/Billing.Tests/Domain/TopupRequestTests.cs b/src/Tests/Billing.Tests/Domain/TopupRequestTests.cs
index 115f9c2fb6..33d736c4df 100644
--- a/src/Tests/Billing.Tests/Domain/TopupRequestTests.cs
+++ b/src/Tests/Billing.Tests/Domain/TopupRequestTests.cs
@@ -12,7 +12,8 @@ public void Create_starts_pending()
{
var r = TopupRequest.Create("tenant-a", 50m, "USD", "need credit", "user-1");
r.Status.ShouldBe(TopupRequestStatus.Pending);
- r.Amount.ShouldBe(50m);
+ r.Amount.Amount.ShouldBe(50m);
+ r.Amount.Currency.ShouldBe("USD");
r.InvoiceId.ShouldBeNull();
}
diff --git a/src/Tests/Catalog.Tests/Domain/MoneyTests.cs b/src/Tests/Catalog.Tests/Domain/MoneyTests.cs
deleted file mode 100644
index ca906fb87f..0000000000
--- a/src/Tests/Catalog.Tests/Domain/MoneyTests.cs
+++ /dev/null
@@ -1,133 +0,0 @@
-using FSH.Modules.Catalog.Domain;
-
-namespace Catalog.Tests.Domain;
-
-public sealed class MoneyTests
-{
- #region Construction - Happy Path
-
- [Fact]
- public void Ctor_Should_NormalizeCurrencyToUpper_When_LowercaseProvided()
- {
- // Arrange / Act
- var money = new Money(10.50m, "usd");
-
- // Assert
- money.Currency.ShouldBe("USD");
- money.Amount.ShouldBe(10.50m);
- }
-
- [Fact]
- public void Ctor_Should_AllowZeroAmount_When_AmountIsZero()
- {
- // Arrange / Act
- var money = new Money(0m, "USD");
-
- // Assert
- money.Amount.ShouldBe(0m);
- }
-
- [Fact]
- public void Zero_Should_ReturnZeroAmountWithDefaultCurrency_When_NoCurrencySupplied()
- {
- // Arrange / Act
- Money zero = Money.Zero();
-
- // Assert
- zero.Amount.ShouldBe(0m);
- zero.Currency.ShouldBe("USD");
- }
-
- [Fact]
- public void Zero_Should_UseSuppliedCurrency_When_CurrencyProvided()
- {
- // Arrange / Act
- Money zero = Money.Zero("eur");
-
- // Assert
- zero.Currency.ShouldBe("EUR");
- }
-
- #endregion
-
- #region Construction - Guards
-
- [Theory]
- [InlineData("")]
- [InlineData(" ")]
- public void Ctor_Should_Throw_When_CurrencyIsBlank(string currency)
- {
- // Act / Assert
- Should.Throw(() => new Money(1m, currency));
- }
-
- [Fact]
- public void Ctor_Should_Throw_When_CurrencyIsNull()
- {
- // Act / Assert
- Should.Throw(() => new Money(1m, null!));
- }
-
- [Fact]
- public void Ctor_Should_Throw_When_AmountIsNegative()
- {
- // Act / Assert
- Should.Throw(() => new Money(-0.01m, "USD"));
- }
-
- #endregion
-
- #region Equality / Operators
-
- [Fact]
- public void Equality_Should_BeEqual_When_AmountAndNormalizedCurrencyMatch()
- {
- // Arrange
- var a = new Money(5m, "usd");
- var b = new Money(5m, "USD");
-
- // Assert - currency normalization makes these value-equal
- a.ShouldBe(b);
- (a == b).ShouldBeTrue();
- a.GetHashCode().ShouldBe(b.GetHashCode());
- }
-
- [Fact]
- public void Equality_Should_NotBeEqual_When_AmountsDiffer()
- {
- // Arrange
- var a = new Money(5m, "USD");
- var b = new Money(6m, "USD");
-
- // Assert
- (a != b).ShouldBeTrue();
- }
-
- [Fact]
- public void Equality_Should_NotBeEqual_When_CurrenciesDiffer()
- {
- // Arrange
- var a = new Money(5m, "USD");
- var b = new Money(5m, "EUR");
-
- // Assert
- a.ShouldNotBe(b);
- }
-
- [Fact]
- public void With_Should_ProduceModifiedCopy_When_AmountChangedViaWithExpression()
- {
- // Arrange
- var original = new Money(5m, "USD");
-
- // Act
- Money modified = original with { Amount = 9m };
-
- // Assert
- modified.Amount.ShouldBe(9m);
- modified.Currency.ShouldBe("USD");
- original.Amount.ShouldBe(5m);
- }
-
- #endregion
-}
diff --git a/src/Tests/Framework.Tests/Core/MoneyTests.cs b/src/Tests/Framework.Tests/Core/MoneyTests.cs
new file mode 100644
index 0000000000..5a695e9e0b
--- /dev/null
+++ b/src/Tests/Framework.Tests/Core/MoneyTests.cs
@@ -0,0 +1,174 @@
+using FSH.Framework.Core.Domain;
+
+namespace Framework.Tests.Core;
+
+public sealed class MoneyTests
+{
+ #region Construction
+
+ [Fact]
+ public void Ctor_Should_NormalizeCurrencyToUpper_When_LowercaseProvided()
+ {
+ var money = new Money(10.50m, "usd");
+
+ money.Currency.ShouldBe("USD");
+ money.Amount.ShouldBe(10.50m);
+ }
+
+ [Fact]
+ public void Ctor_Should_AllowZeroAmount_When_AmountIsZero()
+ {
+ var money = new Money(0m, "USD");
+
+ money.Amount.ShouldBe(0m);
+ }
+
+ [Fact]
+ public void Ctor_Should_AllowNegativeAmount_When_AmountIsNegative()
+ {
+ // Money is a ledger primitive: debits and credit reversals are negative amounts.
+ var money = new Money(-12.34m, "USD");
+
+ money.Amount.ShouldBe(-12.34m);
+ }
+
+ [Fact]
+ public void Zero_Should_ReturnZeroAmountWithDefaultCurrency_When_NoCurrencySupplied()
+ {
+ Money zero = Money.Zero();
+
+ zero.Amount.ShouldBe(0m);
+ zero.Currency.ShouldBe("USD");
+ }
+
+ [Fact]
+ public void Zero_Should_UseSuppliedCurrency_When_CurrencyProvided()
+ {
+ Money zero = Money.Zero("eur");
+
+ zero.Currency.ShouldBe("EUR");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Ctor_Should_Throw_When_CurrencyIsBlank(string currency)
+ {
+ Should.Throw(() => new Money(1m, currency));
+ }
+
+ [Fact]
+ public void Ctor_Should_Throw_When_CurrencyIsNull()
+ {
+ Should.Throw(() => new Money(1m, null!));
+ }
+
+ #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]
+ public void Equality_Should_BeEqual_When_AmountAndNormalizedCurrencyMatch()
+ {
+ var a = new Money(5m, "usd");
+ var b = new Money(5m, "USD");
+
+ a.ShouldBe(b);
+ (a == b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Fact]
+ public void Equality_Should_NotBeEqual_When_AmountsDiffer()
+ {
+ (new Money(5m, "USD") != new Money(6m, "USD")).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Equality_Should_NotBeEqual_When_CurrenciesDiffer()
+ {
+ new Money(5m, "USD").ShouldNotBe(new Money(5m, "EUR"));
+ }
+
+ [Fact]
+ public void With_Should_ProduceModifiedCopy_When_AmountChangedViaWithExpression()
+ {
+ var original = new Money(5m, "USD");
+
+ Money modified = original with { Amount = 9m };
+
+ modified.Amount.ShouldBe(9m);
+ modified.Currency.ShouldBe("USD");
+ original.Amount.ShouldBe(5m);
+ }
+
+ #endregion
+}
From 78646743f395b702c3eef64f582a546d726668fa Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Fri, 26 Jun 2026 16:57:12 -0300
Subject: [PATCH 2/3] fix(catalog): enforce non-negative price on aggregate and
sync model snapshot
The shared Money now allows signed amounts (ledger semantics), so the
non-negative price invariant must live on the Catalog aggregate rather than
the value object. Guard Product.Create and ChangePrice so any caller that
bypasses the command validators (seed, import, test helpers) cannot persist a
negative price.
Add a no-op Catalog migration so the model snapshot references the promoted
FSH.Framework.Core.Domain.Money owned type instead of the removed
FSH.Modules.Catalog.Domain.Money; otherwise the next unrelated Catalog
migration would inherit this CLR-type rename. Mirrors the Billing TopupRequest
no-op already in this PR.
Addresses Codex review feedback on #1316.
---
...195522_CatalogMoneyValueObject.Designer.cs | 310 ++++++++++++++++++
.../20260626195522_CatalogMoneyValueObject.cs | 25 ++
.../Catalog/CatalogDbContextModelSnapshot.cs | 2 +-
.../Catalog/Modules.Catalog/Domain/Product.cs | 8 +
.../Catalog.Tests/Domain/ProductTests.cs | 18 +
5 files changed, 362 insertions(+), 1 deletion(-)
create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260626195522_CatalogMoneyValueObject.Designer.cs
create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260626195522_CatalogMoneyValueObject.cs
diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260626195522_CatalogMoneyValueObject.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260626195522_CatalogMoneyValueObject.Designer.cs
new file mode 100644
index 0000000000..3c147d3c9a
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260626195522_CatalogMoneyValueObject.Designer.cs
@@ -0,0 +1,310 @@
+//
+using System;
+using FSH.Modules.Catalog.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.Catalog
+{
+ [DbContext(typeof(CatalogDbContext))]
+ [Migration("20260626195522_CatalogMoneyValueObject")]
+ partial class CatalogMoneyValueObject
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("catalog")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FSH.Modules.Catalog.Domain.Brand", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedBy")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("DeletedOnUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("LogoUrl")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsDeleted");
+
+ b.HasIndex("Slug", "TenantId")
+ .IsUnique()
+ .HasDatabaseName("IX_Brands_Slug")
+ .HasFilter("\"IsDeleted\" = FALSE");
+
+ b.ToTable("Brands", "catalog");
+
+ b.HasAnnotation("Finbuckle:MultiTenant", true);
+ });
+
+ modelBuilder.Entity("FSH.Modules.Catalog.Domain.Category", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedBy")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("DeletedOnUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("ParentCategoryId")
+ .HasColumnType("uuid");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsDeleted");
+
+ b.HasIndex("ParentCategoryId");
+
+ b.HasIndex("Slug", "TenantId")
+ .IsUnique()
+ .HasDatabaseName("IX_Categories_Slug")
+ .HasFilter("\"IsDeleted\" = FALSE");
+
+ b.ToTable("Categories", "catalog");
+
+ b.HasAnnotation("Finbuckle:MultiTenant", true);
+ });
+
+ modelBuilder.Entity("FSH.Modules.Catalog.Domain.Product", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BrandId")
+ .HasColumnType("uuid");
+
+ b.Property("CategoryId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedBy")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("DeletedOnUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Sku")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(220)
+ .HasColumnType("character varying(220)");
+
+ b.Property("Stock")
+ .HasColumnType("integer");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BrandId");
+
+ b.HasIndex("CategoryId");
+
+ b.HasIndex("IsDeleted");
+
+ b.HasIndex("Sku", "TenantId")
+ .IsUnique()
+ .HasDatabaseName("IX_Products_Sku")
+ .HasFilter("\"IsDeleted\" = FALSE");
+
+ b.HasIndex("Slug", "TenantId")
+ .IsUnique()
+ .HasDatabaseName("IX_Products_Slug")
+ .HasFilter("\"IsDeleted\" = FALSE");
+
+ b.ToTable("Products", "catalog");
+
+ b.HasAnnotation("Finbuckle:MultiTenant", true);
+ });
+
+ modelBuilder.Entity("FSH.Modules.Catalog.Domain.ProductImage", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("IsThumbnail")
+ .HasColumnType("boolean");
+
+ b.Property("ProductId")
+ .HasColumnType("uuid");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("TenantId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProductId");
+
+ b.ToTable("ProductImages", "catalog");
+
+ b.HasAnnotation("Finbuckle:MultiTenant", true);
+ });
+
+ modelBuilder.Entity("FSH.Modules.Catalog.Domain.Product", b =>
+ {
+ b.OwnsOne("FSH.Framework.Core.Domain.Money", "Price", b1 =>
+ {
+ b1.Property("ProductId")
+ .HasColumnType("uuid");
+
+ b1.Property("Amount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)")
+ .HasColumnName("PriceAmount");
+
+ b1.Property("Currency")
+ .IsRequired()
+ .HasMaxLength(3)
+ .HasColumnType("character varying(3)")
+ .HasColumnName("PriceCurrency");
+
+ b1.HasKey("ProductId");
+
+ b1.ToTable("Products", "catalog");
+
+ b1.WithOwner()
+ .HasForeignKey("ProductId");
+ });
+
+ b.Navigation("Price")
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("FSH.Modules.Catalog.Domain.ProductImage", b =>
+ {
+ b.HasOne("FSH.Modules.Catalog.Domain.Product", null)
+ .WithMany("Images")
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("FSH.Modules.Catalog.Domain.Product", b =>
+ {
+ b.Navigation("Images");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260626195522_CatalogMoneyValueObject.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260626195522_CatalogMoneyValueObject.cs
new file mode 100644
index 0000000000..135de6ab79
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/20260626195522_CatalogMoneyValueObject.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FSH.Starter.Migrations.PostgreSQL.Catalog
+{
+ ///
+ public partial class CatalogMoneyValueObject : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // No-op: Product.Price moved from the Catalog-local Money to the promoted
+ // FSH.Framework.Core.Domain.Money, but the OwnsOne mapping keeps the same
+ // "PriceAmount" (numeric(18,4)) and "Currency" (varchar(3)) columns, so the
+ // relational schema is unchanged. This migration only carries the model snapshot.
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ // No-op: see Up — the value-object namespace move introduced no schema change to revert.
+ }
+ }
+}
diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/CatalogDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/CatalogDbContextModelSnapshot.cs
index 56d5aa2013..5c1ed2ccab 100644
--- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/CatalogDbContextModelSnapshot.cs
+++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Catalog/CatalogDbContextModelSnapshot.cs
@@ -260,7 +260,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
modelBuilder.Entity("FSH.Modules.Catalog.Domain.Product", b =>
{
- b.OwnsOne("FSH.Modules.Catalog.Domain.Money", "Price", b1 =>
+ b.OwnsOne("FSH.Framework.Core.Domain.Money", "Price", b1 =>
{
b1.Property("ProductId")
.HasColumnType("uuid");
diff --git a/src/Modules/Catalog/Modules.Catalog/Domain/Product.cs b/src/Modules/Catalog/Modules.Catalog/Domain/Product.cs
index 6f4bcee9d3..cd1bbc696b 100644
--- a/src/Modules/Catalog/Modules.Catalog/Domain/Product.cs
+++ b/src/Modules/Catalog/Modules.Catalog/Domain/Product.cs
@@ -52,6 +52,10 @@ public static Product Create(
ArgumentException.ThrowIfNullOrWhiteSpace(sku);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(price);
+ if (price.Amount < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(price), "Price cannot be negative.");
+ }
if (stock < 0)
{
throw new ArgumentOutOfRangeException(nameof(stock), "Stock cannot be negative.");
@@ -115,6 +119,10 @@ public void Update(
public void ChangePrice(Money newPrice)
{
ArgumentNullException.ThrowIfNull(newPrice);
+ if (newPrice.Amount < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(newPrice), "Price cannot be negative.");
+ }
if (newPrice == Price)
{
return;
diff --git a/src/Tests/Catalog.Tests/Domain/ProductTests.cs b/src/Tests/Catalog.Tests/Domain/ProductTests.cs
index b6f6281bc5..d87cefea4d 100644
--- a/src/Tests/Catalog.Tests/Domain/ProductTests.cs
+++ b/src/Tests/Catalog.Tests/Domain/ProductTests.cs
@@ -123,6 +123,14 @@ public void Create_Should_Throw_When_StockIsNegative()
Product.Create("sku", "Name", null, Guid.NewGuid(), Guid.NewGuid(), Money.Zero(), -1));
}
+ [Fact]
+ public void Create_Should_Throw_When_PriceIsNegative()
+ {
+ // Act / Assert - shared Money allows signed amounts; the non-negative price invariant lives on the aggregate
+ Should.Throw(() =>
+ Product.Create("sku", "Name", null, Guid.NewGuid(), Guid.NewGuid(), new Money(-0.01m, "USD"), 0));
+ }
+
[Fact]
public void Create_Should_Throw_When_BrandIdIsEmpty()
{
@@ -249,6 +257,16 @@ public void ChangePrice_Should_Throw_When_NewPriceIsNull()
Should.Throw(() => product.ChangePrice(null!));
}
+ [Fact]
+ public void ChangePrice_Should_Throw_When_NewPriceIsNegative()
+ {
+ // Arrange
+ Product product = CreateValidProduct();
+
+ // Act / Assert
+ Should.Throw(() => product.ChangePrice(new Money(-1m, "USD")));
+ }
+
#endregion
#region AdjustStock
From b5d2d6bc8b4b32ed74184d138947db46646ed079 Mon Sep 17 00:00:00 2001
From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com>
Date: Mon, 29 Jun 2026 02:21:28 -0300
Subject: [PATCH 3/3] refactor(core): trim speculative Money arithmetic pending
a real caller
Money was promoted to BuildingBlocks/Core with same-currency-guarded
Add/Subtract/Multiply, away-from-zero Round, and +/-/* operators, but no
production code consumes them yet; their first real caller is the Billing
subtotal/credit/debit follow-up. Since Money now lives in the protected
BuildingBlocks (highest blast radius), keep the shared surface minimal: drop
the arithmetic and its operators, leaving the value type, currency
normalization, and Zero factory adopted by Product.Price and
TopupRequest.Amount.
The arithmetic and its Framework.Tests cases will be reintroduced together
with the follow-up that actually performs Money arithmetic in Invoice/Wallet.
Per review feedback on PR #1316.
---
src/BuildingBlocks/Core/Domain/Money.cs | 46 -------------
src/Tests/Framework.Tests/Core/MoneyTests.cs | 68 --------------------
2 files changed, 114 deletions(-)
diff --git a/src/BuildingBlocks/Core/Domain/Money.cs b/src/BuildingBlocks/Core/Domain/Money.cs
index a182c9f6e9..ecc7d33b44 100644
--- a/src/BuildingBlocks/Core/Domain/Money.cs
+++ b/src/BuildingBlocks/Core/Domain/Money.cs
@@ -13,50 +13,4 @@ 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/Tests/Framework.Tests/Core/MoneyTests.cs b/src/Tests/Framework.Tests/Core/MoneyTests.cs
index 5a695e9e0b..60caddd989 100644
--- a/src/Tests/Framework.Tests/Core/MoneyTests.cs
+++ b/src/Tests/Framework.Tests/Core/MoneyTests.cs
@@ -65,74 +65,6 @@ 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]