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