From 458824bf2bd26ab07a1333082610be5fccc94110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Mon, 15 Jun 2026 06:36:19 +0200 Subject: [PATCH 1/3] feat(audit): correlation id on payment commands + events (Story 13.7 Phase B gate #4) PaymentCommand extends AuditableCommand (var correlationId + withCorrelationId; zero churn). The three consumed payment events extend AuditableEvent + carry correlation_id (PaidInEvent #10, FirstRecurringPaidInEvent #10, NextRecurringPaidEvent #14). handlePayIn / handleRecurringPayment take a correlationId param threaded from cmd.correlationId at their call sites and stamp it onto the persisted event (.copy). The checkout payIn endpoint prepends HttpCorrelation.correlationInput and stamps the PayIn command, so the originating X-Correlation-Id rides command -> PaidInEvent -> the licensing pod. Bumps genericPersistence -> 0.9-SNAPSHOT, scheduler -> 0.8-SNAPSHOT, notification -> 0.9-SNAPSHOT, version -> 0.9-SNAPSHOT. AuditableCommandSpec (chill/Kryo round-trip, 3 green); +common/+core compile (2.12+2.13) + scalafmtAll clean. Remaining origin coverage (follow-up): preAuthorize / recurring-registration / payInCallback endpoints + the akka-http PaymentService variant + schedule->ExecuteNext command threading. Core events are correlation-ready; gate #5 prefers event.correlationId else the deterministic in-pod fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- build.sbt | 2 +- .../message/payment/transaction.proto | 10 ++++ .../payment/message/PaymentMessages.scala | 12 +++- .../message/AuditableCommandSpec.scala | 57 +++++++++++++++++++ .../typed/PayInCommandHandler.scala | 10 +++- .../RecurringPaymentCommandHandler.scala | 25 +++++--- .../payment/service/CheckoutEndpoints.scala | 11 +++- project/Versions.scala | 8 +-- 8 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 common/src/test/scala/app/softnetwork/payment/message/AuditableCommandSpec.scala diff --git a/build.sbt b/build.sbt index 53e7d2b..371ab39 100644 --- a/build.sbt +++ b/build.sbt @@ -18,7 +18,7 @@ ThisBuild / organization := "app.softnetwork" name := "payment" -ThisBuild / version := "0.9.10.3" +ThisBuild / version := "0.9-SNAPSHOT" ThisBuild / scalaVersion := scala212 diff --git a/common/src/main/protobuf/message/payment/transaction.proto b/common/src/main/protobuf/message/payment/transaction.proto index 2a61b0d..e697016 100644 --- a/common/src/main/protobuf/message/payment/transaction.proto +++ b/common/src/main/protobuf/message/payment/transaction.proto @@ -58,6 +58,10 @@ message PaidInEvent { required string paymentMethodId = 7; required app.softnetwork.payment.model.Transaction.PaymentType paymentType = 8; optional bool printReceipt = 9; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail), set from the + // checkout command. ScalaPB → correlationId: Option[String] + withCorrelationId. + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 10; } message PayInFailedEvent { @@ -176,6 +180,9 @@ message FirstRecurringPaidInEvent { required string recurringPaymentRegistrationId = 7; required app.softnetwork.payment.model.RecurringPayment.RecurringPaymentFrequency frequency = 8; optional google.protobuf.Timestamp nextRecurringPaymentDate = 9 [(scalapb.field).type = "java.util.Date"]; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 10; } message FirstRecurringCardPaymentFailedEvent { @@ -207,6 +214,9 @@ message NextRecurringPaidEvent { optional google.protobuf.Timestamp nextRecurringPaymentDate = 11 [(scalapb.field).type = "java.util.Date"]; required int32 cumulatedDebitedAmount = 12; required int32 cumulatedFeesAmount = 13; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 14; } message NextRecurringPaymentFailedEvent { diff --git a/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala b/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala index 922fdf4..154b24e 100644 --- a/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala +++ b/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala @@ -7,13 +7,21 @@ import app.softnetwork.payment.message.PaymentEvents.{ PaymentEventWithCommand } import app.softnetwork.payment.model._ -import app.softnetwork.persistence.message.{Command, CommandResult, EntityCommand, ErrorMessage} +import app.softnetwork.persistence.message.{ + AuditableCommand, + CommandResult, + EntityCommand, + ErrorMessage +} import app.softnetwork.scheduler.model.Schedule import java.util.Date object PaymentMessages { - trait PaymentCommand extends Command + // Story 13.7 — every payment command carries `var correlationId` + `withCorrelationId` via + // AuditableCommand (zero constructor churn). The checkout endpoints stamp it from the inbound + // X-Correlation-Id; the handlers thread it onto the persisted payment events (the durable hop). + trait PaymentCommand extends AuditableCommand trait PaymentCommandWithKey extends PaymentCommand { def key: String diff --git a/common/src/test/scala/app/softnetwork/payment/message/AuditableCommandSpec.scala b/common/src/test/scala/app/softnetwork/payment/message/AuditableCommandSpec.scala new file mode 100644 index 0000000..b30e837 --- /dev/null +++ b/common/src/test/scala/app/softnetwork/payment/message/AuditableCommandSpec.scala @@ -0,0 +1,57 @@ +package app.softnetwork.payment.message + +import app.softnetwork.payment.message.PaymentMessages.{ExecuteNextRecurringPayment, PayIn} +import com.esotericsoftware.kryo.io.{Input, Output} +import com.twitter.chill.ScalaKryoInstantiator +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +/** Story 13.7 — locks the `AuditableCommand` contract for payment commands: a `correlationId` set at + * the checkout endpoint (from `X-Correlation-Id`) must ride the command across the cluster-sharding + * boundary, since commands are sent via `!?` under the chill/Kryo serializer. The handler then stamps + * it onto the persisted payment event (the durable hop to the licensing pod). If this regresses we + * fall back to a constructor field or a dedicated `serialization-binding` for `AuditableCommand`. + */ +class AuditableCommandSpec extends AnyWordSpec with Matchers { + + private def kryoRoundTrip[T <: AnyRef](value: T): T = { + val instantiator = new ScalaKryoInstantiator() + instantiator.setRegistrationRequired(false) + val kryo = instantiator.newKryo() + val out = new Output(4096) + kryo.writeClassAndObject(out, value) + out.flush() + kryo.readClassAndObject(new Input(out.toBytes)).asInstanceOf[T] + } + + "A payment AuditableCommand" should { + + "expose auditable=false until a correlation id is set, then carry it in place" in { + val cmd = PayIn("o1", "debited", 5100, creditedAccount = "credited") + cmd.auditable shouldBe false + val same = cmd.withCorrelationId("cid-xyz") + same should be theSameInstanceAs cmd // in-place; keeps the concrete type for `!?` + cmd.correlationId shouldBe Some("cid-xyz") + cmd.auditable shouldBe true + } + + "carry correlationId (the trait var) across a chill/Kryo round-trip on PayIn" in { + val cmd = PayIn("o1", "debited", 5100, creditedAccount = "credited") + cmd.withCorrelationId("cid-xyz") + val restored = kryoRoundTrip(cmd) + restored.correlationId shouldBe Some("cid-xyz") + restored.orderUuid shouldBe "o1" + restored.debitedAmount shouldBe 5100 + } + + // The recurring path dispatches ExecuteNextRecurringPayment (scheduled renewals) — lock the + // inherited trait `var` round-trips on that shape too. + "carry correlationId across a Kryo round-trip on ExecuteNextRecurringPayment" in { + val cmd = ExecuteNextRecurringPayment("reg#1", "debited") + cmd.withCorrelationId("schedule#cid") + val restored = kryoRoundTrip(cmd) + restored.correlationId shouldBe Some("schedule#cid") + restored.recurringPaymentRegistrationId shouldBe "reg#1" + } + } +} diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayInCommandHandler.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayInCommandHandler.scala index 602275d..f545390 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayInCommandHandler.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayInCommandHandler.scala @@ -157,7 +157,8 @@ trait PayInCommandHandler registerMeansOfPayment, printReceipt, transaction, - registerWallet + registerWallet, + correlationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -252,7 +253,8 @@ trait PayInCommandHandler registerMeansOfPayment, printReceipt = printReceipt, transaction, - registerWallet + registerWallet, + correlationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -581,7 +583,8 @@ trait PayInCommandHandler registerMeansOfPayment: Boolean, printReceipt: Boolean, transaction: Transaction, - registerWallet: Boolean = false + registerWallet: Boolean = false, + correlationId: Option[String] = None // Story 13.7 — threaded from the checkout command )(implicit system: ActorSystem[_], log: Logger, @@ -749,6 +752,7 @@ trait PayInCommandHandler .withPaymentMethodId(transaction.paymentMethodId.getOrElse("")) .withPaymentType(transaction.paymentType) .withPrintReceipt(printReceipt) + .copy(correlationId = correlationId) // Story 13.7 — durable hop ) ++ (PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala index b685006..2f94654 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala @@ -427,7 +427,8 @@ trait RecurringPaymentCommandHandler replyTo, paymentAccount, recurringPayment, - transaction + transaction, + correlationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -467,7 +468,8 @@ trait RecurringPaymentCommandHandler paymentAccount, recurringPayment, transaction, - scheduleNextPayment = false + scheduleNextPayment = false, + correlationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -529,7 +531,8 @@ trait RecurringPaymentCommandHandler replyTo, paymentAccount, recurringPayment, - transaction + transaction, + correlationId = cmd.correlationId // Story 13.7 ) case _ => val reason = "no transaction" @@ -584,7 +587,8 @@ trait RecurringPaymentCommandHandler replyTo, paymentAccount, recurringPayment, - transaction + transaction, + correlationId = cmd.correlationId // Story 13.7 ) case _ => val reason = "no transaction" @@ -666,7 +670,8 @@ trait RecurringPaymentCommandHandler paymentAccount: PaymentAccount, recurringPayment: RecurringPayment, transaction: Transaction, - scheduleNextPayment: Boolean = true + scheduleNextPayment: Boolean = true, + correlationId: Option[String] = None // Story 13.7 — threaded from the triggering command )(implicit system: ActorSystem[_], log: Logger @@ -738,7 +743,10 @@ trait RecurringPaymentCommandHandler .withFrequency(recurringPayment.getFrequency) .withRecurringPaymentRegistrationId(recurringPayment.getId) .withLastUpdated(lastUpdated) - .copy(nextRecurringPaymentDate = nextRecurringPaymentDate) + .copy( + nextRecurringPaymentDate = nextRecurringPaymentDate, + correlationId = correlationId // Story 13.7 — durable hop + ) } else { NextRecurringPaidEvent.defaultInstance .withDebitedAccount(paymentAccount.externalUuid) @@ -755,7 +763,10 @@ trait RecurringPaymentCommandHandler .withCumulatedDebitedAmount(updatedRecurringPayment.getCumulatedDebitedAmount) .withCumulatedFeesAmount(updatedRecurringPayment.getCumulatedFeesAmount) .withLastUpdated(lastUpdated) - .copy(nextRecurringPaymentDate = nextRecurringPaymentDate) + .copy( + nextRecurringPaymentDate = nextRecurringPaymentDate, + correlationId = correlationId // Story 13.7 — durable hop + ) } ) :+ { nextRecurringPaymentDate match { diff --git a/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala index 488a8d4..f846494 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala @@ -2,6 +2,7 @@ package app.softnetwork.payment.service import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.handlers.PaymentHandler +import app.softnetwork.api.server.HttpCorrelation import app.softnetwork.payment.message.PaymentMessages._ import app.softnetwork.payment.model.SoftPayAccount import app.softnetwork.session.model.{SessionData, SessionDataDecorator} @@ -166,6 +167,7 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) .in(PaymentSettings.PaymentConfig.payInRoute) .in(path[String].description("credited account")) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .post .out( oneOf[PaymentResult]( @@ -184,10 +186,10 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .serverLogic(principal => { - case (language, accept, userAgent, ipAddress, payment, creditedAccount) => + case (language, accept, userAgent, ipAddress, payment, creditedAccount, correlationId) => val browserInfo = extractBrowserInfo(language, accept, userAgent, payment) import payment._ - run( + val cmd = PayIn( orderUuid, externalUuidWithProfile(principal._2), @@ -208,7 +210,10 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { paymentMethodId = paymentMethodId, clientId = principal._1.map(_.clientId).orElse(principal._2.clientId) ) - ).map { + // Story 13.7 — stamp the origin correlation id (from X-Correlation-Id, generated if absent) + // so it rides the command → the persisted PaidInEvent → the licensing pod. + cmd.withCorrelationId(correlationId) + run(cmd).map { case result: PaidIn => Right(result) case result: PaymentRedirection => Right(result) case result: PaymentRequired => Right(result) diff --git a/project/Versions.scala b/project/Versions.scala index 31827ac..87c130e 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -1,12 +1,12 @@ object Versions { - val genericPersistence = "0.8.6.2" + val genericPersistence = "0.9-SNAPSHOT" - val scheduler = "0.8.0" + val scheduler = "0.8-SNAPSHOT" - val notification = "0.9.1" + val notification = "0.9-SNAPSHOT" - val account = "0.8.6.1" + val account = "0.8-SNAPSHOT" val scalatest = "3.2.16" From f03b321c2e768d246304d536a80ceafd68f1de45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Mon, 15 Jun 2026 15:36:08 +0200 Subject: [PATCH 2/3] feat(audit): correlation-id round-trip across payment flows + Stripe metadata (Story 13.7 gate #4) Emit structured payment audit lines and carry a single correlation id end-to-end (HTTP origin -> command -> persisted event -> licensing pod -> notification), with a stable orderUuid fallback so no event/line is ever untraceable. Audit emission (PaymentAuditLog = AuditLog("payment")): charge_succeeded/charge_failed (PayIn), subscription_charged/subscription_charge_failed (recurring), refund/refund_failed, preauthorization_succeeded/failed/canceled, payout_succeeded/failed. Coherence: each command handler shadows the threaded cid with effectiveCorrelationId = cid.getOrElse(), stamped on EVERY event in the persist batch and used for the audit line, so event.correlationId (read by the licensing pod) and the audit line always agree. Stripe WRITE side: providers stamp metadata.correlation_id on object creation (PayIn/PayOut/PreAuth/Refund/Transfer/DirectDebit); RecurringPayment carries it via its metadata map, falling back to the subscription id when no explicit cid. Stripe READ side (StripeEventHandler): webhook_received audit line + resolveCorrelationId(obj,event) = metadata.correlation_id -> external_reference -> order_uuid -> backing subscription id -> event id; all webhook-driven command stamps routed through it. Protos: correlation_id on transaction/payment events + AuditableEvent; commands extend AuditableCommand. Endpoints: correlationInput wired across all payment endpoints (origin at the HTTP request). BasicPaymentService.run stamps the MDC cid on synchronous routes. Tests: AuditableCommandSpec (chill/Kryo cid round-trip), StripeWebhookCorrelationSpec (metadata resolution + subscription-id fallback + webhook_received), PaymentHandlerSpec charge_succeeded capture. scalafmt clean; cross-compiles 2.12 + 2.13. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../protobuf/model/payment/transaction.proto | 9 + .../protobuf/message/payment/payment.proto | 90 +++++++++ .../message/payment/transaction.proto | 45 +++++ .../payment/audit/PaymentAuditLog.scala | 14 ++ .../payment/service/BasicPaymentService.scala | 13 +- .../message/AuditableCommandSpec.scala | 11 +- .../typed/PayInCommandHandler.scala | 189 +++++++++++++++--- .../typed/PayOutCommandHandler.scala | 145 +++++++++++--- .../persistence/typed/PaymentBehavior.scala | 66 +++++- .../typed/PaymentMethodCommandHandler.scala | 13 +- .../PreAuthorizationCommandHandler.scala | 82 +++++++- .../RecurringPaymentCommandHandler.scala | 151 +++++++++++--- .../service/BankAccountEndpoints.scala | 49 +++-- .../service/BillingPortalEndpoints.scala | 11 +- .../payment/service/CheckoutEndpoints.scala | 53 +++-- .../service/KycDocumentEndpoints.scala | 20 +- .../payment/service/MandateEndpoints.scala | 49 +++-- .../service/PaymentAccountEndpoints.scala | 17 +- .../service/PaymentMethodEndpoints.scala | 47 ++++- .../service/RecurringPaymentEndpoints.scala | 65 +++--- .../service/UboDeclarationEndpoints.scala | 42 ++-- .../payment/service/StripeEventHandler.scala | 122 ++++++++--- .../payment/spi/StripeDirectDebitApi.scala | 5 + .../payment/spi/StripePayInApi.scala | 14 ++ .../payment/spi/StripePayOutApi.scala | 4 + .../spi/StripePreAuthorizationApi.scala | 3 + .../payment/spi/StripeRefundApi.scala | 12 ++ .../payment/spi/StripeTransferApi.scala | 11 + testkit/src/test/resources/logback.xml | 76 +++++++ .../payment/handlers/PaymentHandlerSpec.scala | 42 ++++ .../StripeWebhookCorrelationSpec.scala | 108 ++++++++++ 31 files changed, 1308 insertions(+), 270 deletions(-) create mode 100644 common/src/main/scala/app/softnetwork/payment/audit/PaymentAuditLog.scala create mode 100644 testkit/src/test/resources/logback.xml create mode 100644 testkit/src/test/scala/app/softnetwork/payment/service/StripeWebhookCorrelationSpec.scala diff --git a/client/src/main/protobuf/model/payment/transaction.proto b/client/src/main/protobuf/model/payment/transaction.proto index a8d4e2c..883f46b 100644 --- a/client/src/main/protobuf/model/payment/transaction.proto +++ b/client/src/main/protobuf/model/payment/transaction.proto @@ -130,6 +130,7 @@ message PayInTransaction { optional string preAuthorizedTransactionId = 14; optional int32 preAuthorizationDebitedAmount = 15; optional string preRegistrationId = 16; + map metadata = 17; } message PayInWithCardTransaction { @@ -147,6 +148,7 @@ message PayInWithCardTransaction { optional bool printReceipt = 11; optional string statementDescriptor = 12; optional string preRegistrationId = 13; + map metadata = 14; } message PayInWithPayPalTransaction { @@ -165,6 +167,7 @@ message PayInWithPayPalTransaction { optional BrowserInfo browserInfo = 12; optional bool registerPaypal = 13; optional string preRegistrationId = 14; + map metadata = 15; } message RefundTransaction { @@ -177,6 +180,7 @@ message RefundTransaction { required string reasonMessage = 6; required bool initializedByClient = 7; optional int32 feesRefundAmount = 8; + map metadata = 9; } message TransferTransaction { @@ -192,6 +196,7 @@ message TransferTransaction { optional string externalReference = 9; optional string statementDescriptor = 10; optional string payInTransactionId = 11; + map metadata = 12; } message PayOutTransaction { @@ -207,6 +212,7 @@ message PayOutTransaction { optional string externalReference = 9; optional string payInTransactionId = 10; optional string statementDescriptor = 11; + map metadata = 12; } message DirectDebitTransaction { @@ -220,6 +226,7 @@ message DirectDebitTransaction { required string mandateId = 7; required string statementDescriptor = 8; optional string externalReference = 9; + map metadata = 10; } message PreAuthorizationTransaction { @@ -238,6 +245,7 @@ message PreAuthorizationTransaction { optional string statementDescriptor = 12; optional string preRegistrationId = 13; required Transaction.PaymentType paymentType = 14 [default = CARD]; + map metadata = 15; } message PayInWithPreAuthorization { @@ -252,6 +260,7 @@ message PayInWithPreAuthorization { optional int32 feesAmount = 8; optional string statementDescriptor = 9; optional string preRegistrationId = 10; + map metadata = 11; } message PreRegistration{ diff --git a/common/src/main/protobuf/message/payment/payment.proto b/common/src/main/protobuf/message/payment/payment.proto index 22126d5..e861236 100644 --- a/common/src/main/protobuf/message/payment/payment.proto +++ b/common/src/main/protobuf/message/payment/payment.proto @@ -37,6 +37,9 @@ message WalletRegisteredEvent { required string userId = 3; required string walletId = 4; required google.protobuf.Timestamp lastUpdated = 5 [(scalapb.field).type = "java.time.Instant"]; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 6; } message CardPreRegisteredEvent { // deprecated replaced by PaymentMethodPreRegisteredEvent @@ -50,6 +53,9 @@ message CardPreRegisteredEvent { // deprecated replaced by PaymentMethodPreRegis required string walletId = 5; required string cardPreRegistrationId = 6; required app.softnetwork.payment.model.CardOwner owner = 7; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 8; } message PaymentMethodPreRegisteredEvent { @@ -64,6 +70,9 @@ message PaymentMethodPreRegisteredEvent { required string preRegistrationId = 6; required app.softnetwork.payment.model.Transaction.PaymentType paymentType = 7; optional app.softnetwork.payment.model.CardOwner owner = 8; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 9; } message CardRegisteredEvent { // deprecated, replaced by PaymentMethodRegisteredEvent @@ -74,6 +83,9 @@ message CardRegisteredEvent { // deprecated, replaced by PaymentMethodRegistered required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; required string externalUuid = 3; required app.softnetwork.payment.model.Card card = 4; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 5; } message PaymentMethodRegisteredEvent { @@ -87,6 +99,9 @@ message PaymentMethodRegisteredEvent { app.softnetwork.payment.model.Card card = 4; app.softnetwork.payment.model.Paypal paypal = 5; } + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 6; } message PaymentAccountUpsertedEvent { @@ -95,6 +110,9 @@ message PaymentAccountUpsertedEvent { option (scalapb.message).extends = "PaymentAccountEvent"; required app.softnetwork.payment.model.PaymentAccount document = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 3; } message BankAccountUpdatedEvent { @@ -106,6 +124,9 @@ message BankAccountUpdatedEvent { required string userId = 3; required string walletId = 4; required string bankAccountId = 5; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 6; } message MandateUpdatedEvent { @@ -117,6 +138,9 @@ message MandateUpdatedEvent { optional string mandateId = 3; optional app.softnetwork.payment.model.Mandate.MandateStatus mandateStatus = 4; required string bankAccountId = 5; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 6; } message TermsOfPSPAcceptedEvent{ @@ -126,6 +150,9 @@ message TermsOfPSPAcceptedEvent{ required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; required google.protobuf.Timestamp lastAcceptedTermsOfPSP = 3 [(scalapb.field).type = "java.time.Instant"]; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message UboDeclarationUpdatedEvent{ @@ -135,6 +162,9 @@ message UboDeclarationUpdatedEvent{ required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; optional app.softnetwork.payment.model.UboDeclaration uboDeclaration = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message RegularUserValidatedEvent{ @@ -144,6 +174,9 @@ message RegularUserValidatedEvent{ required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; required string userId = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message RegularUserInvalidatedEvent{ @@ -153,6 +186,9 @@ message RegularUserInvalidatedEvent{ required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; required string userId = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message PaymentAccountStatusUpdatedEvent { @@ -162,6 +198,9 @@ message PaymentAccountStatusUpdatedEvent { required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; required app.softnetwork.payment.model.PaymentAccount.PaymentAccountStatus paymentAccountStatus = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message DocumentsUpdatedEvent { @@ -171,6 +210,9 @@ message DocumentsUpdatedEvent { required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; repeated app.softnetwork.payment.model.KycDocument documents = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message DocumentUpdatedEvent { @@ -180,6 +222,9 @@ message DocumentUpdatedEvent { required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; required app.softnetwork.payment.model.KycDocument document = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message BankAccountDeletedEvent { @@ -188,6 +233,9 @@ message BankAccountDeletedEvent { option (scalapb.message).extends = "BroadcastEvent"; required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 3; } message ExternalEntityToPaymentEvent{ @@ -206,6 +254,9 @@ message ExternalEntityToPaymentEvent{ LoadDirectDebitTransactionCommandEvent loadDirectDebitTransaction = 9; CancelMandateCommandEvent cancelMandate = 10; } + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 11; } message PayInWithPreAuthorizationCommandEvent{ @@ -216,6 +267,9 @@ message PayInWithPreAuthorizationCommandEvent{ optional int32 debitedAmount = 3; optional int32 feesAmount = 4; optional string clientId = 5; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 6; } message RefundCommandEvent{ @@ -229,6 +283,9 @@ message RefundCommandEvent{ required bool initializedByClient = 6; optional string clientId = 7; optional int32 feesRefundAmount = 8; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 9; } message PayOutCommandEvent{ @@ -241,6 +298,9 @@ message PayOutCommandEvent{ required string currency = 5 [default = "EUR"]; optional string externalReference = 6; optional string clientId = 7; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 8; } message TransferCommandEvent{ @@ -255,6 +315,9 @@ message TransferCommandEvent{ required bool payOutRequired = 7 [default = true]; optional string externalReference = 8; optional string clientId = 9; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 10; } message DirectDebitCommandEvent{ @@ -267,12 +330,18 @@ message DirectDebitCommandEvent{ required string statementDescriptor = 5; optional string externalReference = 6; optional string clientId = 7; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 8; } message CreateOrUpdatePaymentAccountCommandEvent{ option (scalapb.message).extends = "ProtobufEvent"; option (scalapb.message).extends = "PaymentCommandEvent"; required app.softnetwork.payment.model.PaymentAccount paymentAccount = 1; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 2; } message RegisterRecurringPaymentCommandEvent { @@ -291,6 +360,9 @@ message RegisterRecurringPaymentCommandEvent { optional int32 nextFeesAmount = 11; optional string clientId = 12; optional string cardId = 13; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 14; } message RecurringPaymentRegisteredEvent { @@ -299,6 +371,9 @@ message RecurringPaymentRegisteredEvent { option (scalapb.message).extends = "BroadcastEvent"; required string externalUuid = 1; required app.softnetwork.payment.model.RecurringPayment recurringPayment = 2; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 3; } message CancelPreAuthorizationCommandEvent { @@ -307,6 +382,9 @@ message CancelPreAuthorizationCommandEvent { required string orderUuid = 1; required string cardPreAuthorizedTransactionId = 2; optional string clientId = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message LoadDirectDebitTransactionCommandEvent { @@ -314,6 +392,9 @@ message LoadDirectDebitTransactionCommandEvent { option (scalapb.message).extends = "PaymentCommandEvent"; required string directDebitTransactionId = 1; optional string clientId = 2; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 3; } message PaymentAccountCreatedOrUpdatedEvent { @@ -327,6 +408,9 @@ message PaymentAccountCreatedOrUpdatedEvent { app.softnetwork.payment.model.NaturalUser naturalUser = 4; app.softnetwork.payment.model.LegalUser legalUser = 5; } + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 6; } message CancelMandateCommandEvent { @@ -334,6 +418,9 @@ message CancelMandateCommandEvent { option (scalapb.message).extends = "PaymentCommandEvent"; required string externalUuid = 1; optional string clientId = 2; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 3; } message MandateCancelationFailedEvent { @@ -342,4 +429,7 @@ message MandateCancelationFailedEvent { option (scalapb.message).extends = "BroadcastEvent"; required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 3; } diff --git a/common/src/main/protobuf/message/payment/transaction.proto b/common/src/main/protobuf/message/payment/transaction.proto index e697016..185b110 100644 --- a/common/src/main/protobuf/message/payment/transaction.proto +++ b/common/src/main/protobuf/message/payment/transaction.proto @@ -36,6 +36,9 @@ message PreAuthorizedEvent { required string paymentMethodId = 6; optional bool printReceipt = 7; required app.softnetwork.payment.model.Transaction.PaymentType paymentType = 8; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 9; } message PreAuthorizationFailedEvent { @@ -44,6 +47,9 @@ message PreAuthorizationFailedEvent { required string orderUuid = 1; required string resultMessage = 2; optional app.softnetwork.payment.model.Transaction transaction = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message PaidInEvent { @@ -70,6 +76,9 @@ message PayInFailedEvent { required string orderUuid = 1; required string resultMessage = 2; optional app.softnetwork.payment.model.Transaction transaction = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message PaidOutEvent { @@ -84,6 +93,9 @@ message PaidOutEvent { required string transactionId = 7; required app.softnetwork.payment.model.Transaction.PaymentType paymentType = 8; optional string externalReference = 9; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 10; } message PayOutFailedEvent { @@ -93,6 +105,9 @@ message PayOutFailedEvent { required string resultMessage = 2; optional app.softnetwork.payment.model.Transaction transaction = 3; optional string externalReference = 4; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 5; } message RefundedEvent { @@ -109,6 +124,9 @@ message RefundedEvent { required string reasonMessage = 9; required bool initializedByClient = 10; required app.softnetwork.payment.model.Transaction.PaymentType paymentType = 11; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 12; } message RefundFailedEvent { @@ -117,6 +135,9 @@ message RefundFailedEvent { required string orderUuid = 1; required string resultMessage = 2; optional app.softnetwork.payment.model.Transaction transaction = 3; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 4; } message TransferedEvent { @@ -134,6 +155,9 @@ message TransferedEvent { required app.softnetwork.payment.model.Transaction.PaymentType paymentType = 10; optional string payOutTransactionId = 11; optional string externalReference = 12; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 13; } message TransferFailedEvent { @@ -144,6 +168,9 @@ message TransferFailedEvent { optional app.softnetwork.payment.model.Transaction transaction = 3; optional string externalReference = 4; optional string orderUuid = 5; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 6; } message DirectDebitedEvent { @@ -157,6 +184,9 @@ message DirectDebitedEvent { required string transactionId = 6; required app.softnetwork.payment.model.Transaction.TransactionStatus transactionStatus = 7; optional string externalReference = 8; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 9; } message DirectDebitFailedEvent { @@ -166,6 +196,9 @@ message DirectDebitFailedEvent { required string resultMessage = 2; optional app.softnetwork.payment.model.Transaction transaction = 3; optional string externalReference = 4; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 5; } message FirstRecurringPaidInEvent { @@ -196,6 +229,9 @@ message FirstRecurringCardPaymentFailedEvent { optional app.softnetwork.payment.model.Transaction transaction = 6; required string recurringPaymentRegistrationId = 7; required app.softnetwork.payment.model.RecurringPayment.RecurringPaymentFrequency frequency = 8; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 9; } message NextRecurringPaidEvent { @@ -233,6 +269,9 @@ message NextRecurringPaymentFailedEvent { required app.softnetwork.payment.model.RecurringPayment.RecurringPaymentFrequency frequency = 9; required int32 numberOfRecurringPayments = 10; optional google.protobuf.Timestamp lastRecurringPaymentDate = 11 [(scalapb.field).type = "java.util.Date"]; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 12; } message PreAuthorizationCanceledEvent { @@ -243,6 +282,9 @@ message PreAuthorizationCanceledEvent { required string orderUuid = 3; required string preAuthorizedTransactionId = 4; required bool preAuthorizationCanceled = 5; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 6; } message TransactionUpdatedEvent { @@ -251,4 +293,7 @@ message TransactionUpdatedEvent { option (scalapb.message).extends = "TransactionEvent"; required app.softnetwork.payment.model.Transaction document = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; + // Story 13.7 — cross-service correlation id (durable hop for the audit trail). + option (scalapb.message).extends = "AuditableEvent"; + optional string correlation_id = 3; } \ No newline at end of file diff --git a/common/src/main/scala/app/softnetwork/payment/audit/PaymentAuditLog.scala b/common/src/main/scala/app/softnetwork/payment/audit/PaymentAuditLog.scala new file mode 100644 index 0000000..9ef440f --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/audit/PaymentAuditLog.scala @@ -0,0 +1,14 @@ +package app.softnetwork.payment.audit + +import app.softnetwork.persistence.audit.AuditLog + +object PaymentAuditLog { + + /** Story 13.7 — structured audit trail for the payment pod. service = "payment"; the + * `correlationId` is threaded as data (proto field on the transaction events / `AuditableCommand`), + * never via MDC — the emission points are `thenRun` continuations of the persistence `Effect`, + * where a `ThreadLocal` MDC value would not survive. + */ + private[payment] lazy val audit: AuditLog = AuditLog("payment") + +} diff --git a/common/src/main/scala/app/softnetwork/payment/service/BasicPaymentService.scala b/common/src/main/scala/app/softnetwork/payment/service/BasicPaymentService.scala index 0252dfe..44b1467 100644 --- a/common/src/main/scala/app/softnetwork/payment/service/BasicPaymentService.scala +++ b/common/src/main/scala/app/softnetwork/payment/service/BasicPaymentService.scala @@ -1,10 +1,11 @@ package app.softnetwork.payment.service -import app.softnetwork.api.server.ApiErrors +import app.softnetwork.api.server.{ApiErrors, HttpCorrelation} import app.softnetwork.payment.message.PaymentMessages._ import app.softnetwork.payment.model.BrowserInfo import app.softnetwork.persistence.service.Service import app.softnetwork.persistence.typed.scaladsl.EntityPattern +import org.slf4j.MDC import java.util.TimeZone import scala.concurrent.Future @@ -15,8 +16,16 @@ trait BasicPaymentService extends Service[PaymentCommand, PaymentResult] { def run(command: PaymentCommandWithKey)(implicit tTag: ClassTag[PaymentCommand] - ): Future[PaymentResult] = + ): Future[PaymentResult] = { + // Story 13.7 — synchronous akka-http routes (PaymentService) build the command on the request + // thread, where HttpCorrelation.withCorrelation has put the correlation id on MDC; stamp it onto + // the command here, the single dispatch point. Guarded by isEmpty so a tapir endpoint that already + // set it explicitly (its serverLogic runs in a Future where MDC does not survive — C14) is not + // clobbered. + if (command.correlationId.isEmpty) + Option(MDC.get(HttpCorrelation.MdcKey)).filter(_.nonEmpty).foreach(command.withCorrelationId) super.run(command.key, command) + } def error(result: PaymentResult): ApiErrors.ErrorInfo = result match { diff --git a/common/src/test/scala/app/softnetwork/payment/message/AuditableCommandSpec.scala b/common/src/test/scala/app/softnetwork/payment/message/AuditableCommandSpec.scala index b30e837..d4617cf 100644 --- a/common/src/test/scala/app/softnetwork/payment/message/AuditableCommandSpec.scala +++ b/common/src/test/scala/app/softnetwork/payment/message/AuditableCommandSpec.scala @@ -6,11 +6,12 @@ import com.twitter.chill.ScalaKryoInstantiator import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec -/** Story 13.7 — locks the `AuditableCommand` contract for payment commands: a `correlationId` set at - * the checkout endpoint (from `X-Correlation-Id`) must ride the command across the cluster-sharding - * boundary, since commands are sent via `!?` under the chill/Kryo serializer. The handler then stamps - * it onto the persisted payment event (the durable hop to the licensing pod). If this regresses we - * fall back to a constructor field or a dedicated `serialization-binding` for `AuditableCommand`. +/** Story 13.7 — locks the `AuditableCommand` contract for payment commands: a `correlationId` set + * at the checkout endpoint (from `X-Correlation-Id`) must ride the command across the + * cluster-sharding boundary, since commands are sent via `!?` under the chill/Kryo serializer. The + * handler then stamps it onto the persisted payment event (the durable hop to the licensing pod). + * If this regresses we fall back to a constructor field or a dedicated `serialization-binding` for + * `AuditableCommand`. */ class AuditableCommandSpec extends AnyWordSpec with Matchers { diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayInCommandHandler.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayInCommandHandler.scala index f545390..7858621 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayInCommandHandler.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayInCommandHandler.scala @@ -5,6 +5,7 @@ import akka.actor.typed.{ActorRef, ActorSystem} import akka.persistence.typed.scaladsl.Effect import app.softnetwork.concurrent.Completion import app.softnetwork.payment.annotation.InternalApi +import app.softnetwork.payment.audit.PaymentAuditLog.audit import app.softnetwork.payment.api.config.SoftPayClientSettings import app.softnetwork.payment.config.PaymentSettings.PaymentConfig.payInStatementDescriptor import app.softnetwork.payment.message.PaymentEvents.{ @@ -125,6 +126,12 @@ trait PayInCommandHandler case Some(creditedWalletId) => val registerMeansOfPayment = cmd.registerMeansOfPayment.getOrElse(cmd.registerCard) + val metadata: Map[String, String] = + cmd.correlationId match { + case Some(correlationId) => + Map("correlationId" -> correlationId) + case None => Map.empty + } payIn( Some( PayInTransaction.defaultInstance @@ -146,6 +153,7 @@ trait PayInCommandHandler browserInfo = browserInfo, preRegistrationId = registrationId ) + .withMetadata(metadata) ) ) match { case Some(transaction) => @@ -158,7 +166,7 @@ trait PayInCommandHandler printReceipt, transaction, registerWallet, - correlationId = cmd.correlationId // Story 13.7 + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -221,6 +229,12 @@ trait PayInCommandHandler case Some(creditedWalletId) => val registerMeansOfPayment = cmd.registerMeansOfPayment.getOrElse(false) + val metadata: Map[String, String] = + cmd.correlationId match { + case Some(correlationId) => + Map("correlationId" -> correlationId) + case None => Map.empty + } payIn( Some( PayInTransaction.defaultInstance @@ -242,6 +256,7 @@ trait PayInCommandHandler browserInfo = browserInfo, preRegistrationId = registrationId ) + .withMetadata(metadata) ) ) match { case Some(transaction) => @@ -254,7 +269,7 @@ trait PayInCommandHandler printReceipt = printReceipt, transaction, registerWallet, - correlationId = cmd.correlationId // Story 13.7 + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -283,6 +298,8 @@ trait PayInCommandHandler } case _ => + // Story 13.7 — orderUuid fallback so event + audit line agree (see handlePayIn). + val effectiveCorrelationId: String = cmd.correlationId.getOrElse(orderUuid) Effect .persist( List( @@ -294,15 +311,22 @@ trait PayInCommandHandler .withLastUpdated(now()) .withPaymentMethodId("") .withPaymentType(paymentType) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 ) ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "charge_failed", + "order_uuid" -> orderUuid, + "result" -> s"$paymentType not supported" + ) PayInFailed( "", Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, s"$paymentType not supported" ) ~> replyTo - ) + } } case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } @@ -370,6 +394,8 @@ trait PayInCommandHandler .copy( paymentMethodId = paymentMethodId ) + // Story 13.7 — orderUuid fallback shared by every event in this batch (see handlePayIn). + val effectiveCorrelationId: String = cmd.correlationId.getOrElse(orderUuid) val transactionUpdatedEvent = TransactionUpdatedEvent.defaultInstance .withDocument( @@ -379,6 +405,7 @@ trait PayInCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 if (t.status.isTransactionSucceeded || t.status.isTransactionCreated) { Effect .persist( @@ -391,15 +418,28 @@ trait PayInCommandHandler .withLastUpdated(lastUpdated) .withPaymentMethodId(paymentMethodId.getOrElse("")) .withPaymentType(t.paymentType) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 ) :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + // Story 13.7 — reconciliation/poll confirming a (previously pending) + // charge; persists the PaidInEvent here, so audit here too. + audit.event( + effectiveCorrelationId, + "charge_succeeded", + "order_uuid" -> orderUuid, + "transaction_id" -> t.id, + "amount" -> t.amount, + "fees" -> t.fees, + "currency" -> t.currency, + "result" -> t.status.name + ) PayInTransactionLoaded( transaction.id, transaction.status, None ) ~> replyTo - ) + } } else { Effect .persist( @@ -408,15 +448,26 @@ trait PayInCommandHandler .withOrderUuid(orderUuid) .withResultMessage(t.resultMessage) .withTransaction(updatedTransaction) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 ) :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "charge_failed", + "order_uuid" -> orderUuid, + "transaction_id" -> t.id, + "amount" -> t.amount, + "fees" -> t.fees, + "currency" -> t.currency, + "result" -> t.resultMessage + ) PayInTransactionLoaded( transaction.id, transaction.status, None ) ~> replyTo - ) + } } case None => Effect.none.thenRun(_ => TransactionNotFound ~> replyTo) } @@ -445,7 +496,8 @@ trait PayInCommandHandler handlePayInWithPreAuthorizationFailure( "", replyTo, - "PreAuthorizationTransactionNotFound" + "PreAuthorizationTransactionNotFound", + correlationId = correlationId ) case Some(preAuthorizationTransaction) if !Seq( @@ -457,28 +509,32 @@ trait PayInCommandHandler handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "IllegalPreAuthorizationTransactionStatus" + "IllegalPreAuthorizationTransactionStatus", + correlationId = correlationId ) case Some(preAuthorizationTransaction) if preAuthorizationTransaction.preAuthorizationCanceled.getOrElse(false) => handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "PreAuthorizationCanceled" + "PreAuthorizationCanceled", + correlationId = correlationId ) case Some(preAuthorizationTransaction) if preAuthorizationTransaction.preAuthorizationValidated.getOrElse(false) => handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "PreAuthorizationValidated" + "PreAuthorizationValidated", + correlationId = correlationId ) case Some(preAuthorizationTransaction) if preAuthorizationTransaction.preAuthorizationExpired.getOrElse(false) => handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "PreAuthorizationExpired" + "PreAuthorizationExpired", + correlationId = correlationId ) case Some(preAuthorizationTransaction) if debitedAmount.getOrElse( @@ -487,7 +543,8 @@ trait PayInCommandHandler handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "DebitedAmountAbovePreAuthorizationAmount" + "DebitedAmountAbovePreAuthorizationAmount", + correlationId = correlationId ) case Some(preAuthorizationTransaction) => // load credited payment account @@ -502,6 +559,12 @@ trait PayInCommandHandler import paymentProvider._ creditedPaymentAccount.walletId match { case Some(creditedWalletId) => + val metadata: Map[String, String] = + cmd.correlationId match { + case Some(correlationId) => + Map("correlationId" -> correlationId) + case None => Map.empty + } payIn( Some( PayInTransaction.defaultInstance @@ -522,6 +585,7 @@ trait PayInCommandHandler preRegistrationId = preAuthorizationTransaction.preRegistrationId ) + .withMetadata(metadata) ) ) match { case Some(transaction) => @@ -538,28 +602,32 @@ trait PayInCommandHandler handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "TransactionNotSpecified" + "TransactionNotSpecified", + correlationId = correlationId ) } case _ => handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "CreditedWalletNotFound" + "CreditedWalletNotFound", + correlationId = correlationId ) } case _ => handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "CreditedPaymentAccountNotFound" + "CreditedPaymentAccountNotFound", + correlationId = correlationId ) } case Failure(_) => handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "CreditedPaymentAccountNotFound" + "CreditedPaymentAccountNotFound", + correlationId = correlationId ) } } @@ -567,7 +635,8 @@ trait PayInCommandHandler handlePayInWithPreAuthorizationFailure( "", replyTo, - "PaymentAccountNotFound" + "PaymentAccountNotFound", + correlationId = correlationId ) } @@ -584,7 +653,7 @@ trait PayInCommandHandler printReceipt: Boolean, transaction: Transaction, registerWallet: Boolean = false, - correlationId: Option[String] = None // Story 13.7 — threaded from the checkout command + maybeCorrelationId: Option[String] = None // Story 13.7 — threaded from the checkout command )(implicit system: ActorSystem[_], log: Logger, @@ -594,6 +663,12 @@ trait PayInCommandHandler transaction.id, entityId ) // add transaction id as a key for this payment account + // Story 13.7 — never let a persisted event / audit line be untraceable: fall back to the orderUuid + // (a stable business key) when no HTTP-origin cid was threaded. `correlationId` shadows the param + // so EVERY event persisted below carries the same id (the durable hop the licensing pod reads), and + // `effectiveCorrelationId` feeds the audit line — the two always agree. + val effectiveCorrelationId: String = maybeCorrelationId.getOrElse(orderUuid) + val correlationId: Option[String] = Some(effectiveCorrelationId) val lastUpdated = now() var updatedPaymentAccount = paymentAccount.withLastUpdated(lastUpdated) var transactionUpdatedEvents = { @@ -604,6 +679,7 @@ trait PayInCommandHandler .copy(clientId = paymentAccount.clientId, debitedUserId = paymentAccount.userId) ) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 ) } val walletEvents: List[ExternalSchedulerEvent] = @@ -615,7 +691,8 @@ trait PayInCommandHandler .withLastUpdated(lastUpdated) .copy( userId = paymentAccount.userId.get, - walletId = paymentAccount.walletId.get + walletId = paymentAccount.walletId.get, + correlationId = correlationId // Story 13.7 ) ) } else { @@ -628,7 +705,9 @@ trait PayInCommandHandler .persist( (PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) +: walletEvents) ++ transactionUpdatedEvents + .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 + +: walletEvents) ++ transactionUpdatedEvents ) .thenRun(_ => PaymentRequired( @@ -644,7 +723,9 @@ trait PayInCommandHandler .persist( (PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) +: walletEvents) ++ transactionUpdatedEvents + .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 + +: walletEvents) ++ transactionUpdatedEvents ) .thenRun(_ => PaymentRedirection(transaction.redirectUrl.get) ~> replyTo) case _ => @@ -682,6 +763,7 @@ trait PayInCommandHandler .withExternalUuid(paymentAccount.externalUuid) .withCard(updatedCard) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 ) case paypal: Paypal => updatedPaymentAccount = updatedPaymentAccount.withPaypals( @@ -693,6 +775,7 @@ trait PayInCommandHandler .withExternalUuid(paymentAccount.externalUuid) .withPaypal(paypal) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 ) case _ => List.empty } @@ -733,6 +816,7 @@ trait PayInCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 case _ => } case _ => @@ -756,9 +840,25 @@ trait PayInCommandHandler ) ++ (PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) +: walletEvents) ++ transactionUpdatedEvents + .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 + +: walletEvents) ++ transactionUpdatedEvents ) - .thenRun(_ => PaidIn(transaction.id, transaction.status) ~> replyTo) + .thenRun { _ => + // Story 13.7 — terminal payment audit line; the cid rode in on the command and is + // already on the persisted PaidInEvent (durable hop to the licensing pod). + audit.event( + effectiveCorrelationId, + "charge_succeeded", + "order_uuid" -> orderUuid, + "transaction_id" -> transaction.id, + "amount" -> transaction.amount, + "fees" -> transaction.fees, + "currency" -> transaction.currency, + "result" -> transaction.status.name + ) + PaidIn(transaction.id, transaction.status) ~> replyTo + } } else { log.error( "Order-{} could not be paid in: {} -> {}", @@ -773,14 +873,27 @@ trait PayInCommandHandler .withOrderUuid(orderUuid) .withResultMessage(transaction.resultMessage) .withTransaction(transaction) + .copy(correlationId = correlationId) // Story 13.7 ) ++ (PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) +: walletEvents) ++ transactionUpdatedEvents + .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 + +: walletEvents) ++ transactionUpdatedEvents ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "charge_failed", + "order_uuid" -> orderUuid, + "transaction_id" -> transaction.id, + "amount" -> transaction.amount, + "fees" -> transaction.fees, + "currency" -> transaction.currency, + "result" -> transaction.resultMessage + ) PayInFailed(transaction.id, transaction.status, transaction.resultMessage) ~> replyTo - ) + } } } } @@ -788,16 +901,30 @@ trait PayInCommandHandler private[payment] def handlePayInWithPreAuthorizationFailure( orderUuid: String, replyTo: Option[ActorRef[PaymentResult]], - reason: String + reason: String, + correlationId: Option[String] )(implicit context: ActorContext[_]): Effect[ExternalSchedulerEvent, Option[PaymentAccount]] = { + // Story 13.7 — orderUuid fallback so the event + audit line are traceable and agree (see handlePayIn). + val effectiveCorrelationId: String = correlationId.getOrElse(orderUuid) Effect .persist( List( - PayInFailedEvent.defaultInstance.withOrderUuid(orderUuid).withResultMessage(reason) + PayInFailedEvent.defaultInstance + .withOrderUuid(orderUuid) + .withResultMessage(reason) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 ) ) - .thenRun(_ => PayInWithCardPreAuthorizedFailed(reason) ~> replyTo) + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "charge_failed", + "order_uuid" -> orderUuid, + "result" -> reason + ) + PayInWithCardPreAuthorizedFailed(reason) ~> replyTo + } } } diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayOutCommandHandler.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayOutCommandHandler.scala index baad52e..fe914d9 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayOutCommandHandler.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PayOutCommandHandler.scala @@ -4,8 +4,8 @@ import akka.actor.typed.scaladsl.{ActorContext, TimerScheduler} import akka.actor.typed.{ActorRef, ActorSystem} import akka.persistence.typed.scaladsl.Effect import app.softnetwork.concurrent.Completion +import app.softnetwork.payment.audit.PaymentAuditLog.audit import app.softnetwork.payment.api.config.SoftPayClientSettings -import app.softnetwork.payment.message.PaymentEvents.PaymentAccountUpsertedEvent import app.softnetwork.payment.message.PaymentMessages._ import app.softnetwork.payment.message.TransactionEvents.{ PaidOutEvent, @@ -46,6 +46,30 @@ trait PayOutCommandHandler command match { case cmd: PayOut => import cmd._ + // Story 13.7 — orderUuid fallback shared by every payout event + audit line (see handlePayIn). + val effectiveCorrelationId: String = cmd.correlationId.getOrElse(orderUuid) + def auditPayoutSucceeded(transactionId: String, status: String): Unit = + audit.event( + effectiveCorrelationId, + "payout_succeeded", + "order_uuid" -> orderUuid, + "transaction_id" -> transactionId, + "amount" -> creditedAmount, + "fees" -> feesAmount, + "currency" -> currency, + "result" -> status + ) + def auditPayoutFailed(result: String, transactionId: String = ""): Unit = + audit.event( + effectiveCorrelationId, + "payout_failed", + "order_uuid" -> orderUuid, + "transaction_id" -> transactionId, + "amount" -> creditedAmount, + "fees" -> feesAmount, + "currency" -> currency, + "result" -> result + ) state match { case Some(paymentAccount) => val clientId = paymentAccount.clientId @@ -70,6 +94,12 @@ trait PayOutCommandHandler .find(_.orderUuid == orderUuid) .map(_.id) ) + val metadata: Map[String, String] = + cmd.correlationId match { + case Some(correlationId) => + Map("correlationId" -> correlationId) + case None => Map.empty + } payOut( Some( PayOutTransaction.defaultInstance @@ -85,6 +115,7 @@ trait PayOutCommandHandler externalReference = externalReference, payInTransactionId = pit ) + .withMetadata(metadata) ) ) match { case Some(transaction) => @@ -101,6 +132,7 @@ trait PayOutCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = Some(effectiveCorrelationId)) if (transaction.status.isTransactionFailedForTechnicalReason) { log.error( "Order-{} could not be paid out: {} -> {}", @@ -115,16 +147,20 @@ trait PayOutCommandHandler .withOrderUuid(orderUuid) .withResultMessage(transaction.resultMessage) .withTransaction(transaction) - .copy(externalReference = externalReference) + .copy( + externalReference = externalReference, + correlationId = Some(effectiveCorrelationId) + ) ) :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + auditPayoutFailed(transaction.resultMessage, transaction.id) PayOutFailed( transaction.id, transaction.status, transaction.resultMessage ) ~> replyTo - ) + } } else if ( transaction.status.isTransactionSucceeded || transaction.status.isTransactionCreated ) { @@ -146,12 +182,16 @@ trait PayOutCommandHandler .withCurrency(currency) .withTransactionId(transaction.id) .withPaymentType(transaction.paymentType) - .copy(externalReference = externalReference) + .copy( + externalReference = externalReference, + correlationId = Some(effectiveCorrelationId) + ) ) :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + auditPayoutSucceeded(transaction.id, transaction.status.name) PaidOut(transaction.id, transaction.status) ~> replyTo - ) + } } else { log.error( "Order-{} could not be paid out : {} -> {}", @@ -166,16 +206,20 @@ trait PayOutCommandHandler .withOrderUuid(orderUuid) .withResultMessage(transaction.resultMessage) .withTransaction(transaction) - .copy(externalReference = externalReference) + .copy( + externalReference = externalReference, + correlationId = Some(effectiveCorrelationId) + ) ) :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + auditPayoutFailed(transaction.resultMessage, transaction.id) PayOutFailed( transaction.id, transaction.status, transaction.resultMessage ) ~> replyTo - ) + } } case _ => log.error( @@ -188,16 +232,20 @@ trait PayOutCommandHandler PayOutFailedEvent.defaultInstance .withOrderUuid(orderUuid) .withResultMessage("no transaction returned by provider") - .copy(externalReference = externalReference) + .copy( + externalReference = externalReference, + correlationId = Some(effectiveCorrelationId) // Story 13.7 + ) ) ) - .thenRun(_ => + .thenRun { _ => + auditPayoutFailed("no transaction returned by provider") PayOutFailed( "", Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, "no transaction returned by provider" ) ~> replyTo - ) + } } case _ => Effect @@ -206,16 +254,20 @@ trait PayOutCommandHandler PayOutFailedEvent.defaultInstance .withOrderUuid(orderUuid) .withResultMessage("no bank account") - .copy(externalReference = externalReference) + .copy( + externalReference = externalReference, + correlationId = Some(effectiveCorrelationId) + ) ) ) - .thenRun(_ => + .thenRun { _ => + auditPayoutFailed("no bank account") PayOutFailed( "", Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, "no bank account" ) ~> replyTo - ) + } } case _ => Effect @@ -224,16 +276,20 @@ trait PayOutCommandHandler PayOutFailedEvent.defaultInstance .withOrderUuid(orderUuid) .withResultMessage("no wallet id") - .copy(externalReference = externalReference) + .copy( + externalReference = externalReference, + correlationId = Some(effectiveCorrelationId) + ) ) ) - .thenRun(_ => + .thenRun { _ => + auditPayoutFailed("no wallet id") PayOutFailed( "", Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, "no wallet id" ) ~> replyTo - ) + } } case _ => Effect @@ -242,22 +298,28 @@ trait PayOutCommandHandler PayOutFailedEvent.defaultInstance .withOrderUuid(orderUuid) .withResultMessage("no payment provider user id") - .copy(externalReference = externalReference) + .copy( + externalReference = externalReference, + correlationId = Some(effectiveCorrelationId) + ) ) ) - .thenRun(_ => + .thenRun { _ => + auditPayoutFailed("no payment provider user id") PayOutFailed( "", Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, "no payment provider user id" ) ~> replyTo - ) + } } case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } case cmd: LoadPayOutTransaction => import cmd._ + // Story 13.7 — orderUuid fallback shared by every payout event + audit line (see handlePayIn). + val effectiveCorrelationId: String = cmd.correlationId.getOrElse(orderUuid) state match { case Some(paymentAccount) => paymentAccount.transactions.find(t => @@ -295,6 +357,7 @@ trait PayOutCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = Some(effectiveCorrelationId)) if (t.status.isTransactionSucceeded || t.status.isTransactionCreated) { Effect .persist( @@ -308,16 +371,29 @@ trait PayOutCommandHandler .withCurrency(t.currency) .withTransactionId(t.id) .withPaymentType(t.paymentType) - .copy(externalReference = transaction.externalReference) + .copy( + externalReference = transaction.externalReference, + correlationId = Some(effectiveCorrelationId) + ) ) :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "payout_succeeded", + "order_uuid" -> orderUuid, + "transaction_id" -> t.id, + "amount" -> t.amount, + "fees" -> t.fees, + "currency" -> t.currency, + "result" -> t.status.name + ) PayOutTransactionLoaded( transaction.id, transaction.status, None ) ~> replyTo - ) + } } else { Effect .persist( @@ -326,16 +402,29 @@ trait PayOutCommandHandler .withOrderUuid(orderUuid) .withResultMessage(updatedTransaction.resultMessage) .withTransaction(updatedTransaction) - .copy(externalReference = transaction.externalReference) + .copy( + externalReference = transaction.externalReference, + correlationId = Some(effectiveCorrelationId) + ) ) :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "payout_failed", + "order_uuid" -> orderUuid, + "transaction_id" -> t.id, + "amount" -> t.amount, + "fees" -> t.fees, + "currency" -> t.currency, + "result" -> updatedTransaction.resultMessage + ) PayOutTransactionLoaded( transaction.id, transaction.status, None ) ~> replyTo - ) + } } case None => Effect.none.thenRun(_ => TransactionNotFound ~> replyTo) } diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentBehavior.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentBehavior.scala index 1c4c079..53fda70 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentBehavior.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentBehavior.scala @@ -5,6 +5,7 @@ import akka.actor.typed.{ActorRef, ActorSystem} import akka.cluster.sharding.typed.ShardingEnvelope import akka.persistence.typed.scaladsl.Effect import app.softnetwork.payment.api.config.SoftPayClientSettings +import app.softnetwork.payment.audit.PaymentAuditLog.audit import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.config.PaymentSettings.PaymentConfig.akkaNodeRole import app.softnetwork.payment.handlers.{PaymentDao, SoftPayAccountDao} @@ -314,6 +315,8 @@ trait PaymentBehavior case cmd: Refund => import cmd._ + // Story 13.7 — orderUuid fallback shared by every refund event + audit line (see handlePayIn). + val effectiveCorrelationId: String = cmd.correlationId.getOrElse(orderUuid) state match { case Some(paymentAccount) => val clientId = paymentAccount.clientId @@ -365,6 +368,7 @@ trait PaymentBehavior ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 transaction.status match { case Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON => log.error( @@ -380,15 +384,25 @@ trait PaymentBehavior .withOrderUuid(orderUuid) .withResultMessage(transaction.resultMessage) .withTransaction(transaction) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 ) :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "refund_failed", + "order_uuid" -> orderUuid, + "pay_in_transaction_id" -> payInTransactionId, + "refund_amount" -> refundAmount, + "currency" -> currency, + "result" -> transaction.resultMessage + ) RefundFailed( "", Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, transaction.resultMessage ) ~> replyTo - ) + } case _ => if ( transaction.status.isTransactionSucceeded || transaction.status.isTransactionCreated @@ -414,9 +428,24 @@ trait PaymentBehavior .withReasonMessage(reasonMessage) .withInitializedByClient(initializedByClient) .withPaymentType(transaction.paymentType) + .copy(correlationId = + Some(effectiveCorrelationId) + ) // Story 13.7 ) :+ transactionUpdatedEvent ) - .thenRun(_ => Refunded(transaction.id, transaction.status) ~> replyTo) + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "refund", + "order_uuid" -> orderUuid, + "pay_in_transaction_id" -> payInTransactionId, + "transaction_id" -> transaction.id, + "refund_amount" -> refundAmount, + "currency" -> currency, + "result" -> transaction.status.name + ) + Refunded(transaction.id, transaction.status) ~> replyTo + } } else { log.info( "Order-{} could not be refunded: {} -> {}", @@ -431,15 +460,28 @@ trait PaymentBehavior .withOrderUuid(orderUuid) .withResultMessage(transaction.resultMessage) .withTransaction(transaction) + .copy(correlationId = + Some(effectiveCorrelationId) + ) // Story 13.7 ) :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "refund_failed", + "order_uuid" -> orderUuid, + "pay_in_transaction_id" -> payInTransactionId, + "transaction_id" -> transaction.id, + "refund_amount" -> refundAmount, + "currency" -> currency, + "result" -> transaction.resultMessage + ) RefundFailed( transaction.id, transaction.status, transaction.resultMessage ) ~> replyTo - ) + } } } case _ => @@ -453,15 +495,25 @@ trait PaymentBehavior RefundFailedEvent.defaultInstance .withOrderUuid(orderUuid) .withResultMessage("no transaction returned by provider") + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 ) ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "refund_failed", + "order_uuid" -> orderUuid, + "pay_in_transaction_id" -> payInTransactionId, + "refund_amount" -> refundAmount, + "currency" -> currency, + "result" -> "no transaction returned by provider" + ) RefundFailed( "", Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, "no transaction returned by provider" ) ~> replyTo - ) + } } } case _ => Effect.none.thenRun(_ => IllegalTransactionStatus ~> replyTo) diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentMethodCommandHandler.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentMethodCommandHandler.scala index 776c23d..58a5e5e 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentMethodCommandHandler.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentMethodCommandHandler.scala @@ -46,6 +46,8 @@ trait PaymentMethodCommandHandler command match { case cmd: PreRegisterPaymentMethod => import cmd._ + // Story 13.7 — orderUuid fallback so every persisted event carries a traceable cid. + val effectiveCorrelationId: String = cmd.correlationId.getOrElse(orderUuid) val (pa, registerWallet) = createOrUpdateCustomer(entityId, state, user, currency, clientId) pa match { case Some(paymentAccount) => @@ -54,6 +56,7 @@ trait PaymentMethodCommandHandler .withDocument( paymentAccount ) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 paymentAccount.userId match { case Some(userId) => paymentAccount.walletId match { @@ -83,6 +86,7 @@ trait PaymentMethodCommandHandler .withUserId(userId) .withWalletId(walletId) .withLastUpdated(lastUpdated) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 ) } else { List.empty @@ -108,7 +112,8 @@ trait PaymentMethodCommandHandler .withBirthday(user.birthday) ) case _ => None - } + }, + correlationId = Some(effectiveCorrelationId) // Story 13.7 ) ) ++ walletEvents :+ paymentAccountUpsertedEvent ) @@ -124,6 +129,7 @@ trait PaymentMethodCommandHandler .withUserId(userId) .withWalletId(walletId) .withLastUpdated(lastUpdated) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 ) :+ paymentAccountUpsertedEvent ) .thenRun(_ => PaymentMethodNotPreRegistered ~> replyTo) @@ -207,6 +213,7 @@ trait PaymentMethodCommandHandler updatedPaymentAccount .withLastUpdated(lastUpdated) ) + .copy(correlationId = cmd.correlationId) ) .thenRun(_ => PaymentMethodDisabled ~> replyTo) case _ => Effect.none.thenRun(_ => PaymentMethodNotDisabled ~> replyTo) @@ -244,6 +251,7 @@ trait PaymentMethodCommandHandler PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount.withLastUpdated(lastUpdated)) .withLastUpdated(lastUpdated) + .copy(correlationId = cmd.correlationId) ) .thenRun(_ => PaymentMethodRegistered ~> replyTo) case Some(paypal: Paypal) => @@ -258,6 +266,7 @@ trait PaymentMethodCommandHandler PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount.withLastUpdated(lastUpdated)) .withLastUpdated(lastUpdated) + .copy(correlationId = cmd.correlationId) ) .thenRun(_ => PaymentMethodRegistered ~> replyTo) case _ => @@ -286,6 +295,7 @@ trait PaymentMethodCommandHandler PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount.withLastUpdated(lastUpdated)) .withLastUpdated(lastUpdated) + .copy(correlationId = cmd.correlationId) ) .thenRun(_ => PaymentMethodDisabled ~> replyTo) case Some(paypal: Paypal) => @@ -300,6 +310,7 @@ trait PaymentMethodCommandHandler PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount.withLastUpdated(lastUpdated)) .withLastUpdated(lastUpdated) + .copy(correlationId = cmd.correlationId) ) .thenRun(_ => PaymentMethodDisabled ~> replyTo) case _ => diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PreAuthorizationCommandHandler.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PreAuthorizationCommandHandler.scala index 0dc6006..fcb85e3 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PreAuthorizationCommandHandler.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PreAuthorizationCommandHandler.scala @@ -5,6 +5,7 @@ import akka.actor.typed.{ActorRef, ActorSystem} import akka.persistence.typed.scaladsl.Effect import app.softnetwork.concurrent.Completion import app.softnetwork.payment.api.config.SoftPayClientSettings +import app.softnetwork.payment.audit.PaymentAuditLog.audit import app.softnetwork.payment.message.PaymentEvents.{ PaymentAccountUpsertedEvent, PaymentMethodRegisteredEvent, @@ -117,6 +118,12 @@ trait PreAuthorizationCommandHandler } val registerMeansOfPayment: Boolean = cmd.registerMeansOfPayment.getOrElse(cmd.paymentType.isCard && cmd.registerCard) + val metadata: Map[String, String] = + cmd.correlationId match { + case Some(correlationId) => + Map("correlationId" -> correlationId) + case None => Map.empty + } preAuthorize( PreAuthorizationTransaction.defaultInstance .withAuthorId(userId) @@ -133,6 +140,7 @@ trait PreAuthorizationCommandHandler preRegistrationId = registrationId, paymentType = paymentType ) + .withMetadata(metadata) ) match { case Some(transaction) => handlePreAuthorization( @@ -143,7 +151,8 @@ trait PreAuthorizationCommandHandler registerMeansOfPayment, printReceipt, transaction, - registerWallet + registerWallet, + maybeCorrelationId = cmd.correlationId ) case _ => // pre authorization failed Effect.none.thenRun(_ => PaymentNotPreAuthorized ~> replyTo) @@ -181,7 +190,8 @@ trait PreAuthorizationCommandHandler transaction.copy( preRegistrationId = preRegistrationId, preAuthorizationId = Some(preAuthorizationId) - ) + ), + maybeCorrelationId = cmd.correlationId ) case _ => Effect.none.thenRun(_ => PaymentNotPreAuthorized ~> replyTo) } @@ -190,6 +200,8 @@ trait PreAuthorizationCommandHandler case cmd: CancelPreAuthorization => import cmd._ + // Story 13.7 — orderUuid fallback shared by the cancel event + audit line (see handlePayIn). + val effectiveCorrelationId: String = cmd.correlationId.getOrElse(orderUuid) state match { case Some(paymentAccount) => val clientId = paymentAccount.clientId @@ -215,6 +227,7 @@ trait PreAuthorizationCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 Effect .persist( List( @@ -224,9 +237,19 @@ trait PreAuthorizationCommandHandler .withDebitedAccount(paymentAccount.externalUuid) .withPreAuthorizedTransactionId(preAuthorizationId) .withPreAuthorizationCanceled(preAuthorizationCanceled) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 ) :+ transactionUpdatedEvent ) - .thenRun(_ => PreAuthorizationCanceled(preAuthorizationCanceled) ~> replyTo) + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "preauthorization_canceled", + "order_uuid" -> orderUuid, + "transaction_id" -> preAuthorizationId, + "result" -> preAuthorizationCanceled.toString + ) + PreAuthorizationCanceled(preAuthorizationCanceled) ~> replyTo + } case _ => // should never be the case Effect.none.thenRun(_ => TransactionNotFound ~> replyTo) } @@ -244,7 +267,8 @@ trait PreAuthorizationCommandHandler registerMeansOfPayment: Boolean, printReceipt: Boolean, transaction: Transaction, - registerWallet: Boolean = false + registerWallet: Boolean = false, + maybeCorrelationId: Option[String] )(implicit system: ActorSystem[_], log: Logger, @@ -254,6 +278,10 @@ trait PreAuthorizationCommandHandler transaction.id, entityId ) // add transaction id as a key for this payment account + // Story 13.7 — orderUuid fallback; `correlationId` shadows the param so every event carries the + // same id, `effectiveCorrelationId` feeds the audit line (see handlePayIn). + val effectiveCorrelationId: String = maybeCorrelationId.getOrElse(orderUuid) + val correlationId: Option[String] = Some(effectiveCorrelationId) val lastUpdated = now() var updatedPaymentAccount = paymentAccount.withLastUpdated(lastUpdated) val transactionUpdatedEvent = @@ -265,6 +293,7 @@ trait PreAuthorizationCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) val walletEvents: List[ExternalSchedulerEvent] = if (registerWallet) { List( @@ -274,7 +303,8 @@ trait PreAuthorizationCommandHandler .withLastUpdated(lastUpdated) .copy( userId = paymentAccount.userId.get, - walletId = paymentAccount.walletId.get + walletId = paymentAccount.walletId.get, + correlationId = correlationId ) ) } else { @@ -289,6 +319,7 @@ trait PreAuthorizationCommandHandler PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) ) ++ walletEvents :+ transactionUpdatedEvent ) .thenRun(_ => @@ -307,6 +338,7 @@ trait PreAuthorizationCommandHandler PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) ) ++ walletEvents :+ transactionUpdatedEvent ) .thenRun(_ => @@ -358,6 +390,7 @@ trait PreAuthorizationCommandHandler .withExternalUuid(paymentAccount.externalUuid) .withCard(updatedCard) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) ) case paypal: Paypal => updatedPaymentAccount = updatedPaymentAccount.withPaypals( @@ -369,6 +402,7 @@ trait PreAuthorizationCommandHandler .withExternalUuid(paymentAccount.externalUuid) .withPaypal(paypal) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) ) case _ => List.empty } @@ -392,12 +426,27 @@ trait PreAuthorizationCommandHandler .withLastUpdated(lastUpdated) .withPrintReceipt(printReceipt) .withPaymentType(transaction.paymentType) + .copy(correlationId = correlationId) ) ++ (PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) +: walletEvents) :+ transactionUpdatedEvent + .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) + +: walletEvents) :+ transactionUpdatedEvent ) - .thenRun(_ => PaymentPreAuthorized(transaction.id) ~> replyTo) + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "preauthorization_succeeded", + "order_uuid" -> orderUuid, + "transaction_id" -> transaction.id, + "amount" -> transaction.amount, + "fees" -> transaction.fees, + "currency" -> transaction.currency, + "result" -> transaction.status.name + ) + PaymentPreAuthorized(transaction.id) ~> replyTo + } } else { log.error( "Order-{} could not be pre authorized: {} -> {}", @@ -412,12 +461,27 @@ trait PreAuthorizationCommandHandler .withOrderUuid(orderUuid) .withResultMessage(transaction.resultMessage) .withTransaction(transaction) + .copy(correlationId = correlationId) ) ++ (PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) +: walletEvents) :+ transactionUpdatedEvent + .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) + +: walletEvents) :+ transactionUpdatedEvent ) - .thenRun(_ => PreAuthorizationFailed(transaction.resultMessage) ~> replyTo) + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "preauthorization_failed", + "order_uuid" -> orderUuid, + "transaction_id" -> transaction.id, + "amount" -> transaction.amount, + "fees" -> transaction.fees, + "currency" -> transaction.currency, + "result" -> transaction.resultMessage + ) + PreAuthorizationFailed(transaction.resultMessage) ~> replyTo + } } } } diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala index 2f94654..d1f0069 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala @@ -5,6 +5,7 @@ import akka.actor.typed.scaladsl.{ActorContext, TimerScheduler} import akka.persistence.typed.scaladsl.Effect import app.softnetwork.concurrent.Completion import app.softnetwork.payment.api.config.SoftPayClientSettings +import app.softnetwork.payment.audit.PaymentAuditLog.audit import app.softnetwork.payment.message.PaymentEvents.{ PaymentAccountUpsertedEvent, RecurringPaymentRegisteredEvent @@ -24,7 +25,6 @@ import app.softnetwork.payment.message.PaymentMessages.{ NextRecurringPaid, NextRecurringPaymentFailed, PaymentAccountNotFound, - PaymentError, PaymentRedirection, PaymentResult, RecurringCardPaymentRegistrationNotUpdated, @@ -114,6 +114,12 @@ trait RecurringPaymentCommandHandler ) match { case Some(cardId) => val createdDate = now() + val updatedMetadata = + cmd.correlationId match { + case Some(correlationId) => + cmd.metadata.updated("correlation_id", correlationId) + case None => cmd.metadata + } var recurringPayment = RecurringPayment.defaultInstance .withCreatedDate(createdDate) @@ -131,7 +137,7 @@ trait RecurringPaymentCommandHandler nextDebitedAmount = cmd.nextDebitedAmount, nextFeesAmount = cmd.nextFeesAmount, externalReference = cmd.externalReference, - metadata = cmd.metadata + metadata = updatedMetadata ) val clientId = paymentAccount.clientId .orElse(cmd.clientId) @@ -188,12 +194,16 @@ trait RecurringPaymentCommandHandler recurringPayment.nextPaymentDate.map(_.toDate) ) keyValueDao.addKeyValue(recurringPayment.getId, entityId) + val effectiveCorrelationId = updatedMetadata + .get("correlation_id") + .orElse(Some(recurringPayment.getId)) Effect .persist( List( RecurringPaymentRegisteredEvent.defaultInstance .withExternalUuid(paymentAccount.externalUuid) .withRecurringPayment(recurringPayment) + .copy(correlationId = effectiveCorrelationId) ) :+ PaymentAccountUpsertedEvent.defaultInstance .withDocument( @@ -204,6 +214,7 @@ trait RecurringPaymentCommandHandler .withLastUpdated(createdDate) ) .withLastUpdated(createdDate) + .copy(correlationId = effectiveCorrelationId) ) .thenRun(_ => RecurringPaymentRegistered( @@ -236,6 +247,12 @@ trait RecurringPaymentCommandHandler // } } else { val today = now() + val updatedMetadata = + cmd.correlationId match { + case Some(correlationId) => + cmd.metadata.updated("correlation_id", correlationId) + case None => cmd.metadata + } var recurringPayment = RecurringPayment.defaultInstance .withId(generateUUID()) @@ -252,8 +269,10 @@ trait RecurringPaymentCommandHandler fixedNextAmount = cmd.fixedNextAmount, nextDebitedAmount = cmd.nextDebitedAmount, nextFeesAmount = cmd.nextFeesAmount, - metadata = cmd.metadata + metadata = updatedMetadata ) + val effectiveCorrelationId = + updatedMetadata.get("correlation_id").orElse(Some(recurringPayment.getId)) import app.softnetwork.time._ val nextDirectDebit: List[ExternalEntityToSchedulerEvent] = recurringPayment.nextPaymentDate.map(_.toDate) match { @@ -270,7 +289,8 @@ trait RecurringPaymentCommandHandler 1, Some(false), Some(value), - None + None, + correlationId = effectiveCorrelationId ) ) ) @@ -285,6 +305,7 @@ trait RecurringPaymentCommandHandler RecurringPaymentRegisteredEvent.defaultInstance .withExternalUuid(paymentAccount.externalUuid) .withRecurringPayment(recurringPayment) + .copy(correlationId = effectiveCorrelationId) ) ++ nextDirectDebit :+ PaymentAccountUpsertedEvent.defaultInstance .withDocument( @@ -295,6 +316,7 @@ trait RecurringPaymentCommandHandler .withLastUpdated(today) ) .withLastUpdated(today) + .copy(correlationId = effectiveCorrelationId) ) .thenRun(_ => RecurringPaymentRegistered(recurringPayment.getId) ~> replyTo) } @@ -340,6 +362,8 @@ trait RecurringPaymentCommandHandler recurringPayment.withCardStatus(result.status) ) .withLastUpdated(lastUpdated) + val effectiveCorrelationId = + cmd.correlationId.orElse(Some(recurringPayment.getId)) Effect .persist( List( @@ -348,6 +372,7 @@ trait RecurringPaymentCommandHandler .withRecurringPayment( recurringPayment.withCardStatus(result.status) ) + .copy(correlationId = effectiveCorrelationId) ) ++ { if (result.status.isEnded) { // cancel scheduled payIn for recurring card payment List( @@ -368,6 +393,7 @@ trait RecurringPaymentCommandHandler PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) .withLastUpdated(lastUpdated) + .copy(correlationId = effectiveCorrelationId) ) .thenRun(_ => RecurringCardPaymentRegistrationUpdated(result) ~> replyTo) case _ => @@ -428,7 +454,7 @@ trait RecurringPaymentCommandHandler paymentAccount, recurringPayment, transaction, - correlationId = cmd.correlationId // Story 13.7 + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -469,7 +495,7 @@ trait RecurringPaymentCommandHandler recurringPayment, transaction, scheduleNextPayment = false, - correlationId = cmd.correlationId // Story 13.7 + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -532,7 +558,7 @@ trait RecurringPaymentCommandHandler paymentAccount, recurringPayment, transaction, - correlationId = cmd.correlationId // Story 13.7 + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => val reason = "no transaction" @@ -544,7 +570,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => @@ -557,7 +584,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => // DirectDebit @@ -588,7 +616,7 @@ trait RecurringPaymentCommandHandler paymentAccount, recurringPayment, transaction, - correlationId = cmd.correlationId // Story 13.7 + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => val reason = "no transaction" @@ -600,7 +628,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } } else { @@ -613,7 +642,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => @@ -626,7 +656,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => @@ -639,7 +670,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => @@ -652,7 +684,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } } @@ -671,7 +704,7 @@ trait RecurringPaymentCommandHandler recurringPayment: RecurringPayment, transaction: Transaction, scheduleNextPayment: Boolean = true, - correlationId: Option[String] = None // Story 13.7 — threaded from the triggering command + maybeCorrelationId: Option[String] = None // Story 13.7 — threaded from the triggering command )(implicit system: ActorSystem[_], log: Logger @@ -680,6 +713,11 @@ trait RecurringPaymentCommandHandler transaction.id, entityId ) // add transaction id as a key for this payment account + // Story 13.7 — fall back to the transaction's orderUuid when no HTTP-origin cid was threaded. + // `correlationId` shadows the param so EVERY event below shares the id; `effectiveCorrelationId` + // feeds the audit line — the two always agree (see handlePayIn). + val effectiveCorrelationId: String = maybeCorrelationId.getOrElse(transaction.orderUuid) + val correlationId: Option[String] = Some(effectiveCorrelationId) val lastUpdated = now() var updatedPaymentAccount = paymentAccount.withLastUpdated(lastUpdated) val transactionUpdatedEvent = @@ -691,6 +729,7 @@ trait RecurringPaymentCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) transaction.status match { case Transaction.TransactionStatus.TRANSACTION_CREATED if transaction.redirectUrl.isDefined => // 3ds @@ -781,7 +820,8 @@ trait RecurringPaymentCommandHandler 1, Some(false), Some(value), - None + None, + correlationId = correlationId ) ) ) @@ -800,12 +840,27 @@ trait RecurringPaymentCommandHandler } :+ PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) :+ transactionUpdatedEvent + .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) + :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + // Story 13.7 — subscription charge audit; cid rode in on the triggering command and is + // on the persisted First/Next recurring event (durable hop to the licensing pod). + audit.event( + effectiveCorrelationId, + "subscription_charged", + "registration_id" -> recurringPayment.getId, + "transaction_id" -> transaction.id, + "amount" -> transaction.amount, + "fees" -> transaction.fees, + "currency" -> transaction.currency, + "first" -> first, + "result" -> transaction.status.name + ) (if (first) FirstRecurringPaidIn(transaction.id, transaction.status) else NextRecurringPaid(transaction.id, transaction.status)) ~> replyTo - ) + } } else { log.error( "RecurringPayment-{} failed: {} -> {}", @@ -826,6 +881,7 @@ trait RecurringPaymentCommandHandler .withTransaction(transaction) .withRecurringPaymentRegistrationId(recurringPayment.getId) .withFrequency(recurringPayment.getFrequency) + .copy(correlationId = correlationId) } else { NextRecurringPaymentFailedEvent.defaultInstance .withDebitedAccount(paymentAccount.externalUuid) @@ -838,7 +894,10 @@ trait RecurringPaymentCommandHandler .withType(recurringPayment.`type`) .withFrequency(recurringPayment.getFrequency) .withNumberOfRecurringPayments(recurringPayment.getNumberOfRecurringPayments) - .copy(lastRecurringPaymentDate = recurringPayment.lastRecurringPaymentDate) + .copy( + lastRecurringPaymentDate = recurringPayment.lastRecurringPaymentDate, + correlationId = correlationId + ) } ) :+ { recurringPayment.nextRecurringPaymentDate match { @@ -853,7 +912,8 @@ trait RecurringPaymentCommandHandler 1, Some(false), Some(value), - None + None, + correlationId = correlationId ) ) ) @@ -871,7 +931,18 @@ trait RecurringPaymentCommandHandler } } :+ transactionUpdatedEvent ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "subscription_charge_failed", + "registration_id" -> recurringPayment.getId, + "transaction_id" -> transaction.id, + "amount" -> transaction.amount, + "fees" -> transaction.fees, + "currency" -> transaction.currency, + "first" -> first, + "result" -> transaction.getReasonMessage + ) ( if (first) FirstRecurringCardPaymentFailed( @@ -886,7 +957,7 @@ trait RecurringPaymentCommandHandler transaction.getReasonMessage ) ) ~> replyTo - ) + } } } } @@ -899,8 +970,17 @@ trait RecurringPaymentCommandHandler debitedAmount: Int, feesAmount: Int, currency: String, - reason: String + reason: String, + maybeCorrelationId: Option[String] )(implicit context: ActorContext[_]): Effect[ExternalSchedulerEvent, Option[PaymentAccount]] = { + // Story 13.7 — no transaction/orderUuid in scope here, so fall back to the recurring payment's + // external reference (its orderUuid) then its registration id. `correlationId` shadows the param + // so the failure event + schedule share the id; `effectiveCorrelationId` feeds the audit line. + val effectiveCorrelationId: String = + maybeCorrelationId.getOrElse( + recurringPayment.externalReference.getOrElse(recurringPayment.getId) + ) + val correlationId: Option[String] = Some(effectiveCorrelationId) Effect .persist( List( @@ -914,7 +994,10 @@ trait RecurringPaymentCommandHandler .withType(recurringPayment.`type`) .withFrequency(recurringPayment.getFrequency) .withNumberOfRecurringPayments(recurringPayment.getNumberOfRecurringPayments) - .copy(lastRecurringPaymentDate = recurringPayment.lastRecurringPaymentDate) + .copy( + lastRecurringPaymentDate = recurringPayment.lastRecurringPaymentDate, + correlationId = correlationId + ) ) :+ { recurringPayment.nextRecurringPaymentDate match { case Some(value) => @@ -928,7 +1011,8 @@ trait RecurringPaymentCommandHandler 1, Some(false), Some(value), - None + None, + correlationId = correlationId ) ) ) @@ -946,13 +1030,22 @@ trait RecurringPaymentCommandHandler } } ) - .thenRun(_ => + .thenRun { _ => + audit.event( + effectiveCorrelationId, + "subscription_charge_failed", + "registration_id" -> recurringPayment.getId, + "amount" -> debitedAmount, + "fees" -> feesAmount, + "currency" -> currency, + "result" -> reason + ) NextRecurringPaymentFailed( "", Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, reason ) ~> replyTo - ) + } } } diff --git a/core/src/main/scala/app/softnetwork/payment/service/BankAccountEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/BankAccountEndpoints.scala index 46618c5..7e559a2 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/BankAccountEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/BankAccountEndpoints.scala @@ -1,13 +1,14 @@ package app.softnetwork.payment.service +import app.softnetwork.api.server.HttpCorrelation import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages._ -import app.softnetwork.payment.model.{BankAccountView, PaymentAccount} +import app.softnetwork.payment.model.BankAccountView import app.softnetwork.session.model.{SessionData, SessionDataDecorator} import sttp.capabilities import sttp.capabilities.akka.AkkaStreams -import sttp.model.{HeaderNames, StatusCode} +import sttp.model.StatusCode import sttp.tapir.json.json4s.jsonBody import sttp.tapir.server.ServerEndpoint @@ -21,13 +22,16 @@ trait BankAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val createOrUpdateBankAccount: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.post .in(PaymentSettings.PaymentConfig.bankRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .in(jsonBody[BankAccountCommand].description("Bank account to create or update")) .out( statusCode(StatusCode.Ok) .and(jsonBody[BankAccountCreatedOrUpdated].description("Bank account created or updated")) ) .serverLogic { - case (client, session) => { bankAccountCommand => + case (client, session) => { args => + val correlationId = args._1 + val bankAccountCommand = args._2 import bankAccountCommand._ val updatedBankAccount = if (bankAccount.externalUuid.trim().isEmpty) { @@ -35,14 +39,15 @@ trait BankAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { } else { bankAccount } - run( + val cmd = CreateOrUpdateBankAccount( externalUuidWithProfile(session), updatedBankAccount, clientId = client.map(_.clientId).orElse(session.clientId), bankTokenId = bankTokenId ) - ).map { + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { case r: BankAccountCreatedOrUpdated => Right(r) case other => Left(error(other)) } @@ -53,6 +58,7 @@ trait BankAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val loadBankAccount: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get .in(PaymentSettings.PaymentConfig.bankRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and( jsonBody[BankAccountView] @@ -60,13 +66,13 @@ trait BankAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .serverLogic { case (client, session) => - _ => { - run( - LoadBankAccount( - externalUuidWithProfile(session), - clientId = client.map(_.clientId).orElse(session.clientId) - ) - ).map { + correlationId => { + val cmd = LoadBankAccount( + externalUuidWithProfile(session), + clientId = client.map(_.clientId).orElse(session.clientId) + ) + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { case r: BankAccountLoaded => Right(r.bankAccount.view) case other => Left(error(other)) } @@ -77,16 +83,21 @@ trait BankAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val deleteBankAccount: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.delete .in(PaymentSettings.PaymentConfig.bankRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and(jsonBody[BankAccountDeleted.type]) ) - .serverLogic(principal => - _ => - run(DeleteBankAccount(externalUuidWithProfile(principal._2), Some(false))).map { - case BankAccountDeleted => Right(BankAccountDeleted) - case other => Left(error(other)) - } - ) + .serverLogic { principal => correlationId => + val cmd = DeleteBankAccount( + externalUuidWithProfile(principal._2), + Some(false) + ) + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { + case BankAccountDeleted => Right(BankAccountDeleted) + case other => Left(error(other)) + } + } .description("Delete authenticated user bank account") val bankAccountEndpoints: List[ServerEndpoint[AkkaStreams with capabilities.WebSockets, Future]] = diff --git a/core/src/main/scala/app/softnetwork/payment/service/BillingPortalEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/BillingPortalEndpoints.scala index 5f011d0..cca1b32 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/BillingPortalEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/BillingPortalEndpoints.scala @@ -1,5 +1,6 @@ package app.softnetwork.payment.service +import app.softnetwork.api.server.HttpCorrelation import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages._ @@ -20,20 +21,24 @@ trait BillingPortalEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val createBillingPortalSession: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.post .in(PaymentSettings.PaymentConfig.billingPortalRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .in(jsonBody[BillingPortalRequest]) .out( statusCode(StatusCode.Ok) .and(jsonBody[BillingPortalSessionCreated]) ) .serverLogic { case (client, session) => - req => { - run( + args => { + val correlationId = args._1 + val req = args._2 + val cmd = CreateBillingPortalSession( externalUuidWithProfile(session), req.returnUrl, clientId = client.map(_.clientId).orElse(session.clientId) ) - ).map { + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { case r: BillingPortalSessionCreated => Right(r) case other => Left(error(other)) } diff --git a/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala index f846494..110d9d8 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala @@ -23,7 +23,7 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { def payment(payment: Payment): PartialServerEndpointWithSecurityOutput[ (Seq[Option[String]], Option[String], Method, Option[String]), (Option[SoftPayAccount.Client], SD), - (Option[String], Option[String], Option[String], Option[String], Payment), + (Option[String], Option[String], Option[String], Option[String], Payment, String), Any, (Seq[Option[String]], Option[CookieValueWithMeta]), Unit, @@ -40,6 +40,7 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { .description("Payment to perform") .example(payment) ) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id val preAuthorize: ServerEndpoint[Any with AkkaStreams, Future] = payment( @@ -76,10 +77,10 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .serverLogic(principal => { - case (language, accept, userAgent, ipAddress, payment, creditedAccount) => + case (language, accept, userAgent, ipAddress, payment, correlationId, creditedAccount) => val browserInfo = extractBrowserInfo(language, accept, userAgent, payment) import payment._ - run( + val cmd = PreAuthorize( orderUuid, externalUuidWithProfile(principal._2), @@ -97,7 +98,8 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { paymentMethodId = paymentMethodId, registerMeansOfPayment = registerMeansOfPayment ) - ).map { + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { case result: PaymentPreAuthorized => Right(result) case result: PaymentRedirection => Right(result) case result: PaymentRequired => Right(result) @@ -121,6 +123,7 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .in(query[Boolean]("printReceipt").description("Whether or not a receipt should be printed"))*/ + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( oneOf[PaymentResult]( oneOfVariant[PaymentPreAuthorized]( @@ -138,16 +141,17 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .description("Pre authorize card for 3D secure") - .serverLogic { case (orderUuid, params) => + .serverLogic { case (orderUuid, params, correlationId) => val preAuthorizationIdParameter = params.get("preAuthorizationIdParameter").getOrElse("preAuthorizationId") val preAuthorizationId = params.get(preAuthorizationIdParameter).getOrElse("") val registerMeansOfPayment = params.get("registerMeansOfPayment").getOrElse("false").toBoolean val printReceipt = params.get("printReceipt").getOrElse("false").toBoolean - run( + val cmd = PreAuthorizeCallback(orderUuid, preAuthorizationId, registerMeansOfPayment, printReceipt) - ).map { + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { case result: PaymentPreAuthorized => Right(result) case result: PaymentRedirection => Right(result) case other => Left(error(other)) @@ -167,7 +171,6 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) .in(PaymentSettings.PaymentConfig.payInRoute) .in(path[String].description("credited account")) - .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .post .out( oneOf[PaymentResult]( @@ -186,7 +189,7 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .serverLogic(principal => { - case (language, accept, userAgent, ipAddress, payment, creditedAccount, correlationId) => + case (language, accept, userAgent, ipAddress, payment, correlationId, creditedAccount) => val browserInfo = extractBrowserInfo(language, accept, userAgent, payment) import payment._ val cmd = @@ -230,6 +233,7 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { .description("Pay in query parameters") /*.in(query[String]("transactionId").description("Payment transaction id")) .in(query[Boolean]("printReceipt").description("Whether or not a receipt should be printed"))*/ + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( oneOf[PaymentResult]( oneOfVariant[PaidIn]( @@ -247,16 +251,16 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .description("Pay in with card") - .serverLogic { case (orderUuid, params) => + .serverLogic { case (orderUuid, params, correlationId) => val transactionIdParameter = params.get("transactionIdParameter").getOrElse("transactionId") val transactionId = params.get(transactionIdParameter).getOrElse("") val registerMeansOfPayment = params.get("registerMeansOfPayment").getOrElse("false").toBoolean val printReceipt = params.get("printReceipt").getOrElse("false").toBoolean - run( - PayInCallback(orderUuid, transactionId, registerMeansOfPayment, printReceipt) - ).map { + val cmd = PayInCallback(orderUuid, transactionId, registerMeansOfPayment, printReceipt) + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { case result: PaidIn => Right(result) case result: PaymentRedirection => Right(result) case result: PaymentRequired => Right(result) @@ -287,10 +291,18 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .serverLogic(principal => { - case (language, accept, userAgent, ipAddress, payment, recurringPaymentRegistrationId) => + case ( + language, + accept, + userAgent, + ipAddress, + payment, + correlationId, + recurringPaymentRegistrationId + ) => val browserInfo = extractBrowserInfo(language, accept, userAgent, payment) import payment._ - run( + val cmd = ExecuteFirstRecurringPayment( recurringPaymentRegistrationId, externalUuidWithProfile(principal._2), @@ -298,7 +310,8 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { browserInfo, statementDescriptor ) - ).map { + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { case result: FirstRecurringPaidIn => Right(result) case result: PaymentRedirection => Right(result) case other => Left(error(other)) @@ -313,6 +326,7 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) .in(path[String].description("Recurring payment registration Id")) .in(query[String]("transactionId").description("First recurring payment transaction Id")) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( oneOf[PaymentResult]( oneOfVariant[PaidIn]( @@ -330,10 +344,11 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .description("Execute first recurring payment for 3D secure") - .serverLogic { case (recurringPayInRegistrationId, transactionId) => - run( + .serverLogic { case (recurringPayInRegistrationId, transactionId, cid) => + val cmd = RecurringPaymentCallback(recurringPayInRegistrationId, transactionId) - ).map { + cmd.withCorrelationId(cid) // Story 13.7 — origin stamp + run(cmd).map { case result: PaidIn => Right(result) case result: PaymentRedirection => Right(result) case other => Left(error(other)) diff --git a/core/src/main/scala/app/softnetwork/payment/service/KycDocumentEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/KycDocumentEndpoints.scala index d90c2a1..451267b 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/KycDocumentEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/KycDocumentEndpoints.scala @@ -1,6 +1,6 @@ package app.softnetwork.payment.service -import app.softnetwork.api.server.ApiErrors +import app.softnetwork.api.server.{ApiErrors, HttpCorrelation} import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages._ @@ -29,6 +29,7 @@ trait KycDocumentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) //KYC_REGISTRATION_PROOF, KYC_ARTICLES_OF_ASSOCIATION, KYC_SHAREHOLDER_DECLARATION or KYC_ADDRESS_PROOF .example("KYC_IDENTITY_PROOF") ) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok) .and( @@ -36,14 +37,19 @@ trait KycDocumentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { .description("Kyc document validation report") ) ) - .serverLogic(principal => { documentType => + .serverLogic(principal => { args => + val documentType = args._1 + val cid = args._2 val maybeKycDocumentType: Option[KycDocument.KycDocumentType] = KycDocument.KycDocumentType.enumCompanion.fromName(documentType) maybeKycDocumentType match { case None => Future.successful(Left(ApiErrors.BadRequest("wrong kyc document type"))) case Some(kycDocumentType) => - run(LoadKycDocumentStatus(externalUuidWithProfile(principal._2), kycDocumentType)).map { + val cmd = + LoadKycDocumentStatus(externalUuidWithProfile(principal._2), kycDocumentType) + cmd.withCorrelationId(cid) // Story 13.7 — origin stamp + run(cmd).map { case r: KycDocumentStatusLoaded => Right(r.report) case other => Left(error(other)) } @@ -62,6 +68,7 @@ trait KycDocumentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { .example("KYC_IDENTITY_PROOF") ) .in(multipartBody[UploadDocument].description("Kyc document to record for validation")) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and( jsonBody[KycDocumentAdded].description( @@ -69,14 +76,17 @@ trait KycDocumentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) ) - .serverLogic(principal => { case (documentType, pages) => + .serverLogic(principal => { case (documentType, pages, cid) => val maybeKycDocumentType: Option[KycDocument.KycDocumentType] = KycDocument.KycDocumentType.enumCompanion.fromName(documentType) maybeKycDocumentType match { case None => Future.successful(Left(ApiErrors.BadRequest("wrong kyc document type"))) case Some(kycDocumentType) => - run(AddKycDocument(externalUuidWithProfile(principal._2), pages.bytes, kycDocumentType)) + val cmd = + AddKycDocument(externalUuidWithProfile(principal._2), pages.bytes, kycDocumentType) + cmd.withCorrelationId(cid) // Story 13.7 — origin stamp + run(cmd) .map { case r: KycDocumentAdded => Right(r) case other => Left(error(other)) diff --git a/core/src/main/scala/app/softnetwork/payment/service/MandateEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/MandateEndpoints.scala index 5d9e884..d5bd544 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/MandateEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/MandateEndpoints.scala @@ -1,5 +1,6 @@ package app.softnetwork.payment.service +import app.softnetwork.api.server.HttpCorrelation import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages._ @@ -22,6 +23,7 @@ trait MandateEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { requiredSessionEndpoint.post .in(PaymentSettings.PaymentConfig.mandateRoute) .in(jsonBody[Option[IbanMandate]]) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( oneOf[PaymentResult]( oneOfVariant[MandateCreated.type]( @@ -35,37 +37,41 @@ trait MandateEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) ) - .serverLogic(principal => - maybeIban => - run( - CreateMandate( - externalUuidWithProfile(principal._2), - iban = maybeIban.map(_.iban), - clientId = principal._1.map(_.clientId).orElse(principal._2.clientId) - ) - ).map { - case MandateCreated => Right(MandateCreated) - case r: MandateConfirmationRequired => Right(r) - case other => Left(error(other)) - } - ) + .serverLogic { principal => args => + val maybeIban = args._1 + val correlationId = args._2 + val cmd = + CreateMandate( + externalUuidWithProfile(principal._2), + iban = maybeIban.map(_.iban), + clientId = principal._1.map(_.clientId).orElse(principal._2.clientId) + ) + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { + case MandateCreated => Right(MandateCreated) + case r: MandateConfirmationRequired => Right(r) + case other => Left(error(other)) + } + } .description("Create a mandate for the authenticated payment account") val cancelMandate: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.delete .in(PaymentSettings.PaymentConfig.mandateRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok) .and(jsonBody[MandateCanceled.type].description("Mandate canceled")) ) .serverLogic { case (client, session) => - _ => - run( + correlationId => + val cmd = CancelMandate( externalUuidWithProfile(session), clientId = client.map(_.clientId).orElse(session.clientId) ) - ).map { + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { case MandateCanceled => Right(MandateCanceled) case other => Left(error(other)) } @@ -77,16 +83,19 @@ trait MandateEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { .in(PaymentSettings.PaymentConfig.mandateRoute) .get .in(query[String]("MandateId").description("Mandate Id")) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and(jsonBody[MandateResult]) ) .description("Update mandate status web hook") - .serverLogic(mandateId => - run(UpdateMandateStatus(mandateId)).map { + .serverLogic { case (mandateId, cid) => + val cmd = UpdateMandateStatus(mandateId) + cmd.withCorrelationId(cid) // Story 13.7 — origin stamp + run(cmd).map { case r: MandateStatusUpdated => Right(r.result) case other => Left(error(other)) } - ) + } val mandateEndpoints: List[ServerEndpoint[AkkaStreams with capabilities.WebSockets, Future]] = List( diff --git a/core/src/main/scala/app/softnetwork/payment/service/PaymentAccountEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/PaymentAccountEndpoints.scala index b9d5e3a..63fe05f 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/PaymentAccountEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/PaymentAccountEndpoints.scala @@ -1,5 +1,6 @@ package app.softnetwork.payment.service +import app.softnetwork.api.server.HttpCorrelation import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages._ @@ -24,6 +25,7 @@ trait PaymentAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { .in(clientIp) .in(header[Option[String]](HeaderNames.UserAgent)) .in(jsonBody[UserPaymentAccountCommand].description("Legal or natural user payment account")) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok) .and( @@ -33,7 +35,7 @@ trait PaymentAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .serverLogic { - case (client, session) => { case (ipAddress, userAgent, userAccountCommand) => + case (client, session) => { case (ipAddress, userAgent, userAccountCommand, cid) => import userAccountCommand._ var externalUuid: String = "" val updatedUser: Option[PaymentAccount.User] = { @@ -72,7 +74,7 @@ trait PaymentAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) } } - run( + val cmd = CreateOrUpdateUserPaymentAccount( externalUuidWithProfile(session), updatedUser, @@ -82,7 +84,8 @@ trait PaymentAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { userAgent = userAgent, tokenId = tokenId ) - ).map { + cmd.withCorrelationId(cid) // Story 13.7 — origin stamp + run(cmd).map { case r: UserPaymentAccountCreatedOrUpdated => Right(r) case other => Left(error(other)) } @@ -93,15 +96,17 @@ trait PaymentAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { lazy val loadPaymentAccount: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get .in(PaymentSettings.PaymentConfig.accountRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out(jsonBody[PaymentAccountView].description("Authenticated user payment account")) .serverLogic { case (client, session) => - _ => { - run( + cid => { + val cmd = LoadPaymentAccount( externalUuidWithProfile(session), clientId = client.map(_.clientId).orElse(session.clientId) ) - ).map { + cmd.withCorrelationId(cid) // Story 13.7 — origin stamp + run(cmd).map { case r: PaymentAccountLoaded => Right(r.paymentAccount.view) case other => Left(error(other)) } diff --git a/core/src/main/scala/app/softnetwork/payment/service/PaymentMethodEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/PaymentMethodEndpoints.scala index 7e9b211..2d224dc 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/PaymentMethodEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/PaymentMethodEndpoints.scala @@ -1,5 +1,6 @@ package app.softnetwork.payment.service +import app.softnetwork.api.server.HttpCorrelation import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages._ @@ -21,14 +22,18 @@ trait PaymentMethodEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val loadCards: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get .in(PaymentSettings.PaymentConfig.cardRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and( jsonBody[Seq[CardView]].description("Authenticated user cards") ) ) .serverLogic(principal => - _ => { - run(LoadPaymentMethods(externalUuidWithProfile(principal._2))).map { + cid => { + val cmd = + LoadPaymentMethods(externalUuidWithProfile(principal._2)) + cmd.withCorrelationId(cid) // Story 13.7 — origin stamp + run(cmd).map { case r: PaymentMethodsLoaded => Right(PaymentMethodsView(r.paymentMethods).cards) case other => Left(error(other)) } @@ -39,14 +44,18 @@ trait PaymentMethodEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val loadPaymentMethods: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get .in(PaymentSettings.PaymentConfig.paymentMethodRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and( jsonBody[PaymentMethodsView].description("Authenticated user payment methods") ) ) .serverLogic(principal => - _ => { - run(LoadPaymentMethods(externalUuidWithProfile(principal._2))).map { + cid => { + val cmd = + LoadPaymentMethods(externalUuidWithProfile(principal._2)) + cmd.withCorrelationId(cid) // Story 13.7 — origin stamp + run(cmd).map { case r: PaymentMethodsLoaded => Right(PaymentMethodsView(r.paymentMethods)) case other => Left(error(other)) } @@ -58,6 +67,7 @@ trait PaymentMethodEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { requiredSessionEndpoint.post .in(PaymentSettings.PaymentConfig.cardRoute) .in(jsonBody[PreRegisterPaymentMethod]) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and( jsonBody[PreRegistration] @@ -65,7 +75,9 @@ trait PaymentMethodEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .serverLogic(principal => - cmd => { + args => { + val cmd = args._1 + cmd.withCorrelationId(args._2) // Story 13.7 — origin stamp var updatedUser = if (cmd.user.externalUuid.trim.isEmpty) { cmd.user.withExternalUuid(principal._2.id) @@ -95,6 +107,7 @@ trait PaymentMethodEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { requiredSessionEndpoint.post .in(PaymentSettings.PaymentConfig.paymentMethodRoute) .in(jsonBody[PreRegisterPaymentMethod]) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and( jsonBody[PreRegistration] @@ -102,7 +115,9 @@ trait PaymentMethodEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .serverLogic(principal => - cmd => { + args => { + val cmd = args._1 + cmd.withCorrelationId(args._2) // Story 13.7 — origin stamp var updatedUser = if (cmd.user.externalUuid.trim.isEmpty) { cmd.user.withExternalUuid(principal._2.id) @@ -131,12 +146,18 @@ trait PaymentMethodEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { requiredSessionEndpoint.delete .in(PaymentSettings.PaymentConfig.cardRoute) .in(query[String]("cardId").description("Card id to disable")) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and(jsonBody[PaymentMethodDisabled.type]) ) .serverLogic(principal => - cardId => { - run(DisablePaymentMethod(externalUuidWithProfile(principal._2), cardId)).map { + args => { + val cmd = DisablePaymentMethod( + externalUuidWithProfile(principal._2), + args._1 + ) + cmd.withCorrelationId(args._2) // Story 13.7 — origin stamp + run(cmd).map { case PaymentMethodDisabled => Right(PaymentMethodDisabled) case other => Left(error(other)) } @@ -148,12 +169,18 @@ trait PaymentMethodEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { requiredSessionEndpoint.delete .in(PaymentSettings.PaymentConfig.paymentMethodRoute) .in(query[String]("paymentMethodId").description("Id of Payment method to disable")) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and(jsonBody[PaymentMethodDisabled.type]) ) .serverLogic(principal => - paymentMethodId => { - run(DisablePaymentMethod(externalUuidWithProfile(principal._2), paymentMethodId)).map { + args => { + val cmd = DisablePaymentMethod( + externalUuidWithProfile(principal._2), + args._1 + ) + cmd.withCorrelationId(args._2) // Story 13.7 — origin stamp + run(cmd).map { case PaymentMethodDisabled => Right(PaymentMethodDisabled) case other => Left(error(other)) } diff --git a/core/src/main/scala/app/softnetwork/payment/service/RecurringPaymentEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/RecurringPaymentEndpoints.scala index 98b9b46..f54013c 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/RecurringPaymentEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/RecurringPaymentEndpoints.scala @@ -1,5 +1,6 @@ package app.softnetwork.payment.service +import app.softnetwork.api.server.HttpCorrelation import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages._ @@ -22,6 +23,7 @@ trait RecurringPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] requiredSessionEndpoint.post .in(PaymentSettings.PaymentConfig.recurringPaymentRoute) .in(jsonBody[RegisterRecurringPayment].description("Recurring payment to register")) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( oneOf[PaymentResult]( oneOfVariant[RecurringPaymentRegistered]( @@ -40,18 +42,20 @@ trait RecurringPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] ) ) ) - .serverLogic { case (client, session) => - cmd => - run( - cmd.copy( - debitedAccount = externalUuidWithProfile(session), - clientId = client.map(_.clientId).orElse(session.clientId) - ) - ).map { + .serverLogic { + case (client, session) => { case (cmd, correlationId) => + // copy resets the AuditableCommand var, so stamp the cid AFTER the copy + val updated = cmd.copy( + debitedAccount = externalUuidWithProfile(session), + clientId = client.map(_.clientId).orElse(session.clientId) + ) + updated.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(updated).map { case r: RecurringPaymentRegistered => Right(r) case r: MandateConfirmationRequired => Right(r) case other => Left(error(other)) } + } } .description("Register a recurring payment for the authenticated payment account") @@ -87,6 +91,7 @@ trait RecurringPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] "Recurring card payment update" ) ) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok) .and( @@ -94,13 +99,14 @@ trait RecurringPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] .description("Recurring card payment successfully updated") ) ) - .serverLogic(principal => - cmd => - run(cmd.copy(debitedAccount = externalUuidWithProfile(principal._2))).map { - case r: RecurringCardPaymentRegistrationUpdated => Right(r.result) - case other => Left(error(other)) - } - ) + .serverLogic(principal => { case (cmd, correlationId) => + val updated = cmd.copy(debitedAccount = externalUuidWithProfile(principal._2)) + updated.withCorrelationId(correlationId) // Story 13.7 — origin stamp (after copy) + run(updated).map { + case r: RecurringCardPaymentRegistrationUpdated => Right(r.result) + case other => Left(error(other)) + } + }) .description( "Update recurring card payment registration of the authenticated payment account" ) @@ -109,24 +115,25 @@ trait RecurringPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] requiredSessionEndpoint.delete .in(PaymentSettings.PaymentConfig.recurringPaymentRoute) .in(path[String]) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok) .and(jsonBody[RecurringPayment.RecurringCardPaymentResult]) ) - .serverLogic(principal => - recurringPaymentRegistrationId => - run( - UpdateRecurringCardPaymentRegistration( - externalUuidWithProfile(principal._2), - recurringPaymentRegistrationId, - None, - Some(RecurringPayment.RecurringCardPaymentStatus.ENDED) - ) - ).map { - case r: RecurringCardPaymentRegistrationUpdated => Right(r.result) - case other => Left(error(other)) - } - ) + .serverLogic(principal => { case (recurringPaymentRegistrationId, correlationId) => + val cmd = + UpdateRecurringCardPaymentRegistration( + externalUuidWithProfile(principal._2), + recurringPaymentRegistrationId, + None, + Some(RecurringPayment.RecurringCardPaymentStatus.ENDED) + ) + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { + case r: RecurringCardPaymentRegistrationUpdated => Right(r.result) + case other => Left(error(other)) + } + }) val recurringPaymentEndpoints : List[ServerEndpoint[AkkaStreams with capabilities.WebSockets, Future]] = diff --git a/core/src/main/scala/app/softnetwork/payment/service/UboDeclarationEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/UboDeclarationEndpoints.scala index 4feae77..a0a83bd 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/UboDeclarationEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/UboDeclarationEndpoints.scala @@ -1,5 +1,6 @@ package app.softnetwork.payment.service +import app.softnetwork.api.server.HttpCorrelation import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages._ @@ -25,6 +26,7 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { jsonBody[UboDeclaration.UltimateBeneficialOwner] .description("The UBO to declare for the authenticated legal payment account") ) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok) .and( @@ -32,8 +34,13 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { .description("The UBO successfully recorded") ) ) - .serverLogic(principal => { ubo => - run(CreateOrUpdateUbo(externalUuidWithProfile(principal._2), ubo)).map { + .serverLogic(principal => { args => + val ubo = args._1 + val correlationId = args._2 + val cmd = + CreateOrUpdateUbo(externalUuidWithProfile(principal._2), ubo) + cmd.withCorrelationId(correlationId) // Story 13.7 — origin stamp + run(cmd).map { case r: UboCreatedOrUpdated => Right(r.ubo) case other => Left(error(other)) } @@ -43,6 +50,7 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val loadUboDeclaration: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get .in(PaymentSettings.PaymentConfig.declarationRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok) .and( @@ -51,11 +59,13 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .serverLogic(principal => - _ => - run(GetUboDeclaration(externalUuidWithProfile(principal._2))).map { + cid => { + val cmd = GetUboDeclaration(externalUuidWithProfile(principal._2)) + run(cmd).map { case r: UboDeclarationLoaded => Right(r.declaration.view) case other => Left(error(other)) } + } ) .description("Load the Ubo declaration of the authenticated legal payment account") @@ -69,6 +79,7 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .in(PaymentSettings.PaymentConfig.declarationRoute) + .in(HttpCorrelation.correlationInput) // Story 13.7 — origin correlation id .out( statusCode(StatusCode.Ok).and( jsonBody[UboDeclarationAskedForValidation.type].description( @@ -76,20 +87,19 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) ) - .serverLogic(principal => { case (ipAddress, userAgent, tokenId) => + .serverLogic(principal => { case (ipAddress, userAgent, tokenId, cid) => val session: SD = principal._2 - run( - ValidateUboDeclaration( - externalUuidWithProfile(session), - ipAddress, - userAgent, - tokenId - ) + val cmd = ValidateUboDeclaration( + externalUuidWithProfile(session), + ipAddress, + userAgent, + tokenId ) - .map { - case UboDeclarationAskedForValidation => Right(UboDeclarationAskedForValidation) - case other => Left(error(other)) - } + cmd.withCorrelationId(cid) // Story 13.7 — origin stamp + run(cmd).map { + case UboDeclarationAskedForValidation => Right(UboDeclarationAskedForValidation) + case other => Left(error(other)) + } }) .description("Validate the Ubo declaration of the authenticated legal payment account") diff --git a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala index 65079eb..5cc4368 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala @@ -1,6 +1,7 @@ package app.softnetwork.payment.service import app.softnetwork.concurrent.Completion +import app.softnetwork.payment.audit.PaymentAuditLog.audit import app.softnetwork.payment.handlers.PaymentHandler import app.softnetwork.payment.message.PaymentMessages.{ CreateOrUpdateKycDocument, @@ -83,6 +84,8 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen ) return } + // Story 13.7 — audit the inbound webhook; the Stripe event id is its natural correlation key. + audit.event(event.getId, "webhook_received", "stripe_event_type" -> event.getType) event.getType match { case "account.updated" => @@ -106,7 +109,9 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen ) } //disable account - run(InvalidateRegularUser(accountId)).complete() match { + val cmd = InvalidateRegularUser(accountId) + cmd.withCorrelationId(resolveCorrelationId(account, event)) // Story 13.7 + run(cmd).complete() match { case Success(RegularUserInvalidated) => log.info( s"[Payment Hooks] Stripe Webhook received: Account Updated -> Account disabled for $accountId" @@ -138,7 +143,8 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen documentId, KycDocument.KycDocumentType.KYC_IDENTITY_PROOF, code, - reason + reason, + event.getId ) case _ => } @@ -160,7 +166,8 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen documentId, KycDocument.KycDocumentType.KYC_ADDRESS_PROOF, code, - reason + reason, + event.getId ) case _ => } @@ -181,7 +188,8 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen documentId, KycDocument.KycDocumentType.KYC_REGISTRATION_PROOF, code, - reason + reason, + event.getId ) case _ => } @@ -199,7 +207,9 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen s"[Payment Hooks] Stripe Webhook received: Account Updated -> Charges and Payouts are enabled for $accountId" ) //enable account - run(ValidateRegularUser(account.getId)).complete() match { + val cmd = ValidateRegularUser(account.getId) + cmd.withCorrelationId(resolveCorrelationId(account, event)) // Story 13.7 + run(cmd).complete() match { case Success(RegularUserValidated) => log.info( s"[Payment Hooks] Stripe Webhook received: Account Updated -> Account enabled for $accountId" @@ -258,7 +268,8 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen documentId, KycDocument.KycDocumentType.KYC_IDENTITY_PROOF, code, - reason + reason, + event.getId ) case _ => } @@ -278,7 +289,8 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen documentId, KycDocument.KycDocumentType.KYC_ADDRESS_PROOF, code, - reason + reason, + event.getId ) case _ => } @@ -305,9 +317,13 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen log.info( s"[Payment Hooks] Subscription $subscriptionId invoice paid: $transactionId" ) - run( - RecurringPaymentCallback(subscriptionId, transactionId, Some(customerId)) - ).complete() match { + val cmd = RecurringPaymentCallback( + subscriptionId, + transactionId, + Some(customerId) + ) + cmd.withCorrelationId(resolveCorrelationId(invoice, event)) // Story 13.7 + run(cmd).complete() match { case Success(_) => log.info( s"[Payment Hooks] Recurring payment callback processed: $subscriptionId" @@ -331,9 +347,10 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen log.warn( s"[Payment Hooks] Subscription $subscriptionId invoice payment failed: $transactionId" ) - run( + val cmd = RecurringPaymentCallback(subscriptionId, transactionId, Some(customerId)) - ).complete() match { + cmd.withCorrelationId(resolveCorrelationId(invoice, event)) // Story 13.7 + run(cmd).complete() match { case Success(_) => log.info( s"[Payment Hooks] Payment failure callback processed for $subscriptionId" @@ -356,13 +373,14 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen log.info( s"[Payment Hooks] Subscription $subscriptionId deleted for customer $customerId" ) - run( + val cmd = UpdateRecurringCardPaymentRegistration( customerId, subscriptionId, status = Some(RecurringPayment.RecurringCardPaymentStatus.ENDED) ) - ).complete() match { + cmd.withCorrelationId(resolveCorrelationId(subscription, event)) // Story 13.7 + run(cmd).complete() match { case Success(_) => log.info(s"[Payment Hooks] Subscription $subscriptionId marked as ENDED") case Failure(f) => @@ -386,13 +404,14 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen // If subscription moved to a terminal state, sync local state stripeStatus match { case "canceled" | "incomplete_expired" | "paused" => - run( + val cmd = UpdateRecurringCardPaymentRegistration( customerId, subscriptionId, status = Some(RecurringPayment.RecurringCardPaymentStatus.ENDED) ) - ).complete() match { + cmd.withCorrelationId(resolveCorrelationId(subscription, event)) // Story 13.7 + run(cmd).complete() match { case Success(_) => log.info(s"[Payment Hooks] Subscription $subscriptionId synced to ENDED") case Failure(f) => @@ -415,9 +434,9 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen log.info( s"[Payment Hooks] Payment method $paymentMethodId attached to customer $customerId" ) - run( - RegisterPaymentMethodFromWebhook(customerId, paymentMethodId) - ).complete() match { + val cmd = RegisterPaymentMethodFromWebhook(customerId, paymentMethodId) + cmd.withCorrelationId(resolveCorrelationId(pm, event)) // Story 13.7 + run(cmd).complete() match { case Success(_: PaymentMethodRegistered.type) => log.info( s"[Payment Hooks] Payment method $paymentMethodId registered for $customerId" @@ -451,9 +470,9 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen log.info( s"[Payment Hooks] Payment method $paymentMethodId detached from customer $cid" ) - run( - DisablePaymentMethodFromWebhook(cid, paymentMethodId) - ).complete() match { + val cmd = DisablePaymentMethodFromWebhook(cid, paymentMethodId) + cmd.withCorrelationId(resolveCorrelationId(pm, event)) // Story 13.7 + run(cmd).complete() match { case Success(_: PaymentMethodDisabled.type) => log.info(s"[Payment Hooks] Payment method $paymentMethodId disabled") case Success(other) => @@ -490,7 +509,7 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen .withCountry(Option(a.getCountry).getOrElse("")) .copy(state = Option(a.getState)) } - run( + val cmd = UpdateCustomerFromWebhook( customerId, name = Option(customer.getName), @@ -498,7 +517,8 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen phone = Option(customer.getPhone), address = address ) - ).complete() match { + cmd.withCorrelationId(resolveCorrelationId(customer, event)) // Story 13.7 + run(cmd).complete() match { case Success(_: CustomerUpdated.type) => log.info(s"[Payment Hooks] Customer $customerId info updated") case Success(other) => @@ -516,6 +536,48 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen } } + /** Story 13.7 — read the metadata map off any Stripe object that exposes `getMetadata` (most do: + * PaymentIntent, Subscription, Invoice, Customer, PaymentMethod, …). Reflection keeps this + * generic since the Stripe Java SDK has no common metadata interface. Failure is benign → empty + * map. + */ + private[this] def stripeMetadata(obj: StripeObject): Map[String, String] = + Try { + obj.getClass.getMethod("getMetadata").invoke(obj) match { + case m: java.util.Map[_, _] => + m.asScala.collect { case (k: String, v: String) => k -> v }.toMap + case _ => Map.empty[String, String] + } + }.getOrElse(Map.empty[String, String]) + + /** Story 13.7 — the subscription id that backs this object, used as a correlation fallback: a + * recurring registration with no explicit cid stamps the subscription id as its correlation id + * (cf `RecurringPaymentCommandHandler`), so the webhook must resolve to the same value. + */ + private[this] def subscriptionId(obj: StripeObject): Option[String] = + obj match { + case s: Subscription => Option(s.getId).filter(_.nonEmpty) + case i: Invoice => Option(i.getSubscription).filter(_.nonEmpty) + case _ => None + } + + /** Story 13.7 — resolve the cross-service correlation id for a webhook-driven command (READ side + * of the softpayment <-> Stripe round-trip). The provider writes the id into the Stripe object + * metadata at creation (`correlation_id`); otherwise fall back, in order, to the + * `external_reference` / `order_uuid` it also stamps, then the backing subscription id + * (recurring), then the event id. + */ + private[this] def resolveCorrelationId(obj: StripeObject, event: Event): String = { + val metadata = stripeMetadata(obj) + metadata + .get("correlation_id") + .filter(_.nonEmpty) + .orElse(metadata.get("external_reference").filter(_.nonEmpty)) + .orElse(metadata.get("order_uuid").filter(_.nonEmpty)) + .orElse(subscriptionId(obj)) + .getOrElse(event.getId) + } + private[this] def extractStripeObject[T <: StripeObject: scala.reflect.ClassTag]( event: Event ): Option[T] = { @@ -535,7 +597,8 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen documentId: String, documentType: KycDocument.KycDocumentType, code: String, - reason: String + reason: String, + correlationId: String // Story 13.7 — Stripe event id, threaded from handleStripeEvent ): Unit = { log.warn( s"[Payment Hooks] Stripe Webhook received: Document ID: $documentId refused" @@ -546,12 +609,9 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen .withStatus(KycDocument.KycDocumentStatus.KYC_DOCUMENT_REFUSED) .withRefusedReasonType(code) .withRefusedReasonMessage(reason) - run( - CreateOrUpdateKycDocument( - accountId, - document - ) - ).complete() match { + val cmd = CreateOrUpdateKycDocument(accountId, document) + cmd.withCorrelationId(correlationId) // Story 13.7 — webhook cid = Stripe event id + run(cmd).complete() match { case Success(KycDocumentCreatedOrUpdated) => log.info( s"[Payment Hooks] Stripe Webhook received: Document ID: $documentId refused" diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeDirectDebitApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeDirectDebitApi.scala index 95de008..47d7eba 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeDirectDebitApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeDirectDebitApi.scala @@ -430,6 +430,11 @@ trait StripeDirectDebitApi extends DirectDebitApi { params.setStatementDescriptor(directDebitTransaction.statementDescriptor) } + // Propagate all user-supplied metadata to Stripe payment intent object + directDebitTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + + mlog.info(s"Creating direct debit -> ${new Gson().toJson(params.build())}") + PaymentIntent.create(params.build(), requestOptions) case _ => throw new Exception("Mandate not found") } diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayInApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayInApi.scala index 5601f26..8c2bd92 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayInApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayInApi.scala @@ -99,6 +99,14 @@ trait StripePayInApi extends PayInApi { _: StripeContext => case _ => } + // Propagate all user-supplied metadata to Stripe payment intent object + payInWithPreAuthorization.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + + mlog.info( + s"Capture payment for order ${payInWithPreAuthorization.orderUuid} -> ${new Gson() + .toJson(params.build())}" + ) + resource.capture( params.build(), requestOptions @@ -270,6 +278,9 @@ trait StripePayInApi extends PayInApi { _: StripeContext => } + // Propagate all user-supplied metadata to Stripe payment intent + payInTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + mlog.info( s"Creating pay in for order ${payInTransaction.orderUuid} -> ${new Gson() .toJson(params.build())}" @@ -501,6 +512,9 @@ trait StripePayInApi extends PayInApi { _: StripeContext => ) } + // Propagate all user-supplied metadata to Stripe payment intent + payInTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + mlog.info( s"Creating pay in with PayPal for order ${payInTransaction.orderUuid} -> ${new Gson() .toJson(params.build())}" diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayOutApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayOutApi.scala index ff4f2e4..bdd5c2b 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayOutApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayOutApi.scala @@ -54,6 +54,7 @@ trait StripePayOutApi extends PayOutApi { externalReference = payOutTransaction.externalReference, statementDescriptor = payOutTransaction.statementDescriptor ) + .withMetadata(payOutTransaction.metadata) transfer(Some(transferTransaction)) @@ -129,6 +130,9 @@ trait StripePayOutApi extends PayOutApi { case _ => } + // Propagate all user-supplied metadata to Stripe payout object + payOutTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + if (amountToTransfer <= availableAmount) { val payout = params.build() mlog.info(s"payout: ${new Gson().toJson(payout)}") diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripePreAuthorizationApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePreAuthorizationApi.scala index 2f61e68..9c0ff91 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripePreAuthorizationApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePreAuthorizationApi.scala @@ -182,6 +182,9 @@ trait StripePreAuthorizationApi extends PreAuthorizationApi { _: StripeContext = case _ => } + // Propagate all user-supplied metadata to Stripe payment intent object + preAuthorizationTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + mlog.info( s"Creating pre authorization for order ${preAuthorizationTransaction.orderUuid} -> ${new Gson() .toJson(params.build())}" diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRefundApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRefundApi.scala index d9cc00c..d9cfec5 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRefundApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRefundApi.scala @@ -62,6 +62,9 @@ trait StripeRefundApi extends RefundApi { _: StripeContext => .putMetadata("author_id", refundTransaction.authorId) .putMetadata("reason_message", refundTransaction.reasonMessage) + // Propagate all user-supplied metadata to Stripe refund object + refundTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + mlog.info( s"Processing refund fees for order: ${refundTransaction.orderUuid} -> ${new Gson() .toJson(params)}" @@ -107,6 +110,9 @@ trait StripeRefundApi extends RefundApi { _: StripeContext => .putMetadata("author_id", refundTransaction.authorId) .putMetadata("reason_message", refundTransaction.reasonMessage) + // Propagate all user-supplied metadata to Stripe refund object + refundTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + mlog.info( s"Processing transfer reversal for order: ${refundTransaction.orderUuid} -> ${new Gson() .toJson(params)}" @@ -168,6 +174,9 @@ trait StripeRefundApi extends RefundApi { _: StripeContext => .putMetadata("author_id", refundTransaction.authorId) .putMetadata("reason_message", refundTransaction.reasonMessage) + // Propagate all user-supplied metadata to Stripe refund object + refundTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + mlog.info( s"Processing refund payment for order: ${refundTransaction.orderUuid} -> ${new Gson() .toJson(params)}" @@ -199,6 +208,9 @@ trait StripeRefundApi extends RefundApi { _: StripeContext => .putMetadata("author_id", refundTransaction.authorId) .putMetadata("reason_message", refundTransaction.reasonMessage) + // Propagate all user-supplied metadata to Stripe refund object + refundTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + mlog.info( s"Processing refund payment for order: ${refundTransaction.orderUuid} -> ${new Gson() .toJson(params)}" diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeTransferApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeTransferApi.scala index af93e82..bb47c88 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeTransferApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeTransferApi.scala @@ -121,6 +121,9 @@ trait StripeTransferApi extends TransferApi { _: StripeContext => case _ => } + // Propagate all user-supplied metadata to Stripe transfer object + transferTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + mlog.info( s"Creating transfer for order ${transferTransaction.orderUuid} -> ${new Gson() .toJson(params.build())}" @@ -184,6 +187,14 @@ trait StripeTransferApi extends TransferApi { _: StripeContext => case _ => } + // Propagate all user-supplied metadata to Stripe transfer object + transferTransaction.metadata.foreach { case (k, v) => params.putMetadata(k, v) } + + mlog.info( + s"Creating transfer for order ${transferTransaction.orderUuid} -> ${new Gson() + .toJson(params.build())}" + ) + Transfer.create(params.build(), requestOptions) } diff --git a/testkit/src/test/resources/logback.xml b/testkit/src/test/resources/logback.xml new file mode 100644 index 0000000..6976788 --- /dev/null +++ b/testkit/src/test/resources/logback.xml @@ -0,0 +1,76 @@ + + + + + payment.log + + payment_%d{yyyy-MM-dd}.log + + + [%date{ISO8601}] [%level] [%logger] [%marker] [%thread] - %msg MDC: {%mdc}%n + + + + + 8192 + true + + + + + + + %date{MM/dd HH:mm:ss} %-5level[%thread] %logger{1} - %msg%n + + + + {# Dedicated audit appender — SYNCHRONOUS, never wrapped by ASYNC/neverBlock (C4): audit lines + must never be silently dropped under backpressure. PVC-backed file, ~1y of daily rolls; Loki + holds the authoritative 1y via the promtail sidecar that tails this file → {stream="audit"}. #} + + audit.log + + audit-%d{yyyy-MM-dd}.log + 7 + {# Bound total on-disk audit size so a burst can't fill the log PVC. The appender is + synchronous/non-dropping, so a full disk would otherwise stall the emitting thread. #} + 1GB + + + {# Do NOT ServiceLoader-scan the classpath for Jackson modules. Under JDK 11+ the + transitive jackson-module-jaxb-annotations (from rapidoid/dumbster) would try to load + javax.xml.bind.annotation.XmlElement — removed from the JDK in Java 11 — and blow up + with NoClassDefFoundError. The encoder registers the modules it actually needs itself. #} + false + false + + (?i)[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}***@*** + (?i)\b(?:sk|pk|whsec|rk)_[a-z0-9_]+\b*** + (?i)bearer\s+[a-z0-9._/+=-]+Bearer *** + + + + + {# additivity=false so audit lines do NOT also propagate to root (no duplicate in app.log). + Audit routes ONLY to AUDIT_FILE; the sidecar tails it for {stream="audit"}. No STDOUT copy — + that would be re-shipped to {stream="app"} by the node promtail, double-ingesting every audit + line and polluting the operational stream. #} + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testkit/src/test/scala/app/softnetwork/payment/handlers/PaymentHandlerSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/handlers/PaymentHandlerSpec.scala index 98a637f..e9adb21 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/handlers/PaymentHandlerSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/handlers/PaymentHandlerSpec.scala @@ -9,6 +9,10 @@ import app.softnetwork.payment.model._ import app.softnetwork.payment.scalatest.PostgresPaymentTestKit import app.softnetwork.time._ import app.softnetwork.persistence.now +import app.softnetwork.persistence.audit.AuditLog +import ch.qos.logback.classic.{Logger => LogbackLogger} +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender import app.softnetwork.session.config.Settings import org.scalatest.wordspec.AnyWordSpecLike import org.slf4j.{Logger, LoggerFactory} @@ -1014,6 +1018,44 @@ class PaymentHandlerSpec } } + "emit a charge_succeeded audit line carrying the correlation id (Story 13.7)" in { + // Story 13.7 — proves the cid threaded onto the PayIn command reaches the payment pod's audit + // trail (the same id rides the persisted PaidInEvent to the licensing pod). An explicit cid is + // set so it must win over the orderUuid fallback. + val cid = "cid-payin-137" + val auditLogger = LoggerFactory.getLogger(AuditLog.LoggerName).asInstanceOf[LogbackLogger] + val appender = new ListAppender[ILoggingEvent]() + appender.start() + auditLogger.addAppender(appender) + try { + val cmd = PayIn( + orderUuid, + computeExternalUuidWithProfile(customerUuid, Some("customer")), + 100, + currency, + computeExternalUuidWithProfile(sellerUuid, Some("seller")) + ) + cmd.withCorrelationId(cid) + !?(cmd) await { + case _: PaidIn => + // the emission runs in the behavior's thenRun before the reply, so it is captured by now + val line = + appender.list.toArray.toList.collect { case e: ILoggingEvent => e }.find { e => + val fields = e.getArgumentArray.map(_.toString).toSet + fields.contains("event_type=charge_succeeded") && fields.contains( + s"correlation_id=$cid" + ) + } + assert(line.isDefined, "expected a charge_succeeded audit line carrying the cid") + assert(line.get.getArgumentArray.map(_.toString).contains("service=payment")) + case other => fail(other.toString) + } + } finally { + auditLogger.detachAppender(appender) + appender.stop() + } + } + "pay in / out with PayPal" in { !?( PayIn( diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/StripeWebhookCorrelationSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/StripeWebhookCorrelationSpec.scala new file mode 100644 index 0000000..4525aa7 --- /dev/null +++ b/testkit/src/test/scala/app/softnetwork/payment/service/StripeWebhookCorrelationSpec.scala @@ -0,0 +1,108 @@ +package app.softnetwork.payment.service + +import akka.actor.typed.ActorSystem +import akka.actor.typed.scaladsl.Behaviors +import app.softnetwork.payment.handlers.MockPaymentHandler +import app.softnetwork.payment.message.PaymentMessages._ +import app.softnetwork.persistence.audit.AuditLog +import ch.qos.logback.classic.{Logger => LogbackLogger} +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import com.stripe.Stripe +import com.stripe.model.Event +import com.stripe.net.ApiResource +import org.scalatest.BeforeAndAfterAll +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import org.slf4j.{Logger, LoggerFactory} + +import java.util.concurrent.atomic.AtomicReference +import scala.concurrent.Future +import scala.reflect.ClassTag + +/** Story 13.7 — handler-level proof (no Stripe network) that the webhook path stamps the Stripe + * event id as the correlation id onto the dispatched payment command, the durable hop that rides + * the command → the persisted payment event → the licensing pod. `run` is overridden to capture + * the command instead of dispatching it, so no cluster/entity is needed. + */ +class StripeWebhookCorrelationSpec extends AnyWordSpec with Matchers with BeforeAndAfterAll { + + private val testSystem: ActorSystem[Nothing] = + ActorSystem(Behaviors.empty, "stripe-webhook-correlation") + + override def afterAll(): Unit = testSystem.terminate() + + private val captured = new AtomicReference[List[PaymentCommandWithKey]](Nil) + + private val handler = + new StripeEventHandler with BasicPaymentService with MockPaymentHandler { + override def log: Logger = LoggerFactory.getLogger(getClass) + override implicit def system: ActorSystem[_] = testSystem + // capture instead of dispatching to a sharded entity + override def run(command: PaymentCommandWithKey)(implicit + tTag: ClassTag[PaymentCommand] + ): Future[PaymentResult] = { + captured.updateAndGet(command :: _) + Future.successful(PaymentAccountNotFound) // benign; the handler only logs on the result + } + } + + private def stripeEvent(eventId: String, eventType: String, dataObject: String): Event = { + val json = + s"""{"id":"$eventId","object":"event","api_version":"${Stripe.API_VERSION}",""" + + s""""type":"$eventType","data":{"object":$dataObject}}""" + ApiResource.GSON.fromJson(json, classOf[Event]) + } + + "StripeEventHandler" should { + "resolve the correlation id from the Stripe object metadata and emit webhook_received (Story 13.7)" in { + captured.set(Nil) + val auditLogger = LoggerFactory.getLogger(AuditLog.LoggerName).asInstanceOf[LogbackLogger] + val appender = new ListAppender[ILoggingEvent]() + appender.start() + auditLogger.addAppender(appender) + try { + val event = stripeEvent( + "evt_meta_1", + "customer.subscription.deleted", + """{"id":"sub_meta","object":"subscription","customer":"cus_test",""" + + """"metadata":{"correlation_id":"cid-from-meta"}}""" + ) + handler.handleStripeEvent(event) + // the metadata correlation_id wins over the event id / subscription id + val cmd = captured.get().headOption + cmd shouldBe defined + cmd.get shouldBe a[UpdateRecurringCardPaymentRegistration] + cmd.get.correlationId shouldBe Some("cid-from-meta") + // the inbound webhook is audited, keyed by the Stripe event id + val received = + appender.list.toArray.toList.collect { case e: ILoggingEvent => e }.find { e => + val fields = e.getArgumentArray.map(_.toString).toSet + fields.contains("event_type=webhook_received") && fields.contains( + "correlation_id=evt_meta_1" + ) + } + assert(received.isDefined, "expected a webhook_received audit line carrying the event id") + received.get.getArgumentArray.map(_.toString) should contain("service=payment") + } finally { + auditLogger.detachAppender(appender) + appender.stop() + } + } + + "fall back to the subscription id when no correlation id is in metadata (Story 13.7)" in { + captured.set(Nil) + val event = stripeEvent( + "evt_fallback_2", + "customer.subscription.deleted", + """{"id":"sub_fallback","object":"subscription","customer":"cus_test"}""" + ) + handler.handleStripeEvent(event) + val cmd = captured.get().headOption + cmd shouldBe defined + cmd.get shouldBe a[UpdateRecurringCardPaymentRegistration] + // no metadata cid → the subscription id is the durable correlation key (cf RecurringPaymentCommandHandler) + cmd.get.correlationId shouldBe Some("sub_fallback") + } + } +} From 0d4378a05bbb614fe3e47a6cd6457ffe2574f196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Mon, 15 Jun 2026 15:41:42 +0200 Subject: [PATCH 3/3] docs(audit): clarify correlationId handling in PaymentAuditLog --- .../app/softnetwork/payment/audit/PaymentAuditLog.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/main/scala/app/softnetwork/payment/audit/PaymentAuditLog.scala b/common/src/main/scala/app/softnetwork/payment/audit/PaymentAuditLog.scala index 9ef440f..e12df0e 100644 --- a/common/src/main/scala/app/softnetwork/payment/audit/PaymentAuditLog.scala +++ b/common/src/main/scala/app/softnetwork/payment/audit/PaymentAuditLog.scala @@ -5,9 +5,9 @@ import app.softnetwork.persistence.audit.AuditLog object PaymentAuditLog { /** Story 13.7 — structured audit trail for the payment pod. service = "payment"; the - * `correlationId` is threaded as data (proto field on the transaction events / `AuditableCommand`), - * never via MDC — the emission points are `thenRun` continuations of the persistence `Effect`, - * where a `ThreadLocal` MDC value would not survive. + * `correlationId` is threaded as data (proto field on the transaction events / + * `AuditableCommand`), never via MDC — the emission points are `thenRun` continuations of the + * persistence `Effect`, where a `ThreadLocal` MDC value would not survive. */ private[payment] lazy val audit: AuditLog = AuditLog("payment")