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