From cc55b9f3ec0822d3fc0b3c7e36c7aad703017a02 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:39:12 +0000 Subject: [PATCH 1/3] feat(people): add fee renewals and signed receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Members upload their transfer receipt in My area; admins with people:fees:write approve it, reject it with a reason, mark a fee paid by hand and send reminders from the Member register or the record. Approval issues a numbered receipt (2026/042) that the President downloads as a ZIP, signs with the CIE app and uploads back in the new People › Receipts section; files are matched by the number in their name, corrected by hand, and emailed to the member. The President's own receipt goes to the Vice-president. Receipts are never deleted, only voided. The contract gains FeeDetail, Receipt, fees on the record, fee instructions in My area, people.fees.*, people.receipts.* and two people.me.* procedures, with the provisional permissions people:fees:write and people:receipts:sign. Agent mode seeds fee years and receipts and renders the Italian receipt PDF and the ZIP to sign. docs/people/fees.md describes the flow, screens and PDF template. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0153QstPiakqZmaaVgTaqdnj --- AGENTS.md | 4 +- README.md | 40 +- docs/design.md | 6 + docs/people/README.md | 1 + docs/people/contract.md | 134 +++- docs/people/decisions.md | 15 +- docs/people/fees.md | 95 +++ docs/people/plan.md | 16 + src/components/shell/nav.ts | 14 + src/components/shell/panel.tsx | 30 +- src/components/shell/shell.tsx | 17 +- src/features/people/fee-dialogs.tsx | 573 ++++++++++++++++++ src/features/people/members-page.tsx | 157 ++++- src/features/people/my-area-page.tsx | 5 +- src/features/people/my-fee-card.tsx | 316 ++++++++++ src/features/people/people-fees.functions.ts | 61 ++ src/features/people/people-fees.ts | 127 ++++ src/features/people/people-files.ts | 20 + src/features/people/people-format.ts | 15 + .../people/people-receipts.functions.ts | 47 ++ src/features/people/people-self.functions.ts | 36 +- src/features/people/people.api.server.ts | 56 ++ src/features/people/people.contract.ts | 173 ++++++ src/features/people/people.fake.server.ts | 431 ++++++++++++- src/features/people/people.fixtures.server.ts | 254 +++++++- src/features/people/person-page.tsx | 200 +++++- src/features/people/receipt-dialogs.tsx | 344 +++++++++++ src/features/people/receipt-files.server.ts | 287 +++++++++ src/features/people/receipts-page.tsx | 482 +++++++++++++++ src/routeTree.gen.ts | 21 + src/routes/dashboard.tsx | 18 +- src/routes/dashboard/people/receipts.tsx | 41 ++ src/server/auth.middleware.ts | 16 + src/server/permissions.ts | 2 + tests/people-fees.test.mjs | 181 ++++++ tests/server-security.test.mjs | 23 +- 36 files changed, 4183 insertions(+), 75 deletions(-) create mode 100644 docs/people/fees.md create mode 100644 src/features/people/fee-dialogs.tsx create mode 100644 src/features/people/my-fee-card.tsx create mode 100644 src/features/people/people-fees.functions.ts create mode 100644 src/features/people/people-fees.ts create mode 100644 src/features/people/people-files.ts create mode 100644 src/features/people/people-receipts.functions.ts create mode 100644 src/features/people/receipt-dialogs.tsx create mode 100644 src/features/people/receipt-files.server.ts create mode 100644 src/features/people/receipts-page.tsx create mode 100644 src/routes/dashboard/people/receipts.tsx create mode 100644 tests/people-fees.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 285fce1..625e3b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,9 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de backend's `people.*` responses with `people.contract.ts` and, in agent mode, uses the in-memory fake (`AGENT_PERMISSIONS`, `AGENT_PERSON` pick the viewer). `/dashboard` admits `people:self` without `admin:access`; Capo Admin and Team Lead come from the person's own record (`peopleRoles` in the route context) and only open their - sections. Handoff notes, decisions and the plan: `docs/people/`. + sections. Fees and receipts (`docs/people/fees.md`): `people:fees:write` reviews, marks paid and reminds; + `people:receipts:sign` downloads and uploads signed receipts for the officer on the caller's record. Handoff notes, + decisions and the plan: `docs/people/`. - **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`. ## Conventions diff --git a/README.md b/README.md index 2af26db..de0e2fb 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ requires `azure:members:create`. Groups require `azure:groups:read`; adding and Telegram › Capo Admin and Account › My area for volunteers themselves. The dashboard talks to a typed `people.*` contract (`src/features/people/people.contract.ts`, documented in `docs/people/contract.md`); until the backend implements it, agent mode serves sample data from `people.fake.server.ts`. Permissions: `people:read`, `people:write`, -`people:members:read`, `people:members:write`, `people:appoint`, and `people:self`, which alone admits non-admins to My -area and, by the role on their record, Capo Admin or their team. See `docs/people/README.md`. +`people:members:read`, `people:members:write`, `people:appoint`, `people:fees:write` (review transfer receipts, mark fees +paid, reminders), `people:receipts:sign` (the President and Vice-president sign receipts), and `people:self`, which alone +admits non-admins to My area and, by the role on their record, Capo Admin or their team. See `docs/people/README.md` +and, for fees and receipts, `docs/people/fees.md`. Built with TanStack Start (React 19), Vite+, Nitro, Tailwind CSS v4 and shadcn/Base UI. The server is an OIDC BFF: only it holds the IdP access/refresh/ID tokens. Browsers hold an opaque, HttpOnly session cookie; sessions live in a @@ -29,23 +31,23 @@ Signing in as a real user needs a development client registered in the IdP. Set the following runtime variables (the build does not need secrets): -| Variable | Meaning | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `APP_URL` | Dashboard's public origin. Required outside development; HTTPS in production. Development defaults to `http://localhost:$PORT` (3001). Used for exact Origin CSRF validation and callback/logout URLs. | -| `BACKEND_URL` | tRPC backend origin; use the in-cluster service address in production. Defaults to `http://localhost:3000` in development. | -| `IDP_URL` | Public IdP origin; defaults to `https://auth.polinetwork.org`. Issuer is `$IDP_URL/api/auth`. Browser redirects retain this public origin. | -| `IDP_INTERNAL_URL` | Optional internal IdP origin, e.g. `http://auth-service.auth.svc.cluster.local:3000`. Rewrites discovery, token, JWKS and revocation transport only; issuer, JWT assertion audiences (public token/revoke endpoints) and browser URLs remain public. | -| `OIDC_CLIENT_ID` | Registered confidential dashboard client, with authorization code and refresh grants, scopes `openid profile email offline_access backend:admin`, and backend resource link. | -| `OIDC_CLIENT_PRIVATE_JWK` | Private signing JWK JSON with `kid` and `alg` (`EdDSA`/Ed25519 or `ES256`/P-256); inject from this service's Key Vault. Register only its public JWKS in the IdP. | -| `OIDC_CLIENT_SECRET` | Alternative `client_secret_basic` credential; set exactly one client credential mode. | -| `BACKEND_RESOURCE` | Backend OAuth resource/audience; defaults to `https://backend.internal.polinetwork.org`. Sent on every authorize, code exchange and refresh request. | -| `REDIS_URL` | Dedicated dashboard Redis connection. Required outside development; development defaults to `redis://localhost:6379`. Never point to shared backend Redis. | -| `SESSION_SECRET` | Independent random secret of at least 32 characters, encrypting the 10-minute state/nonce/PKCE cookie. Rotation invalidates pending login attempts. | -| `AGENT_PERMISSIONS` | Development only. Comma-separated permissions for the agent-mode user (default: all). `people:self` alone previews the restricted People view. | -| `AGENT_PERSON` | Development only. The agent-mode user's People record (default `P-0007`, Direttivo). `P-0098` is a Capo Admin, `P-0034` the IT Team Lead, `P-0115` an IT member; `match:P-0115` previews the first sign-in, `none` a sign-in without a record. | -| `AGENT_MODE` | Fake all-permission user in development only. Backend must be a local fake or local legacy test instance. Production ignores this mode. | -| `PORT` | Local listening port (3001 by default); use a port ≥10000 for agent previews. | -| `TEST_REDIS_URL` | Opt-in disposable Redis for integration tests. Tests create/delete only UUID session keys. | +| Variable | Meaning | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `APP_URL` | Dashboard's public origin. Required outside development; HTTPS in production. Development defaults to `http://localhost:$PORT` (3001). Used for exact Origin CSRF validation and callback/logout URLs. | +| `BACKEND_URL` | tRPC backend origin; use the in-cluster service address in production. Defaults to `http://localhost:3000` in development. | +| `IDP_URL` | Public IdP origin; defaults to `https://auth.polinetwork.org`. Issuer is `$IDP_URL/api/auth`. Browser redirects retain this public origin. | +| `IDP_INTERNAL_URL` | Optional internal IdP origin, e.g. `http://auth-service.auth.svc.cluster.local:3000`. Rewrites discovery, token, JWKS and revocation transport only; issuer, JWT assertion audiences (public token/revoke endpoints) and browser URLs remain public. | +| `OIDC_CLIENT_ID` | Registered confidential dashboard client, with authorization code and refresh grants, scopes `openid profile email offline_access backend:admin`, and backend resource link. | +| `OIDC_CLIENT_PRIVATE_JWK` | Private signing JWK JSON with `kid` and `alg` (`EdDSA`/Ed25519 or `ES256`/P-256); inject from this service's Key Vault. Register only its public JWKS in the IdP. | +| `OIDC_CLIENT_SECRET` | Alternative `client_secret_basic` credential; set exactly one client credential mode. | +| `BACKEND_RESOURCE` | Backend OAuth resource/audience; defaults to `https://backend.internal.polinetwork.org`. Sent on every authorize, code exchange and refresh request. | +| `REDIS_URL` | Dedicated dashboard Redis connection. Required outside development; development defaults to `redis://localhost:6379`. Never point to shared backend Redis. | +| `SESSION_SECRET` | Independent random secret of at least 32 characters, encrypting the 10-minute state/nonce/PKCE cookie. Rotation invalidates pending login attempts. | +| `AGENT_PERMISSIONS` | Development only. Comma-separated permissions for the agent-mode user (default: all). `people:self` alone previews the restricted People view. | +| `AGENT_PERSON` | Development only. The agent-mode user's People record (default `P-0007`, Direttivo and President). `P-0098` is a Capo Admin and the Vice-president, `P-0034` the IT Team Lead, `P-0115` an IT member, `P-0045` has a fee receipt to review, `P-0021` a rejected one; `match:P-0115` previews the first sign-in, `none` a sign-in without a record. | +| `AGENT_MODE` | Fake all-permission user in development only. Backend must be a local fake or local legacy test instance. Production ignores this mode. | +| `PORT` | Local listening port (3001 by default); use a port ≥10000 for agent previews. | +| `TEST_REDIS_URL` | Opt-in disposable Redis for integration tests. Tests create/delete only UUID session keys. | Before review run `pnpm check`, `pnpm typecheck`, `pnpm test` and `pnpm build`. Run real Redis tests with `TEST_REDIS_URL=redis://127.0.0.1: pnpm test`. The OIDC integration test uses a local fake provider with diff --git a/docs/design.md b/docs/design.md index 0bf6c33..5f13d4b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -452,6 +452,12 @@ Rules that the templates above don't imply. use `FormDialog` with `submitTone="danger"`; plain confirmations stay `ConfirmDialog`. - **Member register**: status segments scope the table; Number leads on desktop, the name on mobile. Add member from a record opens the register's dialog via `?add=P-…`. Export confirms before downloading. +- **Fees and receipts** (`docs/people/fees.md`): the register's first row action follows the fee (Review receipt, + Send reminder, or an empty slot); its header keeps one outline action (Send reminders) and moves Export to ⋮ when + both apply. People › Receipts is a queue with the pipeline as segments and the officer's to-sign count in the panel. + Review receipt is an `lg` `FormDialog` whose Reject switches it to a required reason (`submitTone="danger"`, Back on + the left). The fee status shows once per page: the record and My area say it in their fee card, not in Member or My + roles. Amounts are `formatCents` ("€10.00") with tabular numbers. - **Admins**: Groups, Capo Admin of reference, Member and Census never hide; the rest follow the table width. The four quick filters scroll sideways on phones. - **Teams**: index cards then all members; a Team Lead without `people:read` sees only their team, as the section diff --git a/docs/people/README.md b/docs/people/README.md index a318f4a..1ffcef8 100644 --- a/docs/people/README.md +++ b/docs/people/README.md @@ -7,6 +7,7 @@ Everything an agent needs to build, or continue building, the People area of the | [`brief.md`](brief.md) | The original brief, verbatim | | [`decisions.md`](decisions.md) | What was settled after the brief: owner decisions, navigation, per-page rules, roles | | [`contract.md`](contract.md) | The `people.*` backend contract the dashboard codes against | +| [`fees.md`](fees.md) | Fees and receipts: renewal flow, signing, reminders, screens and the receipt PDF | | [`plan.md`](plan.md) | Phases, checklist and progress log: **start here to know what is done and what is next** | | [`prototype/`](prototype/) | The clickable prototype's source (structure and copy reference only) | diff --git a/docs/people/contract.md b/docs/people/contract.md index 479c25a..afdf332 100644 --- a/docs/people/contract.md +++ b/docs/people/contract.md @@ -77,8 +77,20 @@ type CensusEntry = { type Account = | { status: "none" } - | { status: "invite"; email: string; sentOn: string; expiresOn: string; sentBy: string } - | { status: "linked"; email: string; via: string; linkedOn: string; how: string } + | { + status: "invite" + email: string + sentOn: string + expiresOn: string + sentBy: string + } + | { + status: "linked" + email: string + via: string + linkedOn: string + how: string + } | { status: "review"; reviewId: string; issue: string } type PersonSummary = { @@ -186,7 +198,12 @@ type Review = { id: string kind: "telegram-conflict" | "ask-to-be-added" | "two-records" | "not-me" | "name-only" since: string - account: { name: string; email: string; telegram: string | null; via: string } + account: { + name: string + email: string + telegram: string | null + via: string + } record: { id: PersonId; name: string } | null other: { id: PersonId; name: string } | null message: string | null @@ -237,21 +254,126 @@ type MyArea = }[] roster: { team: TeamId - members: { name: string; role: TeamRole; telegram: string | null; email: string | null }[] + members: { + name: string + role: TeamRole + telegram: string | null + email: string | null + }[] } | null } | { state: "match" - candidate: { id: PersonId; name: string; course: string; year: string; campus: Campus; roles: string[] } + candidate: { + id: PersonId + name: string + course: string + year: string + campus: Campus + roles: string[] + } reason: string } | { state: "none"; request: { sentOn: string } | null } ``` +## Fees and receipts + +The flow, screens and PDF template are in [`fees.md`](fees.md). `PersonRecord` gains `fees` (`null` for people who +were never members, or without `people:members:read` except on the caller's own record), and the linked `MyArea` gains +`feeInstructions` (bank details while the caller is a current member). Amounts are integer cents; files travel as +`{ fileName, contentType, base64 }`. + +```ts +type FeeDetail = { + year: string // "2026/27" + status: FeeStatus // the same value as member.fee + amountCents: number + dueOn: string + upload: { + fileName: string + size: number + uploadedAt: string + transferDate: string + amountCents: number + } | null + rejection: { reason: string; by: string; at: string } | null // until the member uploads again + payment: { + paidOn: string + method: "transfer" | "cash" + amountCents: number + transferDate: string | null + } | null + lastReminder: string | null +} + +type Receipt = { + id: string + number: string // "2026/042", never reused + year: string // fee year + person: { id: PersonId; name: string } + memberNumber: string + amountCents: number + issuedOn: string + paidOn: string + method: "transfer" | "cash" + transferDate: string | null + status: "toSign" | "signed" | "sent" | "failed" | "voided" + signer: "president" | "vicePresident" // the Vice-president signs the President's own + signedBy: string | null + signedOn: string | null + sentAt: string | null + sentTo: string | null + failure: string | null + voided: { reason: string; by: string; on: string } | null +} + +type Fees = { current: FeeDetail | null; receipts: Receipt[] } // newest first, voided included +``` + +| Procedure | Kind | Input | Output | Needs | +| --------------------------------- | -------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------- | +| `people.fees.review` | query | `{ personId }` | `{ person, memberNumber, m365Email, fee: FeeDetail, rejections, nextReceipt }` | `people:fees:write` | +| `people.fees.upload` | query | `{ personId }` | file: the uploaded transfer receipt | `people:fees:write` | +| `people.fees.approve` | mutation | `{ personId }` | `{ receipt }` (the number issued) | `people:fees:write` | +| `people.fees.reject` | mutation | `{ personId, reason }` | `void` (fee back to Due, or Unpaid after the deadline) | `people:fees:write` | +| `people.fees.markPaid` | mutation | `{ personId, paidOn, amountCents, method, note }` | `{ receipt }` | `people:fees:write` | +| `people.fees.remind` | mutation | `{ personId: PersonId \| null }` | `{ sent, skipped }` (all: Due and Unpaid, skips reminded < 3 days) | `people:fees:write` | +| `people.receipts.list` | query | `{ year: string \| null }` (null: current fee year) | `{ year, years, viewerSigner, officers: { president, vicePresident }, receipts }` | `people:members:read` | +| `people.receipts.download` | query | `{ receiptId }` | file: the signed copy once there is one, else the generated PDF | `people:members:read` | +| `people.receipts.toSign` | mutation | — | file: a ZIP of the receipts waiting for the caller's signature | `people:receipts:sign` | +| `people.receipts.previewSigned` | mutation | `FormData` (`files`: up to 100 PDF or .p7m, 10 MB each) | `{ uploadId, files: { fileId, fileName, size, receiptId, signature: { valid, signer } }[] }` | `people:receipts:sign` | +| `people.receipts.sendSigned` | mutation | `{ uploadId, assignments: Record }` | `{ sent, failed }` | `people:receipts:sign` | +| `people.receipts.retry` | mutation | `{ receiptId }` | `void` | `people:fees:write` | +| `people.receipts.void` | mutation | `{ receiptId, reason }` | `void` | `people:fees:write` | +| `people.me.uploadTransferReceipt` | mutation | `FormData` (`file` PDF/JPG/PNG ≤ 10 MB, `transferDate`, `amountCents`) | `void` (fee → Receipt uploaded) | `people:self` | +| `people.me.receipt` | query | `{ receiptId }` | file: one of the caller's own signed receipts | `people:self` | + +Backend rules for fees and receipts: + +- **Officers**: the association's President and Vice-president are configuration on the backend (or a record field). + `viewerSigner` is the caller's office, and only when they also hold `people:receipts:sign`. Every receipt's `signer` + is `president`, except the President's own (`vicePresident`). `toSign`, `previewSigned` and `sendSigned` act only on + receipts whose `signer` is the caller's office. +- **Numbering**: per fee year, `YYYY/NNN` from the year's first calendar year; issued atomically, never reused, voided + ones included. +- **Signatures**: verify each file for real (PAdES in the PDF or a CAdES `.p7m`), that the certificate is the officer's, + and that the signed document is the receipt it is matched to. Refuse the whole send when any assignment points at an + unsigned file, a receipt that isn't `toSign`, another officer's receipt, or the same receipt twice. +- **Email**: send the signed receipt to the member's Microsoft 365 address; on failure keep the receipt `failed` with + the mail server's reason until Retry. +- **Reminders**: automatic ones go out 14 and 3 days before the deadline to members whose fee is Due; manual ones + follow `people.fees.remind`. Each sets `lastReminder` and writes an activity line. +- **Uploads**: check the content, not the extension (PDF, JPEG or PNG; PDF or CMS for signed files), and the size. +- **Activity**: upload, approval, rejection (with the reason), Mark as paid (with the note), issue, signature, email, + failure, retry, void and reminders each add a line to the person's activity. +- **Members** download only their own receipts, and only once signed (`signed`, `sent` or `failed`). + ## Permissions New IdP permissions (add to `src/server/permissions.ts`): `people:self`, `people:read`, `people:write`, -`people:members:read`, `people:members:write`, `people:appoint`. Capo Admin and Team Lead are not permissions: they +`people:members:read`, `people:members:write`, `people:appoint`, and for fees `people:fees:write` and +`people:receipts:sign`. Capo Admin and Team Lead are not permissions: they come from the signed-in person's record (`people.me.get`), and the backend scopes `people.capo.*` and team writes to them. diff --git a/docs/people/decisions.md b/docs/people/decisions.md index 97763b6..61b7b6e 100644 --- a/docs/people/decisions.md +++ b/docs/people/decisions.md @@ -43,8 +43,8 @@ Known prototype defects to not reproduce: ## Navigation -- **People** is a rail service (`UsersRound`), placed first after Search. Sections: Overview, Member register, Admins, - Teams, Census, Linking. Deep pages: person (`/dashboard/people/$personId`), team (`/dashboard/people/teams/$teamId`), +- **People** is a rail service (`UsersRound`), placed first after Search. Sections: Overview, Member register, + Receipts, Admins, Teams, Census, Linking. Deep pages: person (`/dashboard/people/$personId`), team (`/dashboard/people/teams/$teamId`), campaign (`/dashboard/people/census/$campaignId`). - **Capo Admin** is a Telegram section (`/dashboard/telegram/capo`, icon `Crown`), visible only to Capo Admins. A Capo Admin without other rights sees Telegram with that one section, so no panel (one-section rule). @@ -52,7 +52,8 @@ Known prototype defects to not reproduce: and Sign-in & security (today's `/dashboard/account`, unchanged content). - A **Team Lead** without people rights gets People with only Teams, which opens their team directly. - People with only `people:self`: the logo links to My area instead of Overview. -- Linking shows its needs-review count in the panel, like Reports › Open, because it is a queue. +- Linking shows its needs-review count in the panel, like Reports › Open, because it is a queue. Receipts does the + same with the receipts waiting for the signed-in officer. ## Data and display @@ -109,6 +110,11 @@ Known prototype defects to not reproduce: this you?" card with the matched record and the match reason. No record: empty state with Ask to be added, then a "Waiting for HR" state. +- **Fees and receipts**: see [`fees.md`](fees.md). The register's Fee column keeps its badge and gains the fee's own + row action; Send reminders becomes the register's outline action and Export moves to the header ⋮ when the viewer + has both. People › Receipts is the signing queue. The record gets a Fees and receipts card, My area a Membership fee + card. Receipts are never deleted, only voided. + ## Roles to permissions | Viewer | Permissions (IdP) | Sees | @@ -120,5 +126,8 @@ Known prototype defects to not reproduce: | Team member | `people:self` | My area with their team roster | | Admin only | `people:self` | My area | +Fees add `people:fees:write` (the Direttivo, or whoever handles payments) and `people:receipts:sign` (the President +and the Vice-president, who sign only as the officer on their own record). + Contacts (phone, personal email) need `people:read`, or Capo Admin scope / Team Lead role over that person. HR notes and full census answers need `people:read`. The backend enforces all of this again; the dashboard only hides. diff --git a/docs/people/fees.md b/docs/people/fees.md new file mode 100644 index 0000000..8d3432e --- /dev/null +++ b/docs/people/fees.md @@ -0,0 +1,95 @@ +# People area: fees and receipts + +How a member renews and gets a receipt. Shapes and procedures are in [`contract.md`](contract.md) ("Fees and +receipts"); `src/features/people/people.contract.ts` stays the source of truth. The design canvas (desktop and mobile, +light and dark, every state) is , private to the owner; like the +People prototype it fixes structure and copy, while spacing comes from the primitives. + +## The flow + +1. **The member pays** by bank transfer, outside the dashboard, with the reference My area gives them + (`Quota 2026/27 0344 Name Surname`), then uploads the bank's transfer receipt in **Account › My area** with the + transfer date and amount. The fee turns **Receipt uploaded**. +2. **An admin reviews** it (`people:fees:write`) from **People › Member register** or the person's record: **Approve** + or **Reject** with a reason the member reads in My area and in the email. Without an upload, **Mark as paid** records + a payment by hand (date, amount, bank transfer or cash, optional note). Every change shows at once. +3. **The system issues the receipt** on approval or Mark as paid: a PDF filled in from the record, numbered in issue + order per fee year (`2026/042`: the fee year's first calendar year and a sequence never reused, voided ones + included). It is **To sign**. +4. **The President signs** (`people:receipts:sign`): **People › Receipts › Download to sign** gives a ZIP of the + receipts waiting for them; they sign each PDF outside the dashboard with the CIE app and come back to **Upload + signed**. The backend matches each file by the receipt number in its name and checks the signature; the President + corrects any match by hand and sends. Each receipt becomes **Signed**, then **Sent** once emailed to the member's + Microsoft 365 address, or **Send failed** with the mail server's reason (Retry sending on the row). +5. **The President's own receipt** is signed by the **Vice-president**: it waits in their queue, and the President's + upload marks it **Needs another signer**. +6. Receipts are **never deleted**. **Void receipt** keeps the number and the file with a reason; voiding this year's + only receipt reopens the fee (Due, or Unpaid after the deadline) so a correct one can be issued. + +Fee states (from the People contract, unchanged): **Due** (warning) before the deadline, **Receipt uploaded** (brand), +**Paid** (success), **Unpaid** (danger) after the deadline. Receipt states: **To sign** (warning), **Signed** (brand), +**Sent** (success), **Send failed** (danger), **Voided** (neutral). + +## Reminders + +- **Automatic**, by the backend: an email 14 and 3 days before the deadline (31 Oct for 2026/27) to every current + member whose fee is Due. The dashboard shows when the last one went out. +- **By hand**: Send reminder on a register row or record (no confirmation: it only sends an email), or the register's + page action **Send reminders**, which confirms first and skips members reminded in the last 3 days. +- Every reminder, upload, review, payment, signature, email and void is a line in the person's Activity. + +## Permissions + +| Permission | Gives | +| ---------------------- | ------------------------------------------------------------------------------------------------------ | +| `people:members:read` | People › Receipts (read-only), the record's Fees and receipts card, receipt downloads | +| `people:fees:write` | Review receipt, Mark as paid, Send reminder(s), Retry sending, Void receipt | +| `people:receipts:sign` | Download to sign, Upload signed, for the officer on the signed-in record (President or Vice-president) | +| `people:self` | My area's fee card: pay, upload the transfer receipt, download one's own signed receipts | + +These names are provisional, in the style of the People permissions. Holding `people:receipts:sign` is not enough: the +backend signs only for the person who holds the office (`viewerSigner`), so the queue and the ZIP follow the record, +not the permission. + +## Screens + +- **Member register**: the Fee column keeps its badge and filter; the row's first action follows the fee (Review + receipt for Receipt uploaded, Send reminder for Due and Unpaid, an empty slot otherwise, so icons align); ⋮ adds Mark + as paid. The page's outline action is Send reminders, and Export moves into the header's ⋮ when the viewer has both + (one primary and at most one outline per header). +- **Review receipt** (lg dialog, bottom sheet on phones): the uploaded file (PDF framed, image shown, Open in a new + tab), the member and fee year, the declared transfer, earlier rejections, and the receipt number approving would + issue. Reject switches the dialog to a required reason with three one-click starts; Back returns. +- **People › Receipts** (new section, `Receipt` icon, after Member register): fee-year menu, search, a segmented filter + over the pipeline with totals, and a table (Receipt, Member, Amount, Issued on, Signer, Status). Signers see "n + receipts are waiting for your signature" and the two header actions; the section shows their count in the panel, like + Reports › Open. A danger alert leads to Send failed. +- **Upload signed receipts** (lg dialog): one row per file with its receipt (a select to correct the match) and the + outcome: Signed, Not signed, Needs another signer, No receipt, Duplicate or Already signed. "Send n receipts" sends + only the Signed ones. +- **Person record**: a Fees and receipts card under Member (the fee status lives there only): the current year, the + upload or payment, the last rejection and reminder, then every receipt with its PDF. +- **My area**: a Membership fee card under the census: bank details with copy buttons and Upload transfer receipt + while Due or Unpaid, the rejection reason when there is one, the review state while uploaded, the receipt being + signed once paid, and the signed receipts to download. + +## Receipt PDF + +A4, Italian, light only. In agent mode `receipt-files.server.ts` renders it; the backend owns the real one. + +- Header: PoliNetwork, legal form, registered address and tax code (placeholders until the backend has them); + "Ricevuta n. 2026/042" and "Milano, 15 ottobre 2026" on the right. +- Title "Ricevuta di pagamento della quota associativa". +- Ricevuto da: name, tax code (placeholder), member number. +- La somma di: the amount in figures (`€ 10,00`) and in words (`dieci/00 euro`). +- A titolo di: "Quota associativa per l'anno sociale 2026/27", the method (Bonifico bancario / Contanti) and its date. +- A placeholder for the tax note, if one applies. +- Signature space: "Il Presidente" (or "Il Vicepresidente" on the President's own receipt) over a line and the name. +- Footer: association details and "Ricevuta 2026/042 · pagina 1 di 1". + +## Agent mode + +The sample data adds fee years and receipts from their own seed: a sent 2025/26 receipt for older members, this year's +receipts in payment order with the newest to sign, one Send failed and one Voided. `AGENT_PERSON=P-0007` (default) is +the President, `P-0098` the Vice-president, `P-0045` has a receipt to review and `P-0021` a rejected one. A file counts +as signed when it is a `.p7m`, holds a PDF signature dictionary (`/ByteRange`) or has "signed"/"firmata" in its name. diff --git a/docs/people/plan.md b/docs/people/plan.md index 8290121..12f8f2f 100644 --- a/docs/people/plan.md +++ b/docs/people/plan.md @@ -117,6 +117,17 @@ src/routes/dashboard/account/ me.tsx (My area), index.tsx (today's Account page architecture lines - [x] PR description final, all checks green +### 11. Fees and receipts ([`fees.md`](fees.md)) + +- [x] Contract: `FeeDetail`, `Receipt`, `fees` on the record, `feeInstructions` in My area, the `people.fees.*`, + `people.receipts.*` and two `people.me.*` procedures; permissions `people:fees:write`, `people:receipts:sign` +- [x] Agent mode: seeded fee years and receipts, review, Mark as paid, reminders, signing, email failure, void; the + receipt PDF and the ZIP to sign (`receipt-files.server.ts`) +- [x] Member register: fee row actions, Send reminders, Export in ⋮; Review receipt and Mark as paid dialogs +- [x] People › Receipts with its panel count; Upload signed receipts; Void receipt; Retry sending +- [x] Person record Fees and receipts card; My area Membership fee card and Upload transfer receipt +- [ ] Backend: implement the procedures, the officers, automatic reminders, signature verification and email + ## Progress log Newest last. One line per commit or session: date, what was done, what is next. @@ -169,3 +180,8 @@ Newest last. One line per commit or session: date, what was done, what is next. every People route redirects to sign-in. Still open: empty states are written for every list but were never seen with empty data (the fake has no empty mode), loading skeletons were not looked at, and the signed-in flow without agent mode needs a development IdP client. +- 2026-10-16: phase 11 (fees and receipts) on its own branch over this PR: contract, agent-mode data and files, Member + register actions, People › Receipts, Upload signed receipts, record and My area cards, `docs/people/fees.md`. + Verified in agent mode at 1440 and 390, light and dark, as the President (`P-0007`) and a rejected member + (`P-0021`): approve, reject, Mark as paid, reminders, download to sign, upload and send, retry, void, member upload. + Next: the backend side. diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index 596d25b..746dbe6 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -15,6 +15,7 @@ import { LayoutList, Link2, LayoutDashboard, + Receipt, type LucideIcon, MessageCircle, MessagesSquare, @@ -88,6 +89,7 @@ export type PageMatch = | { kind: "people-overview" } | { kind: "people-person"; personId: string } | { kind: "people-members" } + | { kind: "people-receipts" } | { kind: "people-admins" } | { kind: "people-teams" } | { kind: "people-team"; teamId: string } @@ -166,6 +168,14 @@ const people: Service = { permission: "people:members:read", searchPlaceholder: "Search by name, number or email…", }, + { + id: "receipts", + title: "Receipts", + icon: Receipt, + path: "/dashboard/people/receipts", + permission: "people:members:read", + searchPlaceholder: "Search by name or receipt number…", + }, { id: "admins", title: "Admins", @@ -423,6 +433,7 @@ export function matchPath(pathname: string): PageMatch { if (second === undefined) return { kind: "people-overview" } if (third === undefined) { if (second === "members") return { kind: "people-members" } + if (second === "receipts") return { kind: "people-receipts" } if (second === "admins") return { kind: "people-admins" } if (second === "teams") return { kind: "people-teams" } if (second === "census") return { kind: "people-census" } @@ -481,6 +492,7 @@ export function serviceFor(match: PageMatch): Service { case "people-overview": case "people-person": case "people-members": + case "people-receipts": case "people-admins": case "people-teams": case "people-team": @@ -529,6 +541,8 @@ export function sectionFor(match: PageMatch): Section | null { return section(people, "overview") case "people-members": return section(people, "members") + case "people-receipts": + return section(people, "receipts") case "people-admins": return section(people, "admins") case "people-teams": diff --git a/src/components/shell/panel.tsx b/src/components/shell/panel.tsx index e380927..d5d5a25 100644 --- a/src/components/shell/panel.tsx +++ b/src/components/shell/panel.tsx @@ -52,6 +52,8 @@ export type SectionItemsProps = { pendingReports: Promise /** People › Linking's Needs review queue, streamed like `pendingReports`. */ needsReview: Promise + /** People › Receipts: the receipts waiting for the signed-in officer's signature, streamed the same way. */ + toSign: Promise /** `panel` for the desktop list, `pills` for the horizontally scrolling row under the mobile top bar. */ variant?: "panel" | "pills" } @@ -62,7 +64,14 @@ const pillActiveClass = "border-[color-mix(in_oklab,var(--pn-accent)_45%,transparent)] bg-(--pn-accent-soft) text-(--pn-accent) hover:text-(--pn-accent)" /** The section links of one service; shared by the panel and the mobile section pills. */ -export function SectionItems({ service, match, pendingReports, needsReview, variant = "panel" }: SectionItemsProps) { +export function SectionItems({ + service, + match, + pendingReports, + needsReview, + toSign, + variant = "panel", +}: SectionItemsProps) { const current = sectionFor(match) const deep = isDeepPage(match) const pills = variant === "pills" @@ -97,6 +106,11 @@ export function SectionItems({ service, match, pendingReports, needsReview, vari ) : null} + {service.id === "people" && section.id === "receipts" ? ( + + + + ) : null} {service.id === "people" && section.id === "linking" ? ( @@ -112,11 +126,12 @@ export type PanelProps = { match: PageMatch pendingReports: Promise needsReview: Promise + toSign: Promise className?: string } /** The 240px section panel (200px below 1100px). States the service name once, in its header. */ -export function Panel({ service, match, pendingReports, needsReview, className }: PanelProps) { +export function Panel({ service, match, pendingReports, needsReview, toSign, className }: PanelProps) { return ( ) } /** Below 760px: the service's sections as a horizontally scrolling row of pills under the top bar. */ -export function SectionPills({ service, match, pendingReports, needsReview, className }: PanelProps) { +export function SectionPills({ service, match, pendingReports, needsReview, toSign, className }: PanelProps) { const ref = useRef(null) const currentPath = sectionFor(match)?.path useLayoutEffect(() => { @@ -164,6 +185,7 @@ export function SectionPills({ service, match, pendingReports, needsReview, clas match={match} pendingReports={pendingReports} needsReview={needsReview} + toSign={toSign} variant="pills" /> diff --git a/src/components/shell/shell.tsx b/src/components/shell/shell.tsx index 227710a..4cb3234 100644 --- a/src/components/shell/shell.tsx +++ b/src/components/shell/shell.tsx @@ -87,6 +87,8 @@ export type DashboardShellProps = { pendingReports: Promise /** People › Linking's Needs review count; streams in, null when it couldn't be loaded or isn't readable. */ needsReview: Promise + /** People › Receipts' count of receipts waiting for the signed-in officer; null for everyone else. */ + toSign: Promise } /** @@ -94,15 +96,15 @@ export type DashboardShellProps = { * service's section pills, then the page), then the bottom nav. Pages render their header through `PageBar` and * their body inside `PageContent`; both scroll with the page. */ -export function DashboardShell({ user, pendingReports, needsReview }: DashboardShellProps) { +export function DashboardShell({ user, pendingReports, needsReview, toSign }: DashboardShellProps) { return ( - + ) } -function DashboardFrame({ user: dashboardUser, pendingReports, needsReview }: DashboardShellProps) { +function DashboardFrame({ user: dashboardUser, pendingReports, needsReview, toSign }: DashboardShellProps) { const pathname = useRenderedPathname() const match = matchPath(pathname) const rawService = serviceFor(match) @@ -178,7 +180,13 @@ function DashboardFrame({ user: dashboardUser, pendingReports, needsReview }: Da exit={{ transform: "translateX(-100%)", transition: reduceMotion ? { duration: 0 } : panelExit }} className="flex h-full shrink-0 min-[1440px]:absolute min-[1440px]:inset-y-0 min-[1440px]:left-0" > - + ) : null} @@ -199,6 +207,7 @@ function DashboardFrame({ user: dashboardUser, pendingReports, needsReview }: Da match={match} pendingReports={pendingReports} needsReview={needsReview} + toSign={toSign} className="shell:hidden" /> ) : null} diff --git a/src/features/people/fee-dialogs.tsx b/src/features/people/fee-dialogs.tsx new file mode 100644 index 0000000..8121b4c --- /dev/null +++ b/src/features/people/fee-dialogs.tsx @@ -0,0 +1,573 @@ +import { useServerFn } from "@tanstack/react-start" +import { Bell, ExternalLink, FileText } from "lucide-react" +import { type ReactNode, useEffect, useState } from "react" + +import { + buttonMotion, + ConfirmDialog, + fieldControl, + fieldHintId, + FormDialog, + FormField, + InlineAlert, + KeyValueList, + SegmentedControl, + Spinner, +} from "@/components/primitives" +import { appToast } from "@/components/shell" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { formatDateTime, pluralize } from "@/lib/format" +import { cn } from "@/lib/utils" + +import { DateField } from "./date-field" +import { centsToInput, formatCents, parseEuros } from "./people-fees" +import { + approveFee, + getFeeReview, + getFeeUpload, + markFeePaid, + rejectFee, + sendFeeReminders, +} from "./people-fees.functions" +import { fileBlob } from "./people-files" +import { FEE_STATUS, formatDay, PAYMENT_METHOD, todayIso } from "./people-format" +import { errorMessage, useRefreshAfter } from "./people-hooks" +import { MonoChip, StateBadge } from "./people-ui" +import type { FeeReview, FilePayload, PaymentMethod } from "./people.contract" + +/** Reasons offered as one-click starts for Reject; the admin can edit the text. */ +const COMMON_REASONS = [ + { + label: "Wrong amount", + text: "The amount on the receipt isn't the fee. Transfer the rest and upload both receipts.", + }, + { + label: "Not a transfer receipt", + text: "The file is a screenshot of the payment page, not the transfer receipt. Upload the receipt your bank issued.", + }, + { label: "Unreadable file", text: "The file can't be read. Upload a clearer PDF or photo of the transfer receipt." }, +] + +export type FeeTarget = { id: string; name: string; number: string } + +/** Loads the member's current fee year once per opening. */ +function useFeeReview(personId: string) { + const load = useServerFn(getFeeReview) + const [review, setReview] = useState(null) + const [failed, setFailed] = useState(false) + useEffect(() => { + let current = true + load({ data: { personId } }).then( + (result) => { + if (current) setReview(result) + }, + (caught) => { + console.error(caught) + if (current) setFailed(true) + } + ) + return () => { + current = false + } + }, [load, personId]) + return { review, failed } +} + +function Loading({ children }: { children: ReactNode }) { + return ( +

+ + {children} +

+ ) +} + +/** The uploaded transfer receipt: a PDF in a frame, an image as itself, with a link to open it in a new tab. */ +function UploadPreview({ file, size }: { file: FilePayload | null; size: number }) { + const [url, setUrl] = useState(null) + useEffect(() => { + if (!file) return + const next = URL.createObjectURL(fileBlob(file)) + setUrl(next) + return () => URL.revokeObjectURL(next) + }, [file]) + const image = file?.contentType.startsWith("image/") ?? false + return ( +
+
+ + {file?.fileName ?? "Loading…"} + {Math.max(1, Math.round(size / 1024))} KB + {url ? ( + + ) : null} +
+ {url && image ? ( + The uploaded transfer receipt + ) : url ? ( +