feat(pay-05): real handler bodies (Phase 2b — DDB stores + SES + sanitization) - #6
Conversation
…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
left a comment
There was a problem hiding this comment.
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 inaws-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 narrowConditionalCheckFailedExceptiondiscrimination 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). @NewSpanparity 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 toInstant.now()whenevent.createdis 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
- PR #5 merges first (squash, per team-lead's plan).
- 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). - PR #6 rebases onto main; security re-review of the M-NEW-1 patch (small surface — should be a fast pass).
infrastructure!14lands the matching alarm-pattern alternation inaws-webhook-events-monitoring.tofu.- PR #6 +
infrastructure!14apply together. The first deploy usesaudit_log_ssm_bridge_ready=falseand the handler is in degraded mode (loud, alarmed); flag flips to true after the application repo'stablesToExportMR lands; degraded mode auto-clears on next Lambda image roll.
— Tomás (Security Champion)
db0bcc8 to
0186148
Compare
…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>
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
left a comment
There was a problem hiding this comment.
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 thePENDING_AUDIT_LOG_BRIDGEsentinel.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_readyflips fromfalse→true, old containers in degraded mode race with new containers in normal mode. Both serve traffic during rollover. Old-container events stayprocessed=falseand 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.
@Valueproperty mapping.audit-log.table-name←AUDIT_LOG_TABLE_NAMEenv 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,
markProcessedis 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:
- PR #6 merges to main (this APPROVE clears it).
- Companion infra MR lands and applies the extended alarm pattern.
- THEN apply the lambda image with
audit_log_ssm_bridge_ready=false. - Application repo
tablesToExportMR lands; flip the var totrue; 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 forprocessed=falseWebhookEvent 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)
- This PR merges to main (squash, per team-lead's plan).
- Companion infra MR (alarm-pattern alternation) lands and applies.
- 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. - Application repo
tablesToExportMR lands. Flipvar.audit_log_ssm_bridge_ready=true. Apply. Next image roll auto-clears degraded mode.
— Tomás (Security Champion)
miguelaferreira
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| @Value("${investment-payment.table-name}") String tableName, | ||
| @Value("${investment-payment.by-reservation-index-name:investmentPaymentByReservation}") |
There was a problem hiding this comment.
These values could better be defined in a new configuration properties implementation.
There was a problem hiding this comment.
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>
Re-pushed addressing review feedback (HEAD
|
Acknowledged — closing this thread. No — Rui |
Summary
PAY-05 Phase 2b — full implementation of
payment_intent.succeededandpayment_intent.payment_failedhandlers perdocs/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 oninfrastructure!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— allUpdate/Putbuilders so the handler composes a singleTransactWriteItemsper event.SesEmailService— Phase-1 placeholder English copy, URL-connection HTTP client (native-image-friendly), best-effort failure (returnsfalse, 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;IllegalArgumentExceptioncarries field names only (no values, M1 hygiene applied to error paths).Real handlers
PaymentIntentSucceededHandler.handle(...)— full §4.1 sequence: extract → load + guard → atomic 4-opTransactWriteItems(paymentprocessed → success, reservationconfirmed → executed, idempotentPut UserInvestment,Put AuditLog) → SES best-effort.TransactionCanceledException-only catch per Tomás §10 R4.PaymentIntentFailedHandler.handle(...)— full §4.2: same load+guard discipline; sanitiseslastPaymentError.message(cap to ≤500 chars + strip control chars per §10 M1; logs Stripe'serrorCodeonly); branches reservation target onexpiresAtvsevent.created(pendingfor retry-window-open,expiredpast-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 intoAUDIT_LOG_TABLE_NAMEwhenvar.audit_log_ssm_bridge_ready=false, and short-circuit before any DDB read or write. Without this:PutItemtargets a non-existent table.Behavioural contract on degraded mode:
"DEGRADED MODE"+ the sentinel value (one-line ops signal "bridge not ready").markProcessed. TheWebhookEventrow staysprocessed=falseso 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 ininfrastructure!14).aws-webhook-events-monitoring.tofulog-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
PaymentIntentSucceededHandlerTestPaymentIntentFailedHandlerTestWebhookEventDispatcherTestIgnoredEventHandlerTestWebhookIdempotencyStoreTestWebhookEventProcessorTestStripeSignatureVerifierTestsecret/*./gradlew test: BUILD SUCCESSFUL.Tomás's M1–M4 / R1–R4 + M-NEW-1 closure
PaymentIntentFailedHandler.sanitizeErrorMessagebuildAuditDetailsprocessed=false > 5minalarminfrastructure!14; strict periodic-scan promoted to Phase-1 launch gate (M-NEW-2)data.object.id) in both handler tests; case 17 (missing/badevent.created) inWebhookEventProcessorTestaws-stripe-webhook-iam.tofu(infrastructure!14)Condition: ses:FromAddressaws-stripe-webhook-iam.tofu(infrastructure!14)lambdaVersion+gitShain details@Value("${dd.version}")+@Value("${git.sha}")TransactionCanceledException-specific catchException)degradedModeflag + new unit test in each handler test classCross-track impact
receipt_emailis not always populated by PaymentLambda; PAY-21 closes that gap).executed/failed/expired/successenum values the schema already declares. The §9.3 design doc pin (unit test asserting handlers write only documented status values) covers regressions.InvestorIbanaccess, no PII CMK Decrypt grants.AuditLogrow 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
"AuditLog"toamplify/backend.ts:tablesToExport, deploys.var.audit_log_ssm_bridge_ready = trueinsecrets.auto.tfvars, replan + reapply.AUDIT_LOG_TABLE_NAMEresolves 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") toaws-webhook-events-monitoring.tofu. Same review pair, separate scope.PR review pair
References
docs/pay-05-handler-design.md(§9 Tomás review log + §11 M-NEW-1 fold-in)infrastructure!14(merged) + follow-up alarm MR (TBD link)