Guard status transitions and close off stale pending payments

- StatusCatalog gains an Unresolved state and a PRECEDENCE order
  (Pending -> Unresolved -> Failed -> Success -> Paid); canTransition only
  allows moves up it, so a callback and a status query racing each other
  cannot make the status flap. A refused transition still stores the row.
- PaymentInitiation carries a @Version optimistic lock; callback and query
  paths go through blockingWithRetry, which replays the losing unit of work
  once instead of dropping it.
- Reconciliation runs two passes per tick, stale first: anything Pending past
  payments.reconciliation.stale-age (3h) is marked Unresolved with
  resolvedBy = EXPIRY, then anything past pending-age (now 3m) is re-queried.
- Explicit lowercase @Table names on the entities that were relying on
  derived naming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
spiro-alvin-nyasimi
2026-08-24 16:21:49 +03:00
parent cd186868fa
commit d8048c1591
24 changed files with 163 additions and 10 deletions

View File

@@ -79,7 +79,11 @@ Spring Boot 4 / Java 21 reactive (WebFlux) multi-provider mobile-money service (
- `PROVIDER_LIMITS` — configurable payment ceilings, `UNIQUE (provider, period, scope)`. Periods come from the `LimitPeriod` enum (PER_TRANSACTION, DAILY, MONTHLY — adding a constant is all a new period needs); `scope` from the `LimitScope` enum (PER_PAYER buckets by paying MSISDN, MERCHANT sums every payer on the provider; defaults to PER_PAYER and is ignored by PER_TRANSACTION). Defaults are seeded by `DataSeeder` only when the triple is absent (and only for the markets `<operator>.country` actually configures), so runtime edits survive a restart. `PaymentLimitService.enforce` runs in every `<Provider>Service.initiatePayment` **before** the initiation is persisted (breaches leave no DB row) and raises `PaymentLimitExceededException` → 422 `LIMIT_EXCEEDED`. Cumulative periods sum non-Failed initiations inside the window (per MSISDN for PER_PAYER, provider-wide for MERCHANT), so PENDING pushes count against the cap. An unrecognised `scope` on a row falls back to PER_PAYER (the tighter interpretation).
- **Provider-call audit trail** (`models/audit`, `repository/audit`, `ProviderCallAudit`): every outbound call and inbound callback is recorded per operator in its own independent tables — `mpesa_requests` / `mpesa_responses` / `mpesa_callback_responses`, and the `airtel_*` and `mtn_*` equivalents. Responses and callbacks reference their request `@ManyToOne`. The nine entities are **fully standalone** — no shared supertype, no `@MappedSuperclass`, no discriminator, no join. `ProviderCallAudit` therefore dispatches on `Operator` with an explicit branch per provider rather than polymorphically, which is verbose on purpose: a new operator will not compile until all three of its tables are wired up. Writes run on the `auditExecutor` pool (bounded queue, drop-on-overflow, daemon threads) so a payment is never delayed or failed by its audit trail; ordering within a call is kept by chaining onto the request's `CompletableFuture` rather than blocking on it. Credentials matching `ProviderCallAudit.SECRETS` (Password, passkey, api-key, secret, authorization, access_token) are masked before anything is stored.
- `@Table` names must be lowercase — Postgres folds unquoted DDL identifiers to lowercase and Spring Data quotes an explicit entity name verbatim. Column names carry no `@Column` annotation, so they are derived and adapt to the dialect's casing on their own; keep it that way.
- `PaymentReconciliationJob` reconciles PENDING initiations of **all** providers older than `payments.reconciliation.pending-age` (default 5m) by dispatching to the right `PaymentProviderService`; interval `payments.reconciliation.fixed-delay` (default 60s).
- `PaymentReconciliationJob` runs two passes per tick (interval `payments.reconciliation.fixed-delay`, default 60s), **stale first** so hopeless payments stop being re-queried:
1. anything still Pending past `payments.reconciliation.stale-age` (default 3h) → `Unresolved` via `markStaleUnresolved`, `resolvedBy = EXPIRY`;
2. anything still Pending past `payments.reconciliation.pending-age` (default **3m**) → live provider status query, dispatched to the right `PaymentProviderService`.
- **Status transitions are guarded, not blind.** `StatusCatalog.PRECEDENCE` orders the states least- to most-informed (Pending → Unresolved → Failed → Success → Paid) and `canTransition` only allows moves *up* it. So a callback and a status query racing each other cannot make the status flap: re-asserting the current state is a silent no-op (DEBUG), a worse verdict is refused with a WARN, and a receipt-bearing callback can still rescue a payment the query gave up on (Unresolved → Paid). **A refused transition never discards the row** — the callback is still stored, only the status is held back.
- `PaymentInitiation` carries a `@Version` optimistic lock. When a callback and a query genuinely commit at the same instant the loser's transaction rolls back; `PaymentLifecycleService.blockingWithRetry` replays it once, so the callback row still lands and the guard then declines the redundant status change. Use it for any new path that resolves a payment.
**Configuration (`application.yml`):**
- Config is read via `Environment.getProperty` by project convention (no `@ConfigurationProperties`).