Skip to content

feat(pay-05): real handler bodies (Phase 2b — DDB stores + SES + sanitization) - #6

Merged
miguelaferreira merged 9 commits into
mainfrom
feat/pay-05-phase-2b-handler-bodies
May 4, 2026
Merged

feat(pay-05): real handler bodies (Phase 2b — DDB stores + SES + sanitization)#6
miguelaferreira merged 9 commits into
mainfrom
feat/pay-05-phase-2b-handler-bodies

Conversation

@ff-team-sobrado

@ff-team-sobrado ff-team-sobrado commented May 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PAY-05 Phase 2b — full implementation of payment_intent.succeeded and payment_intent.payment_failed handlers per docs/pay-05-handler-design.md §4.1 + §4.2. Stacked on PR #5 (now merged). Closes the BLOCKING amendment Tomás M-NEW-1 raised on infrastructure!14 (degraded-mode short-circuit on the AuditLog SSM-bridge sentinel).

Phase 2b body work (carried forward from the original scope)

Stores + services (raw-DDB discipline, not Enhanced client)

  • InvestmentReservationStore, InvestmentPaymentStore, UserInvestmentStore, AuditLogStore — all Update/Put builders so the handler composes a single TransactWriteItems per event.
  • SesEmailService — Phase-1 placeholder English copy, URL-connection HTTP client (native-image-friendly), best-effort failure (returns false, never throws). PAY-21 swaps in pt-PT templated copy.
  • SesV2ClientFactory@Replaces(SesV2Client.class) mirroring the Secrets Manager / DynamoDB factory pattern.
  • ReservationKey — Stripe-metadata-parsed record; IllegalArgumentException carries field names only (no values, M1 hygiene applied to error paths).

Real handlers

  • PaymentIntentSucceededHandler.handle(...) — full §4.1 sequence: extract → load + guard → atomic 4-op TransactWriteItems (payment processed → success, reservation confirmed → executed, idempotent Put UserInvestment, Put AuditLog) → SES best-effort. TransactionCanceledException-only catch per Tomás §10 R4.
  • PaymentIntentFailedHandler.handle(...) — full §4.2: same load+guard discipline; sanitises lastPaymentError.message (cap to ≤500 chars + strip control chars per §10 M1; logs Stripe's errorCode only); branches reservation target on expiresAt vs event.created (pending for retry-window-open, expired past-window); 3-op TWI; no email.

M-NEW-1 — degraded-mode short-circuit (BLOCKING amendment from infrastructure!14)

Both handlers now detect the "PENDING_AUDIT_LOG_BRIDGE" sentinel that Tofu writes into AUDIT_LOG_TABLE_NAME when var.audit_log_ssm_bridge_ready=false, and short-circuit before any DDB read or write. Without this:

  1. Stripe webhook arrives. PAY-02 HMAC + idempotency record succeed.
  2. Handler builds the 4-item TWI; the AuditLog PutItem targets a non-existent table.
  3. DDB cancels the entire transaction atomically — payment + reservation + UserInvestment + AuditLog all roll back.
  4. Handler returns 200, Stripe stops retrying, and Sobrado has zero record of the payment.

Behavioural contract on degraded mode:

  • ERROR log at construction with the canary "DEGRADED MODE" + the sentinel value (one-line ops signal "bridge not ready").
  • ERROR log at every dispatch carrying eventId + eventType + the same canary.
  • No DDB reads, no DDB writes, no SES, no markProcessed. The WebhookEvent row stays processed=false so the §6 / M3 alarm fires.

Sentinel string identity ("PENDING_AUDIT_LOG_BRIDGE") and canary ("DEGRADED MODE") are structurally coupled to:

  • aws-stripe-webhook-lambda.tofu (env-var collapse value, already in infrastructure!14).
  • aws-webhook-events-monitoring.tofu log-metric-filter pattern alternation ?"M3 alarm will surface this" ?"DEGRADED MODE" (ships in the follow-up infrastructure MR opened alongside this PR).

Tests — 78 total, 0 failures

Class Tests
PaymentIntentSucceededHandlerTest 10 (incl. M-NEW-1 degraded mode + M4 case 16)
PaymentIntentFailedHandlerTest 14 (incl. M-NEW-1 degraded mode + M4 case 16 + M1 sanitisation)
WebhookEventDispatcherTest 10
IgnoredEventHandlerTest 5
WebhookIdempotencyStoreTest 2
WebhookEventProcessorTest 15 (incl. M4 case 17 fallback + body-echo regression)
StripeSignatureVerifierTest 14
secret/* 8

./gradlew test: BUILD SUCCESSFUL.

Tomás's M1–M4 / R1–R4 + M-NEW-1 closure

Item Status Where
M1 errorMessage cap+sanitise PaymentIntentFailedHandler.sanitizeErrorMessage
M2 receivedAt in AuditLog.details both handlers' buildAuditDetails
M3 processed=false > 5min alarm ✅ partial-coverage (markProcessed canary) shipped in infrastructure!14; strict periodic-scan promoted to Phase-1 launch gate (M-NEW-2)
M4 defensive event-shape tests case 16 (missing data.object.id) in both handler tests; case 17 (missing/bad event.created) in WebhookEventProcessorTest
R1 IAM wildcard scoping comment aws-stripe-webhook-iam.tofu (infrastructure!14)
R2 SES Condition: ses:FromAddress aws-stripe-webhook-iam.tofu (infrastructure!14)
R3 lambdaVersion+gitSha in details both handlers' constructor @Value("${dd.version}") + @Value("${git.sha}")
R4 TransactionCanceledException-specific catch both handlers (NOT generic Exception)
M-NEW-1 degraded-mode short-circuit ✅ THIS PR both handlers' degradedMode flag + new unit test in each handler test class

Cross-track impact

  • PAY-21 (SES email): this PR ships the email trigger with English placeholder copy. PAY-21 swaps in pt-PT templated copy + investor-email lookup (Stripe's receipt_email is not always populated by PaymentLambda; PAY-21 closes that gap).
  • PAY-09 (Flutter PaymentBloc): no change — this PR writes only the existing executed/failed/expired/success enum values the schema already declares. The §9.3 design doc pin (unit test asserting handlers write only documented status values) covers regressions.
  • PAY-23 (PII CMK): respected — no InvestorIban access, no PII CMK Decrypt grants.
  • PAY-07 (reconciliation): the AuditLog row this handler writes is the in-DDB business-event audit trail. The S3 raw-webhook-body bucket is PAY-08 (separate); the Stripe-vs-DDB daily diff is PAY-07.

Apply order to clear the AuditLog SSM bridge gate

  1. Application MR (TBD) adds "AuditLog" to amplify/backend.ts:tablesToExport, deploys.
  2. Infrastructure: set var.audit_log_ssm_bridge_ready = true in secrets.auto.tfvars, replan + reapply.
  3. Lambda's AUDIT_LOG_TABLE_NAME resolves to the real table name on the next image roll. Degraded-mode auto-clears at cold start.

Until step 3 lands, the Lambda runs in degraded mode end-to-end: handlers short-circuit, the "DEGRADED MODE" ERROR fires per dispatch, the M3 alarm extension catches it. Zero risk of silent payment data loss during the cross-repo deploy window.

Companion infrastructure MR

Follow-up MR adds the M-NEW-1 alarm-pattern alternation (?"M3 alarm will surface this" ?"DEGRADED MODE") to aws-webhook-events-monitoring.tofu. Same review pair, separate scope.

PR review pair

  • Rui primary (handler architecture, store discipline, M-NEW-1 wiring, test surface).
  • Tomás security review (M-NEW-1 patch verification, M1+M2+M4 verification in code, R1+R4 in code, sanitisation correctness).

References

…her (scaffold)

This MR is the architecture half of PAY-05. The follow-up MR replaces
the scaffold handler bodies with the real TransactWriteItems + AuditLog
+ SES email logic; this MR sets up the routing and the contract review.

Why split it: the design is mostly review (contracts, transitions, IAM
scope, AuditLog payload) and the implementation is mostly mechanical
(four DDB writes per handler, two handlers). Reviewing them together
mixes "is the architecture right" with "is the SDK call right" and
slows both. Tomás reviews this MR for the design + IAM-expansion plan;
the follow-up MR is purely the handler bodies against the design.

Changes
-------

- `docs/pay-05-handler-design.md` (new): full PAY-05 contract — dispatch
  table, idempotency boundary, status-transition table copied from the
  application schema's documented matrix, per-handler write sequences,
  AuditLog payload shape, partial-write recovery, IAM scope expansion
  list, test coverage matrix (12 unit + 3 smoke = 15 cases hitting the
  6+ negative paths Notion requires), explicit out-of-scope list.

- `dispatch/EventHandler.java` (new): per-event-type handler interface.
  Single method `handle(eventType, eventId)`. Idempotency + failure
  semantics documented in the javadoc.

- `dispatch/IgnoredEventHandler.java` (new): default fallback for any
  event type not in the dispatch table (and for null types). Logs +
  returns. Same observable behaviour as PAY-02's unknown-type branch.

- `dispatch/handlers/PaymentIntentSucceededHandler.java` (new): SCAFFOLD.
  @nAmed("payment_intent.succeeded"). Logs + returns. Body lands in
  follow-up MR.

- `dispatch/handlers/PaymentIntentFailedHandler.java` (new): SCAFFOLD.
  @nAmed("payment_intent.payment_failed"). Logs + returns. Body lands
  in follow-up MR.

- `dispatch/WebhookEventDispatcher.java` (refactor): replaces the
  hard-coded `KNOWN_EVENT_TYPES` set with a `Map<String, EventHandler>`
  injected by Micronaut from all `@Named` `EventHandler` beans. Routing
  is one map lookup; misses fall through to `IgnoredEventHandler`. New
  handlers are added as @nAmed beans without touching the dispatcher.

- Test updates:
  - `dispatch/WebhookEventDispatcherTest.java`: rewritten against the
    table-driven shape. Asserts routing fidelity per type, fall-through
    on unrecognised types and null types, exception propagation
    (orchestrator owns the catch — see WebhookEventProcessorTest), and
    empty-table fall-through.
  - `dispatch/IgnoredEventHandlerTest.java` (new): null-safety on both
    parameters; never throws.

- `WebhookEventProcessor` (unchanged): the dispatcher's public method
  signature is preserved (`dispatch(String eventType, String eventId)`)
  so the orchestrator's call site and its mock-based test stay green.

Observable behaviour
--------------------

Identical to PAY-02. Every event is logged + returns 200; no DDB writes
beyond the existing WebhookEvent idempotency row. The PAY-02 smoke test
(`infrastructure/scripts/smoke-test-stripe-webhook.sh`) is unaffected.

Tests
-----

- `./gradlew test`: BUILD SUCCESSFUL (all suites, 0 failures).
- Dispatcher routing tests cover: succeeded → succeeded handler;
  payment_failed → failed handler; 4 unrecognised types → ignored
  handler; null type → ignored handler; throwing handler propagates;
  empty table routes everything to ignored.

Refs
----

- Notion PAY-05: https://www.notion.so/34fd48539664810c9036d028db1b5e39
- `docs/pay-05-handler-design.md` (this MR)
- `application/plans/epic-pay/2026-04-27-team-review.md` sub-ticket PAY-05
- Follow-up MR (handler bodies + IAM scope expansion in infrastructure
  repo): TBD this sprint.

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
…r + body-echo regression test

This commit is Phase 2a of PAY-05. It evolves the dispatch interface so
the per-event handlers receive a typed, parsed view of the event
(including the type-specific `event.data.object` JSON node) instead of
just the (eventType, eventId) tuple. This is the input shape the
follow-up Phase 2b MR's real handler bodies (TransactWriteItems +
AuditLog + SES email — see `docs/pay-05-handler-design.md` §4) need.

The actual handler bodies remain SCAFFOLD in this commit, but they now
extract the relevant fields from `event.data.object` (paymentIntentId,
last_payment_error.message) and log them — observable behaviour is
still strictly a superset of PAY-02's no-op log; no DDB writes, no
SES.

Changes
-------

- New `event/StripeWebhookEvent.java` — record carrying eventId,
  eventType, created (Instant from `event.created` epoch second),
  and dataObject (the type-specific `event.data.object` JsonNode).
  Documented why a record (GraalVM native-image footprint, value
  equality without bytecode generation) and why dataObject stays
  untyped at this boundary (handlers bind their own typed view).

- `dispatch/EventHandler` signature changes from
  `handle(String eventType, String eventId)` to
  `handle(StripeWebhookEvent event)`. Idempotency + failure
  semantics in javadoc unchanged.

- `dispatch/IgnoredEventHandler.handle(...)` adapts to the new
  signature; behaviour unchanged.

- `dispatch/WebhookEventDispatcher.dispatch(...)` signature changes
  from `(String, String)` to `(StripeWebhookEvent)`; routing logic
  unchanged.

- `dispatch/handlers/PaymentIntentSucceededHandler` body is still
  SCAFFOLD but now extracts `event.data.object.id` (the Stripe
  paymentIntentId) and logs it alongside the eventId. The follow-up
  MR replaces the body with the real TransactWriteItems + AuditLog +
  SES email path.

- `dispatch/handlers/PaymentIntentFailedHandler` body is still
  SCAFFOLD but now extracts `event.data.object.id` and the error
  message hash from `event.data.object.last_payment_error.message`.
  Follow-up MR replaces with the real path.

- `WebhookEventProcessor#process(...)` builds a `StripeWebhookEvent`
  after JSON parse + idempotency check, with `event.created` lifted
  to an `Instant` (falls back to receiver clock if Stripe omitted
  it — defensive). The build is private + static; no behavioural
  change for the verify / parse / idempotency / replay paths.

  Also added a security comment at the InvalidSignatureException
  catch site that pins Tomás's pre-flight invariant: do NOT echo
  rawBody in the failure log path — the exception message is
  parameterised and contains only the failure category.

Test changes
------------

- `WebhookEventDispatcherTest` rewritten against the new
  signature. All routing scenarios preserved (succeeded, failed,
  4 unrecognised types, null type, exception propagation, empty
  table). The null-type assertion now uses ArgumentCaptor to verify
  the StripeWebhookEvent's eventType is null.

- `IgnoredEventHandlerTest` adapts to the typed event signature.

- `WebhookEventProcessorTest`:
  - All `dispatch(eventType, eventId)` matchers updated to
    `dispatch(StripeWebhookEvent)` with ArgumentCaptor where field-
    level assertions are needed.
  - **New test `invalidSignatureLogPathDoesNotEchoRequestBody`** —
    Tomás's PAY-05 pre-flight ask: pins the contract that the
    signature-rejection log path doesn't echo request body content.
    Plants a `DO_NOT_LOG_ME_42` canary in the body, attaches a
    Logback ListAppender, asserts no log line contains the canary
    or `secret_canary` field name. Belt-and-braces regression guard
    against future log-statement edits in the catch block.

Tests: `./gradlew test` BUILD SUCCESSFUL.

Cross-repo coordination remaining for Phase 2b (separate MRs)
-------------------------------------------------------------

- **infrastructure repo:** IAM scope expansion (DDB grants on
  InvestmentReservation, UserInvestment, AuditLog tables;
  ses:SendEmail on the from-domain identity), 5 new env vars wired
  via the SSM bridge, `aws-stripe-webhook-lambda.tofu` env block.
- **payment-lambda repo (optional optimisation):** extend the Stripe
  PaymentIntent metadata block to include `paymentRowId` +
  `paymentRowVersion` so the webhook can do a direct DDB GetItem on
  InvestmentPayment instead of the reservation-FK Query + filter
  fallback path. Documented in `docs/pay-05-handler-design.md` §4.1.
- **stripe-webhook repo:** four DDB stores (InvestmentReservation,
  InvestmentPayment, UserInvestment, AuditLog) + SesEmailService +
  real handler bodies + 12 unit + 3 smoke = 15 test cases per the
  design doc §8 coverage matrix. All slot into the typed-event
  shape this commit lands.

Refs
----

- Notion PAY-05: https://www.notion.so/34fd48539664810c9036d028db1b5e39
- `docs/pay-05-handler-design.md` (this branch)
- Tomás PAY-03 review pre-flight item for PAY-05 — the
  no-body-echo invariant is now regression-pinned by
  `invalidSignatureLogPathDoesNotEchoRequestBody`

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
…dler design

Tomás's PAY-05 design review (2026-05-03) returned APPROVE WITH
AMENDMENTS. Folding all 8 items into `docs/pay-05-handler-design.md`
now, while the warm context is here, so the Phase-2 (handler-bodies)
MR opens against a contract Tomás has already signed off on the shape
of. None of these items block the scaffold MR — that's pure routing
infrastructure with no IAM / no DDB writes / no behaviour change vs
PAY-02; Tomás's APPROVE on the scaffold is unconditional. This
commit only edits the design doc, not the code.

## Changes

### Mandatory (blocking the Phase-2 MR)

- **M1** — §4.2 step 3 + persist contract: `lastPaymentError.message`
  is raw Stripe-supplied data. New rules: length-cap to ≤500 chars on
  write; strip control chars; document Flutter MUST NOT render as
  HTML; log Stripe's `errorCode` (categorised: `card_declined`,
  `insufficient_funds`, etc.) at INFO, NOT the raw message — same
  posture as PAY-23's `PiiLogFilter` STRICT default. The full
  sanitized message is persisted to DDB but stays out of CloudWatch.
- **M2** — §5 details JSON shape + rationale: add `receivedAt`
  (Lambda wall-clock at handler dispatch) to `AuditLog.details`;
  `AuditLog.timestamp` keeps `event.created` (Stripe-attestable). A
  meaningful gap between the two (>5 min) is itself an alarm signal
  pointing at Stripe-side delivery latency or our queueing hops.
- **M3** — §6 + Phase-2b infrastructure MR: provision a CloudWatch
  alarm on `WebhookEvent.processed = false` count > 0 for >5 min.
  Routes to the same Slack chatbot as the webhook DLQ alarm (via
  `aws_sns_topic.webhook_dlq_alarms` already wired by PAY-03 /
  `infrastructure!12`). Ops paging target <10 min. Was a dashboard
  query in v1; Tomás's bar is correct — silent data corruption is the
  worst-class outcome.
- **M4** — §8 test matrix expands from 12+3 = 15 to 14+3 = 17 cases:
  case 16 = `event.data.object.id` missing/null → log + return; case
  17 = `event.created` missing/zero → log warn + fallback to
  `Instant.now()` for the AuditLog timestamp.

### Recommended (non-blocking)

- **R1** — §7 wildcard-ARN scoping note: document the assumption that
  `*<TableName>*` is safe TODAY because each env is a separate AWS
  account; carry a TODO for the future "consolidate accounts"
  scenario where the patterns must be tightened to SSM-bridged exact
  ARNs.
- **R2** — §7 SES `Condition: ses:FromAddress` note: SES grant uses
  `Resource = arn:aws:ses:eu-west-1:<acct>:identity/<from-domain>`
  PLUS `Condition: ses:FromAddress = "noreply@<env-domain>"`. Grant
  covers `ses:SendEmail` only (not `ses:SendRawEmail`); PAY-21 owns
  bounce/complaint configuration and any attachment-bearing emails.
- **R3** — §5 details JSON shape: add `lambdaVersion` (image tag,
  sourced from `DD_VERSION`) and `gitSha` (build-time embedded
  resource) to `AuditLog.details` for forensic correlation. "Which
  build of the handler wrote this row" should not depend on
  CloudWatch log retention.
- **R4** — §6 catch specificity note: the handler's catch around
  `TransactWriteItems` MUST be `ConditionalCheckFailedException`-
  specific (or, for the transactional API,
  `TransactionCanceledException` followed by inspection of per-item
  cancellation reasons). Generic `catch (Exception e)` would swallow
  real bugs (transient DDB / IAM regressions / throttling) into the
  guard-violation path. Distinct outcomes: guard violations
  log+skip+200 (no human action); real bugs propagate to the
  orchestrator's outer catch which logs ERROR + class + stack and
  leaves `WebhookEvent.processed=false` for the M3 alarm to fire on.

### Cross-track interactions §9.3

- PAY-23 PII CMK constraint: PAY-05 does not touch `InvestorIban` or
  the PII CMK; if Phase 2 ever surfaces a need, route through Tomás
  first.
- PAY-09 BLoC enum: Phase 2 writes only the existing schema enum
  values (`executed` / `failed` / `expired` / `success`); no
  SendMessage to Rui-flutter needed. Pin this with a Phase-2 unit
  test asserting the handler writes only documented values.
- PAY-22: orthogonal; gated by Tomás's separate §12.4 9-item
  checklist when it spawns.

## Front-matter status

- "Status:" updated to flag the 2026-05-03 amendments folded in.
- "Reviewers:" updated to record Tomás's APPROVE WITH AMENDMENTS
  verdict pointing at §9.

## What's deliberately not in this commit

- No code changes. The scaffold MR's behaviour is unchanged: every
  event still logs + returns 200; the typed-event signature lands as
  Phase 2a.
- The Phase-2b MR (handler bodies + IAM expansion + smoke test +
  alarm provisioning) lands separately. This commit is the contract
  it implements against.

Tests: `./gradlew test` BUILD SUCCESSFUL (no code changes; sanity
re-run only).

Refs:
- Tomás review (saved to `/tmp/pay-05-review.md`); summary inline
  in his MR comment thread.
- `application/plans/epic-pay/2026-04-27-team-review.md` PAY-05.
- Notion PAY-05: https://www.notion.so/34fd48539664810c9036d028db1b5e39

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
…ES email + tests

Phase 2b of PAY-05. Replaces the SCAFFOLD bodies on
`PaymentIntent{Succeeded,Failed}Handler` with the real implementation
specified in `docs/pay-05-handler-design.md` §4.1 + §4.2 (with all
8 of Tomás's M1–M4 + R1–R4 amendments folded in).

Companion infrastructure MR (IAM scope expansion, 5 new env vars on
`aws-stripe-webhook-lambda.tofu`, CloudWatch alarm on
`WebhookEvent.processed=false`, smoke test) lands separately in the
infrastructure repo against the same review pair.

What's in this commit
=====================

## New supporting classes

- `reservation/ReservationKey.java` — record mirroring the Amplify
  schema's `InvestmentReservation` identifier tuple. Built from the
  Stripe PaymentIntent metadata block PaymentLambda writes
  (`reservationUserId`, `reservationInvestmentId`,
  `reservationInvestmentVersion`, `reservationRequestedAt`,
  `reservationVersion`). `IllegalArgumentException` messages contain
  field names only — never values — so callers can safely log them.

- `dynamodb/ReservationView.java` + `dynamodb/PaymentView.java` —
  immutable projected views of the two tables. Tomás-veto-discipline:
  only the fields the handlers act on are projected.

- `dynamodb/InvestmentReservationStore.java` — strong-consistent
  `GetItem` + `buildStatusUpdate(...)` (returns a `Update` payload to
  embed in `TransactWriteItems`; no execution). Mirrors the
  `payment-lambda` repo's `InvestmentReservationStore` for key shape
  consistency (the literal `#` characters in the Amplify-generated
  sort-key attribute name + matching separator-joined value).

- `dynamodb/InvestmentPaymentStore.java` — `findByReservationAndIntent`
  via the Amplify-auto-generated GSI on the reservation FK
  (configurable index name via env var
  `investment-payment.by-reservation-index-name`, default
  `"investmentPaymentByReservation"` so a smoke-test mismatch is fixable
  without a code deploy). Filters in-memory by `stripePaymentIntentId`
  — the result set is small (1–3 across retries). `buildStatusUpdate`
  composes the conditional update payload.

- `dynamodb/UserInvestmentStore.java` — `buildIdempotentPut` carries
  `attribute_not_exists(userId)` as the second-line idempotency
  primitive. The first line is the `WebhookEvent` row from PAY-02.

- `dynamodb/AuditLogStore.java` — `buildPut` composes the
  `AuditLog` row per design §5: reuses `DATA_ACCESS` enum,
  semantic distinction in the searchable `action` field
  (`payment.succeeded` / `payment.failed`). Details JSON includes
  `receivedAt` (Tomás §10 M2), `lambdaVersion` + `gitSha` (R3),
  `lastPaymentErrorCode` + sanitized `lastPaymentError` on failures
  (M1).

- `email/SesEmailService.java` — Phase-1 placeholder English copy.
  PAY-21 swaps to a templated, pt-PT version. SES failure is
  best-effort: returns `false`, never throws — payment is confirmed
  in DDB regardless. URL-connection HTTP client (consistent with the
  rest of this Lambda).

- `email/SesV2ClientFactory.java` — `@Replaces(SesV2Client.class)` +
  URL-connection pattern. Same shape as `SecretsManagerClientFactory`
  and `DynamoDbClientFactory`.

## Real handler bodies

- `PaymentIntentSucceededHandler.handle(...)` — implements design §4.1
  end-to-end: extract reservation key from metadata; load reservation
  + status guard (`confirmed`); load payment + status guard
  (`processed`); single `TransactWriteItems` with 4 ops (payment
  `processed -> success`, reservation `confirmed -> executed`,
  idempotent `Put UserInvestment`, `Put AuditLog`); SES email
  best-effort. `TransactionCanceledException` is the only catch
  (Tomás §10 R4: distinct path for guard violations vs real bugs);
  any other exception propagates to the orchestrator's outer catch.

- `PaymentIntentFailedHandler.handle(...)` — implements design §4.2:
  same status-guard discipline; sanitizes `lastPaymentError.message`
  (cap to 500 chars + strip control chars per Tomás §10 M1; logs
  `errorCode` only, NOT raw message — same posture as PAY-23's
  `PiiLogFilter` STRICT default); branches reservation target on
  `expiresAt` vs `event.created` (`pending` for retry-window-open,
  `expired` for past-window); 3-op `TransactWriteItems` (no
  UserInvestment); no email.

## build.gradle

- New `software.amazon.awssdk:sesv2` dependency with the same
  apache-/netty-client exclusions as the other AWS SDK clients.

Tests
=====

- `PaymentIntentSucceededHandlerTest` — 8 unit tests covering: happy
  path (4-write TWI + SES), payment-already-success guard,
  reservation-already-executed guard, missing payment, missing
  reservation, `TransactionCanceledException` is swallowed (concurrent
  guard violation), SES failure does not roll back DDB, missing
  `data.object.id` (Tomás §10 M4 case 16), missing `receipt_email`
  (DDB writes happen, SES skipped — PAY-21 will extend).

- `PaymentIntentFailedHandlerTest` — 8 unit tests covering: happy
  path with retry-window-open (reservation `pending`), happy path
  with past-`expiresAt` (reservation `expired`), error-message
  sanitization (cap to 500 + strip control chars + null/empty
  handling — Tomás §10 M1), missing-id defensive case (M4 case 16),
  isPastExpiry helper edge cases (null / empty / unparseable
  `expiresAt` — defensive against schema rows from before D-4
  shipped per-method expiry).

`./gradlew test`: BUILD SUCCESSFUL.

What's still pending for Phase 2b (separate MRs)
================================================

- **Infrastructure repo:**
  - IAM scope expansion on the Lambda role (DDB on
    `*InvestmentReservation*` get/update + index/Query;
    `*UserInvestment*` get/put; `*AuditLog*` put; `ses:SendEmail`
    on the from-domain identity ARN with
    `Condition: ses:FromAddress = "noreply@<env-domain>"` per
    Tomás §10 R2).
  - 5 new env vars on `aws-stripe-webhook-lambda.tofu` resolved via
    the Amplify SSM bridge: `INVESTMENT_RESERVATION_TABLE_NAME`,
    `INVESTMENT_PAYMENT_TABLE_NAME`,
    `INVESTMENT_PAYMENT_BY_RESERVATION_INDEX_NAME`,
    `USER_INVESTMENT_TABLE_NAME`, `AUDIT_LOG_TABLE_NAME`,
    `SES_FROM_ADDRESS`.
  - CloudWatch alarm on `WebhookEvent.processed=false` count > 0
    for >5 min, routing to the existing `webhook_dlq_alarms` SNS
    topic (Tomás §10 M3).
  - `scripts/smoke-test-pay-05-handlers.sh` driving
    `stripe trigger payment_intent.succeeded /
    payment_intent.payment_failed` against dev — asserts the 4 DDB
    writes + AuditLog + (when receipt_email present) email; replay
    asserts single UserInvestment row (cases 13/14/15 of design §8).

- **payment-lambda repo (optional optimisation):**
  - Extend `CreatePaymentIntentProcessor.stripeMetadata(...)` to
    include `paymentRowId` and `paymentRowVersion`. Lets PAY-05's
    webhook do a direct `GetItem` instead of the FK Query + filter
    fallback. Not strictly needed (the fallback path works, and
    has tests proving so) but cleaner.

- **stripe-webhook repo follow-up:**
  - SES `receipt_email` extension: pull investor email from the
    metadata block once PaymentLambda surfaces it (avoids depending
    on Stripe's `receipt_email` field, which the current
    PaymentLambda doesn't populate). PAY-21 owns this transition.
  - `WebhookEvent.processed = true` update after successful TWI —
    currently a TODO (the row stays `processed=false` even on
    success today; the M3 alarm will catch any rows that linger).
    Fix is small (extend `WebhookIdempotencyStore` with a
    `markProcessed(eventId)` method); landing alongside the
    infrastructure MR.

Refs
====

- Notion PAY-05: https://www.notion.so/34fd48539664810c9036d028db1b5e39
- `docs/pay-05-handler-design.md` (this branch — §9 Tomás review log)
- Scaffold PR (parent of this stack): #5
- Tomás's PAY-05 design review (saved /tmp/pay-05-review.md, summary
  in PR #5 thread)

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
…nups)

Two cleanups team-lead asked for before Tomás's Phase-2b code review:

1. **`WebhookIdempotencyStore.markProcessed(eventId)`** — and call from
   both handlers after successful `TransactWriteItems`. Without it the
   `WebhookEvent` row stays `processed=false` forever and Tomás §10
   M3's CloudWatch alarm (
   `count(processed=false) > 0 for >5min`) would fire on every handled
   event. Best-effort semantics: any failure on the markProcessed
   update logs at WARN and is swallowed — the business state is
   already correct in DDB and the M3 alarm is the safety net for
   anything that lingers. Added `@NewSpan` for tracing parity with
   the rest of the store.

2. **Explicit tests for Tomás §10 M4 case 17** — the design doc said
   case 17 was a structural fallback in `WebhookEventProcessor.buildEvent(...)`
   but had no unit test pinning it. Added two tests:
   - `missingEventCreatedFallsBackToReceiverClock` — body with no
     `created` field; asserts `dispatched.created()` equals the
     fixed-clock receiver `now`.
   - `nonNumericEventCreatedFallsBackToReceiverClock` — body with
     `"created":"oops-not-a-number"` (Stripe shouldn't ever do this,
     but the parser must be defensive); asserts the same fallback.

   Both tests use ArgumentCaptor on the dispatcher mock to inspect
   the `StripeWebhookEvent.created()` field on the dispatched event.

Constructor changes
-------------------

`PaymentIntentSucceededHandler` and `PaymentIntentFailedHandler` both
gain a `WebhookIdempotencyStore` constructor parameter (one extra
position before `DynamoDbClient`). The handler tests are updated to
inject the mock; the happy-path tests now also verify
`markProcessed(EVENT_ID)` is called.

Tests
-----

`./gradlew test`: BUILD SUCCESSFUL.

What this commit does NOT touch
-------------------------------

- The Phase-2b infrastructure MR (IAM scope expansion + 6 SSM env
  vars + M3 CloudWatch alarm + smoke test) — separate MR in the
  infrastructure repo, not yet started.
- The PaymentLambda metadata-extension optional optimisation —
  deferred to PAY-21 if needed.
- `receipt_email` plumbing — known gap; PAY-21 closes by populating
  the field on the PaymentIntent or by adding the email to the
  metadata block.

Refs
----

- Tomás §10 M3 (CloudWatch alarm — handled in companion infra MR)
- Tomás §10 M4 case 17 (defensive test — addressed here)
- `docs/pay-05-handler-design.md` §6 (best-effort + alarm safety net)

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>

@ff-team-sobrado ff-team-sobrado left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Posted on behalf of Tomás (Security Champion) — same pattern as the prior PR #5 reviews. Tomás cannot post directly from his sub-agent session (GitHub MCP doesn't surface there). The team bot identity is the GitHub author of this review. Authorship is Tomás's; verbatim below.

Verdict at a glance: APPROVE for PR #6 cleanup commit 0186148. However: a companion BLOCKING amendment on infrastructure!14 (M-NEW-1, silent-payment-data-loss vulnerability in the audit_log_ssm_bridge_ready=false deploy window) requires a small handler change in this same Lambda code repository before the Phase-2b deploy is safe. The M-NEW-1 fix lands as a follow-up commit on this branch (feat/pay-05-phase-2b-handler-bodies) before merge. See /tmp/pay-05-mr14-review.md §M-NEW-1 (also at https://gitlab.com/functorful/projects/sobrado/infrastructure/-/merge_requests/14#note_3309173324) for full code shape + unit test + extended alarm pattern.


Tomás re-review — PR #6 cleanup commit 0186148: APPROVE

Verified the two cleanups Rui-backend added on top of PR #6's prior body — markProcessed + Tomás §10 M4 case 17 explicit tests. Both are in the right shape; APPROVE for merge once PR #5 lands and PR #6 rebases onto main.

WebhookIdempotencyStore.markProcessed(eventId)

Implementation in src/main/java/com/functorful/stripewebhook/idempotency/WebhookIdempotencyStore.java:

  • attribute_exists(eventId) condition — defends against the unlikely "mark something that doesn't exist" race (won't happen given the recordFirstDelivery → handler order, but belt-and-braces).
  • WARN-not-ERROR severity — correct: business state is already in DDB at this point; the M3 alarm is the safety net for any row that lingers.
  • Canary string "M3 alarm will surface this if the row stays false for >5min" in the log line — structurally coupled to the infra-side log-metric-filter pattern in aws-webhook-events-monitoring.tofu. Drift between the two would silently break detection; pinning the exact string in code review is the only mitigation. Pinned.
  • Logs eventId + errorClass.getSimpleName() only — no PII leakage, no exception message that could carry user-supplied content.
  • Catches RuntimeException (broad) — correct for this path. The narrow ConditionalCheckFailedException discrimination my §10 R4 asked for applies to the TWI catch in the handlers, NOT to markProcessed (where the noise/signal trade-off favours catching everything → WARN → M3 alarm).
  • @NewSpan parity with the rest of the store. ✅

Both handlers call markProcessed after TWI ✅

PaymentIntentSucceededHandler and PaymentIntentFailedHandler invoke idempotencyStore.markProcessed(event.eventId()) after the TWI returns. Tests assert via verify(idempotencyStore).markProcessed(EVENT_ID).

M4 case 17 explicit tests ✅

WebhookEventProcessorTest:

  • missingEventCreatedFallsBackToReceiverClock — orchestrator falls back to Instant.now() when event.created is absent.
  • nonNumericEventCreatedFallsBackToReceiverClock — same fallback when the field is present but non-numeric.

The fallback was already present in WebhookEventProcessor.buildEvent(...); these tests pin the contract so a future "clean up the orchestrator" refactor can't silently drop it.

Caveat — does NOT close MR !14's BLOCKING amendment

This APPROVE covers only the PR #6 commit 0186148. The companion infrastructure MR !14 carries one BLOCKING amendment (M-NEW-1: silent-payment-data-loss vulnerability in the audit_log_ssm_bridge_ready=false deploy window) that requires a small handler change in this same Lambda code repository before the Phase-2b deploy is safe.

Specifically: both PaymentIntentSucceededHandler and PaymentIntentFailedHandler need a degraded-mode short-circuit when AUDIT_LOG_TABLE_NAME is the literal sentinel "PENDING_AUDIT_LOG_BRIDGE". Without it, the handler attempts a 4-item TWI against a non-existent AuditLog table, all 4 writes roll back atomically, Stripe gets 200, and Sobrado has zero record of the payment. See /tmp/pay-05-mr14-review.md §M-NEW-1 for the full code shape + unit test + extended alarm pattern.

Recommended merge order

  1. PR #5 merges first (squash, per team-lead's plan).
  2. M-NEW-1 patch lands as a follow-up commit on feat/pay-05-phase-2b-handler-bodies (degraded-mode handler short-circuit + unit test).
  3. PR #6 rebases onto main; security re-review of the M-NEW-1 patch (small surface — should be a fast pass).
  4. infrastructure!14 lands the matching alarm-pattern alternation in aws-webhook-events-monitoring.tofu.
  5. PR #6 + infrastructure!14 apply together. The first deploy uses audit_log_ssm_bridge_ready=false and the handler is in degraded mode (loud, alarmed); flag flips to true after the application repo's tablesToExport MR lands; degraded mode auto-clears on next Lambda image roll.

— Tomás (Security Champion)

@ff-team-sobrado
ff-team-sobrado force-pushed the feat/pay-05-phase-2b-handler-bodies branch 2 times, most recently from db0bcc8 to 0186148 Compare May 4, 2026 14:57
…inel (Tomás M-NEW-1)

Both PaymentIntentSucceededHandler and PaymentIntentFailedHandler now
detect the "PENDING_AUDIT_LOG_BRIDGE" sentinel value Tofu writes into
AUDIT_LOG_TABLE_NAME when var.audit_log_ssm_bridge_ready=false, and
short-circuit BEFORE any DDB read or write. Without this, the
TransactWriteItems would target a non-existent AuditLog table, the
entire transaction would roll back atomically, Stripe would receive
200, and Sobrado would have zero record of the payment — the
silent-payment-data-loss class Tomás flagged in the
infrastructure!14 review.

Behavioural contract on degraded mode:
- ERROR log at construction with the canary "DEGRADED MODE" + the
  sentinel value (so a single line in CloudWatch tells ops which
  flag is wrong).
- ERROR log at every dispatch carrying eventId + eventType + the
  same canary.
- No DDB reads, no DDB writes, no SES, no markProcessed call. The
  WebhookEvent row stays processed=false so the §6/M3 alarm fires.

Sentinel string identity ("PENDING_AUDIT_LOG_BRIDGE") and canary
("DEGRADED MODE") are structurally coupled to:
- aws-stripe-webhook-lambda.tofu (env-var collapse value);
- aws-webhook-events-monitoring.tofu (alarm pattern alternation:
  ?"M3 alarm will surface this" ?"DEGRADED MODE"; ships in the
  follow-up infra MR).

Tests:
- New unit test in each handler test class:
  degradedMode_doesNotTouchDdbAndDoesNotMarkProcessed — instantiates
  the handler with the sentinel, dispatches a synthetic event, asserts
  zero DDB calls, no SES, no markProcessed.
- Existing test seam constructors take an extra String
  auditLogTableName parameter; non-degraded tests pass a
  non-sentinel value.

Refs:
- infrastructure!14 §M-NEW-1
  (https://gitlab.com/functorful/projects/sobrado/infrastructure/-/merge_requests/14#note_3309173324)
- docs/pay-05-handler-design.md §11 (new "Degraded-mode short-circuit"
  section + §9.3 review fold-in row)

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
@ff-team-sobrado
ff-team-sobrado changed the base branch from feat/pay-05-event-handlers to main May 4, 2026 14:58
Resolves three add/add conflicts that surface because PR #5 was squash-
merged: the same file contents now exist on both sides of the merge
graph. HEAD's version is a strict superset (PR #5 content + Phase-2b
real handler bodies + M-NEW-1 degraded-mode short-circuit), so "ours"
is the correct resolution for all three:

- docs/pay-05-handler-design.md (PR #5 added §1-§9; we added §11
  M-NEW-1 + §9.3 review fold-in row).
- src/main/java/com/functorful/stripewebhook/dispatch/handlers/PaymentIntentSucceededHandler.java
  (PR #5 added the SCAFFOLD shell; we replaced it with the real body).
- src/main/java/com/functorful/stripewebhook/dispatch/handlers/PaymentIntentFailedHandler.java
  (same shape).

./gradlew test: BUILD SUCCESSFUL, 78 tests, 0 failures.

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>

@ff-team-sobrado ff-team-sobrado left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Posted on behalf of Tomás (Security Champion) — same pattern as the prior PR #5 / PR #6 reviews. Tomás cannot post directly from his sub-agent session (GitHub MCP doesn't surface there). The team bot identity is the GitHub author of this review. Authorship is Tomás's; verbatim below.


Tomás re-review — M-NEW-1 patch (06e0060) + merge-of-main (528aefc): APPROVE

This is the re-review I committed to in the prior 0186148 APPROVE (which carried the explicit caveat that the M-NEW-1 amendment from infrastructure!14 had to land in this same repository before the Phase-2b deploy could be considered safe). Both commits in scope here:

  • 06e0060 — degraded-mode short-circuit on the PENDING_AUDIT_LOG_BRIDGE sentinel.
  • 528aefc — merge-of-main resolution after PR #5 squash; "ours" resolution verified correct (HEAD is a strict superset).

Verdict

APPROVE for merge. All nine items on my standing PAY-23-style mandatory checklist verified against the patch. One operational caveat documented at the bottom — coordination, not a code defect.

Verification — code-side checklist (9/9 PASS)

# Check Verdict Evidence
1 Short-circuit fires before any DDB read or write if (degradedMode) { log.error(...); return; } is the very first statement in both handlers' handle(...) after @NewSpan. Verified in both PaymentIntentSucceededHandler.java and PaymentIntentFailedHandler.java.
2 Short-circuit fires before any SES call Succeeded handler — sesEmailService is only invoked after the TWI; degraded path returns before reaching it.
3 Short-circuit does not call markProcessed markProcessed(event.eventId()) is only reached after the TWI succeeds in both handlers. Degraded path returns earlier. The WebhookEvent row stays processed=false so the M3 alarm path remains intact (caveat below on alarm-pattern landing).
4 Sentinel string identity matches Tofu literal exactly Verified across both sides: AUDIT_LOG_BRIDGE_PENDING_SENTINEL = "PENDING_AUDIT_LOG_BRIDGE" (Java constant, both handlers) ↔ aws-stripe-webhook-lambda.tofu:122 (AUDIT_LOG_TABLE_NAME = var.audit_log_ssm_bridge_ready ? ... : "PENDING_AUDIT_LOG_BRIDGE").
5 Canary string "DEGRADED MODE" lands in the dispatch ERROR log log.error("PAY-05 handler in {} — AuditLog SSM bridge not ready ...", DEGRADED_MODE_CANARY, event.eventId(), event.eventType()) in both handlers.
6 No PII leakage in degraded-mode log lines Construction log emits sentinel value (constant) + canary (constant); dispatch log emits eventId (Stripe evt_xxx) + eventType (payment_intent.succeeded / .payment_failed) + canary. No user IDs, no amounts, no email addresses, no last_payment_error.message.
7 Tests verify zero side effects under the sentinel degradedMode_doesNotTouchDdbAndDoesNotMarkProcessed in both PaymentIntentSucceededHandlerTest and PaymentIntentFailedHandlerTest. Each asserts: verify(reservationStore, never()).load(any()), verify(paymentStore, never()).findByReservationAndIntent(...), verify(dynamoDbClient, never()).transactWriteItems(...), verify(idempotencyStore, never()).markProcessed(any()), plus verify(sesEmailService, never()).sendPaymentConfirmation(...) on the succeeded side.
8 Existing tests' constructors updated to pass a non-sentinel auditLogTableName Both setUp() blocks now pass "AuditLog-test-table" to the test-seam constructor — confirmed normal-mode tests still exercise the full TWI + markProcessed path. CI: 78/78 GREEN.
9 Bean fail-closed on missing env var @Value("${audit-log.table-name}") carries no default — Micronaut bean construction throws if the env var is unset, fail-closed. Lambda init aborts rather than silently entering degraded mode on a config bug.

PAY-23 territory respected

Re-confirmed across all 17 changed files: no InvestorIban access, no sobrado-pii-cmk Decrypt grant, no pii-crypto-layer import. PAY-22 lane is uncontaminated.

Devil's-advocate review (none reach blocking)

  • Race during config flip / image roll. When var.audit_log_ssm_bridge_ready flips from falsetrue, old containers in degraded mode race with new containers in normal mode. Both serve traffic during rollover. Old-container events stay processed=false and either get retried by Stripe or are manually replayed; new-container events flow normally. No data corruption — accepted.
  • What if the sentinel matches accidentally? "PENDING_AUDIT_LOG_BRIDGE" is a deliberately unusual literal — extremely unlikely to be a legitimate DDB table name. Accepted.
  • Construction-time log spam. Single ERROR per cold start (Micronaut @Singleton). Accepted — degraded Lambda should be loud.
  • Dispatch-time log spam. Every event in degraded mode emits an ERROR. Accepted: in degraded mode you're already dropping events on the floor; the spam IS the desired signal.
  • @Value property mapping. audit-log.table-nameAUDIT_LOG_TABLE_NAME env var via Micronaut's standard kebab/uppercase convention. Accepted.

Operational caveat — companion infra alarm-pattern alternation NOT yet landed (MUST-LAND-BEFORE-DEPLOY)

I verified the current state of aws-webhook-events-monitoring.tofu:

$ grep "pattern" infrastructure/.../aws-webhook-events-monitoring.tofu
pattern = "\"M3 alarm will surface this\""

The companion follow-up infra MR (referenced in this PR's description as "TBD link") that extends the pattern to ?"M3 alarm will surface this" ?"DEGRADED MODE" has not yet been merged. Concretely:

  • In degraded mode, markProcessed is never called → the existing "M3 alarm will surface this" partial-coverage filter never fires.
  • The "DEGRADED MODE" ERROR logs DO fire, but the alarm pattern does not match them yet.
  • The strict periodic-scan M3 alarm (M-NEW-2) is promoted to a Phase-1 launch gate, not yet shipped.

Net effect during the audit-log-ssm-bridge=false deploy window: zero alarm signal, only ERROR-level CloudWatch log lines. Stripe receives 200 (handler returns normally), so Stripe stops retrying. Data is not lost — the WebhookEvent row stays processed=false and is replayable — but ops has no automated trigger to know it's time to replay.

Recommendation: the companion infra MR (alarm-pattern alternation) MUST land in the same apply window as PR #6. Order:

  1. PR #6 merges to main (this APPROVE clears it).
  2. Companion infra MR lands and applies the extended alarm pattern.
  3. THEN apply the lambda image with audit_log_ssm_bridge_ready=false.
  4. Application repo tablesToExport MR lands; flip the var to true; apply; next image roll auto-clears degraded mode.

Reversing 2 and 3 puts the silent-payment-data-loss class one missed CloudWatch ERROR line away from materialising.

Pre-conditions for closing PAY-05

  • ✅ This PR (M-NEW-1 patch) approved → mergeable.
  • 🔴 Companion infra MR (alarm-pattern alternation) — confirm landed before apply.
  • ⚠️ Manual replay tooling for processed=false WebhookEvent rows — defer to OPS lane; not a PR #6 blocker, but should not be omitted from the Phase-1 launch checklist.

Recommended merge order (unchanged from prior review)

  1. This PR merges to main (squash, per team-lead's plan).
  2. Companion infra MR (alarm-pattern alternation) lands and applies.
  3. Apply the lambda image with audit_log_ssm_bridge_ready=false. Verify in CloudWatch that the construction-time "DEGRADED MODE" ERROR line fires and the alarm matches it.
  4. Application repo tablesToExport MR lands. Flip var.audit_log_ssm_bridge_ready=true. Apply. Next image roll auto-clears degraded mode.

— Tomás (Security Champion)

@miguelaferreira miguelaferreira left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There are a few places where we should make better use of the micronaut framework. I left inline comments on those places.

In addition to that, the store design for the interactions with DDB does not seem to me like a better implementation than the repository design we use in some of the internal functions (eg. the data-handler function in the sobrado project).

static final String EVENT_TYPE_DATA_ACCESS = "DATA_ACCESS";

private final String tableName;
private final ObjectMapper objectMapper;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why isn't this object mapper always injected via application context?

Having it injected allows us to configure it via the framework interface, and make sure that the same singleton is used everywhere.

Also, this should be the object mapper from micronaut serialization, not jackson.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 7432ab2: AuditLogStore constructor now takes io.micronaut.serde.ObjectMapper (Micronaut Serde) — no more new ObjectMapper() on the AuditLog write path. Entry.details is now Map<String, Object> (built as LinkedHashMap for insertion-order stability). Both handlers lost their objectMapper field entirely; AuditLogStore is the single serialisation point. Test seam dual-constructor dropped. Note: WebhookEventProcessor.OBJECT_MAPPER is still Jackson because StripeWebhookEvent.dataObject is a Jackson JsonNode that all handlers walk via tree API — migrating that ripples through every handler + test, kept out of this commit; happy to do it as a separate commit/PR if you want.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Filed as ARCH-08: Notion ticket.

Scope of the follow-up: replace WebhookEventProcessor.readTree(rawBody) with typed-record deserialization + sealed event hierarchy, switch StripeWebhookEvent.dataObject from Jackson JsonNode to typed payload records (internal DTOs, not Stripe SDK direct re-export), delete every direct com.fasterxml.jackson import from stripe-webhook production sources, ship an ADR establishing Micronaut Serde as canonical, and add a CI static check to prevent reintroduction. Sibling to ARCH-09 (@Value audit) but ARCH-08 lands first since the parsing/model rewrite is the larger structural change.

Sequenced after PR #6 merges + SEC-PAY-11 (pii-crypto-layer v0.2.0) ships, so the migration inherits a clean consumer baseline.

Thread closed on this PR — no further code changes here.

— Rui

Comment on lines +71 to +72
@Value("${investment-payment.table-name}") String tableName,
@Value("${investment-payment.by-reservation-index-name:investmentPaymentByReservation}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These values could better be defined in a new configuration properties implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e6e27f7: new InvestmentPaymentProperties (@ConfigurationProperties("investment-payment")) holds both values. tableName has no default (fail-closed); byReservationIndexName defaults to "investmentPaymentByReservation" — centralised on the bean rather than embedded in an @Value default. Constructor now (DynamoDbClient, InvestmentPaymentProperties). Env-var binding unchanged (INVESTMENT_PAYMENT_TABLE_NAME and INVESTMENT_PAYMENT_BY_RESERVATION_INDEX_NAME resolve via Micronaut's standard kebab-to-uppercase). Same pattern can extend to InvestmentReservationStore, UserInvestmentStore, and AuditLogStore — happy to do that wholesale in a follow-up if you want consistency across the package.

…rties

Addresses Miguel review on PR #6, inline comment on
`InvestmentPaymentStore.java:72`: "These values could better be
defined in a new configuration properties implementation."

Replaces the two inline `@Value` injections on the
`InvestmentPaymentStore` constructor with a typed
`InvestmentPaymentProperties` bean (`@ConfigurationProperties("investment-payment")`).
The bean exposes:

  * `tableName` (no default, fail-closed) — bound to the
    `INVESTMENT_PAYMENT_TABLE_NAME` env var.
  * `byReservationIndexName` (default `"investmentPaymentByReservation"`)
    — bound to `INVESTMENT_PAYMENT_BY_RESERVATION_INDEX_NAME`.

Net effect:
  * Constructor signature shrinks from `(DynamoDbClient, String, String)`
    to `(DynamoDbClient, InvestmentPaymentProperties)`.
  * Default for the GSI name is centralised on the properties bean
    (with a constant `DEFAULT_BY_RESERVATION_INDEX_NAME` for forensic
    grep-ability) rather than embedded in an `@Value` default.
  * Env-var binding is unchanged — Micronaut's standard
    kebab-to-uppercase mapping still resolves the same env vars.

No behaviour change. Test suite (78 tests) green —
`InvestmentPaymentStore` is mocked in the only consumers
(`PaymentIntentSucceededHandlerTest`,
`PaymentIntentFailedHandlerTest`), so no test setup churn was needed.

Same pattern can be applied to the other stores
(`InvestmentReservationStore`, `UserInvestmentStore`, `AuditLogStore`)
in a follow-up if Miguel wants that consistency wholesale; this MR
addresses the specific comment.

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
…tails

Addresses Miguel review on PR #6, inline comment on
`AuditLogStore.java:50`:

  > "Why isn't this object mapper always injected via application context?
  >  Having it injected allows us to configure it via the framework
  >  interface, and make sure that the same singleton is used everywhere.
  >  Also, this should be the object mapper from micronaut serialization,
  >  not jackson."

Two changes, both focused on the AuditLog write path:

1. **Inject `io.micronaut.serde.ObjectMapper`.** `AuditLogStore` now
   takes the framework-managed Micronaut Serde mapper as a constructor
   argument; the previous `new com.fasterxml.jackson.databind.ObjectMapper()`
   pattern (which bypassed DI and constructed a per-Lambda-init Jackson
   mapper) is gone. The dual-constructor "test seam" is also gone — the
   public constructor IS the test-friendly one now.

2. **Build details as `Map<String, Object>`, not Jackson `ObjectNode`.**
   The handlers used to build the `AuditLog.details` payload via
   `objectMapper.createObjectNode()` + `details.put(...)` — pure Jackson
   tree-API. That was the lever that forced a Jackson mapper to be
   threaded through. Switching to `LinkedHashMap<String, Object>`
   (preserving insertion order so JSON serialisation is stable across
   runs) means handlers no longer need an `ObjectMapper` field at all
   — `AuditLogStore` owns the single mapper used for serialisation.
   Nested objects (`reservationKey`, `transition`) are themselves
   `LinkedHashMap<String, Object>`; Micronaut Serde walks the map shape
   and emits the corresponding nested JSON.

Net effect:
  * `AuditLogStore` constructor: `(String tableName, ObjectMapper)`
    where `ObjectMapper` is `io.micronaut.serde.ObjectMapper`.
  * `AuditLogStore.Entry.details` field type: `Map<String, Object>`.
  * `PaymentIntentSucceededHandler` and `PaymentIntentFailedHandler`:
    `objectMapper` field removed entirely; `buildAuditDetails(...)`
    returns `Map<String, Object>`; constructor signatures shrink by
    one parameter.
  * Test seams: dual-constructor pattern dropped from both handlers
    and `AuditLogStore`. Tests use the now-singular public constructor
    directly.

`WebhookEventProcessor` continues to use Jackson's `ObjectMapper` for
parsing the inbound webhook body (`readTree(rawBody)` → `JsonNode`).
That tree-parse path drives the `StripeWebhookEvent.dataObject` shape
which all handlers walk via Jackson `JsonNode` — switching it would
ripple through every handler and test. Out of scope for this commit;
happy to follow up with a dedicated commit if Miguel wants the
parse-side migration too.

Test suite (78 tests) green.

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
@ff-team-sobrado

Copy link
Copy Markdown
Contributor Author

Re-pushed addressing review feedback (HEAD 7432ab2)

Two new commits on feat/pay-05-phase-2b-handler-bodies, addressing both inline comments. Replied inline on each thread; summary here:

AuditLogStore.java:50 — Micronaut Serde + DI ✅

refactor(pay-05): inject Micronaut Serde ObjectMapper for AuditLog details (7432ab2)

  • Switched the constructor parameter type to io.micronaut.serde.ObjectMapper — Micronaut Serde, not Jackson.
  • Dropped the new ObjectMapper() per-Lambda-init pattern; the framework-managed singleton is now used everywhere on the AuditLog write path.
  • Refactored AuditLogStore.Entry.details from Jackson ObjectNode to Map<String, Object> — handlers no longer need a Jackson mapper threaded through to build details. Both PaymentIntentSucceededHandler and PaymentIntentFailedHandler shrunk by one constructor parameter and lost their objectMapper field entirely. AuditLogStore now owns the single serialisation point.
  • Test-seam dual-constructor pattern dropped from all three classes.

WebhookEventProcessor still uses Jackson ObjectMapper for readTree(rawBody) because StripeWebhookEvent.dataObject is a Jackson JsonNode that all handlers walk. Switching that ripples through every handler + test — out of scope here, happy to follow up if you want it migrated too. Open to a separate commit / PR for the parse-side.

InvestmentPaymentStore.java:72@ConfigurationProperties

refactor(pay-05): bind InvestmentPaymentStore via @ConfigurationProperties (e6e27f7)

  • New InvestmentPaymentProperties (@ConfigurationProperties("investment-payment")) holding tableName (no default, fail-closed) and byReservationIndexName (default "investmentPaymentByReservation" — centralised on the bean rather than embedded in an @Value default).
  • InvestmentPaymentStore constructor reduced to (DynamoDbClient, InvestmentPaymentProperties).
  • Env-var binding unchanged — INVESTMENT_PAYMENT_TABLE_NAME and INVESTMENT_PAYMENT_BY_RESERVATION_INDEX_NAME still resolve via Micronaut's standard kebab-to-uppercase mapping.

Same pattern can extend to InvestmentReservationStore, UserInvestmentStore, and AuditLogStore (which still uses an inline @Value for its single audit-log.table-name) — happy to do that wholesale in a follow-up if you want consistency across the package.

Body comment — store vs. repository design

You wrote:

"the store design for the interactions with DDB does not seem to me like a better implementation than the repository design we use in some of the internal functions (eg. the data-handler function in the sobrado project)."

Brief rationale below — flagging where the patterns are aligned and where they aren't, then proposing a concrete next step.

The store pattern is not the same surface as the data-handler's AbstractRepository + Enhanced Client. The two patterns split deliberately on three dimensions:

  1. Atomicity surface. The PAY-05 handlers build a single TransactWriteItems that spans 3-4 different tables with per-write ConditionExpressions (e.g. status = :expected). The Enhanced Client's transactWriteItems does support this in v2.x via WriteBatch, but the conditional-expression composition for the cross-table case is awkward enough that we fall back to the low-level DynamoDbClient shape. data-handler doesn't do TWI — its repositories do isolated GetItem / Query, so the high-level Enhanced Client is the right tool there.
  2. Tomás's projection-whitelist veto. Each store carries an explicit PROJECTION_EXPRESSION constant (e.g. InvestmentPaymentStore.PROJECTION_EXPRESSION = "id, version, userId, amountCents, currency, stripePaymentIntentId, #s") and a corresponding tight *View record. This is part of the PAY-23-style PII-discipline review — the projection list is a code-review surface that's easier to keep tight on the low-level path. payment-lambda shares this convention (see backend/payment-lambda/.../InvestmentStore.java:39's "Tomás's veto list … item Add tracing spans #4: project ONLY priceCents").
  3. Schema flexibility under Amplify churn. Amplify Gen-2 generates DDB schemas with implicit fields (__typename, _lastChangedAt) that Enhanced Client annotated POJOs need to keep in sync with. The low-level shape lets us write only the fields we care about and ignore the rest — Amplify schema updates don't force store-class regeneration.

So today: data-handler (stream-event-driven, isolated read-modify-write per record, Enhanced Client repositories) and payment-lambda + stripe-webhook (synchronous request-driven, atomic cross-table TWIs with conditional expressions, low-level stores) are intentionally on different patterns.

That said — your feedback isn't wrong. There IS overlap and the codebase would benefit from a more unified "this is how we talk to DDB" interface. Concrete next step I'd propose: open a PAY-XX-store-consolidation ticket once PAY-05 ships, scoped to:

  • evaluate whether Enhanced Client + WriteBatch can handle the per-write conditional-expression case cleanly (I think the gap has narrowed in SDK v2.x);
  • if yes, port payment-lambda first (it's the simplest store surface), then stripe-webhook;
  • if not, document the deliberate split (which patterns belong to which Lambda style) so new contributors aren't left guessing.

I'd rather not do that exploration as part of PR #6 since it'd substantially expand scope on a hot-path Phase-2b deploy. OK if I file the ticket and we come back to it once PAY-05 is in prd? If you'd rather see it done in PR #6, I can do that — but flagging the timeline trade-off honestly.

Tests are green on the new HEAD (78/78). Ready for re-review.

— Rui

@miguelaferreira
miguelaferreira merged commit 187bd9a into main May 4, 2026
4 checks passed
@ff-team-sobrado

Copy link
Copy Markdown
Contributor Author

Miguel: accepts the 3-dimension argument; use Store going forward where appropriate.

Acknowledged — closing this thread. No PAY-XX-store-consolidation follow-up ticket; the project standard going forward is "Store pattern wherever it makes more sense than Repository," documented by precedent.

— Rui

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.

2 participants