diff --git a/docs/database/images/receipts.png b/docs/database/images/receipts.png new file mode 100644 index 0000000..52c4e73 Binary files /dev/null and b/docs/database/images/receipts.png differ diff --git a/docs/database/receipts.dbml b/docs/database/receipts.dbml new file mode 100644 index 0000000..490a2ea --- /dev/null +++ b/docs/database/receipts.dbml @@ -0,0 +1,104 @@ +// Receipts and their prices +// Paste this file into https://dbdiagram.io to render the diagram. +// Matches the migration 20261107041912_CreateReceipts. +// donors and food_items come from the migration 20261105163138_CreateDonorsAndFoodItems +// and are shown here because receipts link to them. + +Table donors { + id uuid [not null, default: `gen_random_uuid()`] + name varchar(255) [not null] + address text [null] + postal_code varchar(20) [null] + contact_person varchar(255) [null] + created_at timestamptz [not null, default: `now()`] + + indexes { + id [pk, name: 'pk_donors'] + } + + checks { + `length(name) > 0` [name: 'ck_donors_name_not_empty'] + } + + Note: 'Organizations or people who give food' +} + +Table food_items { + id uuid [not null, default: `gen_random_uuid()`] + donor_id uuid [not null] + name varchar(255) [not null] + quantity numeric(12,2) [not null] + unit varchar(20) [not null] + donation_date date [not null] + quality varchar(1) [not null, note: 'A, B or C'] + created_at timestamptz [not null, default: `now()`] + + indexes { + id [pk, name: 'pk_food_items'] + (id, donor_id) [unique, name: 'ak_food_items_id_donor_id', note: 'Added by CreateReceipts for the receipt_items link'] + donor_id [name: 'ix_food_items_donor_id'] + } + + checks { + `length(name) > 0` [name: 'ck_food_items_name_not_empty'] + `quality IN ('A', 'B', 'C')` [name: 'ck_food_items_quality'] + `quantity > 0` [name: 'ck_food_items_quantity_positive'] + `length(unit) > 0` [name: 'ck_food_items_unit_not_empty'] + } + + Note: 'Food donated by a donor, with its date and quality' +} + +Table receipts { + id uuid [not null, default: `gen_random_uuid()`] + donor_id uuid [not null] + total_amount numeric(12,2) [not null, note: 'Sum of the rounded line values, in dollars'] + receipt_date date [not null, default: `(now() AT TIME ZONE 'America/Montreal')::date`, note: 'Date in Montreal time, used for the tax year'] + created_at timestamptz [not null, default: `now()`, note: 'Stored in UTC'] + + indexes { + id [pk, name: 'pk_receipts'] + (id, donor_id) [unique, name: 'ak_receipts_id_donor_id'] + donor_id [name: 'ix_receipts_donor_id'] + } + + checks { + `total_amount >= 0` [name: 'ck_receipts_total_amount_not_negative'] + } + + Note: 'Tax receipts given to donors' +} + +Table receipt_items { + receipt_id uuid [not null] + food_item_id uuid [not null] + donor_id uuid [not null, note: 'Donor of both the receipt and the food item'] + quantity numeric(12,2) [not null, note: 'Copied from the food item when the receipt was made'] + unit varchar(20) [not null, note: 'Copied from the food item when the receipt was made'] + unit_price numeric(12,4) [not null, note: 'Price of one unit when the receipt was made'] + quality_percent numeric(5,2) [not null, note: 'A = 100, B = 50 to 75, C = 0'] + + indexes { + (receipt_id, food_item_id) [pk, name: 'pk_receipt_items'] + food_item_id [unique, name: 'ix_receipt_items_food_item_id'] + (food_item_id, donor_id) [name: 'ix_receipt_items_food_item_id_donor_id'] + (receipt_id, donor_id) [name: 'ix_receipt_items_receipt_id_donor_id'] + } + + checks { + `quantity > 0` [name: 'ck_receipt_items_quantity_positive'] + `length(unit) > 0` [name: 'ck_receipt_items_unit_not_empty'] + `unit_price > 0` [name: 'ck_receipt_items_unit_price_positive'] + `quality_percent BETWEEN 0 AND 100` [name: 'ck_receipt_items_quality_percent_range'] + } + + Note: 'Food items on a receipt. Line value = round(quantity x unit_price x quality_percent / 100, 2), half away from zero' +} + +Ref fk_food_items_donors_donor_id: food_items.donor_id > donors.id [delete: restrict] + +Ref fk_receipts_donors_donor_id: receipts.donor_id > donors.id [delete: restrict] + +Ref fk_receipt_items_receipts_receipt_id_donor_id: receipt_items.(receipt_id, donor_id) > receipts.(id, donor_id) [delete: cascade] + +Ref fk_receipt_items_food_items_food_item_id_donor_id: receipt_items.(food_item_id, donor_id) > food_items.(id, donor_id) [delete: restrict] diff --git a/docs/database/receipts.md b/docs/database/receipts.md new file mode 100644 index 0000000..eeee932 --- /dev/null +++ b/docs/database/receipts.md @@ -0,0 +1,111 @@ +# Receipts and their prices + +These two tables hold the tax receipts given to donors and the value of each food item on them. A receipt belongs to one donor and lists the food items that donor gave. For each item, the receipt keeps the quantity, unit, price and quality percentage used to value it, so the amount can always be checked and recalculated, even if the food item is edited later. + +## Diagram + +![Receipts diagram](images/receipts.png) + +The diagram is generated from [receipts.dbml](receipts.dbml). To update it, paste the content of that file into [dbdiagram.io](https://dbdiagram.io). + +The donors and food_items tables come from an earlier step. They are shown in the diagram because receipts link to them. + +## Tables + +### receipts + +One row per tax receipt. + +| Column | Meaning | +| --- | --- | +| id | Code that identifies the receipt, assigned automatically | +| donor_id | The donor who receives the receipt (see the donors table) | +| total_amount | Total value of the receipt, in dollars and cents. It is the sum of the rounded values of its food items. | +| receipt_date | Date of the receipt in Montreal time, set automatically. This is the date used for the tax year. | +| created_at | Date and time the receipt was created, stored in UTC | + +### receipt_items + +One row per food item on a receipt. + +| Column | Meaning | +| --- | --- | +| receipt_id | The receipt this line belongs to | +| food_item_id | The food item valued on this line (see the food_items table) | +| donor_id | The donor of both the receipt and the food item. It makes sure they are the same donor. | +| quantity | Quantity of the food item when the receipt was made, copied from the food item | +| unit | Unit of that quantity, such as kg or case, copied from the food item | +| unit_price | Price of one unit of the item when the receipt was made, in dollars, with up to 4 decimal places. For example, $0.125 per kg. | +| quality_percent | Share of the price kept for the item's quality grade: 100 for grade A, between 50 and 75 for grade B, 0 for grade C | + +## How the value is calculated + +The value of each food item on a receipt is: + +quantity × unit price × quality percentage + +The total of the receipt is the sum of these values. For example, a bakery gives 12.50 kg of bread of grade B and 3 cases of apples of grade A: + +| Item | Quantity | Unit price | Quality | Value | +| --- | --- | --- | --- | --- | +| Bread | 12.50 kg | $4.00 | 75 % | $37.50 | +| Apples | 3 cases | $2.49 | 100 % | $7.47 | +| Total | | | | $44.97 | + +The quantity, unit, unit price and quality percentage are all saved on the receipt itself. If the food item or the price grid changes later, receipts that already exist keep the values they were made with. + +All amounts are stored as exact decimal numbers, so no cent is lost along the way. + +### Rounding + +1. Each line value is rounded to the cent. +2. A value exactly halfway between two cents is rounded away from zero. +3. The total is the sum of the rounded line values, so it always matches the line-by-line detail. + +For example, 1 kg × $0.45 × 50 % = $0.225, which gives $0.23. + +This is what PostgreSQL's round() does. In .NET, use Math.Round(value, 2, MidpointRounding.AwayFromZero). The default Math.Round(value, 2) rounds half to even and would give $0.22. + +## Dates and time zones + +receipt_date is the date to use for the tax year and on the printed receipt. The database sets it to the current date in Montreal time. A receipt made on December 31 at 8 p.m. in Montreal is dated December 31, even though it is already January 1 in UTC. + +created_at is a precise moment, stored and read back in UTC. Convert it to America/Montreal before showing it. The app can save a time with any offset, such as DateTimeOffset.Now, because it is converted to UTC before it is written. + +## How the tables are linked + +1. Each receipt belongs to exactly one donor. A donor can have many receipts. +2. Each receipt lists its food items through receipt_items. +3. A food item can appear on only one receipt, so one donation cannot get two tax receipts. +4. A receipt can only list food items given by its own donor. + +## Rules enforced by the database + +1. A receipt must belong to a donor that exists. +2. A donor cannot be deleted while they still have a receipt. +3. A food item cannot be deleted while it is on a receipt. +4. When a receipt is deleted, its lines are deleted with it. The food items themselves are kept. +5. A food item cannot be on two receipts. +6. The food item and the receipt on a line must have the same donor. This is enforced by including donor_id in both links. +7. The total of a receipt cannot be negative. It can be $0, for example when every item is grade C. +8. A quantity must be greater than 0, and a unit cannot be empty. +9. A unit price must be greater than $0. +10. A quality percentage must be between 0 and 100. + +Because donor_id is part of both links, the app cannot change the donor of a receipt or of a food item once it is saved. + +The app, not the database, makes sure that the total matches the sum of the rounded lines. + +When cancelled and replacement receipts are added (issue #31), rule 5 will need to allow a food item on a cancelled receipt and on the receipt that replaces it. + +## Migration + +| Item | Location | +| --- | --- | +| Entity Framework migration | [20261107041912_CreateReceipts](../../src/MoissonMontreal.Api/Data/Migrations/20261107041912_CreateReceipts.cs) | +| Equivalent SQL script | [0003_create_receipts.sql](sql/0003_create_receipts.sql) | +| Table settings | [ReceiptConfiguration.cs](../../src/MoissonMontreal.Api/Data/Configurations/ReceiptConfiguration.cs) and [ReceiptItemConfiguration.cs](../../src/MoissonMontreal.Api/Data/Configurations/ReceiptItemConfiguration.cs) | + +The SQL script is generated from the migration with `dotnet ef migrations script CreateDonorsAndFoodItems CreateReceipts --project src/MoissonMontreal.Api --output docs/database/sql/0003_create_receipts.sql`. It must be run after [0001_create_users_and_roles.sql](sql/0001_create_users_and_roles.sql) and [0002_create_donors_and_food_items.sql](sql/0002_create_donors_and_food_items.sql). It records itself in the migration history, so Entity Framework will not apply it a second time. + +The generated file starts with a UTF-8 byte order mark (BOM). Remove it before committing, for example with `sed -i '1s/^\xEF\xBB\xBF//' docs/database/sql/0003_create_receipts.sql`. diff --git a/docs/database/sql/0002_create_donors_and_food_items.sql b/docs/database/sql/0002_create_donors_and_food_items.sql new file mode 100644 index 0000000..ae07368 --- /dev/null +++ b/docs/database/sql/0002_create_donors_and_food_items.sql @@ -0,0 +1,36 @@ +START TRANSACTION; +CREATE TABLE donors ( + id uuid NOT NULL DEFAULT (gen_random_uuid()), + name character varying(255) NOT NULL, + address text, + postal_code character varying(20), + contact_person character varying(255), + created_at timestamp with time zone NOT NULL DEFAULT (now()), + CONSTRAINT pk_donors PRIMARY KEY (id), + CONSTRAINT ck_donors_name_not_empty CHECK (length(name) > 0) +); + +CREATE TABLE food_items ( + id uuid NOT NULL DEFAULT (gen_random_uuid()), + donor_id uuid NOT NULL, + name character varying(255) NOT NULL, + quantity numeric(12,2) NOT NULL, + unit character varying(20) NOT NULL, + donation_date date NOT NULL, + quality character varying(1) NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT (now()), + CONSTRAINT pk_food_items PRIMARY KEY (id), + CONSTRAINT ck_food_items_name_not_empty CHECK (length(name) > 0), + CONSTRAINT ck_food_items_quality CHECK (quality IN ('A', 'B', 'C')), + CONSTRAINT ck_food_items_quantity_positive CHECK (quantity > 0), + CONSTRAINT ck_food_items_unit_not_empty CHECK (length(unit) > 0), + CONSTRAINT fk_food_items_donors_donor_id FOREIGN KEY (donor_id) REFERENCES donors (id) ON DELETE RESTRICT +); + +CREATE INDEX ix_food_items_donor_id ON food_items (donor_id); + +INSERT INTO "__EFMigrationsHistory" (migration_id, product_version) +VALUES ('20261105163138_CreateDonorsAndFoodItems', '10.0.13'); + +COMMIT; + diff --git a/docs/database/sql/0003_create_receipts.sql b/docs/database/sql/0003_create_receipts.sql new file mode 100644 index 0000000..30e6726 --- /dev/null +++ b/docs/database/sql/0003_create_receipts.sql @@ -0,0 +1,45 @@ +START TRANSACTION; +ALTER TABLE food_items ADD CONSTRAINT ak_food_items_id_donor_id UNIQUE (id, donor_id); + +CREATE TABLE receipts ( + id uuid NOT NULL DEFAULT (gen_random_uuid()), + donor_id uuid NOT NULL, + total_amount numeric(12,2) NOT NULL, + receipt_date date NOT NULL DEFAULT ((now() AT TIME ZONE 'America/Montreal')::date), + created_at timestamp with time zone NOT NULL DEFAULT (now()), + CONSTRAINT pk_receipts PRIMARY KEY (id), + CONSTRAINT ak_receipts_id_donor_id UNIQUE (id, donor_id), + CONSTRAINT ck_receipts_total_amount_not_negative CHECK (total_amount >= 0), + CONSTRAINT fk_receipts_donors_donor_id FOREIGN KEY (donor_id) REFERENCES donors (id) ON DELETE RESTRICT +); + +CREATE TABLE receipt_items ( + receipt_id uuid NOT NULL, + food_item_id uuid NOT NULL, + donor_id uuid NOT NULL, + quantity numeric(12,2) NOT NULL, + unit character varying(20) NOT NULL, + unit_price numeric(12,4) NOT NULL, + quality_percent numeric(5,2) NOT NULL, + CONSTRAINT pk_receipt_items PRIMARY KEY (receipt_id, food_item_id), + CONSTRAINT ck_receipt_items_quality_percent_range CHECK (quality_percent BETWEEN 0 AND 100), + CONSTRAINT ck_receipt_items_quantity_positive CHECK (quantity > 0), + CONSTRAINT ck_receipt_items_unit_not_empty CHECK (length(unit) > 0), + CONSTRAINT ck_receipt_items_unit_price_positive CHECK (unit_price > 0), + CONSTRAINT fk_receipt_items_food_items_food_item_id_donor_id FOREIGN KEY (food_item_id, donor_id) REFERENCES food_items (id, donor_id) ON DELETE RESTRICT, + CONSTRAINT fk_receipt_items_receipts_receipt_id_donor_id FOREIGN KEY (receipt_id, donor_id) REFERENCES receipts (id, donor_id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX ix_receipt_items_food_item_id ON receipt_items (food_item_id); + +CREATE INDEX ix_receipt_items_food_item_id_donor_id ON receipt_items (food_item_id, donor_id); + +CREATE INDEX ix_receipt_items_receipt_id_donor_id ON receipt_items (receipt_id, donor_id); + +CREATE INDEX ix_receipts_donor_id ON receipts (donor_id); + +INSERT INTO "__EFMigrationsHistory" (migration_id, product_version) +VALUES ('20261107041912_CreateReceipts', '10.0.13'); + +COMMIT; + diff --git a/src/MoissonMontreal.Api/Data/Configurations/ReceiptConfiguration.cs b/src/MoissonMontreal.Api/Data/Configurations/ReceiptConfiguration.cs new file mode 100644 index 0000000..1f7b4d2 --- /dev/null +++ b/src/MoissonMontreal.Api/Data/Configurations/ReceiptConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MoissonMontreal.Core.Entities; + +namespace MoissonMontreal.Api.Data.Configurations; + +/// +/// Database rules of the receipts table and its link to the donors table. +/// +public class ReceiptConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the columns, constraint and donor relationship of the receipts table. + /// + /// Builder used to configure the receipt entity. + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(t => t.HasCheckConstraint("ck_receipts_total_amount_not_negative", "total_amount >= 0")); + + builder.Property(r => r.Id).HasDefaultValueSql("gen_random_uuid()"); + + builder.Property(r => r.TotalAmount).HasPrecision(12, 2); + + builder.Property(r => r.ReceiptDate).HasDefaultValueSql("(now() AT TIME ZONE 'America/Montreal')::date"); + + builder.Property(r => r.CreatedAt) + .HasDefaultValueSql("now()") + .HasConversion(v => v.ToUniversalTime(), v => v); + + builder.HasOne(r => r.Donor) + .WithMany() + .HasForeignKey(r => r.DonorId) + .OnDelete(DeleteBehavior.Restrict); + } +} \ No newline at end of file diff --git a/src/MoissonMontreal.Api/Data/Configurations/ReceiptItemConfiguration.cs b/src/MoissonMontreal.Api/Data/Configurations/ReceiptItemConfiguration.cs new file mode 100644 index 0000000..0527fbe --- /dev/null +++ b/src/MoissonMontreal.Api/Data/Configurations/ReceiptItemConfiguration.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MoissonMontreal.Core.Entities; + +namespace MoissonMontreal.Api.Data.Configurations; + +/// +/// Database rules of the receipt_items table and its links to the receipts and food_items tables. +/// +public class ReceiptItemConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the keys, copied values, constraints and relationships of the receipt_items table. + /// Both links include donor_id, so a receipt can only list food items given by its own donor. + /// + /// Builder used to configure the receipt item entity. + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(t => + { + t.HasCheckConstraint("ck_receipt_items_quantity_positive", "quantity > 0"); + t.HasCheckConstraint("ck_receipt_items_unit_not_empty", "length(unit) > 0"); + t.HasCheckConstraint("ck_receipt_items_unit_price_positive", "unit_price > 0"); + t.HasCheckConstraint("ck_receipt_items_quality_percent_range", "quality_percent BETWEEN 0 AND 100"); + }); + + builder.HasKey(i => new { i.ReceiptId, i.FoodItemId }); + + builder.HasIndex(i => i.FoodItemId).IsUnique(); + + builder.Property(i => i.Quantity).HasPrecision(12, 2); + + builder.Property(i => i.Unit).HasMaxLength(20); + + builder.Property(i => i.UnitPrice).HasPrecision(12, 4); + + builder.Property(i => i.QualityPercent).HasPrecision(5, 2); + + builder.HasOne(i => i.Receipt) + .WithMany(r => r.Items) + .HasForeignKey(i => new { i.ReceiptId, i.DonorId }) + .HasPrincipalKey(r => new { r.Id, r.DonorId }) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(i => i.FoodItem) + .WithMany() + .HasForeignKey(i => new { i.FoodItemId, i.DonorId }) + .HasPrincipalKey(f => new { f.Id, f.DonorId }) + .OnDelete(DeleteBehavior.Restrict); + } +} \ No newline at end of file diff --git a/src/MoissonMontreal.Api/Data/Migrations/20261107041912_CreateReceipts.Designer.cs b/src/MoissonMontreal.Api/Data/Migrations/20261107041912_CreateReceipts.Designer.cs new file mode 100644 index 0000000..de5465e --- /dev/null +++ b/src/MoissonMontreal.Api/Data/Migrations/20261107041912_CreateReceipts.Designer.cs @@ -0,0 +1,440 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MoissonMontreal.Api.Data; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MoissonMontreal.Api.Data.Migrations +{ + [DbContext(typeof(MoissonDbContext))] + [Migration("20261107041912_CreateReceipts")] + partial class CreateReceipts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.Donor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Address") + .HasColumnType("text") + .HasColumnName("address"); + + b.Property("ContactPerson") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("contact_person"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("postal_code"); + + b.HasKey("Id") + .HasName("pk_donors"); + + b.ToTable("donors", null, t => + { + t.HasCheckConstraint("ck_donors_name_not_empty", "length(name) > 0"); + }); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.FoodItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DonationDate") + .HasColumnType("date") + .HasColumnName("donation_date"); + + b.Property("DonorId") + .HasColumnType("uuid") + .HasColumnName("donor_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("Quality") + .IsRequired() + .HasMaxLength(1) + .HasColumnType("character varying(1)") + .HasColumnName("quality"); + + b.Property("Quantity") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("quantity"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.HasKey("Id") + .HasName("pk_food_items"); + + b.HasAlternateKey("Id", "DonorId") + .HasName("ak_food_items_id_donor_id"); + + b.HasIndex("DonorId") + .HasDatabaseName("ix_food_items_donor_id"); + + b.ToTable("food_items", null, t => + { + t.HasCheckConstraint("ck_food_items_name_not_empty", "length(name) > 0"); + + t.HasCheckConstraint("ck_food_items_quality", "quality IN ('A', 'B', 'C')"); + + t.HasCheckConstraint("ck_food_items_quantity_positive", "quantity > 0"); + + t.HasCheckConstraint("ck_food_items_unit_not_empty", "length(unit) > 0"); + }); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DonorId") + .HasColumnType("uuid") + .HasColumnName("donor_id"); + + b.Property("ReceiptDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasColumnName("receipt_date") + .HasDefaultValueSql("(now() AT TIME ZONE 'America/Montreal')::date"); + + b.Property("TotalAmount") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("total_amount"); + + b.HasKey("Id") + .HasName("pk_receipts"); + + b.HasAlternateKey("Id", "DonorId") + .HasName("ak_receipts_id_donor_id"); + + b.HasIndex("DonorId") + .HasDatabaseName("ix_receipts_donor_id"); + + b.ToTable("receipts", null, t => + { + t.HasCheckConstraint("ck_receipts_total_amount_not_negative", "total_amount >= 0"); + }); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.ReceiptItem", b => + { + b.Property("ReceiptId") + .HasColumnType("uuid") + .HasColumnName("receipt_id"); + + b.Property("FoodItemId") + .HasColumnType("uuid") + .HasColumnName("food_item_id"); + + b.Property("DonorId") + .HasColumnType("uuid") + .HasColumnName("donor_id"); + + b.Property("QualityPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)") + .HasColumnName("quality_percent"); + + b.Property("Quantity") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("quantity"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("UnitPrice") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)") + .HasColumnName("unit_price"); + + b.HasKey("ReceiptId", "FoodItemId") + .HasName("pk_receipt_items"); + + b.HasIndex("FoodItemId") + .IsUnique() + .HasDatabaseName("ix_receipt_items_food_item_id"); + + b.HasIndex("FoodItemId", "DonorId") + .HasDatabaseName("ix_receipt_items_food_item_id_donor_id"); + + b.HasIndex("ReceiptId", "DonorId") + .HasDatabaseName("ix_receipt_items_receipt_id_donor_id"); + + b.ToTable("receipt_items", null, t => + { + t.HasCheckConstraint("ck_receipt_items_quality_percent_range", "quality_percent BETWEEN 0 AND 100"); + + t.HasCheckConstraint("ck_receipt_items_quantity_positive", "quantity > 0"); + + t.HasCheckConstraint("ck_receipt_items_unit_not_empty", "length(unit) > 0"); + + t.HasCheckConstraint("ck_receipt_items_unit_price_positive", "unit_price > 0"); + }); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_roles_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("roles", (string)null); + + b.HasData( + new + { + Id = 1, + Code = "Supply", + Name = "Supply" + }, + new + { + Id = 2, + Code = "SupplyDirector", + Name = "Supply Director" + }, + new + { + Id = 3, + Code = "Finance", + Name = "Finance" + }, + new + { + Id = 4, + Code = "Admin", + Name = "Admin" + }); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EntraObjectId") + .HasColumnType("uuid") + .HasColumnName("entra_object_id"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("EntraObjectId") + .IsUnique() + .HasDatabaseName("ix_users_entra_object_id"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_users_role_id"); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("ck_users_email_lowercase", "email = lower(email)"); + }); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.FoodItem", b => + { + b.HasOne("MoissonMontreal.Core.Entities.Donor", "Donor") + .WithMany("FoodItems") + .HasForeignKey("DonorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_food_items_donors_donor_id"); + + b.Navigation("Donor"); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.Receipt", b => + { + b.HasOne("MoissonMontreal.Core.Entities.Donor", "Donor") + .WithMany() + .HasForeignKey("DonorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_receipts_donors_donor_id"); + + b.Navigation("Donor"); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.ReceiptItem", b => + { + b.HasOne("MoissonMontreal.Core.Entities.FoodItem", "FoodItem") + .WithMany() + .HasForeignKey("FoodItemId", "DonorId") + .HasPrincipalKey("Id", "DonorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_receipt_items_food_items_food_item_id_donor_id"); + + b.HasOne("MoissonMontreal.Core.Entities.Receipt", "Receipt") + .WithMany("Items") + .HasForeignKey("ReceiptId", "DonorId") + .HasPrincipalKey("Id", "DonorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_receipt_items_receipts_receipt_id_donor_id"); + + b.Navigation("FoodItem"); + + b.Navigation("Receipt"); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.User", b => + { + b.HasOne("MoissonMontreal.Core.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_users_roles_role_id"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.Donor", b => + { + b.Navigation("FoodItems"); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.Receipt", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.Role", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MoissonMontreal.Api/Data/Migrations/20261107041912_CreateReceipts.cs b/src/MoissonMontreal.Api/Data/Migrations/20261107041912_CreateReceipts.cs new file mode 100644 index 0000000..35a20af --- /dev/null +++ b/src/MoissonMontreal.Api/Data/Migrations/20261107041912_CreateReceipts.cs @@ -0,0 +1,111 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoissonMontreal.Api.Data.Migrations +{ + /// + public partial class CreateReceipts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddUniqueConstraint( + name: "ak_food_items_id_donor_id", + table: "food_items", + columns: new[] { "id", "donor_id" }); + + migrationBuilder.CreateTable( + name: "receipts", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + donor_id = table.Column(type: "uuid", nullable: false), + total_amount = table.Column(type: "numeric(12,2)", precision: 12, scale: 2, nullable: false), + receipt_date = table.Column(type: "date", nullable: false, defaultValueSql: "(now() AT TIME ZONE 'America/Montreal')::date"), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_receipts", x => x.id); + table.UniqueConstraint("ak_receipts_id_donor_id", x => new { x.id, x.donor_id }); + table.CheckConstraint("ck_receipts_total_amount_not_negative", "total_amount >= 0"); + table.ForeignKey( + name: "fk_receipts_donors_donor_id", + column: x => x.donor_id, + principalTable: "donors", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "receipt_items", + columns: table => new + { + receipt_id = table.Column(type: "uuid", nullable: false), + food_item_id = table.Column(type: "uuid", nullable: false), + donor_id = table.Column(type: "uuid", nullable: false), + quantity = table.Column(type: "numeric(12,2)", precision: 12, scale: 2, nullable: false), + unit = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + unit_price = table.Column(type: "numeric(12,4)", precision: 12, scale: 4, nullable: false), + quality_percent = table.Column(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_receipt_items", x => new { x.receipt_id, x.food_item_id }); + table.CheckConstraint("ck_receipt_items_quality_percent_range", "quality_percent BETWEEN 0 AND 100"); + table.CheckConstraint("ck_receipt_items_quantity_positive", "quantity > 0"); + table.CheckConstraint("ck_receipt_items_unit_not_empty", "length(unit) > 0"); + table.CheckConstraint("ck_receipt_items_unit_price_positive", "unit_price > 0"); + table.ForeignKey( + name: "fk_receipt_items_food_items_food_item_id_donor_id", + columns: x => new { x.food_item_id, x.donor_id }, + principalTable: "food_items", + principalColumns: new[] { "id", "donor_id" }, + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_receipt_items_receipts_receipt_id_donor_id", + columns: x => new { x.receipt_id, x.donor_id }, + principalTable: "receipts", + principalColumns: new[] { "id", "donor_id" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_receipt_items_food_item_id", + table: "receipt_items", + column: "food_item_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_receipt_items_food_item_id_donor_id", + table: "receipt_items", + columns: new[] { "food_item_id", "donor_id" }); + + migrationBuilder.CreateIndex( + name: "ix_receipt_items_receipt_id_donor_id", + table: "receipt_items", + columns: new[] { "receipt_id", "donor_id" }); + + migrationBuilder.CreateIndex( + name: "ix_receipts_donor_id", + table: "receipts", + column: "donor_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "receipt_items"); + + migrationBuilder.DropTable( + name: "receipts"); + + migrationBuilder.DropUniqueConstraint( + name: "ak_food_items_id_donor_id", + table: "food_items"); + } + } +} diff --git a/src/MoissonMontreal.Api/Data/Migrations/MoissonDbContextModelSnapshot.cs b/src/MoissonMontreal.Api/Data/Migrations/MoissonDbContextModelSnapshot.cs index feed2d4..eaffa10 100644 --- a/src/MoissonMontreal.Api/Data/Migrations/MoissonDbContextModelSnapshot.cs +++ b/src/MoissonMontreal.Api/Data/Migrations/MoissonDbContextModelSnapshot.cs @@ -113,6 +113,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_food_items"); + b.HasAlternateKey("Id", "DonorId") + .HasName("ak_food_items_id_donor_id"); + b.HasIndex("DonorId") .HasDatabaseName("ix_food_items_donor_id"); @@ -128,6 +131,110 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("MoissonMontreal.Core.Entities.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DonorId") + .HasColumnType("uuid") + .HasColumnName("donor_id"); + + b.Property("ReceiptDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasColumnName("receipt_date") + .HasDefaultValueSql("(now() AT TIME ZONE 'America/Montreal')::date"); + + b.Property("TotalAmount") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("total_amount"); + + b.HasKey("Id") + .HasName("pk_receipts"); + + b.HasAlternateKey("Id", "DonorId") + .HasName("ak_receipts_id_donor_id"); + + b.HasIndex("DonorId") + .HasDatabaseName("ix_receipts_donor_id"); + + b.ToTable("receipts", null, t => + { + t.HasCheckConstraint("ck_receipts_total_amount_not_negative", "total_amount >= 0"); + }); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.ReceiptItem", b => + { + b.Property("ReceiptId") + .HasColumnType("uuid") + .HasColumnName("receipt_id"); + + b.Property("FoodItemId") + .HasColumnType("uuid") + .HasColumnName("food_item_id"); + + b.Property("DonorId") + .HasColumnType("uuid") + .HasColumnName("donor_id"); + + b.Property("QualityPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)") + .HasColumnName("quality_percent"); + + b.Property("Quantity") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("quantity"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("UnitPrice") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)") + .HasColumnName("unit_price"); + + b.HasKey("ReceiptId", "FoodItemId") + .HasName("pk_receipt_items"); + + b.HasIndex("FoodItemId") + .IsUnique() + .HasDatabaseName("ix_receipt_items_food_item_id"); + + b.HasIndex("FoodItemId", "DonorId") + .HasDatabaseName("ix_receipt_items_food_item_id_donor_id"); + + b.HasIndex("ReceiptId", "DonorId") + .HasDatabaseName("ix_receipt_items_receipt_id_donor_id"); + + b.ToTable("receipt_items", null, t => + { + t.HasCheckConstraint("ck_receipt_items_quality_percent_range", "quality_percent BETWEEN 0 AND 100"); + + t.HasCheckConstraint("ck_receipt_items_quantity_positive", "quantity > 0"); + + t.HasCheckConstraint("ck_receipt_items_unit_not_empty", "length(unit) > 0"); + + t.HasCheckConstraint("ck_receipt_items_unit_price_positive", "unit_price > 0"); + }); + }); + modelBuilder.Entity("MoissonMontreal.Core.Entities.Role", b => { b.Property("Id") @@ -263,6 +370,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Donor"); }); + modelBuilder.Entity("MoissonMontreal.Core.Entities.Receipt", b => + { + b.HasOne("MoissonMontreal.Core.Entities.Donor", "Donor") + .WithMany() + .HasForeignKey("DonorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_receipts_donors_donor_id"); + + b.Navigation("Donor"); + }); + + modelBuilder.Entity("MoissonMontreal.Core.Entities.ReceiptItem", b => + { + b.HasOne("MoissonMontreal.Core.Entities.FoodItem", "FoodItem") + .WithMany() + .HasForeignKey("FoodItemId", "DonorId") + .HasPrincipalKey("Id", "DonorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_receipt_items_food_items_food_item_id_donor_id"); + + b.HasOne("MoissonMontreal.Core.Entities.Receipt", "Receipt") + .WithMany("Items") + .HasForeignKey("ReceiptId", "DonorId") + .HasPrincipalKey("Id", "DonorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_receipt_items_receipts_receipt_id_donor_id"); + + b.Navigation("FoodItem"); + + b.Navigation("Receipt"); + }); + modelBuilder.Entity("MoissonMontreal.Core.Entities.User", b => { b.HasOne("MoissonMontreal.Core.Entities.Role", "Role") @@ -280,6 +422,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("FoodItems"); }); + modelBuilder.Entity("MoissonMontreal.Core.Entities.Receipt", b => + { + b.Navigation("Items"); + }); + modelBuilder.Entity("MoissonMontreal.Core.Entities.Role", b => { b.Navigation("Users"); diff --git a/src/MoissonMontreal.Api/Data/MoissonDbContext.cs b/src/MoissonMontreal.Api/Data/MoissonDbContext.cs index 594cd02..0c53bd8 100644 --- a/src/MoissonMontreal.Api/Data/MoissonDbContext.cs +++ b/src/MoissonMontreal.Api/Data/MoissonDbContext.cs @@ -29,6 +29,16 @@ public class MoissonDbContext(DbContextOptions options) : DbCo /// public DbSet FoodItems => Set(); + /// + /// Tax receipts given to donors. + /// + public DbSet Receipts => Set(); + + /// + /// Food items on each receipt, with the price used to value them. + /// + public DbSet ReceiptItems => Set(); + /// /// Names every table, column, key and index in snake_case. /// diff --git a/src/MoissonMontreal.Core/Entities/Receipt.cs b/src/MoissonMontreal.Core/Entities/Receipt.cs new file mode 100644 index 0000000..5a22a48 --- /dev/null +++ b/src/MoissonMontreal.Core/Entities/Receipt.cs @@ -0,0 +1,42 @@ +namespace MoissonMontreal.Core.Entities; + +/// +/// Tax receipt given to a donor for the food items they donated. +/// +public class Receipt +{ + /// + /// Identifier of the receipt, assigned by the database. + /// + public Guid Id { get; set; } + + /// + /// Identifier of the donor who receives this receipt. + /// + public Guid DonorId { get; set; } + + /// + /// Donor who receives this receipt. + /// + public Donor Donor { get; set; } = null!; + + /// + /// Total value of the receipt in dollars, with two decimal places. Cannot be negative. + /// + public decimal TotalAmount { get; set; } + + /// + /// Date of the receipt in Montreal time, set by the database. Used for the tax year. + /// + public DateOnly ReceiptDate { get; set; } + + /// + /// Date and time the receipt was created, set by the database. Stored and read back in UTC. + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Food items on this receipt, with the price used for each one. + /// + public ICollection Items { get; set; } = []; +} \ No newline at end of file diff --git a/src/MoissonMontreal.Core/Entities/ReceiptItem.cs b/src/MoissonMontreal.Core/Entities/ReceiptItem.cs new file mode 100644 index 0000000..56f2e70 --- /dev/null +++ b/src/MoissonMontreal.Core/Entities/ReceiptItem.cs @@ -0,0 +1,53 @@ +namespace MoissonMontreal.Core.Entities; + +/// +/// One food item on a receipt, with the quantity, price and quality percentage used to value it. +/// The value of the line is the quantity times the unit price times the quality percentage, rounded to the cent. +/// +public class ReceiptItem +{ + /// + /// Identifier of the receipt this line belongs to. + /// + public Guid ReceiptId { get; set; } + + /// + /// Receipt this line belongs to. + /// + public Receipt Receipt { get; set; } = null!; + + /// + /// Identifier of the donor of both the receipt and the food item, so they always match. + /// + public Guid DonorId { get; set; } + + /// + /// Identifier of the food item valued on this line. + /// + public Guid FoodItemId { get; set; } + + /// + /// Food item valued on this line. + /// + public FoodItem FoodItem { get; set; } = null!; + + /// + /// Quantity of the food item when the receipt was made. Must be greater than zero. + /// + public decimal Quantity { get; set; } + + /// + /// Unit of the quantity when the receipt was made, such as kg or case. + /// + public string Unit { get; set; } = string.Empty; + + /// + /// Price of one unit of the food item when the receipt was made, in dollars, with up to four decimal places. Must be greater than zero. + /// + public decimal UnitPrice { get; set; } + + /// + /// Percentage of the price applied for the item's quality grade, from 0 to 100. + /// + public decimal QualityPercent { get; set; } +} \ No newline at end of file diff --git a/tests/MoissonMontreal.Tests/Api/ReceiptsMigrationTests.cs b/tests/MoissonMontreal.Tests/Api/ReceiptsMigrationTests.cs new file mode 100644 index 0000000..a4d035c --- /dev/null +++ b/tests/MoissonMontreal.Tests/Api/ReceiptsMigrationTests.cs @@ -0,0 +1,497 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore; +using MoissonMontreal.Api.Data; +using MoissonMontreal.Core.Entities; +using MoissonMontreal.Tests.Fixtures; + +namespace MoissonMontreal.Tests.Api; + +[Trait("Category", "Integration")] +public class ReceiptsMigrationTests(PostgresFixture postgres) : IClassFixture +{ + [Fact] + public async Task Migrate_CreatesReceiptsAndReceiptItems_OnEmptyDatabase() + { + await using var context = CreateContext("receipts_empty"); + + await context.Database.MigrateAsync(); + + Assert.Equal(0, await context.Receipts.CountAsync()); + Assert.Equal(0, await context.ReceiptItems.CountAsync()); + } + + [Fact] + public async Task Receipt_IsLinkedToItsDonor() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + var saved = await context.Receipts.Include(r => r.Donor).SingleAsync(r => r.Id == seed.ReceiptId); + + Assert.Equal(seed.DonorId, saved.DonorId); + Assert.Equal(seed.DonorId, saved.Donor.Id); + } + + [Fact] + public async Task Receipt_IsLinkedToItsFoodItems() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + var foodItemIds = await context.ReceiptItems + .Where(i => i.ReceiptId == seed.ReceiptId) + .Select(i => i.FoodItem.Id) + .ToListAsync(); + + Assert.Equal(new[] { seed.BreadId, seed.ApplesId }.Order(), foodItemIds.Order()); + } + + [Fact] + public async Task Receipt_StoresTotalAmount() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + var saved = await context.Receipts.SingleAsync(r => r.Id == seed.ReceiptId); + + Assert.Equal(44.97m, saved.TotalAmount); + } + + [Fact] + public async Task Receipt_SetsCreatedAt() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + var saved = await context.Receipts.SingleAsync(r => r.Id == seed.ReceiptId); + + Assert.NotEqual(default, saved.CreatedAt); + } + + [Fact] + public async Task Receipt_SetsReceiptDate_InMontrealTime() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + var saved = await context.Receipts.SingleAsync(r => r.Id == seed.ReceiptId); + var montreal = TimeZoneInfo.FindSystemTimeZoneById("America/Montreal"); + + Assert.Equal(DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(saved.CreatedAt, montreal).DateTime), saved.ReceiptDate); + } + + [Fact] + public async Task Receipt_SavesCreatedAt_WhenGivenInMontrealTime() + { + var seed = await SeedAsync(); + var newYearsEve = new DateTimeOffset(2026, 12, 31, 20, 0, 0, TimeSpan.FromHours(-5)); + Guid receiptId; + + await using (var saveContext = CreateContext()) + { + var receipt = new Receipt { DonorId = seed.DonorId, TotalAmount = 0m, CreatedAt = newYearsEve }; + saveContext.Receipts.Add(receipt); + await saveContext.SaveChangesAsync(); + receiptId = receipt.Id; + } + + await using var context = CreateContext(); + var saved = await context.Receipts.SingleAsync(r => r.Id == receiptId); + + Assert.Equal(newYearsEve, saved.CreatedAt); + Assert.Equal(TimeSpan.Zero, saved.CreatedAt.Offset); + } + + [Fact] + public async Task ReceiptItem_StoresUnitPriceWithFourDecimals() + { + var seed = await SeedAsync(); + var foodItemId = await AddFoodItemAsync(seed.DonorId); + + await using (var saveContext = CreateContext()) + { + saveContext.ReceiptItems.Add(Item(seed.ReceiptId, seed.DonorId, foodItemId, unitPrice: 0.125m)); + await saveContext.SaveChangesAsync(); + } + + await using var context = CreateContext(); + var saved = await context.ReceiptItems.SingleAsync(i => i.FoodItemId == foodItemId); + + Assert.Equal(0.125m, saved.UnitPrice); + } + + [Fact] + public async Task ReceiptItem_StoresQualityPercent() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + var saved = await context.ReceiptItems.SingleAsync(i => i.ReceiptId == seed.ReceiptId && i.FoodItemId == seed.BreadId); + + Assert.Equal(75m, saved.QualityPercent); + } + + [Fact] + public async Task ReceiptItem_KeepsItsQuantityAndUnit_WhenTheFoodItemChanges() + { + var seed = await SeedAsync(); + + await using (var editContext = CreateContext()) + { + var bread = await editContext.FoodItems.SingleAsync(f => f.Id == seed.BreadId); + bread.Quantity = 20m; + bread.Unit = "caisse"; + await editContext.SaveChangesAsync(); + } + + await using var context = CreateContext(); + var saved = await context.ReceiptItems.SingleAsync(i => i.ReceiptId == seed.ReceiptId && i.FoodItemId == seed.BreadId); + + Assert.Equal(12.50m, saved.Quantity); + Assert.Equal("kg", saved.Unit); + } + + [Fact] + public async Task ReceiptItems_LineValuesAddUpToTotalAmount() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + var lineValues = await context.ReceiptItems + .Where(i => i.ReceiptId == seed.ReceiptId) + .OrderBy(i => i.FoodItem.Name) + .Select(i => Math.Round(i.Quantity * i.UnitPrice * i.QualityPercent / 100m, 2)) + .ToListAsync(); + var total = await context.Receipts.Where(r => r.Id == seed.ReceiptId).Select(r => r.TotalAmount).SingleAsync(); + + Assert.Equal([37.50m, 7.47m], lineValues); + Assert.Equal(total, lineValues.Sum()); + } + + [Fact] + public async Task ReceiptItem_LineValueRoundsHalfAwayFromZero() + { + var seed = await SeedAsync(); + var foodItemId = await AddFoodItemAsync(seed.DonorId); + + await using (var saveContext = CreateContext()) + { + saveContext.ReceiptItems.Add(Item(seed.ReceiptId, seed.DonorId, foodItemId, unitPrice: 0.45m, qualityPercent: 50m)); + await saveContext.SaveChangesAsync(); + } + + await using var context = CreateContext(); + var lineValue = await context.ReceiptItems + .Where(i => i.FoodItemId == foodItemId) + .Select(i => Math.Round(i.Quantity * i.UnitPrice * i.QualityPercent / 100m, 2)) + .SingleAsync(); + + Assert.Equal(0.23m, lineValue); + Assert.Equal(Math.Round(0.225m, 2, MidpointRounding.AwayFromZero), lineValue); + } + + [Fact] + public async Task ReceiptItem_AcceptsZeroPercent_ForQualityC() + { + var seed = await SeedAsync(); + var foodItemId = await AddFoodItemAsync(seed.DonorId); + + await using var context = CreateContext(); + context.Receipts.Add(new Receipt + { + DonorId = seed.DonorId, + TotalAmount = 0m, + Items = [Item(Guid.Empty, seed.DonorId, foodItemId, unitPrice: 3.00m, qualityPercent: 0m)] + }); + + await context.SaveChangesAsync(); + } + + [Fact] + public async Task DeleteReceipt_DeletesItsItems_ButKeepsTheFoodItems() + { + var seed = await SeedAsync(); + + await using (var deleteContext = CreateContext()) + { + deleteContext.Receipts.Remove(await deleteContext.Receipts.SingleAsync(r => r.Id == seed.ReceiptId)); + await deleteContext.SaveChangesAsync(); + } + + await using var context = CreateContext(); + Assert.Equal(0, await context.ReceiptItems.CountAsync(i => i.ReceiptId == seed.ReceiptId)); + Assert.Equal(2, await context.FoodItems.CountAsync(f => f.Id == seed.BreadId || f.Id == seed.ApplesId)); + } + + [Fact] + public async Task SaveReceipt_Throws_WhenDonorDoesNotExist() + { + await using var context = CreateContext(); + await context.Database.MigrateAsync(); + + context.Receipts.Add(new Receipt { DonorId = Guid.NewGuid(), TotalAmount = 10m }); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "fk_receipts_donors_donor_id"); + } + + [Fact] + public async Task SaveReceiptItem_Throws_WhenFoodItemDoesNotExist() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + context.ReceiptItems.Add(Item(seed.ReceiptId, seed.DonorId, Guid.NewGuid())); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "fk_receipt_items_food_items_food_item_id_donor_id"); + } + + [Fact] + public async Task SaveReceiptItem_Throws_WhenFoodItemIsAlreadyOnTheReceipt() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + context.ReceiptItems.Add(Item(seed.ReceiptId, seed.DonorId, seed.BreadId)); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "pk_receipt_items"); + } + + [Fact] + public async Task SaveReceiptItem_Throws_WhenFoodItemIsOnAnotherReceipt() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + context.Receipts.Add(new Receipt + { + DonorId = seed.DonorId, + TotalAmount = 37.50m, + Items = [Item(Guid.Empty, seed.DonorId, seed.BreadId, quantity: 12.50m, qualityPercent: 75m)] + }); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "ix_receipt_items_food_item_id"); + } + + [Fact] + public async Task SaveReceiptItem_Throws_WhenFoodItemBelongsToAnotherDonor() + { + var seed = await SeedAsync(); + var otherDonorId = await AddDonorAsync(); + var otherFoodItemId = await AddFoodItemAsync(otherDonorId); + + await using var context = CreateContext(); + context.ReceiptItems.Add(Item(seed.ReceiptId, seed.DonorId, otherFoodItemId)); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "fk_receipt_items_food_items_food_item_id_donor_id"); + } + + [Fact] + public async Task SaveReceiptItem_Throws_WhenReceiptBelongsToAnotherDonor() + { + var seed = await SeedAsync(); + var otherDonorId = await AddDonorAsync(); + var otherFoodItemId = await AddFoodItemAsync(otherDonorId); + + await using var context = CreateContext(); + context.ReceiptItems.Add(Item(seed.ReceiptId, otherDonorId, otherFoodItemId)); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "fk_receipt_items_receipts_receipt_id_donor_id"); + } + + [Fact] + public async Task DeleteDonor_Throws_WhenReceiptsRemain() + { + Guid donorId; + await using (var seedContext = CreateContext()) + { + await seedContext.Database.MigrateAsync(); + var donor = new Donor { Name = "Épicerie du Plateau" }; + seedContext.Receipts.Add(new Receipt { Donor = donor, TotalAmount = 0m }); + await seedContext.SaveChangesAsync(); + donorId = donor.Id; + } + + await using var context = CreateContext(); + context.Donors.Remove(await context.Donors.SingleAsync(d => d.Id == donorId)); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "fk_receipts_donors_donor_id"); + } + + [Fact] + public async Task DeleteFoodItem_Throws_WhenItIsOnAReceipt() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + context.FoodItems.Remove(await context.FoodItems.SingleAsync(f => f.Id == seed.BreadId)); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "fk_receipt_items_food_items_food_item_id_donor_id"); + } + + [Fact] + public async Task SaveChanges_Throws_WhenTotalAmountIsNegative() + { + var seed = await SeedAsync(); + + await using var context = CreateContext(); + context.Receipts.Add(new Receipt { DonorId = seed.DonorId, TotalAmount = -1m }); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "ck_receipts_total_amount_not_negative"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task SaveChanges_Throws_WhenQuantityIsNotPositive(int quantity) + { + var seed = await SeedAsync(); + var foodItemId = await AddFoodItemAsync(seed.DonorId); + + await using var context = CreateContext(); + context.ReceiptItems.Add(Item(seed.ReceiptId, seed.DonorId, foodItemId, quantity: quantity)); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "ck_receipt_items_quantity_positive"); + } + + [Fact] + public async Task SaveChanges_Throws_WhenUnitIsEmpty() + { + var seed = await SeedAsync(); + var foodItemId = await AddFoodItemAsync(seed.DonorId); + + await using var context = CreateContext(); + context.ReceiptItems.Add(Item(seed.ReceiptId, seed.DonorId, foodItemId, unit: "")); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "ck_receipt_items_unit_not_empty"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task SaveChanges_Throws_WhenUnitPriceIsNotPositive(int unitPrice) + { + var seed = await SeedAsync(); + var foodItemId = await AddFoodItemAsync(seed.DonorId); + + await using var context = CreateContext(); + context.ReceiptItems.Add(Item(seed.ReceiptId, seed.DonorId, foodItemId, unitPrice: unitPrice)); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "ck_receipt_items_unit_price_positive"); + } + + [Theory] + [InlineData(-1)] + [InlineData(101)] + public async Task SaveChanges_Throws_WhenQualityPercentIsOutOfRange(int qualityPercent) + { + var seed = await SeedAsync(); + var foodItemId = await AddFoodItemAsync(seed.DonorId); + + await using var context = CreateContext(); + context.ReceiptItems.Add(Item(seed.ReceiptId, seed.DonorId, foodItemId, qualityPercent: qualityPercent)); + + await PostgresConstraintAssertions.AssertViolatesConstraintAsync(context, "ck_receipt_items_quality_percent_range"); + } + + private async Task SeedAsync() + { + await using var context = CreateContext(); + await context.Database.MigrateAsync(); + + var donor = new Donor { Name = "Boulangerie Saint-Laurent" }; + var bread = new FoodItem + { + Donor = donor, + Name = "Pain", + Quantity = 12.50m, + Unit = "kg", + DonationDate = new DateOnly(2026, 11, 3), + Quality = "B" + }; + var apples = new FoodItem + { + Donor = donor, + Name = "Pommes", + Quantity = 3m, + Unit = "caisse", + DonationDate = new DateOnly(2026, 11, 3), + Quality = "A" + }; + var receipt = new Receipt + { + Donor = donor, + TotalAmount = 44.97m, + Items = + [ + new ReceiptItem { FoodItem = bread, Quantity = bread.Quantity, Unit = bread.Unit, UnitPrice = 4.00m, QualityPercent = 75m }, + new ReceiptItem { FoodItem = apples, Quantity = apples.Quantity, Unit = apples.Unit, UnitPrice = 2.49m, QualityPercent = 100m } + ] + }; + + context.Receipts.Add(receipt); + await context.SaveChangesAsync(); + + return new Seed(donor.Id, receipt.Id, bread.Id, apples.Id); + } + + private async Task AddDonorAsync() + { + await using var context = CreateContext(); + var donor = new Donor { Name = "Marché Jean-Talon" }; + context.Donors.Add(donor); + await context.SaveChangesAsync(); + + return donor.Id; + } + + private async Task AddFoodItemAsync(Guid donorId) + { + await using var context = CreateContext(); + var foodItem = new FoodItem + { + DonorId = donorId, + Name = "Yogourt", + Quantity = 1m, + Unit = "kg", + DonationDate = new DateOnly(2026, 11, 3), + Quality = "B" + }; + context.FoodItems.Add(foodItem); + await context.SaveChangesAsync(); + + return foodItem.Id; + } + + private static ReceiptItem Item( + Guid receiptId, + Guid donorId, + Guid foodItemId, + decimal quantity = 1m, + string unit = "kg", + decimal unitPrice = 4.00m, + decimal qualityPercent = 100m) => new() + { + ReceiptId = receiptId, + DonorId = donorId, + FoodItemId = foodItemId, + Quantity = quantity, + Unit = unit, + UnitPrice = unitPrice, + QualityPercent = qualityPercent + }; + + private MoissonDbContext CreateContext(string? database = null) + { + var connectionString = postgres.ConnectionString; + if (database is not null) + { + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + builder["Database"] = database; + connectionString = builder.ConnectionString; + } + + return new MoissonDbContext(new DbContextOptionsBuilder().UseNpgsql(connectionString).Options); + } + + private sealed record Seed(Guid DonorId, Guid ReceiptId, Guid BreadId, Guid ApplesId); +}