04 — Client & Order Lifecycle
Domain Overview
The Client & Order Lifecycle domain covers the end-to-end journey from client onboarding through order fulfillment. In the Almafrica platform, clients are business buyers (not farmers) who purchase agricultural produce. The lifecycle begins when a field agent registers a new client via the mobile app's multi-step form, continues through crop demand capture and admin approval, and culminates in order creation, fulfillment, and delivery.
This domain is deeply offline-first: client registration, crop demand capture, and order creation all save to local SQLite first, then sync to the backend via the queue-based sync engine. Documents use chunked uploads for reliability on poor networks.
Key Actors
| Actor | Actions |
|---|---|
| Field Agent | Creates clients, captures crop demands, manages client documents |
| Admin/Manager | Approves/rejects clients, creates and processes orders |
| CSA (Customer Service Agent) | Views orders, manages delivery logistics |
| Client | Views their own orders (restricted access via JWT ClientId claim) |
Domain Summary Diagram
Workflows
WF-CLI-01: Client Onboarding (Multi-Step)
Trigger: Agent taps "Add Client" button on AgentHomeScreen
Frequency: On-demand (per client registration)
Offline Support: Yes -- full offline creation with draft persistence and queue-based sync
Workflow Diagram
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | AddClientScreenMultiStep | Agent initiates client registration | N/A |
| 2 | IF | ClientFormDraftService.hasDraft() | Check for existing draft in SharedPreferences | Returns false on error |
| 3 | Function | ClientFormDraftService.loadDraft() | Restore form data, profile photo, documents from draft | Falls back to empty form |
| 4 | Form | ClientInfoStep | Collects: company name, contact person/role, phone, email, province/territory/village, address, GPS coords, years in operation, preferred contact method, business type, registration number, collection point, payment method, bank details, currency, profile photo | Field-level validation, GPS auto-capture via LocationService |
| 5 | Validation | ClientFormData.isStep1Valid() | Requires: company name, contact name, role, phone, province, address, years in operation, GPS (non-zero), preferred contact | Blocks progression to Step 2 |
| 6 | Function | ClientFormDraftService.saveDraft() | Persists form state to SharedPreferences as JSON (schema version 2) | Logs error, continues |
| 7 | Form | CropDemandStep | Collects per-crop: crop selection, sourcing origin, provinces/countries, purchase frequency, avg volume, price range, purchase priorities, common issues, notes | Separate validation form key for add-crop section |
| 8 | Form | ClientDocumentsFormSection | File picker (PDF, JPG, PNG, GIF), max 10MB, document type selection dialog | Size validation, type restriction |
| 9 | Database | ClientDraftService.saveClientLocally() | INSERT into clients table with UUID, sync_status=pending, approval_status=PendingApproval | Schema-aware column detection cached |
| 10 | Queue | SyncQueueService.enqueueEntity() | Enqueues client entity for background sync | Persists to sync_queue table |
| 11 | Database | ClientCropDemandDraftService.saveDrafts() | Batch INSERT into client_crop_demand_drafts with client local ID | Batch commit for performance |
| 12 | Function | ClientDocumentService.uploadDocument() | Copies file to local storage, creates LocalClientDocument, attempts immediate upload if online | Falls back to pending status |
| 13 | API | POST /api/clients | ClientsController.Create -- validates via CreateClientRequestDtoValidator, creates Identity user, creates Client entity | Returns 400 with validation errors |
| 14 | Function | ClientService._reconcileAfterSync() | Updates local record with server ID, links crop demand drafts to server client ID | Handles ID mapping |
Data Transformations
- Input:
ClientFormData(company name, contact person, GPS, crop demands, documents) - Processing:
ClientFormData.toMap()serializes to flat JSON for draft;toApiPayload()on crop demands strips local-only fields;ClientDraftServicebuildsLocalClientmodel from form data - Output:
LocalClientrow in SQLite (offline) orClientResponseDTO from backend (online)
Error Handling
- Draft corruption:
_normalizeDraftShape()handles schema migration between draft versions, normalizespaymentAccountNumber/paymentAccountaliasing, and repairslocalPath/filePathinconsistencies in document entries - GPS failure: Falls back to manual coordinate entry if
LocationServicecannot acquire position - Sync failure: Client remains in local SQLite with
sync_status=failed;SyncManagerretries on next connectivity event - Server validation error: HTTP 400 response stored in
sync_errorcolumn; client shown to agent with error badge
Cross-References
- Triggers: WF-CLI-02 (crop demands can be added inline), WF-CLI-06 (documents uploaded during onboarding)
- Triggered by: Agent home screen navigation
- Related: WF-SYNC-01 (sync engine processes the queue)
WF-CLI-02: Client Crop Demand Creation
Trigger: Agent adds crop demand during client onboarding (Step 2) or via standalone ClientCropDemandFormScreen
Frequency: On-demand (per crop demand entry)
Offline Support: Yes -- saved as local draft, synced via CropDemandsSync
Workflow Diagram
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | CropDemandStep / ClientCropDemandFormScreen | User initiates crop demand entry | Separate form key prevents validation conflict with main form |
| 2 | Form | CropSelectionWidget | Category-filtered crop picker from cached master data | Falls back to text search |
| 3 | Function | ClientMasterDataResolver | Resolves local/display IDs to server GUIDs for: measurement units, currencies, countries, purchase priorities, common issues | Throws if critical IDs missing |
| 4 | Database | ClientCropDemandDraftService.saveDraftWithPhoto() | INSERT into client_crop_demand_drafts with UUID, payload JSON, optional pending photo path | Returns null on error |
| 5 | API | POST /api/clients/{clientId}/crop-demands | ClientCropDemandService.CreateClientCropDemandAsync -- backend derives cropCategoryId from crop entity | 400 on validation failure |
| 6 | Function | ClientCropDemandDraftService.markAsSynced() | Deletes draft if no pending photo; keeps draft with server ID if photo upload needed | Handles photo lifecycle |
| 7 | API | POST /api/clients/{id}/crop-demands/{id}/photo | IClientCropDemandService.UpdateCropDemandPhotoAsync | Retried by CropDemandsSync.uploadPendingPhotos() |
Data Transformations
- Input:
ClientCropDemandData(cropId, sourcingOrigin, provinces/countries, frequency, volume, price range, priorities, issues, notes) - Processing:
ClientCropDemandData.toApiPayload()stripscropCategoryId(backend derives it);ClientMasterDataResolvermaps display values to server GUIDs;_mergeServerDemandsWithPendingDrafts()overlays local edits on cached server data - Output:
LocalClientCropDemandrow inclient_crop_demand_draftstable;ClientCropDemandDtofrom API
Error Handling
- Master data missing: Throws descriptive exception (e.g., "Missing measurement unit ... Sync master data and retry") -- UI shows error message
- 403 Forbidden: Client may have been reassigned; silently skips pull, returns cached data
- Cache invalidation:
_invalidatedClientIdsset forces fresh read after local update, bypassing stale cache - Offline merge:
_mergeServerDemandsWithPendingDrafts()deduplicates byserverDemandId, overlaying local pending edits on top of cached server data
Cross-References
- Triggers: WF-CLI-06 (photo upload for crop demand)
- Triggered by: WF-CLI-01 (inline during onboarding), standalone from client details screen
- Related: WF-SYNC-03 (crop demand sync via
CropDemandsSync)
WF-CLI-03: Client Approval Workflow
Trigger: Admin reviews pending client on web dashboard or via API Frequency: Event-driven (per client submission) Offline Support: No -- approval requires backend connectivity
Workflow Diagram
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | Admin Dashboard / ClientsController | Admin initiates review | N/A |
| 2 | API | GET /api/clients/{id} | Fetches full client details with navigation properties | 404 if not found |
| 3 | API | POST /api/clients/{id}/approve | ClientsController.ApproveClient delegates to IClientApprovalService | 403 if not authorized |
| 4 | API | POST /api/clients/{id}/reject | ClientsController.RejectClient requires reason in body | 400 if reason missing |
| 5 | Function | ClientApprovalService.ApproveClientAsync() | Wraps in audit scope, calls client.Approve(authUserId) domain method, persists | Result.Failure on not found |
| 6 | Function | ClientApprovalService.RejectClientAsync() | Validates reason, calls client.Reject(authUserId, reason) domain method | ArgumentException caught and returned as failure |
| 7 | Database | context.SaveChangesAsync() | Persists status change to PostgreSQL | Transaction rollback on failure |
| 8 | Sync | Mobile sync pull | Agent's SyncManager pulls updated client status on next sync cycle | Status reflected in local approval_status column |
Data Transformations
- Input: Client ID + admin decision (approve or reject with reason)
- Processing:
Client.Approve(authUserId)/Client.Reject(authUserId, reason)domain methods mutate entity state, settingApprovalStatus,ApprovedByUserId,ApprovalDate(or rejection equivalents) - Output: Updated
Cliententity in PostgreSQL; status propagated to mobile via next sync
Error Handling
- Concurrent modification: EF Core concurrency tokens prevent conflicting updates
- Invalid state transition: Domain entity validates current state before allowing approve/reject
- Mobile staleness: Agent may see stale
PendingApprovaluntil next sync;ClientService._syncSingleClient()reconciles on pull - Rejected client editing:
ClientRemovedFromQueueNotificationalerts agent if client is locked post-approval
Cross-References
- Triggers: WF-CLI-04 (approved clients can have orders created)
- Triggered by: WF-CLI-01 (client submission creates pending approval)
- Related: WF-AUTH-01 (admin permissions checked via
[Authorize])
WF-CLI-04: Order Creation
Trigger: Admin/CSA creates order for a client via CreateOrderScreen
Frequency: On-demand (per order)
Offline Support: Yes -- local-first with sync queue
Workflow Diagram
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | CreateOrderScreen | User initiates order creation | N/A |
| 2 | Form | CreateOrderScreen | Collects: client, order items (crop, qty, unit, price, delivery date), delivery address, delivery method, logistics partner, currency, notes | Field validation |
| 3 | Function | OrderService.createOrder() | Builds Order object with LOCAL-{uuid} number, calculates total, generates UUIDs for items | N/A |
| 4 | Database | OrderLocalService.saveOrderOffline() | INSERT into orders + order_items tables with sync_status=pending | Returns null on error |
| 5 | Queue | SyncQueueService.enqueueEntity() | Enqueues with entityType=order, operation=create, priority 1 | Persisted to queue table |
| 6 | API | POST /api/orders | OrdersController.Create -- sets createdByUserId from JWT, delegates to IOrderService.CreateOrderAsync() | 400 on validation failure |
| 7 | Function | OrderService._reconcileCreatedOrder() | Saves server order, updates server_id on old local row, moves order items to server ID, deletes old local row | Handles ID mismatch |
| 8 | Sync | OrderSyncService.syncOrderQueueItem() | Background processor for queued creates/updates/deletes | Reconciles on success, throws on failure |
Data Transformations
- Input:
CreateOrderRequest(clientId, orderDate, expectedDeliveryDate, deliveryAddress, deliveryMethodId, logisticsPartnerId, currencyId, notes, list ofCreateOrderItemRequest) - Processing: Each
CreateOrderItemRequestspecifies eithercropId(existing crop) orcropTypeId(find-or-create); backend generates sequentialorderNumber; mobile assigns temporaryLOCAL-{uuid.substring(0,8)}number - Output:
Ordermodel with items in SQLite (offline) orOrderDtofrom API (online)
Error Handling
- Network timeout:
_isNetworkError()detects connection/send/receive timeouts; order stays queued - Server validation failure: Non-network
DioExceptiontriggersmarkAsFailed()with extracted error message - Duplicate submission: Queue processor de-duplicates by
entityId - Stale client ID:
_resolveServerId()maps local UUIDs to server IDs for clients created offline
Cross-References
- Triggers: WF-CLI-05 (order enters fulfillment pipeline)
- Triggered by: WF-CLI-03 (client must be approved to receive orders)
- Related: WF-STOCK-01 (stock checked during out-for-delivery transition)
WF-CLI-05: Order Fulfillment Flow
Trigger: Admin/CSA progresses order through status transitions Frequency: Event-driven (per status change) Offline Support: Yes -- optimistic local status update with sync queue
Workflow Diagram
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | OrderDetailsScreen / OrdersListScreen | User triggers status transition | N/A |
| 2 | Function | OrderService._offlineStatusTransition() | Generic handler: reads existing order, applies optimistic status update, enqueues, attempts API call | Catches DioException, distinguishes network vs. server errors |
| 3 | Database | OrderLocalService.saveOrderOffline() | Overwrites order row with new status, sync_status=pending | Returns null on failure |
| 4 | Queue | SyncQueueService.enqueueEntity() | Enqueues with entityType=orderStatusTransition, priority 2 (higher than creates) | Persisted to queue |
| 5 | API | POST /api/orders/{id}/out-for-delivery | OrdersController.MarkAsOutForDelivery -- passes userId for stock out finalization; backend performs FEFO allocation (First Expiry First Out) to deduct warehouse stock | 400 if insufficient stock |
| 6 | API | POST /api/orders/{id}/delivered | OrdersController.MarkAsDelivered -- optional DeliveryDate parameter | 400 on invalid transition |
| 7 | API | POST /api/orders/{id}/cancel | OrdersController.Cancel -- optional reason | 400 on invalid transition |
| 8 | API | POST /api/orders/{id}/payment | OrdersController.RecordPayment -- requires Permission.ManagePayments | 400 on validation |
| 9 | Sync | OrderSyncService.syncStatusTransitionQueueItem() | Resolves server ID, calls correct endpoint based on action field, reconciles response | Throws on non-200 response |
Data Transformations
- Input: Order ID + action string (
confirm,start_processing,out_for_delivery,delivered,cancel) + optional reason/delivery date - Processing:
_offlineStatusTransition()builds optimisticOrder.copyWith(status: newStatus), enqueues payload{orderId, action, reason?}. Backend validates state machine transitions, performs stock deductions on out-for-delivery. - Output: Updated
Orderin local SQLite (immediate) and PostgreSQL (on sync); stock movements created in warehouse (for out-for-delivery)
Error Handling
- Invalid transition: Backend returns 400 if state machine rules violated (e.g., cannot go from Pending to Delivered directly); mobile
markAsFailed()stores error - Insufficient stock: Out-for-delivery fails if FEFO allocation cannot satisfy all order items; order remains InProgress
- Optimistic rollback: If sync fails, the local order retains the optimistic status but
sync_status=failedalerts the UI; next successful sync reconciles - Payment failures:
RecordPaymentRequestamount added optimistically tototalPaid; on API failure, local totals may drift until next sync pull
Cross-References
- Triggers: WF-STOCK-03 (out-for-delivery triggers FEFO stock deduction)
- Triggered by: WF-CLI-04 (order creation starts in Pending status)
- Related: WF-SYNC-01 (order sync via
OrderSyncService)
WF-CLI-06: Client Document Management
Trigger: Agent attaches document during client registration (Step 3) or from client details screen Frequency: On-demand (per document upload) Offline Support: Yes -- files stored locally first, uploaded when connectivity available
Workflow Diagram
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | ClientDocumentsFormSection | User picks file via FilePicker | Size/type validation |
| 2 | Validation | _addDocument() | Checks file size <= 10MB, extension in [pdf, jpg, jpeg, png, gif] | Shows snackbar error |
| 3 | UI | Document type dialog | User selects from: BusinessLicense, NationalId, TaxCertificate, BankStatement, ProofOfAddress, Other | Required selection |
| 4 | Function | ClientDocumentLocalDataSource.copyFileToLocalStorage() | Copies original file to app's local document storage directory | Throws on copy failure |
| 5 | Database | ClientDocumentLocalDataSource.insertDocument() | INSERT LocalClientDocument with UUID, sync_status=pending | Error propagated |
| 6 | Function | ClientDocumentService._uploadToServer() | Routes to chunked or direct upload based on 1MB threshold | Falls back from chunked to direct for files under 10MB |
| 7 | Function | ChunkedUploadService.uploadFile() | Splits file into chunks, uploads to DigitalOcean Spaces via presigned URLs, returns CDN URL | Progress callback for UI |
| 8 | API | POST /api/clients/{id}/documents | ClientsController.UploadDocument -- direct multipart upload, 120s timeout | DioException with status code |
| 9 | API | POST /api/clients/{id}/documents/register | ClientsController.RegisterDocument -- registers chunked upload URL with backend | Returns ClientDocumentDto |
| 10 | Database | ClientDocumentLocalDataSource.updateSyncStatus() | Updates sync status, stores serverId and documentUrl from server response | N/A |
| 11 | Function | ClientDocumentService.syncPendingDocuments() | Iterates pending documents, skips if client not synced, uploads remaining | Returns count of synced docs |
Data Transformations
- Input: File path + document type + optional description
- Processing:
_normalizeDocumentType()maps legacy names (e.g.,TaxRegistrationtoTaxCertificate);_getMimeType()derives MIME from extension; chunked upload splits into chunks and reassembles at CDN; direct upload usesFormDatawithMultipartFile - Output:
LocalClientDocumentin SQLite withdocumentUrlpointing to CDN (DigitalOcean Spaces)
Error Handling
- Chunked upload failure: Falls back to direct upload if file size <= 10MB; otherwise fails permanently
- Client not yet synced: Documents queued as
pending;syncPendingDocuments()skips documents whose parent client has noserver_id; retried when client syncs - File not found:
_uploadToServer()checksFile(localFilePath).exists()before attempting upload; throws if missing - Retry mechanism:
retryDocumentSync()method allows manual retry from UI;syncPendingDocuments()runs during each sync cycle - Deduplication:
getClientDocuments()merges local docs (byclientLocalId) with server docs (byserverId), avoiding duplicates when both sources have the same document
Cross-References
- Triggers: None directly
- Triggered by: WF-CLI-01 (documents attached during onboarding), client details screen (post-creation document adds)
- Related: WF-SYNC-01 (sync cycle calls
syncPendingDocuments())
Domain Integration Map
Key Data Tables
| SQLite Table | Purpose | Key Columns |
|---|---|---|
clients | Local client records | id, server_id, company_name, approval_status, sync_status, assigned_agent_id |
client_crop_demand_drafts | Crop demand drafts and cache | id, client_local_id, client_server_id, payload (JSON), sync_status, pending_photo_path |
client_documents | Document metadata and sync tracking | id, client_local_id, server_id, document_type, local_file_path, document_url, sync_status |
orders | Order records with offline support | id, server_id, order_number, client_id, status (int), total_amount, sync_status |
order_items | Line items per order | id, order_id, crop_id, quantity, unit_price, total_price |
sync_queue | Pending sync operations | entity_type, entity_id, operation, payload_json, priority |
API Endpoints Summary
| Method | Endpoint | Permission | Controller |
|---|---|---|---|
POST | /api/clients | CreateClients | ClientsController.Create |
POST | /api/clients/{id}/approve | ApproveClients | ClientsController.ApproveClient |
POST | /api/clients/{id}/reject | ApproveClients | ClientsController.RejectClient |
POST | /api/clients/{id}/documents | CreateClients | ClientsController.UploadDocument |
POST | /api/clients/{id}/documents/register | CreateClients | ClientsController.RegisterDocument |
GET | /api/clients/{id}/crop-demands | ViewClients | ClientsController.GetCropDemands |
POST | /api/clients/{id}/crop-demands | CreateClients | ClientsController.AddCropDemand |
PUT | /api/clients/{id}/crop-demands/{demandId} | EditClients | ClientsController.UpdateCropDemand |
POST | /api/orders | CreateOrders | OrdersController.Create |
PUT | /api/orders/{id} | EditOrders | OrdersController.Update |
POST | /api/orders/{id}/confirm | ProcessOrders | OrdersController.Confirm |
POST | /api/orders/{id}/start-processing | ProcessOrders | OrdersController.StartProcessing |
POST | /api/orders/{id}/out-for-delivery | ProcessOrders | OrdersController.MarkAsOutForDelivery |
POST | /api/orders/{id}/delivered | ProcessOrders | OrdersController.MarkAsDelivered |
POST | /api/orders/{id}/cancel | CancelOrders | OrdersController.Cancel |
POST | /api/orders/{id}/payment | ManagePayments | OrdersController.RecordPayment |
GET | /api/orders/summary | ViewOrders | OrdersController.GetSummary |