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:
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user