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/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 2a61b0d..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 { @@ -58,6 +64,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 { @@ -66,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 { @@ -80,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 { @@ -89,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 { @@ -105,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 { @@ -113,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 { @@ -130,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 { @@ -140,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 { @@ -153,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 { @@ -162,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 { @@ -176,6 +213,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 { @@ -189,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 { @@ -207,6 +250,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 { @@ -223,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 { @@ -233,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 { @@ -241,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..e12df0e --- /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/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/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 new file mode 100644 index 0000000..d4617cf --- /dev/null +++ b/common/src/test/scala/app/softnetwork/payment/message/AuditableCommandSpec.scala @@ -0,0 +1,58 @@ +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..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) => @@ -157,7 +165,8 @@ trait PayInCommandHandler registerMeansOfPayment, printReceipt, transaction, - registerWallet + registerWallet, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -220,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 @@ -241,6 +256,7 @@ trait PayInCommandHandler browserInfo = browserInfo, preRegistrationId = registrationId ) + .withMetadata(metadata) ) ) match { case Some(transaction) => @@ -252,7 +268,8 @@ trait PayInCommandHandler registerMeansOfPayment, printReceipt = printReceipt, transaction, - registerWallet + registerWallet, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -281,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( @@ -292,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) } @@ -368,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( @@ -377,6 +405,7 @@ trait PayInCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = Some(effectiveCorrelationId)) // Story 13.7 if (t.status.isTransactionSucceeded || t.status.isTransactionCreated) { Effect .persist( @@ -389,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( @@ -406,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) } @@ -443,7 +496,8 @@ trait PayInCommandHandler handlePayInWithPreAuthorizationFailure( "", replyTo, - "PreAuthorizationTransactionNotFound" + "PreAuthorizationTransactionNotFound", + correlationId = correlationId ) case Some(preAuthorizationTransaction) if !Seq( @@ -455,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( @@ -485,7 +543,8 @@ trait PayInCommandHandler handlePayInWithPreAuthorizationFailure( preAuthorizationTransaction.orderUuid, replyTo, - "DebitedAmountAbovePreAuthorizationAmount" + "DebitedAmountAbovePreAuthorizationAmount", + correlationId = correlationId ) case Some(preAuthorizationTransaction) => // load credited payment account @@ -500,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 @@ -520,6 +585,7 @@ trait PayInCommandHandler preRegistrationId = preAuthorizationTransaction.preRegistrationId ) + .withMetadata(metadata) ) ) match { case Some(transaction) => @@ -536,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 ) } } @@ -565,7 +635,8 @@ trait PayInCommandHandler handlePayInWithPreAuthorizationFailure( "", replyTo, - "PaymentAccountNotFound" + "PaymentAccountNotFound", + correlationId = correlationId ) } @@ -581,7 +652,8 @@ trait PayInCommandHandler registerMeansOfPayment: Boolean, printReceipt: Boolean, transaction: Transaction, - registerWallet: Boolean = false + registerWallet: Boolean = false, + maybeCorrelationId: Option[String] = None // Story 13.7 — threaded from the checkout command )(implicit system: ActorSystem[_], log: Logger, @@ -591,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 = { @@ -601,6 +679,7 @@ trait PayInCommandHandler .copy(clientId = paymentAccount.clientId, debitedUserId = paymentAccount.userId) ) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 ) } val walletEvents: List[ExternalSchedulerEvent] = @@ -612,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 { @@ -625,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( @@ -641,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 _ => @@ -679,6 +763,7 @@ trait PayInCommandHandler .withExternalUuid(paymentAccount.externalUuid) .withCard(updatedCard) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 ) case paypal: Paypal => updatedPaymentAccount = updatedPaymentAccount.withPaypals( @@ -690,6 +775,7 @@ trait PayInCommandHandler .withExternalUuid(paymentAccount.externalUuid) .withPaypal(paypal) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 ) case _ => List.empty } @@ -730,6 +816,7 @@ trait PayInCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) // Story 13.7 case _ => } case _ => @@ -749,12 +836,29 @@ trait PayInCommandHandler .withPaymentMethodId(transaction.paymentMethodId.getOrElse("")) .withPaymentType(transaction.paymentType) .withPrintReceipt(printReceipt) + .copy(correlationId = correlationId) // Story 13.7 — durable hop ) ++ (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: {} -> {}", @@ -769,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 - ) + } } } } @@ -784,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 b685006..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 _ => @@ -427,7 +453,8 @@ trait RecurringPaymentCommandHandler replyTo, paymentAccount, recurringPayment, - transaction + transaction, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -467,7 +494,8 @@ trait RecurringPaymentCommandHandler paymentAccount, recurringPayment, transaction, - scheduleNextPayment = false + scheduleNextPayment = false, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => Effect.none.thenRun(_ => @@ -529,7 +557,8 @@ trait RecurringPaymentCommandHandler replyTo, paymentAccount, recurringPayment, - transaction + transaction, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => val reason = "no transaction" @@ -541,7 +570,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => @@ -554,7 +584,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => // DirectDebit @@ -584,7 +615,8 @@ trait RecurringPaymentCommandHandler replyTo, paymentAccount, recurringPayment, - transaction + transaction, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) case _ => val reason = "no transaction" @@ -596,7 +628,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } } else { @@ -609,7 +642,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => @@ -622,7 +656,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => @@ -635,7 +670,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } case _ => @@ -648,7 +684,8 @@ trait RecurringPaymentCommandHandler debitedAmount, feesAmount, currency, - reason + reason, + maybeCorrelationId = cmd.correlationId // Story 13.7 ) } } @@ -666,7 +703,8 @@ trait RecurringPaymentCommandHandler paymentAccount: PaymentAccount, recurringPayment: RecurringPayment, transaction: Transaction, - scheduleNextPayment: Boolean = true + scheduleNextPayment: Boolean = true, + maybeCorrelationId: Option[String] = None // Story 13.7 — threaded from the triggering command )(implicit system: ActorSystem[_], log: Logger @@ -675,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 = @@ -686,6 +729,7 @@ trait RecurringPaymentCommandHandler ) ) .withLastUpdated(lastUpdated) + .copy(correlationId = correlationId) transaction.status match { case Transaction.TransactionStatus.TRANSACTION_CREATED if transaction.redirectUrl.isDefined => // 3ds @@ -738,7 +782,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 +802,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 { @@ -770,7 +820,8 @@ trait RecurringPaymentCommandHandler 1, Some(false), Some(value), - None + None, + correlationId = correlationId ) ) ) @@ -789,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: {} -> {}", @@ -815,6 +881,7 @@ trait RecurringPaymentCommandHandler .withTransaction(transaction) .withRecurringPaymentRegistrationId(recurringPayment.getId) .withFrequency(recurringPayment.getFrequency) + .copy(correlationId = correlationId) } else { NextRecurringPaymentFailedEvent.defaultInstance .withDebitedAccount(paymentAccount.externalUuid) @@ -827,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 { @@ -842,7 +912,8 @@ trait RecurringPaymentCommandHandler 1, Some(false), Some(value), - None + None, + correlationId = correlationId ) ) ) @@ -860,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( @@ -875,7 +957,7 @@ trait RecurringPaymentCommandHandler transaction.getReasonMessage ) ) ~> replyTo - ) + } } } } @@ -888,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( @@ -903,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) => @@ -917,7 +1011,8 @@ trait RecurringPaymentCommandHandler 1, Some(false), Some(value), - None + None, + correlationId = correlationId ) ) ) @@ -935,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 488a8d4..110d9d8 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} @@ -22,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, @@ -39,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( @@ -75,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), @@ -96,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) @@ -120,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]( @@ -137,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)) @@ -184,10 +189,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 = PayIn( orderUuid, externalUuidWithProfile(principal._2), @@ -208,7 +213,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) @@ -225,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]( @@ -242,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) @@ -282,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), @@ -293,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)) @@ -308,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]( @@ -325,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/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" 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") + } + } +}