Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/database/images/schema.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
170 changes: 170 additions & 0 deletions docs/database/schema.dbml
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Full database schema of the Moisson Montreal receipt app
// Paste this file into https://dbdiagram.io to render the diagram.
// Matches the migrations 20261103000127_CreateUsersAndRoles, 20261105163138_CreateDonorsAndFoodItems
// and 20261107041912_CreateReceipts.

Project moisson_montreal {
database_type: 'PostgreSQL'
Note: 'Accounts of the employees, donors and their donated food, and the tax receipts with the prices used to value them'
}

Table roles {
id integer [not null, note: 'Fixed values, inserted by the migration']
code varchar(50) [not null, note: 'Stable identifier used by the permission checks']
name varchar(100) [not null, note: 'Label shown in the app']

indexes {
id [pk, name: 'pk_roles']
code [unique, name: 'ix_roles_code']
name [unique, name: 'ix_roles_name']
}

Note: 'Department-based roles that determine what a user can see and do'
}

Table users {
id integer [not null, increment]
email varchar(256) [not null, note: 'Work email, always lowercase']
entra_object_id uuid [null, note: 'Microsoft Entra id, filled on the first sign-in']
display_name varchar(200) [not null]
role_id integer [not null]
is_active boolean [not null, default: true, note: 'False once the admin removes access']
created_at timestamptz [not null, default: `now()`, note: 'Stored in UTC']
updated_at timestamptz [null, note: 'Stored in UTC, empty if never changed']

indexes {
id [pk, name: 'pk_users']
email [unique, name: 'ix_users_email']
entra_object_id [unique, name: 'ix_users_entra_object_id']
role_id [name: 'ix_users_role_id']
}

checks {
`email = lower(email)` [name: 'ck_users_email_lowercase']
}

Note: 'Employees allowed to use the app'
}

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()`, note: 'Stored in UTC']

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, note: 'Date the food was received']
quality varchar(1) [not null, note: 'A, B or C']
created_at timestamptz [not null, default: `now()`, note: 'Stored in UTC']

indexes {
id [pk, name: 'pk_food_items']
(id, donor_id) [unique, name: 'ak_food_items_id_donor_id', note: 'Lets receipt_items check the donor']
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', note: 'Lets receipt_items check the donor']
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', note: 'A food item can only be on one receipt']
(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_users_roles_role_id: users.role_id > roles.id [delete: restrict]

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]

TableGroup accounts {
roles
users
}

TableGroup donations {
donors
food_items
}

TableGroup tax_receipts {
receipts
receipt_items
}

Records roles(id, code, name) {
1, 'Supply', 'Supply'
2, 'SupplyDirector', 'Supply Director'
3, 'Finance', 'Finance'
4, 'Admin', 'Admin'
}
159 changes: 159 additions & 0 deletions docs/database/schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Database overview

This page explains what the receipt app stores and how the information fits together. It is written for everyone on the project, including people who do not write code.

The database has three parts:

| Part | Tables | What it holds |
| --- | --- | --- |
| Accounts | roles, users | Who can use the app and what each person is allowed to do |
| Donations | donors, food_items | Who gave food, what they gave, when, and in what condition |
| Tax receipts | receipts, receipt_items | The receipts given to donors and the value of each food item on them |

## Diagram

![Database diagram](images/schema.png)

How to read it:

- Each box is a table, which works like a spreadsheet: one row per record, one column per piece of information.
- A line between two boxes is a link. For example, the line between donors and receipts means that every receipt points to the donor who receives it.
- The boxes are grouped by part: Accounts, Donations and Tax receipts.

The diagram is generated from [schema.dbml](schema.dbml). To update it, paste the content of that file into [dbdiagram.io](https://dbdiagram.io) and export the image.

## Tables

### roles

The four roles of the app, one per department: Supply, Supply Director, Finance and Admin. A role decides what a person can see and do. For example, Finance gives the second approval on a receipt. The four roles are created automatically and do not need to be entered by hand.

### users

One row per employee who has access to the app: their work email, their name, their role, and whether their access is still active. Employees sign in with their Microsoft work account, so no password is stored in the app. When someone leaves, the admin deactivates their access instead of deleting them, so the history of who did what is kept.

More detail: [Users and roles](users-roles.md).

### donors

The organizations and people who give food: name, address, postal code and contact person. This is the information printed on the tax receipt. Two donors can have the same name, for example two branches of the same grocery chain.

### food_items

The food given by a donor. Each row holds what the food is, the quantity and its unit (for example 12.50 kg), the date it was received, and its quality grade:

| Grade | Meaning | Share of the price kept |
| --- | --- | --- |
| A | Comparable to what a grocery store sells | 100 % |
| B | Has imperfections | Between 50 % and 75 %, a range set by the warehouse director |
| C | Close to its expiry date | 0 %, no value |

Every food item belongs to one donor.

### receipts

One row per tax receipt. It holds the donor who receives it, the total value in dollars and cents, and the date of the receipt.

More detail: [Receipts and their prices](receipts.md).

### receipt_items

The lines of a receipt, one per food item. Each line keeps a copy of everything used to value the item at the time: the quantity, the unit, the price of one unit and the quality percentage. If a food item or a price is changed later, receipts that already exist do not change.

More detail: [Receipts and their prices](receipts.md).

## How a receipt is valued

The value of each line is:

quantity × price of one unit × quality percentage

The total of the receipt is the sum of its lines. For example, a bakery gives 12.50 kg of bread in grade B and 3 cases of apples in grade A:

| Item | Quantity | Price of one unit | Quality | Value |
| --- | --- | --- | --- | --- |
| Bread | 12.50 kg | $4.00 | 75 % | $37.50 |
| Apples | 3 cases | $2.49 | 100 % | $7.47 |
| Total | | | | $44.97 |

Each line is rounded to the cent, and an amount exactly halfway, such as $0.225, is rounded up to $0.23. The total is the sum of the rounded lines, so it always matches the detail shown on screen.

## How the information is protected

The database itself refuses anything that would break these rules, even if there were a mistake in the app.

**No duplicate receipts**
- A food item can be on only one receipt, so the same donation cannot get two tax receipts.
- The same food item cannot appear twice on one receipt.

**The right donor**
- A receipt can only list food given by its own donor.
- A receipt and a food item always belong to a donor that exists.

**Nothing in use can be deleted**
- A donor cannot be deleted while they have food items or receipts.
- A food item cannot be deleted while it is on a receipt.
- A role cannot be deleted while someone has it.

**Exact amounts**
- All amounts are stored as exact decimal numbers, so no cent is lost along the way.
- Prices keep up to 4 decimal places, so a price such as $0.125 per kg is stored exactly.
- Totals and line values are in dollars and cents.

**Valid values**
- A quantity and a price must be greater than zero.
- A quality percentage must be between 0 and 100.
- A quality grade must be A, B or C.
- A receipt total cannot be negative.
- Donor names, food names and units cannot be empty.

**Accounts**
- Two employees cannot have the same email or the same Microsoft account.
- Emails are stored in lowercase, so the same address cannot be entered twice with different capital letters.

## Dates and times

- **Dates without a time** are stored as plain calendar dates, with no time zone involved. This covers the date a donation was received and the date of a receipt.
- **The date of a receipt** is set automatically to the current date in Montreal. It decides the tax year. A receipt made on December 31 at 8 p.m. is dated December 31, even though it is already January 1 in universal time.
- **Moments with a time**, such as when an account or a receipt was created, are stored in universal time (UTC) and shown in Montreal time.

## Not built yet

These parts of the project are planned in their own issues and will add tables later:

| Feature | Issue |
| --- | --- |
| Price grid with effective dates, and price lookup by date | #24, #25 |
| Quality grades with their percentages | #26 |
| Food catalog with Business Central codes | #27 |
| Calculation of a donation's value | #30 |
| Receipt statuses: prepared, approved at two levels, issued, cancelled and replaced | #31 |
| Receipt numbering, unique and with no gaps | #32 |
| PDF receipt | #40 |

When cancelled and replacement receipts are added, the rule "a food item can be on only one receipt" will be adjusted so that a replacement receipt can list the same food as the receipt it replaces.

## Words used on this page

| Word | Meaning |
| --- | --- |
| Table | A list of records of the same kind, like a spreadsheet tab |
| Column | One piece of information kept for every record, like a spreadsheet column |
| Link | A column that points to a record in another table, such as the donor of a receipt |
| UTC | Universal time, the same everywhere in the world. Montreal is 4 or 5 hours behind it. |

## For developers

Each table comes from one of these migrations:

| Tables | Migration | SQL script | Detailed page |
| --- | --- | --- | --- |
| roles, users | [20261103000127_CreateUsersAndRoles](../../src/MoissonMontreal.Api/Data/Migrations/20261103000127_CreateUsersAndRoles.cs) | [0001_create_users_and_roles.sql](sql/0001_create_users_and_roles.sql) | [users-roles.md](users-roles.md) |
| donors, food_items | [20261105163138_CreateDonorsAndFoodItems](../../src/MoissonMontreal.Api/Data/Migrations/20261105163138_CreateDonorsAndFoodItems.cs) | [0002_create_donors_and_food_items.sql](sql/0002_create_donors_and_food_items.sql) | This page |
| receipts, receipt_items | [20261107041912_CreateReceipts](../../src/MoissonMontreal.Api/Data/Migrations/20261107041912_CreateReceipts.cs) | [0003_create_receipts.sql](sql/0003_create_receipts.sql) | [receipts.md](receipts.md) |

The SQL scripts must be run in order on an empty database.

Every date and time is converted to UTC before it is written, by [UtcDateTimeOffsetConverter.cs](../../src/MoissonMontreal.Api/Data/UtcDateTimeOffsetConverter.cs), which [MoissonDbContext.cs](../../src/MoissonMontreal.Api/Data/MoissonDbContext.cs) applies to every DateTimeOffset column.

The tests in [SchemaDiagramTests.cs](../../tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs) build an empty database from the migrations and check that every .dbml file in this folder matches it exactly: tables, columns, types, defaults, keys, indexes, checks, links and the roles data. Defaults and checks are compared by their full expression, after PostgreSQL rewrites the diagram's version in its own spelling. dbdiagram.io cannot draw a partial index (an index with a WHERE condition) or an index on an expression, so the tests fail if a migration adds one. When that happens, for example for replacement receipts (#31), describe the condition in the index note and extend the tests. Run them with `dotnet test --filter "FullyQualifiedName~MoissonMontreal.Tests.Docs"`. When a migration changes the schema, update [schema.dbml](schema.dbml), the .dbml of that part, and the images.
2 changes: 1 addition & 1 deletion docs/database/sql/0001_create_users_and_roles.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
migration_id character varying(150) NOT NULL,
product_version character varying(32) NOT NULL,
CONSTRAINT pk___ef_migrations_history PRIMARY KEY (migration_id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ public void Configure(EntityTypeBuilder<Receipt> builder)

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.Property(r => r.CreatedAt).HasDefaultValueSql("now()");

builder.HasOne(r => r.Donor)
.WithMany()
Expand Down
9 changes: 9 additions & 0 deletions src/MoissonMontreal.Api/Data/MoissonDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
optionsBuilder.UseSnakeCaseNamingConvention();
}

/// <summary>
/// Converts every date and time to UTC before it is written, so a value such as DateTimeOffset.Now can be saved.
/// </summary>
/// <param name="configurationBuilder">Builder used to configure the conventions of the model.</param>
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<DateTimeOffset>().HaveConversion<UtcDateTimeOffsetConverter>();
}

/// <summary>
/// Applies every table configuration found in the Configurations folder.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions src/MoissonMontreal.Api/Data/UtcDateTimeOffsetConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

namespace MoissonMontreal.Api.Data;

/// <summary>
/// Converts a date and time to UTC before it is written, because Npgsql only accepts UTC values for timestamptz columns.
/// Values are read back unchanged, in UTC.
/// </summary>
public class UtcDateTimeOffsetConverter() : ValueConverter<DateTimeOffset, DateTimeOffset>(v => v.ToUniversalTime(), v => v);
Loading
Loading