Skip to content

feat(payment): pay bookings and settle them through a saga, closing US-005, US-006 and EPIC-03 - #26

Merged
GODSCAR1 merged 4 commits into
mainfrom
feat/payments
Aug 19, 2026
Merged

GODSCAR1 merged 4 commits into
mainfrom
feat/payments

Conversation

@GODSCAR1

Copy link
Copy Markdown
Collaborator

Summary

A passenger can now pay a booking, and the outcome travels back through the system until the booking reflects it. POST /api/v1/payments on the new payment-service asks booking-service what is owed, charges a deterministic gateway that decides by card number, and records the payment together with an outbox row in a single transaction. A relay carries that row to Kafka as payment.succeeded.v1 or payment.failed.v1, keyed by booking id. booking-service consumes both topics: a success confirms the booking (US-005), a failure marks it FAILED and returns the seats to flight-service through a new release endpoint (US-006). Every consumer claims the event id in a processed_events table before doing any work, so the at-least-once delivery an outbox implies costs nothing on redelivery.

Changes

payment-service (new)

  • Three Maven modules in the ADR-006 shape. The domain holds Payment, CardNumber with Luhn validation, Money that rejects zero and negative amounts, and PaymentStatus.

  • PayBookingService reads the amount from booking-service over Feign, because an amount taken from the request body would let anyone pay what they liked. It charges the gateway and hands the result to PaymentRecorder, a separate bean so @UnitOfWork is not self-invoked.

  • DecliningCardGateway refuses 4000000000000002 and accepts everything else, so either saga path can be shown on purpose.

  • The outbox: PaymentSettled is published as a domain event, a BEFORE_COMMIT listener writes the row so it joins the payment's transaction, and OutboxRelay claims pending rows with FOR UPDATE SKIP LOCKED, sends with acks=all, waits for the broker's acknowledgement, and only then marks them published.
    booking-service

  • GET /api/v1/bookings/{id}, which is where payment-service learns the amount and the status.

  • One Kafka listener per topic, feeding ConfirmBookingService and FailBookingService. Both claim the event id first through processed_events (INSERT ... ON CONFLICT DO NOTHING), so a redelivered message is a no-op.

  • Failing is two steps that deliberately do not share a transaction: mark the booking FAILED, then release the seats over HTTP and record seats_released_at. In that order, because a crash in between leaves seats held on a failed booking, which a sweep can find, and never a PENDING booking with no seats.

  • BookingStatus grows CONFIRMED and FAILED; migrations add seats_released_at and the processed_events table.
    flight-service

  • DELETE /api/v1/flights/{flightId}/seat-blocks/{seatBlockId} releases a hold and returns its seats, answering 204 whether or not the block still exists, so the compensation can be retried safely.

  • Outbound ports regrouped by aggregate into port/out/flight and port/out/seatblock.
    Infrastructure and docs

  • Compose gains a single-node Kafka in KRaft mode with two advertised listeners, one for the network and one for the host; both services that use it wait on its health check.

  • scripts/smoke-payment.ps1 walks the whole journey against the running stack; -Decline walks the failing one.

  • PaymentJourneyE2ETest joins the e2e module, which now starts seven containers: three databases, the broker and the three services.

  • ARCHITECTURE.md gains ADR-013 (the payment saga) and ADR-014 (consuming a payment event once), and ADR-001 is amended: only payment owns an outbox today.

Notes

Decisions worth reading before the code, argued in full in the ADRs:

  • The passenger calls payment-service directly, and payment asks booking for the amount (ADR-013). The compensation lives in booking, which knows which hold belongs to which booking; payment never learns that flight-service exists.
  • The gateway decides by card number, not by chance. An end-to-end test that fails one run in ten teaches people to re-run rather than to read.
  • The event id travels in the payload from the outbox row that produced it, and the consumer claims it before working (ADR-014). Confirming twice would be harmless; releasing seats twice would not.
    Boot 4 and Testcontainers 2 renames that cost time this round:
Needed Instead of Why
spring-boot-kafka spring-kafka alone Boot 4 moved the Kafka autoconfiguration into its own module; spring-kafka alone puts KafkaTemplate on the classpath with nothing wiring it
testcontainers-kafka kafka Testcontainers 2 prefixed every module, and the class moved to org.testcontainers.kafka.KafkaContainer
KafkaContainer with @ServiceConnection in both smoke test configurations a fixed bootstrap-servers listener containers and the relay start with the application, so any full-context test now needs a broker to point them at
@MockitoBean KafkaTemplate in the hand-built outbox slice importing the Kafka autoconfiguration the relay bean cannot be built without a template, and nothing in that slice sends

Left out on purpose, and documented in the ADR consequences rather than papered over:

  • Nothing retries a release that fails past Resilience4j's attempts. The booking stays FAILED with seats_released_at unset; the partial index the sweep would use exists, the sweep does not.
  • processed_events is never purged, and a message naming a booking that does not exist redelivers forever for want of a dead letter topic.
  • Between the charge and the consumption, a paid booking still reads PENDING. A read of a booking is a snapshot, not a verdict.

Testing

./mvnw -B clean verify runs 267 tests with zero failures; the two skips are the e2e classes staying behind their flag. ./mvnw -B verify -pl e2e-tests "-Dairline.e2e=true" runs the 20 e2e tests against the real stack, including both saga journeys: pay and poll until CONFIRMED, pay with the declining card and poll until FAILED with the seats back on the flight. scripts/smoke-payment.ps1 was run both ways against compose: the happy path ends CONFIRMED with the hold alive and 118 seats left, and -Decline ends FAILED with 120 seats, zero holds and seats_released_at set.

The tests that carry the new guarantees: PaymentOutboxSliceTest proves the outbox row shares the payment's transaction (a failed save announces nothing) and that the payload carries nothing about the card; OutboxRelayTest proves the relay sends what waits, keys by aggregate, and marks what it sent so a second sweep sends nothing; SettleBookingSliceTest proves confirming, failing with its compensation, and that a second delivery of a claimed event does nothing.

Closes #8 (US-005)
Closes #9 (US-006)
Closes #7 (EPIC-03)

@GODSCAR1
GODSCAR1 requested a review from RicardoRB August 19, 2026 17:29
@GODSCAR1 GODSCAR1 self-assigned this Aug 19, 2026
@GODSCAR1
GODSCAR1 merged commit bf38973 into main Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[US-006] Gestionar fallo de pago [US-005] Pagar una reserva [EPIC-03] Procesamiento de pagos

1 participant