Module FieldAgent — Opérations cash en tournée

Module dédié aux opérations cash collectées par un agent terrain (FIELD_AGENT) hors de l'agence physique, sans session de caisse.

Pourquoi un module séparé du TellerModule ?

TellerService impose l'ouverture / clôture d'une session caisse (comptage cash, écarts, etc.). En tournée village, ce modèle n'est pas applicable. FieldAgentService :

  • Travaille sans session (TellerSession)
  • Crée des transactions standards (DEPOSIT, LOAN_REPAYMENT) tagguées via performedById = userId agent pour audit + suivi
  • Délègue à SavingsService.performDeposit() pour la mécanique comptable + génération de référence transaction

Le rapprochement final (l'agent rend le cash au caissier en rentrant) sera tracé par un workflow back-office dédié à venir.

Endpoints

POST /v1/field-agent/cash-deposit

Rôles : FIELD_AGENT, DIRECTOR

// Body
{ "accountId": "uuid", "amount": 5000, "notes": "Tournée village Tové" }

// Réponse
{
  "transaction": { "id", "reference", "amount", "balanceAfter", "performedAt" },
  "account": { "id", "accountNumber" }
}

Effets :

  • Transaction DEPOSIT créée sur le compte
  • Écritures comptables OHADA automatiques (via SavingsService)
  • Audit log field-agent.cash-deposit
  • SMS au membre via MemberNotifier (template teller.deposit)
  • Push notification au membre via MemberPushHelper.notifyDeposit (channel FIELD_AGENT)

POST /v1/field-agent/loan-repayment

Rôles : FIELD_AGENT, DIRECTOR

// Body
{ "loanId": "uuid", "amount": 10000, "notes": "..." }

// Réponse
{ "ok": true, "paidSchedules": 1, "loanClosed": false }

Effets :

  • Impute le montant sur les échéances PENDING / OVERDUE / PARTIAL dans l'ordre chronologique
  • Marque les échéances PAID quand paidAmount >= totalDue
  • Si toutes les échéances sont soldées → LoanApplication.status = CLOSED
  • Trace une transaction LOAN_REPAYMENT (informative) sur le 1er compte actif du membre
  • Audit, SMS (loan.repaid), push (MemberPushHelper.notifyLoanRepayment)

⚠️ Différence avec TellerService.loanRepayment : pas de débit d'un compte épargne du membre (le cash est collecté directement).

POST /v1/field-agent/loan-disbursement

Rôles : FIELD_AGENT, DIRECTOR

// Body — le montant est fixé par le prêt approuvé, pas par l'agent
{ "loanId": "uuid", "notes": "Remis au domicile, présence de Mme Y" }

// Réponse
{
  "ok": true,
  "reference": "PRT-2026-001",
  "amount": 100000,
  "loan": { /* loan complet avec schedules */ }
}

Logique :

  • Vérifie que le prêt existe, est APPROVED, et a approvedAmount > 0
  • Délègue tout à LoansService.disburse(tenant, loanId, { description }, agentId) avec toAccountId: undefined (= cash direct, pas de virement compte épargne)
  • LoansService.disburse gère :
    • Génération de l'échéancier (cf. buildSchedule())
    • Passage à ACTIVE + disbursedAt = now
    • Écritures comptables OHADA via AccountingService
    • SMS loan.disbursed au membre
    • Push notification MemberPushHelper.notifyLoanDisbursement
    • Webhook LOAN_DISBURSED
  • Le wrapper field-agent ajoute uniquement :
    • Audit log field-agent.loan-disbursement (canal FIELD_AGENT, notes terrain)

⚠️ Séparation des pouvoirs : un agent ne peut pas approuver et décaisser le même dossier. L'APPROVED doit venir d'un DIRECTOR ou CREDIT_MANAGER en amont (via le back-office web).

GET /v1/field-agent/my-day

Rôles : FIELD_AGENT, DIRECTOR

Résumé des opérations de l'agent aujourd'hui (performedAt >= startOfDay).

{
  "date": "2026-06-09T00:00:00.000Z",
  "operationsCount": 7,
  "totalDeposit": 35000,       // collecté (dépôts)
  "totalRepayment": 12000,     // collecté (remboursements crédit)
  "totalDisbursement": 50000,  // sorti vers les membres (décaissements)
  "totalCollected": 47000,     // = totalDeposit + totalRepayment
  "recentOperations": [
    { "id", "type", "amount", "reference", "performedAt" },
    // ... jusqu'à 20
  ]
}

totalCollected exclut volontairement totalDisbursement — le décaissement n'est pas de l'argent encaissé, c'est de l'argent distribué. Pour le rapprochement caisse de fin de tournée, l'agent doit avoir : (cash de départ) + totalCollected − totalDisbursement = (cash en poche actuel).

Utilisé par l'écran « Ma journée » du profil agent mobile.

Dépendances

  • SavingsService (mécanique dépôt + génération référence)
  • MemberNotifier (SMS via NotificationsModule global)
  • MemberPushHelper (push via PushModule global)
  • AuditService

Audit actions

  • field-agent.cash-deposit — newValue : { accountId, memberId, amount, channel: 'FIELD_AGENT', notes }
  • field-agent.loan-repayment — newValue : { amount, channel: 'FIELD_AGENT', schedules: [scheduleIds] }
  • field-agent.loan-disbursement — newValue : { amount, memberId, channel: 'FIELD_AGENT', notes }

UI mobile (Flutter)

Côté fiche membre agent (lib/screens/agent/member_detail_screen.dart) :

  • Bouton « Encaisser dépôt » → ouvre CashOperationSheet (kind=deposit) avec liste des comptes ACTIVE
  • Bouton « Remboursement crédit » → ouvre CashOperationSheet (kind=loanRepayment) avec liste des crédits ACTIVE
  • Le sheet ferme après succès et invalide le provider de détail membre (refresh comptes + crédits)

Profil agent (lib/screens/agent/agent_profile_screen.dart) :

  • Carte « Ma journée » consomme /field-agent/my-day via _myDayProvider Riverpod

À venir

  • Workflow rapprochement caisse : l'agent rend le cash au TELLER qui valide le différentiel totalCollected − totalDisbursement
  • Mode hors-ligne avec queue de sync (utile en zone sans réseau)
  • Génération de reçu PDF imprimable côté mobile
  • Photo de la pièce d'identité à la création membre (KYC accéléré)