diff --git a/src/Modules/Catalog/Modules.Catalog/Domain/Money.cs b/src/BuildingBlocks/Core/Domain/Money.cs
similarity index 68%
rename from src/Modules/Catalog/Modules.Catalog/Domain/Money.cs
rename to src/BuildingBlocks/Core/Domain/Money.cs
index 6cc4659c62..ecc7d33b44 100644
--- a/src/Modules/Catalog/Modules.Catalog/Domain/Money.cs
+++ b/src/BuildingBlocks/Core/Domain/Money.cs
@@ -1,4 +1,4 @@
-namespace FSH.Modules.Catalog.Domain;
+namespace FSH.Framework.Core.Domain;
public sealed record Money
{
@@ -8,10 +8,6 @@ public sealed record Money
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();
}
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/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/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/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/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/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
diff --git a/src/Tests/Catalog.Tests/Domain/MoneyTests.cs b/src/Tests/Framework.Tests/Core/MoneyTests.cs
similarity index 67%
rename from src/Tests/Catalog.Tests/Domain/MoneyTests.cs
rename to src/Tests/Framework.Tests/Core/MoneyTests.cs
index ca906fb87f..60caddd989 100644
--- a/src/Tests/Catalog.Tests/Domain/MoneyTests.cs
+++ b/src/Tests/Framework.Tests/Core/MoneyTests.cs
@@ -1,18 +1,16 @@
-using FSH.Modules.Catalog.Domain;
+using FSH.Framework.Core.Domain;
-namespace Catalog.Tests.Domain;
+namespace Framework.Tests.Core;
public sealed class MoneyTests
{
- #region Construction - Happy Path
+ #region Construction
[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);
}
@@ -20,20 +18,25 @@ public void Ctor_Should_NormalizeCurrencyToUpper_When_LowercaseProvided()
[Fact]
public void Ctor_Should_AllowZeroAmount_When_AmountIsZero()
{
- // Arrange / Act
var money = new Money(0m, "USD");
- // Assert
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()
{
- // Arrange / Act
Money zero = Money.Zero();
- // Assert
zero.Amount.ShouldBe(0m);
zero.Currency.ShouldBe("USD");
}
@@ -41,52 +44,35 @@ public void Zero_Should_ReturnZeroAmountWithDefaultCurrency_When_NoCurrencySuppl
[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
+ #region Equality
[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());
@@ -95,35 +81,22 @@ public void Equality_Should_BeEqual_When_AmountAndNormalizedCurrencyMatch()
[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();
+ (new Money(5m, "USD") != new Money(6m, "USD")).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);
+ new Money(5m, "USD").ShouldNotBe(new Money(5m, "EUR"));
}
[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);