Skip to content

feat(backend): payments ledger with Paystack webhook verification & double-entry reconciliation - #51

Merged
meshackyaro merged 6 commits into
workman-labs:developmentfrom
oss-dw:feat/payments-ledger-paystack
Aug 26, 2026
Merged

feat(backend): payments ledger with Paystack webhook verification & double-entry reconciliation#51
meshackyaro merged 6 commits into
workman-labs:developmentfrom
oss-dw:feat/payments-ledger-paystack

Conversation

@bbjiggy

@bbjiggy bbjiggy commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #41

What this changes

The payment path was a shell: PaymentController was entirely commented out, TransactionServiceImpl and TransactionHistoryServiceImpl were empty stubs, and payment state depended on the client returning from the Paystack redirect. A closed browser tab lost the payment.

This replaces that with a money path that does not depend on the client coming back:

  • Signature-verified webhooks. HMAC-SHA512 over the raw request bytes, compared with MessageDigest.isEqual, before any parsing. The controller takes byte[], not a DTO, so a forged payload is never deserialized.
  • An append-only double-entry ledger as the source of truth. Balanced debits and credits, enforced by the aggregate itself; Payment, Payout, Transaction and TransactionHistory are all derived from it.
  • Explicit state machines for payment and payout, with the legal transition table declared as data. Illegal transitions throw and are recorded as discrepancies.
  • A scheduled reconciliation job that pulls provider state and reports divergence rather than correcting it.

Architectural decisions

Full write-up in backend-api/docs/PAYMENTS_LEDGER.md. The ones worth arguing about here:

The ledger is the source of truth; Payment.status is a cache. Balances are computed by summing entries, never by reading a stored total. Transaction/TransactionHistory are rebuilt as a projection keyed on payment_reference, which is what the issue asked for and also means the client-facing view can never disagree with the books.

Balance is an invariant of the aggregate, not a rule callers follow. LedgerTransaction validates debits == credits in @PrePersist and throws UnbalancedLedgerTransactionException. @PreUpdate on both the transaction and its entries throws unconditionally — append-only is enforced by the mapping, not by convention. A future contributor who adds a fifth posting rule cannot write an unbalanced one without the insert failing.

Idempotency is a unique index, not a lookup. A SELECT before INSERT is a race; two concurrent deliveries of the same retry both see "not processed" and both credit. Instead the claim row is saveAndFlushed inside the same transaction as the effect, and the duplicate is detected by catching DataIntegrityViolationException outside the transaction boundary. The key is derived — event-type:data.id, falling back to event-type:sha256:<body digest> when Paystack sends no id — so an event without an id is still deduplicated by content.

Money is integer minor units everywhere except the API boundary. No double touches an amount. MinorUnits converts using Currency.getDefaultFractionDigits() rather than a hardcoded 100, with RoundingMode.UNNECESSARY so it refuses to silently round instead of losing a fraction of a kobo. The platform fee is basis points, floor-divided.

Reconciliation reports, it does not fix. A job that quietly rewrites the books to match the provider hides exactly the bug you needed to see. It files a ReconciliationDiscrepancy with a type (PROVIDER_STATUS_DIVERGENCE, AMOUNT_DIVERGENCE, ILLEGAL_TRANSITION, UNKNOWN_REFERENCE, MISSING_PROVIDER_RECORD, LEDGER_IMBALANCE) and an operator acknowledges or resolves it. No transaction is held open across the HTTP call to Paystack.

A refused event still answers 200. Paystack retries non-2xx. An event we have correctly decided not to apply — a duplicate, an illegal transition — is not a transport failure, and returning 500 would produce an infinite retry loop against a decision that will never change. Only a bad signature answers non-2xx (401).

Room for the on-chain leg. The issue notes this is the fiat leg and the model must leave room for both. Payment/Payout carry a PaymentProvider discriminator (PAYSTACK, with STELLAR reserved) and the chart of accounts is currency-agnostic, so the Soroban leg can post into the same journal rather than a parallel one.

Security

/api/v1/webhooks/** is added to PUBLIC_ENDPOINTS in SecurityConfig, with a comment explaining why: it authenticates the payload, not the caller. It deliberately sits outside /api/v1/payments so that no future widening of the permit-all matcher can accidentally expose the token-gated payment routes. The reconciliation surface is @PreAuthorize("hasRole('ADMIN')").

An unconfigured or blank Paystack secret fails closed — every webhook is rejected, rather than a blank key verifying an attacker's blank-key signature.

New dependencies

None. springdoc, OkHttp, Jackson and MockWebServer were already in the build.

Schema / migrations

The project uses ddl-auto=update, so a fresh database needs nothing (verified by running the full suite against a newly created database).

An existing database needs one statement before deploy, because a stale Postgres CHECK constraint predates the new TransactionStatus values:

ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_transaction_status_check;

This is called out in the docs and in backend-api/README.md. I originally documented the enum change as additive and that was wrong — Hibernate does not widen an existing check constraint, and I confirmed the behaviour against a scratch database rather than assuming it.

Removed

  • controllers/PaymentController.java — the commented-out shell, replaced by the real one in payment/api/.
  • src/test/java/com/guildworkman/api/payment/controller/PaymentController.java — a live @RestController sitting in the test tree. It registered real endpoints during every @SpringBootTest and did not exist in production, so tests were exercising routes the deployed app doesn't have.

Tests

Tests run: 496, Failures: 0, Errors: 0, Skipped: 0./mvnw verify, green against a freshly created database.

119 of those are new, across 9 classes, and cover each acceptance criterion directly:

Criterion Where
Forged/missing signature rejected, no state mutated PaystackSignatureVerifierTest, PaymentWebhookIntegrationTest
Same event twice → exactly one ledger effect PaymentWebhookIntegrationTest (replay, and concurrent delivery)
Total debits == total credits LedgerPostingTest, PaymentReconciliationIntegrationTest (trial balance)
Payment completes without the client returning PaymentWebhookIntegrationTest — capture is driven entirely by the webhook
Illegal transitions rejected PaymentLifecycleTest, plus out-of-order events end-to-end
Divergence surfaced as actionable signal PaymentReconciliationIntegrationTest
Failure and concurrency paths forged signatures, malformed bodies, provider 5xx/timeouts (MockWebServer), 8-thread concurrent sweeps
Consistent error contract PaymentEndpointSecurityTest; RFC 7807 ProblemDetail via GlobalExceptionHandler

Writing the concurrency tests found a real bug in my own code, which is in the history rather than papered over: DiscrepancyRecorder.record was @Transactional(REQUIRES_NEW) and caught DataIntegrityViolationException inside its own transaction. A flush failure marks the transaction rollback-only, so the catch returned normally and Spring then failed the commit with UnexpectedRollbackException — two reconciliation sweeps racing to file the same finding would have errored. Fixed by splitting the inserter from the recorder so the catch sits outside the boundary, matching the existing EscrowOrchestrationInserter pattern.

Also in scope from the issue checklist

  • Maven dependency caching in CI — already provided by setup-java's cache: maven in .github/workflows/test.yml; no change needed, and the workflow comment says so.
  • OpenAPI — all three new controllers carry @Tag/@Operation annotations, consistent with the rest of the codebase.

Not in scope

Payouts are recorded, not initiated. The ledger books transfer.success/failed/reversed events and the payout state machine is complete, but nothing here calls Paystack's Transfer API — that needs transfer-recipient management, which is its own piece of work. Enumerated with the other follow-ups in the docs.

… for payments

Introduces the accounting core the rest of the payments work is built on:
a fixed chart of accounts, balanced journal entries, and the invariants
that keep them trustworthy.

- LedgerTransaction/LedgerEntry are append-only. A correction is a new
  posting, never an edit. Enforced twice: every column is
  updatable = false with no setters exposed, so there is no in-process
  path to a mutation, and a @PreUpdate callback turns any mutation that
  reaches a flush into a loud failure rather than a silently dropped
  write.
- The balance check is the @PrePersist callback on the aggregate, not a
  rule callers are trusted to follow, so an unbalanced posting cannot
  reach the database through any code path -- including one written
  later by someone who never read the class.
- Money is integer minor units end to end, matching the provider's wire
  format; the platform commission is basis points so the arithmetic
  stays integral. Conversion to a decimal happens once, at the edge, in
  MinorUnits, using the currency's own fraction digits rather than a
  hardcoded 100 -- NGN and USD have two, JPY has none.
- Accounts are currency-agnostic: a posting balances within one
  currency, so a balance is only meaningful per-currency. Seeding one
  account per (code, currency) pair would multiply the chart by every
  currency Paystack settles in without strengthening any invariant.
- Payment and Payout carry a provider discriminator (PAYSTACK, plus a
  reserved STELLAR nothing writes yet) so the on-chain leg can post into
  the same books later without a schema change to a table that will by
  then hold production money records.
…d explicit payment & payout state machines

Payment state stops depending on the client returning from a redirect.
Capture is driven by the provider's webhook, verified and applied
exactly once.

- The signature is checked over the raw request bytes, before parsing.
  The controller takes byte[], not a DTO: Jackson does not promise to
  reproduce the bytes it read -- key order, whitespace and number
  formatting are all free to change -- and every such difference breaks
  the MAC. Comparison is MessageDigest.isEqual, since String.equals
  short-circuits on the first differing character and leaks how much of
  a forged prefix was correct.
- An empty secret rejects everything. Skipping verification when
  unconfigured is an unauthenticated endpoint that moves money, and it
  fails open in exactly the deployment most likely to be misconfigured.
- Idempotency is a database constraint, not an in-memory cache: only a
  unique index can decide a winner across two instances. Paystack's
  envelope carries no delivery id, so the key is derived --
  event-type:data.id, falling back to a digest of the raw body. The
  claim row and the effect share one transaction, so a redelivery after
  success does nothing and a redelivery after a failure is reprocessed;
  claiming separately (the EscrowOrchestrationInserter pattern, right
  for a long external RPC) would let a crash between claim and effect
  permanently swallow a payment notification.
- Illegal transitions are refused, not coerced. A refund overtaking its
  charge would post against money the books say was never collected --
  balanced, and describing something that did not happen. PaymentStateMachine
  checks before touching the entity, so a refusal leaves it untouched
  and the audit row can still commit.
- A refused event still answers 200: a 4xx makes the provider retry an
  event that can never become legal and eventually disable the endpoint.
  The divergence is recorded as a discrepancy instead.
- Transaction/TransactionHistory become projections of the ledger,
  replacing the empty service stubs. Nothing writes TransactionHistory:
  a persisted history table would be a third copy of facts the ledger
  already holds, and one that can drift.
…ose the payment REST surface

Adds the job that proves the books match the provider, and the HTTP
surface for starting and reading a payment.

- Reconciliation reports; it does not fix. It never posts to the ledger
  and never changes a captured payment's state. An automatic fix
  destroys the evidence that anything was wrong, and an accounting
  system whose divergences quietly disappear is worse than one with no
  reconciliation at all, because it looks correct. The one state change
  it makes -- closing out a charge the provider calls failed/abandoned
  while the platform still has it open -- involves no money on either
  side and files no finding, because a client who closed the checkout
  tab is not an accounting problem.
- PaystackClient returns an empty Optional for a 404 and throws for
  anything else, so one provider outage cannot file a missing-record
  finding against every payment in the sweep. The sweep holds no
  transaction across its HTTP calls.
- The sweep also checks the trial balance per currency. Nothing short of
  a bug in a posting rule can unbalance it, which is why it is checked
  on a schedule rather than only in tests.
- The payment reference is generated by the platform, not the provider.
  If the provider assigned it, an initialize call that timed out after
  the transaction was created would leave a live charge with no local
  row and nothing to correlate the eventual webhook against.
- The webhook lives at /api/v1/webhooks/**, deliberately not under
  /api/v1/payments. It authenticates the payload rather than the caller,
  and keeping it on a separate prefix means no future widening of the
  permit-all matcher can reach the token-gated payment routes.
  Reconciliation views are ADMIN-only: a trial balance is every naira
  the platform holds.
- Removes both dead payment controllers -- the commented-out one in
  controllers/, and a live @RestController that had been sitting in the
  test source tree. Component scanning covers the test classpath, so
  that one was a real controller during @SpringBootTest runs and absent
  in production: endpoints that passed tests and did not exist when
  deployed.
…s, ledger balance and reconciliation divergence

Each test is named after the property it pins rather than the method it
calls, because the properties are the point.

- Signature: valid, forged, missing, blank, truncated, uppercase, and a
  genuine signature lifted onto tampered bytes; plus the
  misconfiguration case, where the interesting question is which way the
  endpoint fails.
- Idempotency: a redelivered event applies once, and eight threads
  delivering the same event concurrently produce exactly one ledger
  effect with no thread seeing an error.
- Balance: the trial balance is asserted after every money-moving
  scenario, and once more the other way round -- every account's balance
  summed with its natural sign nets to zero -- after a charge, refund,
  payout and reversal sequence.
- Out-of-order: a refund overtaking its charge and a payout reversal
  overtaking its settlement are both refused, leave the ledger empty,
  and file a finding.
- Rounding: a charge refunded in three awkward instalments returns the
  commission to the unit and leaves every affected account at zero --
  the case a per-instalment floor gets wrong.
- Reconciliation: one test per row of the decision table, plus the grace
  window, finding dedupe, ledger-imbalance detection, and the operator
  workflow.
- Provider boundary: driven against a real MockWebServer, so the request
  actually put on the wire is part of what is asserted -- including that
  a 404 and an outage stay distinguishable.

Includes a regression test for a bug this suite found: DiscrepancyRecorder
caught the unique-key violation inside its own REQUIRES_NEW transaction.
A flush failure marks that transaction rollback-only, so the catch
returned normally and Spring then failed the commit with
UnexpectedRollbackException -- two instances racing to file the same
finding would have errored instead of absorbing the duplicate. The
insert now lives in its own bean with the catch outside the boundary,
matching EscrowOrchestrationInserter.
…nual migration

PAYMENTS_LEDGER.md is the reference for the ledger: the chart of accounts and
the four posting rules, why balance is an invariant of the aggregate rather
than a rule callers remember, why the signature is checked over raw bytes
before parsing, why idempotency is a unique index and not a lookup, and why
reconciliation reports divergence instead of quietly correcting it.

It also carries the two things an operator needs and cannot infer from the
code: the single ALTER TABLE that an existing database needs before deploy
(a stale CHECK constraint on transactions.transaction_status predates the new
enum values; a fresh database needs nothing), and the runbook for the cases
that actually happen — a payment the provider says succeeded that the books
never captured, a discrepancy that turns out to be fine, LEDGER_IMBALANCE.

The READMEs are corrected where they now describe the wrong system: payments
are no longer "no REST surface", Transaction/TransactionHistory are a
projection of the ledger rather than the record itself, and the remaining gap
is named precisely — payouts are recorded from transfer events, not initiated,
because that needs transfer-recipient management.
@meshackyaro

Copy link
Copy Markdown
Contributor

Closes #41

What this changes

The payment path was a shell: PaymentController was entirely commented out, TransactionServiceImpl and TransactionHistoryServiceImpl were empty stubs, and payment state depended on the client returning from the Paystack redirect. A closed browser tab lost the payment.

This replaces that with a money path that does not depend on the client coming back:

  • Signature-verified webhooks. HMAC-SHA512 over the raw request bytes, compared with MessageDigest.isEqual, before any parsing. The controller takes byte[], not a DTO, so a forged payload is never deserialized.
  • An append-only double-entry ledger as the source of truth. Balanced debits and credits, enforced by the aggregate itself; Payment, Payout, Transaction and TransactionHistory are all derived from it.
  • Explicit state machines for payment and payout, with the legal transition table declared as data. Illegal transitions throw and are recorded as discrepancies.
  • A scheduled reconciliation job that pulls provider state and reports divergence rather than correcting it.

Architectural decisions

Full write-up in backend-api/docs/PAYMENTS_LEDGER.md. The ones worth arguing about here:

The ledger is the source of truth; Payment.status is a cache. Balances are computed by summing entries, never by reading a stored total. Transaction/TransactionHistory are rebuilt as a projection keyed on payment_reference, which is what the issue asked for and also means the client-facing view can never disagree with the books.

Balance is an invariant of the aggregate, not a rule callers follow. LedgerTransaction validates debits == credits in @PrePersist and throws UnbalancedLedgerTransactionException. @PreUpdate on both the transaction and its entries throws unconditionally — append-only is enforced by the mapping, not by convention. A future contributor who adds a fifth posting rule cannot write an unbalanced one without the insert failing.

Idempotency is a unique index, not a lookup. A SELECT before INSERT is a race; two concurrent deliveries of the same retry both see "not processed" and both credit. Instead the claim row is saveAndFlushed inside the same transaction as the effect, and the duplicate is detected by catching DataIntegrityViolationException outside the transaction boundary. The key is derived — event-type:data.id, falling back to event-type:sha256:<body digest> when Paystack sends no id — so an event without an id is still deduplicated by content.

Money is integer minor units everywhere except the API boundary. No double touches an amount. MinorUnits converts using Currency.getDefaultFractionDigits() rather than a hardcoded 100, with RoundingMode.UNNECESSARY so it refuses to silently round instead of losing a fraction of a kobo. The platform fee is basis points, floor-divided.

Reconciliation reports, it does not fix. A job that quietly rewrites the books to match the provider hides exactly the bug you needed to see. It files a ReconciliationDiscrepancy with a type (PROVIDER_STATUS_DIVERGENCE, AMOUNT_DIVERGENCE, ILLEGAL_TRANSITION, UNKNOWN_REFERENCE, MISSING_PROVIDER_RECORD, LEDGER_IMBALANCE) and an operator acknowledges or resolves it. No transaction is held open across the HTTP call to Paystack.

A refused event still answers 200. Paystack retries non-2xx. An event we have correctly decided not to apply — a duplicate, an illegal transition — is not a transport failure, and returning 500 would produce an infinite retry loop against a decision that will never change. Only a bad signature answers non-2xx (401).

Room for the on-chain leg. The issue notes this is the fiat leg and the model must leave room for both. Payment/Payout carry a PaymentProvider discriminator (PAYSTACK, with STELLAR reserved) and the chart of accounts is currency-agnostic, so the Soroban leg can post into the same journal rather than a parallel one.

Security

/api/v1/webhooks/** is added to PUBLIC_ENDPOINTS in SecurityConfig, with a comment explaining why: it authenticates the payload, not the caller. It deliberately sits outside /api/v1/payments so that no future widening of the permit-all matcher can accidentally expose the token-gated payment routes. The reconciliation surface is @PreAuthorize("hasRole('ADMIN')").

An unconfigured or blank Paystack secret fails closed — every webhook is rejected, rather than a blank key verifying an attacker's blank-key signature.

New dependencies

None. springdoc, OkHttp, Jackson and MockWebServer were already in the build.

Schema / migrations

The project uses ddl-auto=update, so a fresh database needs nothing (verified by running the full suite against a newly created database).

An existing database needs one statement before deploy, because a stale Postgres CHECK constraint predates the new TransactionStatus values:

ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_transaction_status_check;

This is called out in the docs and in backend-api/README.md. I originally documented the enum change as additive and that was wrong — Hibernate does not widen an existing check constraint, and I confirmed the behaviour against a scratch database rather than assuming it.

Removed

  • controllers/PaymentController.java — the commented-out shell, replaced by the real one in payment/api/.
  • src/test/java/com/guildworkman/api/payment/controller/PaymentController.java — a live @RestController sitting in the test tree. It registered real endpoints during every @SpringBootTest and did not exist in production, so tests were exercising routes the deployed app doesn't have.

Tests

Tests run: 496, Failures: 0, Errors: 0, Skipped: 0./mvnw verify, green against a freshly created database.

119 of those are new, across 9 classes, and cover each acceptance criterion directly:

Criterion Where
Forged/missing signature rejected, no state mutated PaystackSignatureVerifierTest, PaymentWebhookIntegrationTest
Same event twice → exactly one ledger effect PaymentWebhookIntegrationTest (replay, and concurrent delivery)
Total debits == total credits LedgerPostingTest, PaymentReconciliationIntegrationTest (trial balance)
Payment completes without the client returning PaymentWebhookIntegrationTest — capture is driven entirely by the webhook
Illegal transitions rejected PaymentLifecycleTest, plus out-of-order events end-to-end
Divergence surfaced as actionable signal PaymentReconciliationIntegrationTest
Failure and concurrency paths forged signatures, malformed bodies, provider 5xx/timeouts (MockWebServer), 8-thread concurrent sweeps
Consistent error contract PaymentEndpointSecurityTest; RFC 7807 ProblemDetail via GlobalExceptionHandler
Writing the concurrency tests found a real bug in my own code, which is in the history rather than papered over: DiscrepancyRecorder.record was @Transactional(REQUIRES_NEW) and caught DataIntegrityViolationException inside its own transaction. A flush failure marks the transaction rollback-only, so the catch returned normally and Spring then failed the commit with UnexpectedRollbackException — two reconciliation sweeps racing to file the same finding would have errored. Fixed by splitting the inserter from the recorder so the catch sits outside the boundary, matching the existing EscrowOrchestrationInserter pattern.

Also in scope from the issue checklist

  • Maven dependency caching in CI — already provided by setup-java's cache: maven in .github/workflows/test.yml; no change needed, and the workflow comment says so.
  • OpenAPI — all three new controllers carry @Tag/@Operation annotations, consistent with the rest of the codebase.

Not in scope

Payouts are recorded, not initiated. The ledger books transfer.success/failed/reversed events and the payout state machine is complete, but nothing here calls Paystack's Transfer API — that needs transfer-recipient management, which is its own piece of work. Enumerated with the other follow-ups in the docs.

Good job, but could still be better as PR still requires some important improvements. Find them below:

Merge readiness and risk assessment

  • Readiness: Not ready to merge — several operational and correctness checks remain.
  • Tests: I do not see mention of end-to-end / integration tests that exercise:
    • webhook signature verification (valid & invalid),
    • duplicate / retried events (idempotency),
    • illegal transitions and discrepancy recording,
    • reconciliation job behavior (report-only).
      Please add tests covering these paths (unit + integration where appropriate).
  • DB migrations: The new ledger tables, unique indexes (idempotency claim), and any indexes needed for projection/performance must have migration scripts (Flyway/Liquibase). Confirm migrations are present and reviewed.
  • Observability and alerts: Reconciliation reports divergences — you should add metrics/alerts for:
    • frequent reconciliation discrepancies,
    • failing webhook verification spikes,
    • reconciliation job failures.
  • Performance: Balances computed by summing postings can be expensive at scale. Ensure:
    • appropriate indexes (payment_reference, event timestamps) exist,
    • projection queries are optimized (use aggregate indices or materialized views if needed).
  • Security: Operational secrets (Paystack secret key) must be read from secrets vault / env and not logged. Confirm no secrets are printed in logs.
  • JPA lifecycle hooks: The design uses @PrePersist/@PreUpdate to enforce invariants and append-only. Double-check that this won't break other JPA lifecycle operations (merging detached entities, tests that use saveAndFlush, etc.) — especially ensure @PreUpdate unconditional throws doesn't cause confusing errors in non-ledger code paths.
  • Idempotency handling: Good pattern (unique row + catching exception outside tx). Confirm the claim key derivation is deterministic and collision-resistant:
    • prefers provider event id, falling back to sha256(body) — ensure canonicalization is correct so retries aren't mistakenly treated as different (but since signature validated on raw bytes, sha256(body) fallback is OK).
  • Error semantics: Returning 200 for refused/duplicate events is correct to avoid infinite retries; just ensure operator visibility (logs/metrics) is sufficient so rejected events aren't invisible.

Concrete follow-ups I want to see before merging

  • Add or point to DB migration files (Flyway/Liquibase) for the new ledger tables and unique indexes.
  • Add tests covering signature verification, idempotency, illegal transitions, and reconciliation reporting.
  • Add or confirm indexes for the most frequent projection queries (payment_reference, ledger entry timestamps) and a short note on expected scale/perf at N transactions/day.
  • Ensure secrets are vault-backed and not logged; add a short ops doc about key rotation and webhook secret rollover.
  • Add monitoring/alerts for reconciliation discrepancy counts and webhook verification failures.

Possible improvements and suggestions

  • Add a README snippet or architecture diagram in backend-api/docs/PAYMENTS_LEDGER.md that shows:
    • the event flow (Paystack -> webhook -> claim + ledger append),
    • projection rebuild path to Transaction / TransactionHistory,
    • reconciliation job interaction and operator acknowledgement flow.
  • Consider adding a small integration test harness that can stand up an in-memory database and simulate provider retries and reconciliation runs (makes future maintenance safer).
  • In the idempotency fallback (sha256 of body), explicitly comment on how whitespace or timestamp fields in the provider payload could make the body differ across retries; if Paystack guarantees exact bytes on retries, note that in comments; otherwise consider normalized content hashing when safe.
  • Add clearer operator-facing logs when an event is refused due to illegal transition (so triage is straightforward) and include a reconciliation discrepancy id in the log message for cross-reference.
  • Add a brief performance note in the code/comments where balances are computed (e.g., "This query must be paginated; consider materialized view if > X entries").

…f at the JPA layer

Review follow-ups on the payments ledger, plus one correction to a claim the
PR made about its own design.

PaymentMetrics adds the counters the money path was missing, following the
SigningMetrics convention already in this codebase. The PAYMENTS_LEDGER doc
had claimed there was no Micrometer dependency to build on and that structured
logs were therefore the whole story; that was simply wrong — actuator and the
Prometheus registry have been in the build all along — and the failures worth
alerting on here are exactly the silent ones. A secret rotated but never
redeployed rejects every webhook and throws nowhere anyone is looking; a
scheduled sweep that dies stops reporting divergence and looks identical to
finding none. Both now have counters. Event type is collapsed to a closed set
before it becomes a tag, so nobody who can reach the webhook can mint
unbounded time series by posting new type strings.

The append-only guarantee is now tested through JPA rather than by calling the
guard method directly, and doing so corrected the mechanism the docs described.
A rewritten posting does not throw: every column is updatable = false, so
Hibernate emits no UPDATE and @PreUpdate is never reached. The mapping is what
protects the row and the callback is a backstop for a column added later
without the flag. The test asserts the durable row instead of the exception, so
it keeps holding whichever defence engages. It also covers the case that would
actually hurt — a posting merely attached to the persistence context while
unrelated entities are written in the same transaction, which is the shape of
the capture path itself and which an over-eager guard would break.

The trial balance summed the whole journal with no index behind it. Adding
(currency, direction) makes it an index scan; the repository now states the
scale where that stops being enough and what the fix would be, rather than
leaving the next person to measure it in production.

Also: the discrepancy id is in the log line that refuses an event, so triage
goes straight to the finding; the parser records why a canonicalized digest is
deliberately not used; and the ops guide covers Paystack secret rotation, where
a MISMATCH spike is expected and a NOT_CONFIGURED counter is a page.
@bbjiggy

bbjiggy commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — this was a useful review. Pushed f13c5ba, which acts on most of it and corrects one claim the PR made about its own design. CI is green at 500 tests.

One of your points found a real mistake, so I'll start there.

You were right about metrics, and the doc was wrong about why they were missing

PAYMENTS_LEDGER.md said observability was structured logs only because "there is no Actuator/Micrometer dependency anywhere in this codebase." That is false — spring-boot-starter-actuator and micrometer-registry-prometheus have been in the build all along, and signing/service/SigningMetrics.java is an established convention I should have followed. I carried that sentence over from an earlier doc without checking it.

PaymentMetrics now publishes at /actuator/prometheus:

Meter Tags Alert on
payments.webhook.events type, outcome Sustained REJECTED; APPLIED dropping to zero in business hours
payments.webhook.signature.failures reason Any NOT_CONFIGURED; rising MISMATCH is someone probing
payments.reconciliation.discrepancies type Any LEDGER_IMBALANCE; rising PROVIDER_STATUS_DIVERGENCE
payments.reconciliation.sweeps outcome failed, and completed going flat — a dead sweep looks exactly like "no divergence found"
payments.provider.unreachable Sustained rate = Paystack unreachable, not "nothing happening"

One thing worth flagging since it's a footgun in this specific design: the event type is caller-supplied, so tagging it directly would let anyone who can reach the webhook mint unbounded Prometheus series by POSTing new type strings. PaymentMetrics.eventType collapses anything outside the handled set to other.

reconcile() also counts its own failure now. A @Scheduled method that throws is logged once by Spring and is otherwise invisible.

Your @PreUpdate concern was well-placed, and the answer corrected me

I tested it instead of reasoning about it, and the result contradicted the PR body. LedgerAppendOnlyJpaTest covers both directions you raised:

  1. Does the guard misfire? No. A posting merely attached to the persistence context while unrelated entities are written and flushed in the same transaction doesn't trip anything. That's the case that would actually hurt — it's the shape of the capture path itself, which loads postings and saves a Payment in one unit of work.
  2. Does a rewritten posting survive? The row is untouched — but not because @PreUpdate fires. It never fires. Every column is updatable = false, so Hibernate emits no UPDATE at all and the callback is unreachable. The mapping is the defence; the callback is a backstop for a column someone adds later without the flag.

So the PR body overstated the guard's role. The test asserts the durable row rather than an exception, so it keeps holding whichever mechanism engages, and the doc now says which one actually does.

Indexes and expected scale

totalByDirection — the trial balance — was summing the whole journal with nothing behind it. Added idx_ledger_entry_currency_direction (currency, direction).

payment_reference, event timestamps and the idempotency key were already indexed (idx_ledger_transaction_payment, idx_processed_webhook_received, uk_processed_webhook_event_key, plus unique refs on payments/payouts).

On scale, written into the repository javadoc rather than left implicit: four entries per capture, so at ~10k captures/day the aggregate is milliseconds on an index. It's deliberately not paginated — a partial sum isn't a trial balance; summing half the journal reports an imbalance that isn't there. The number to watch is ~10M entries, where the fix is a rolling balance snapshot with periodic close. That introduces a derived total that can itself be wrong, so I'd rather not add it before it's needed.

Secrets, logs, and key rotation

Audited: the secret never reaches a logger. The only line that mentions it names the property, not the value; the invalid-signature line logs the body length, not the supplied signature; PaystackProperties has no Lombok @ToString, so it can't leak through an interpolated properties object. It's read from the environment only — never in application.properties, the image, or git.

Added a rotation runbook: a MISMATCH spike during the swap is expected (deliveries signed with the old key), and Paystack's own retry brings them back once the new key is live, so nothing needs replaying by hand. NOT_CONFIGURED is the one that deserves a page.

Refusals now log the discrepancy id, so triage goes from the log line straight to the finding.

On the idempotency fallback

You noted sha256(body) is OK given the signature is over raw bytes — agreed, and I've documented why the canonicalized alternative you floated is deliberately rejected. Canonicalizing means hashing something other than what was signed, and excluding "volatile" fields is a guess about a schema we don't own. Guess wrong and two distinct events collapse onto one key, which drops a real event silently. The raw digest's failure mode is a duplicate the state machine refuses loudly. That's the safer way to be wrong.

Diagrams

Added three Mermaid diagrams to PAYMENTS_LEDGER.md: event flow (delivery → signature gate → claim+effect transaction), the reconciliation/operator loop, and the projection tree showing what derives from the journal.


The tests you asked for already exist

All four categories are in the PR — I think this may have been read from the description rather than the Files tab, so here are the specific methods:

  • Signature verificationPaystackSignatureVerifierTest (10): valid, forged, missing, blank, truncated, a real signature over tampered bytes, and fail-closed on an unconfigured secret. End-to-end in PaymentWebhookIntegrationTest, asserting a forged delivery mutates nothing.
  • Duplicate/retried eventsPaymentWebhookIntegrationTest: byte-identical replay, replay after refund, and concurrent delivery of the same event across threads, asserting exactly one ledger effect.
  • Illegal transitions + discrepancy recordingPaymentLifecycleTest (25) covers every legal and illegal transition on both machines; out-of-order events are driven end-to-end through the webhook and asserted to produce ILLEGAL_TRANSITION.
  • Reconciliation report-onlyPaymentReconciliationIntegrationTest (15), including that a divergent sweep leaves the ledger byte-identical, and an 8-thread concurrent sweep filing the same finding produces one row and no error.

That last test found a genuine bug in my own code before this review (UnexpectedRollbackException from catching a constraint violation inside REQUIRES_NEW); the fix is in the branch history rather than squashed away.

DB migrations — I'd like your call before I do this one

This is the one item I haven't actioned, because I don't think I should decide it inside a payments PR.

There is no Flyway or Liquibase in this repo. Schema is spring.jpa.hibernate.ddl-auto=update for all 26 entities across every feature — escrow_orchestration_requests and on_chain_events landed the same way. So there are no existing migration files for the ledger tables to sit alongside.

Adding Flyway properly means baselining all 26 existing tables, adding the payments migration, and switching to ddl-auto=validate. That's a repo-wide infrastructure change touching every feature and every developer's local database, and a baseline that mismatches production breaks startup everywhere. Bundling it into this PR seems like the riskier path, and scoping Flyway to only the 7 new tables is worse than either extreme — two tools managing one database, with Hibernate free to alter Flyway-owned tables.

I'm happy to do the full baseline as a dedicated follow-up PR. Say the word and I'll open it.

In the meantime the schema requirements are explicit in PAYMENTS_LEDGER.md#schema--migrations: the 7 new tables create themselves on first deploy, and one manual statement is needed on any existing database, because ddl-auto=update cannot widen the stale CHECK constraint on transactions.transaction_status:

ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_transaction_status_check;

A fresh database needs nothing — CI proves that every run, since it starts a clean Postgres and the suite passes.

@meshackyaro

Copy link
Copy Markdown
Contributor

Thanks — this was a useful review. Pushed f13c5ba, which acts on most of it and corrects one claim the PR made about its own design. CI is green at 500 tests.

One of your points found a real mistake, so I'll start there.

You were right about metrics, and the doc was wrong about why they were missing

PAYMENTS_LEDGER.md said observability was structured logs only because "there is no Actuator/Micrometer dependency anywhere in this codebase." That is false — spring-boot-starter-actuator and micrometer-registry-prometheus have been in the build all along, and signing/service/SigningMetrics.java is an established convention I should have followed. I carried that sentence over from an earlier doc without checking it.

PaymentMetrics now publishes at /actuator/prometheus:

Meter Tags Alert on
payments.webhook.events type, outcome Sustained REJECTED; APPLIED dropping to zero in business hours
payments.webhook.signature.failures reason Any NOT_CONFIGURED; rising MISMATCH is someone probing
payments.reconciliation.discrepancies type Any LEDGER_IMBALANCE; rising PROVIDER_STATUS_DIVERGENCE
payments.reconciliation.sweeps outcome failed, and completed going flat — a dead sweep looks exactly like "no divergence found"
payments.provider.unreachable — Sustained rate = Paystack unreachable, not "nothing happening"
One thing worth flagging since it's a footgun in this specific design: the event type is caller-supplied, so tagging it directly would let anyone who can reach the webhook mint unbounded Prometheus series by POSTing new type strings. PaymentMetrics.eventType collapses anything outside the handled set to other.

reconcile() also counts its own failure now. A @Scheduled method that throws is logged once by Spring and is otherwise invisible.

Your @PreUpdate concern was well-placed, and the answer corrected me

I tested it instead of reasoning about it, and the result contradicted the PR body. LedgerAppendOnlyJpaTest covers both directions you raised:

  1. Does the guard misfire? No. A posting merely attached to the persistence context while unrelated entities are written and flushed in the same transaction doesn't trip anything. That's the case that would actually hurt — it's the shape of the capture path itself, which loads postings and saves a Payment in one unit of work.
  2. Does a rewritten posting survive? The row is untouched — but not because @PreUpdate fires. It never fires. Every column is updatable = false, so Hibernate emits no UPDATE at all and the callback is unreachable. The mapping is the defence; the callback is a backstop for a column someone adds later without the flag.

So the PR body overstated the guard's role. The test asserts the durable row rather than an exception, so it keeps holding whichever mechanism engages, and the doc now says which one actually does.

Indexes and expected scale

totalByDirection — the trial balance — was summing the whole journal with nothing behind it. Added idx_ledger_entry_currency_direction (currency, direction).

payment_reference, event timestamps and the idempotency key were already indexed (idx_ledger_transaction_payment, idx_processed_webhook_received, uk_processed_webhook_event_key, plus unique refs on payments/payouts).

On scale, written into the repository javadoc rather than left implicit: four entries per capture, so at ~10k captures/day the aggregate is milliseconds on an index. It's deliberately not paginated — a partial sum isn't a trial balance; summing half the journal reports an imbalance that isn't there. The number to watch is ~10M entries, where the fix is a rolling balance snapshot with periodic close. That introduces a derived total that can itself be wrong, so I'd rather not add it before it's needed.

Secrets, logs, and key rotation

Audited: the secret never reaches a logger. The only line that mentions it names the property, not the value; the invalid-signature line logs the body length, not the supplied signature; PaystackProperties has no Lombok @ToString, so it can't leak through an interpolated properties object. It's read from the environment only — never in application.properties, the image, or git.

Added a rotation runbook: a MISMATCH spike during the swap is expected (deliveries signed with the old key), and Paystack's own retry brings them back once the new key is live, so nothing needs replaying by hand. NOT_CONFIGURED is the one that deserves a page.

Refusals now log the discrepancy id, so triage goes from the log line straight to the finding.

On the idempotency fallback

You noted sha256(body) is OK given the signature is over raw bytes — agreed, and I've documented why the canonicalized alternative you floated is deliberately rejected. Canonicalizing means hashing something other than what was signed, and excluding "volatile" fields is a guess about a schema we don't own. Guess wrong and two distinct events collapse onto one key, which drops a real event silently. The raw digest's failure mode is a duplicate the state machine refuses loudly. That's the safer way to be wrong.

Diagrams

Added three Mermaid diagrams to PAYMENTS_LEDGER.md: event flow (delivery → signature gate → claim+effect transaction), the reconciliation/operator loop, and the projection tree showing what derives from the journal.

The tests you asked for already exist

All four categories are in the PR — I think this may have been read from the description rather than the Files tab, so here are the specific methods:

  • Signature verificationPaystackSignatureVerifierTest (10): valid, forged, missing, blank, truncated, a real signature over tampered bytes, and fail-closed on an unconfigured secret. End-to-end in PaymentWebhookIntegrationTest, asserting a forged delivery mutates nothing.
  • Duplicate/retried eventsPaymentWebhookIntegrationTest: byte-identical replay, replay after refund, and concurrent delivery of the same event across threads, asserting exactly one ledger effect.
  • Illegal transitions + discrepancy recordingPaymentLifecycleTest (25) covers every legal and illegal transition on both machines; out-of-order events are driven end-to-end through the webhook and asserted to produce ILLEGAL_TRANSITION.
  • Reconciliation report-onlyPaymentReconciliationIntegrationTest (15), including that a divergent sweep leaves the ledger byte-identical, and an 8-thread concurrent sweep filing the same finding produces one row and no error.

That last test found a genuine bug in my own code before this review (UnexpectedRollbackException from catching a constraint violation inside REQUIRES_NEW); the fix is in the branch history rather than squashed away.

DB migrations — I'd like your call before I do this one

This is the one item I haven't actioned, because I don't think I should decide it inside a payments PR.

There is no Flyway or Liquibase in this repo. Schema is spring.jpa.hibernate.ddl-auto=update for all 26 entities across every feature — escrow_orchestration_requests and on_chain_events landed the same way. So there are no existing migration files for the ledger tables to sit alongside.

Adding Flyway properly means baselining all 26 existing tables, adding the payments migration, and switching to ddl-auto=validate. That's a repo-wide infrastructure change touching every feature and every developer's local database, and a baseline that mismatches production breaks startup everywhere. Bundling it into this PR seems like the riskier path, and scoping Flyway to only the 7 new tables is worse than either extreme — two tools managing one database, with Hibernate free to alter Flyway-owned tables.

I'm happy to do the full baseline as a dedicated follow-up PR. Say the word and I'll open it.

In the meantime the schema requirements are explicit in PAYMENTS_LEDGER.md#schema--migrations: the 7 new tables create themselves on first deploy, and one manual statement is needed on any existing database, because ddl-auto=update cannot widen the stale CHECK constraint on transactions.transaction_status:

ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_transaction_status_check;

A fresh database needs nothing — CI proves that every run, since it starts a clean Postgres and the suite passes.

Here's my read on the comment, then the migration point.

Overall: This is a strong, well-substantiated response. A few things stand out as genuinely good engineering practice, not just good PR etiquette:

  • The metrics correction is handled honestly — they didn't just add metrics, they retracted a false claim in their own doc and cited the exact files (SigningMetrics.java) that should have been the precedent. Good instinct on eventType collapsing unbounded caller-supplied tags to other — that's a real cardinality-explosion vector in Prometheus and easy to miss.
  • The @PreUpdate finding is the best part of the whole comment. They tested rather than reasoned, found the callback never fires because updatable = false makes it unreachable, and corrected the PR's own claim about why the guard holds. That's a meaningfully different (and more accurate) safety argument than what was originally written, and it's the kind of thing that's easy to hand-wave past in a review.
  • The idempotency-key reasoning (raw digest over canonicalized) is sound: choosing a loud duplicate-rejection over a silent dropped-event is the right failure mode to prefer when you don't own the schema.
  • Pointing you to specific test methods instead of re-asserting "tests exist" is the right way to close out a review comment that flagged missing coverage.

Nothing here reads as hand-wavy — claims are backed by file names, test names, or an explicit test result. I'd merge on the substance of this comment.

On the DB migration: I agree with declining to bundle it into this PR — repo-wide ddl-auto migration touching all 26 entities is a different blast radius than a payments feature PR, and their reasoning against scoping Flyway to just the 7 new tables (two tools fighting over one schema) is correct.

But rather than telling them to go ahead and open the follow-up PR themselves, I'd suggest asking them to open it as an issue instead — not a PR. Reasons that are worth saying explicitly in your reply:

  • A repo-wide Flyway baseline is a maintainer-level decision (versioning scheme, baseline strategy, how it interacts with everyone's local dev DBs) — you likely want to weigh in on the design before code exists, not review it after the fact.
  • It keeps this contributor's payments PR scoped and mergeable without an implicit commitment to a second PR from them.
  • An issue documenting the problem (no Flyway/Liquibase, ddl-auto=update across 26 entities, the one manual ALTER TABLE needed for existing DBs) preserves everything they already wrote here — you can literally reference this PR comment — without obligating them or you to a timeline.

A reply along these lines should do it:

Agreed on keeping this out of the PR. Rather than you opening a follow-up PR, could you file this as an issue instead? The baseline strategy affects all 26 entities and every contributor's local DB, so I'd like to make that call as a repo-wide decision rather than review it as a fait accompli in a PR. Feel free to copy your migration analysis above into the issue — it's a good summary of the tradeoffs.

Do that and this is ready to approve and merge

@bbjiggy

bbjiggy commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Filed as #52 — agreed that's the better shape for it, and for the reason you gave: the baseline strategy is a repo-wide call that wants deciding before code exists, not reviewing after.

I carried the tradeoff analysis over and added the concrete details a design discussion will actually need, which I checked against a schema built fresh from the current entity set rather than restating the PR comment:

  • 26 entities produce 27 tables. The extra is transaction_history_transactions, an implicit @ManyToMany join table on TransactionHistory.
  • 4 entities have no explicit @Table(name = ...)Address, Consultation, ConsultationAvailability, TransactionHistory — so they depend on Hibernate's implicit naming strategy. A hand-written baseline has to reproduce those names exactly or ddl-auto=validate fails at startup, which is the kind of thing that's easy to discover late.
  • CI structurally cannot catch this class of bug today. It starts a clean Postgres every run, so only the create-from-scratch path is ever exercised. The transactions_transaction_status_check failure is invisible in CI and appears only against a long-lived database — which is why the issue proposes a job that migrates from the previous release's schema as a separate question worth settling.

The five decisions I flagged as yours rather than mine: Flyway vs. Liquibase, baseline-at-V1 vs. start-from-payments, whether ddl-auto becomes validate, how existing contributor databases get reset, and whether CI gains that second job. Happy to pick the work up once those are settled — but nothing in this PR depends on it.

I left difficulty, priority and campaign labels off #52 deliberately; those looked like maintainer calls rather than mine to assign.

This PR is unchanged since f13c5ba and CI is green at 500 tests. The one operational note for merging still stands: a fresh database needs nothing, and an existing one needs the single documented ALTER TABLE until #52 supersedes it.

@meshackyaro meshackyaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really appreciate the thoroughness here @bbjiggy — the metrics retraction, the @PreUpdate testing-over-reasoning, and the idempotency tradeoff reasoning are all the kind of rigor that makes a payments PR trustworthy. Merging on the strength of this. And thanks for laying out the migration tradeoffs so clearly — made spinning it off into its own issue an easy call rather than a debate. Nice work all around.

@meshackyaro
meshackyaro merged commit fa4373e into workman-labs:development Aug 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Payments Ledger with Paystack Webhook Verification & Double-Entry Reconciliation

2 participants