08 — Form Specifications (Fees Module)
- 1. Create Fee Structure Form —
POST /api/v1/fees/structures - 2. Edit Fee Structure Form —
PATCH /api/v1/fees/structures/:id - 3. Generate Invoice Form (dialog) —
POST /api/v1/fees/invoices/generate - 4. Record Payment Form (sheet) —
POST /api/v1/fees/invoices/:id/payments - 5. Payments (v2) forms —
POST /payments,POST /payments/refund - Form-level rules (all)
- Client-side error priority
Every form field-by-field. Validation mirrors class-validator decorators exactly from
src/modules/fees/dto/*.ts; errors arrive as 400VALIDATION_ERRORwitherror.details[].message(http-exception.filter.ts:27-35, 103-108). Client validates inline, submits, and lets server 400 shadow the client.
1. Create Fee Structure Form — POST /api/v1/fees/structures
(create-fee-structure.dto.ts:26-68)
| # | Field | Label | Keyboard | Validation (server) | Client UX |
|---|---|---|---|---|---|
| 1 | name | Name | text | @IsString() required (:28-29) | required; autofocus; TextInputAction.next |
| 2 | classId | Class | — | @IsMongoId() required (:32-33) | AppDropdown (Academics classes); immutable after create |
| 3 | academicYearId | Academic year | — | @IsMongoId() required | AppDropdown; immutable after create |
| 4 | items[] | Line items | required | @IsArray() + @ValidateNested FeeItemDto (:39-43) | LineItemsEditor — dynamic list of {name, amount} |
| 4a | items[].name | Item name | yes | @IsString() (fee-item dto :16-18) | text; required |
| 4b | items[].amount | Item amount | yes | @IsNumber() @Min(0) (:20-23) | MoneyField; ≥ 0; currency context |
| 5 | totalAmount | Total | required | @IsNumber() @Min(0) (create-fee-structure.dto.ts:45-48) | auto-sum hint Σ items; editable; flag mismatch (server won't) |
| 6 | currency | Currency | no | @IsOptional() @IsString(); server default XAF (:50-53, fee-structure.schema.ts:27-28) | AppDropdown default XAF (USD etc. future (proposed)) |
| 7 | dueDate | Due date | required | @IsDateString() (:55-57), server converts to Date (fees.service.ts:46-47) | AppDatePicker; client min = today |
| 8 | lateFee | Late fee | no | @IsOptional() @IsNumber() @Min(0); default 0 | MoneyField optional; unused by backend today (OQ-7) |
| 9 | isActive | Active | no | @IsOptional() @IsBoolean(); default true | AppSwitch default on |
- Server sends
dueDateas ISO string; storeDatein schema (fee-structure.schema.ts:30-31,fees.service.ts:88). - Submit →
POST→ 201 doc → list + snackbar. Errors: 400 → field errors; 5xx → keep form.
2. Edit Fee Structure Form — PATCH /api/v1/fees/structures/:id
(update-fee-structure.dto.ts:15-54)
| # | Field | Required | Server validation | Client UX |
|---|---|---|---|---|
| 1 | name | no | @IsOptional() @IsString() | prefilled |
| 2 | items[] | no | @IsOptional() + nested | prefilled; full replace on PATCH (fees.service.ts:86-89 — whole array swaps) |
| 3 | totalAmount | no | @IsOptional() @IsNumber() @Min(0) | prefilled |
| 4 | currency | no | @IsOptional() @IsString() | prefilled |
| 5 | dueDate | no | @IsOptional() @IsDateString() | picker; past-due warned (server allows) |
| 6 | lateFee | no | @IsOptional() @IsNumber() @Min(0) | prefilled |
| 7 | isActive | no | @IsOptional() @IsBoolean() | prefilled switch |
- Immutable:
classId,academicYearIdare not in the DTO — disabled in edit form with note "Class and academic year cannot be changed after creation". - Submit →
PATCH→ 200 doc; 404 "Fee structure not found." (fees.service.ts:91).
3. Generate Invoice Form (dialog) — POST /api/v1/fees/invoices/generate
(generate-invoice.dto.ts:24-42)
| # | Field | Label | Required | Validation (server) | Client UX |
|---|---|---|---|---|---|
| 1 | studentId | Student | required | @IsMongoId() (:28-29) | AppDropdown (Students module); searchable |
| 2 | feeStructureId | Fee structure | required | @IsMongoId() (:32-33) | hidden — bound to the structure being viewed |
| 3 | academicYearId | Academic year | required | @IsMongoId() (:36-37) | from active academic year selector |
| 4 | discounts[] | Discounts | no | @IsOptional() @IsArray() + nested DiscountDto (:40-42) | DiscountEditor (name + amount ≥ 0) |
| 4a | discounts[].name | name | yes (per item) | @IsString() (:16-18) | text |
| 4b | discounts[].amount | amount | yes (per item) | @IsNumber() @Min(0) (:20-22) | MoneyField ≥ 0 |
- Side effects (server):
totalAmount = max(0, structure.totalAmount − Σ disc)(fees.service.ts:117-119); statusissued,issuedAt: now(fees.service.ts:122-130);InvoiceIssued→emails/send-invoice(event-queue-map.ts:41). - Errors: 404 "Fee structure not found." (
fees.service.ts:105); 409DUPLICATE_RESOURCE"Invoice already exists for this student and term." (fees.service.ts:112-114) → inline info banner, do not close dialog. - Preview: client shows
totalAmountafter discounts before submitting.
4. Record Payment Form (sheet) — POST /api/v1/fees/invoices/:id/payments
(record-payment.dto.ts:11-44)
| # | Field | Label | Required | Validation (server) | Client UX |
|---|---|---|---|---|---|
| 1 | invoiceId | Invoice | required | @IsMongoId() (:14-15) | hidden from route :id — never editable |
| 2 | amount | Amount | required | @IsNumber() @Min(0) (:17-19) | MoneyField, prefilled = due; overpay warn allowed (OQ-2); shows currency |
| 3 | paymentMethod | Method | required | @IsString() + enum list (:22-25) — not @IsEnum, invalid string is 400 validation | PaymentMethodSelector; values cash, bank_transfer, mobile_money, cheque, other (payment.schema.ts:7-13) |
| 4 | reference | Reference | no | @IsOptional() @IsString() | optional; placeholder "Bank ref / MoMo tx id" |
| 5 | idempotencyKey | idempotency key | required | @IsString() (:33-34); unique index (payment.schema.ts:40-41) | hidden, uuid(); field reused on retry (critical: same key = replay, no double-post) — fees.service.ts:148-152` |
| 6 | paidAt | Paid on | required | @IsDateString() (:36-38), server → Date (fees.service.ts:164) | AppDatePicker default today; no future-date cap server-side (2026) |
| 7 | notes | Notes | no | @IsOptional() @IsString() | optional, maxLength 500 (client) |
- Success: 201 payment doc; server sets
status: completedalways (fees.service.ts:162-166) and recomputes invoicepaidAmount+ status (fees.service.ts:168-176). - 409: "Invoice is already paid or cancelled." (
fees.service.ts:159) → close + info banner. - 409 replay: same key → returns original payment: treat as success.
- Offline: blocked (write-op; no offline queue for fees).
5. Payments (v2) forms — POST /payments, POST /payments/refund
(process-payment.dto.ts:5-54, refund-payment.dto.ts:4-19)
Process (online):
| Field | Required | Server validation | Client |
|---|---|---|---|
amount | yes | @IsNumber() @Min(0) | MoneyField |
currency | no | @IsOptional() @IsString(); default USD (:13-15) | locked to tenant/display |
gateway | yes | @IsEnum(PaymentGateway) — stripe,paypal,flutterwave,paystack,razorpay,cash,bank_transfer,cheque (payments/schemas/payment.schema.ts:7-14) | embed/gateway SDK (forward-looking) |
gatewayTransactionId | no | optional string | gateway response |
invoiceId | no | optional string | from invoice detail |
invoiceType | no | optional string | — |
payerId/Email/Name | no | optional strings | payer identity |
description | no | optional string | line item text |
Refund:
| Field | Required | Validation | Client |
|---|---|---|---|
paymentId | yes | @IsString() | hidden |
amount | no | @IsOptional() @IsNumber() @Min(0); server caps refundedAmount + amount ≤ amount (payments.service.ts:81-86) | default = remaining |
reason | no | optional string | text |
- Refund rules: only
completedpayments refundable (payments.service.ts:77-79); full →refunded, partial →partially_refunded(payments.service.ts:87-90).
Form-level rules (all)
- Double-submit: disabled while pending; idempotency key guards rerun.
- Optimistic: no optimistic writes — payment/invoice/structure writes are
server-confirmed (
00-shared/07 §9); only discount/line-item local editing and filters are local. - Undo: line-item removal inside a form = UNDO snackbar (4 s); structure delete = confirm dialog, no undo (soft-delete, no restore endpoint).
- Abandonment: create/edit structure + record-payment keep drafts locally
(proposed)(shared_preferences); back from dirty structure form → "Discard changes?" dialog; record-payment sheet back → "Payment not recorded — discard?" (key NOT reused after explicit discard). - Keyboard: number pad for amounts;
.nextsequence;Ctrl/Cmd+Entersubmits (desktop). - Error copy: business 4xx from envelope
message; codes for the rest (00-shared/07 §11). Never render raw server internals.
Client-side error priority
- 400
VALIDATION_ERROR→ field errors (mapdetails[].messageby field hint,http-exception.filter.ts:103-108). - 404
RESOURCE_NOT_FOUND→ empty/back state. - 409
DUPLICATE_RESOURCE→ info banner (invoice exists / already paid / replay). - 422
BUSINESS_RULE_VIOLATION→ banner (currently unused by fees; reserved). - 429 → countdown; 5xx → keep form + retry.