From ebaec7e0df050c0af64c1621ec6baaefc05effe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 31 Jul 2024 06:35:57 +0200 Subject: [PATCH] implements Stripe provider --- .../softnetwork/payment/api/SoftPayApi.scala | 2 +- build.sbt | 8 +- client/src/main/protobuf/api/payment.proto | 16 + .../main/protobuf/model/payment/card.proto | 2 + .../protobuf/model/payment/document.proto | 8 + .../protobuf/model/payment/paymentUser.proto | 21 + .../protobuf/model/payment/transaction.proto | 39 + .../payment/api/PaymentClient.scala | 32 +- .../payment/api/config/ApiKeys.scala | 5 +- .../api/config/SoftPayClientSettings.scala | 12 +- .../payment/model/LegalUserDecorator.scala | 6 +- .../model/PayInTransactionDecorator.scala | 72 + common/src/main/resources/reference.conf | 13 +- .../softnetwork/payment/config/Payment.scala | 23 + .../payment/config/PaymentSettings.scala | 39 +- .../payment/config/ProviderConfig.scala | 50 + .../payment/message/PaymentMessages.scala | 110 +- .../payment/service/HooksEndpoints.scala | 2 +- .../app/softnetwork/payment/spi/CardApi.scala | 95 + .../payment/spi/DirectDebitApi.scala | 75 + .../softnetwork/payment/spi/PayInApi.scala | 88 + .../softnetwork/payment/spi/PayOutApi.scala | 28 + .../payment/spi/PaymentAccountApi.scala | 192 ++ .../payment/spi/PaymentContext.scala | 14 + .../payment/spi/PaymentProvider.scala | 466 +--- .../payment/spi/PaymentProviderSpi.scala | 3 +- .../payment/spi/PaymentProviders.scala | 5 +- .../payment/spi/RecurringPaymentApi.scala | 58 + .../softnetwork/payment/spi/RefundApi.scala | 28 + .../softnetwork/payment/spi/TransferApi.scala | 21 + .../payment/api/PaymentServer.scala | 40 +- .../payment/handlers/PaymentDao.scala | 40 +- .../payment/launch/PaymentGuardian.scala | 20 +- .../query/PaymentCommandProcessorStream.scala | 2 +- .../persistence/typed/PaymentBehavior.scala | 694 ++++-- .../persistence/typed/PaymentKvBehavior.scala | 4 +- .../service/BankAccountEndpoints.scala | 19 +- .../payment/service/CardEndpoints.scala | 6 +- .../service/CardPaymentEndpoints.scala | 158 +- .../service/KycDocumentEndpoints.scala | 4 +- .../payment/service/MandateEndpoints.scala | 6 +- .../payment/service/PaymentService.scala | 369 +-- .../service/PaymentServiceEndpoints.scala | 4 +- .../service/RecurringPaymentEndpoints.scala | 8 +- .../service/RootPaymentEndpoints.scala | 6 +- .../service/UboDeclarationEndpoints.scala | 18 +- .../softnetwork/payment/config/MangoPay.scala | 119 +- .../payment/config/MangoPaySettings.scala | 14 +- .../service/MangoPayHooksEndpoints.scala | 6 +- .../payment/spi/MangoPayProvider.scala | 707 +++--- project/Versions.scala | 2 + stripe/build.sbt | 8 + ...softnetwork.payment.spi.PaymentProviderSpi | 1 + stripe/src/main/resources/reference.conf | 14 + .../payment/config/StripeApi.scala | 176 ++ .../payment/config/StripeSettings.scala | 27 + .../payment/service/StripeEventHandler.scala | 290 +++ .../service/StripeHooksDirectives.scala | 37 + .../service/StripeHooksEndpoints.scala | 42 + .../payment/spi/StripeAccountApi.scala | 2176 +++++++++++++++++ .../payment/spi/StripeCardApi.scala | 391 +++ .../payment/spi/StripeDirectDebitApi.scala | 219 ++ .../payment/spi/StripePayInApi.scala | 619 +++++ .../payment/spi/StripePayOutApi.scala | 283 +++ .../payment/spi/StripeProvider.scala | 113 + .../spi/StripeRecurringPaymentApi.scala | 70 + .../payment/spi/StripeRefundApi.scala | 284 +++ .../payment/spi/StripeTransferApi.scala | 140 ++ stripe/src/test/resources/reference.conf | 3 + .../payment/config/StripeApiSpec.scala | 18 + testkit/build.sbt | 4 +- .../api/PaymentGrpcServicesTestKit.scala | 8 +- .../payment/api/PaymentProviderTestKit.scala | 23 + .../payment/api/SoftPayClientTestKit.scala | 20 - .../softnetwork/payment/data/package.scala | 45 +- .../scalatest/PaymentEndpointsTestKit.scala | 56 +- .../scalatest/PaymentRouteTestKit.scala | 35 +- .../scalatest/PaymentRoutesTestKit.scala | 50 +- .../payment/scalatest/PaymentTestKit.scala | 20 +- .../payment/scalatest/SoftPayTestKit.scala | 4 +- .../scalatest/StripePaymentRouteTestKit.scala | 31 + .../scalatest/StripePaymentTestKit.scala | 10 + .../payment/spi/MockMangoPayProvider.scala | 316 ++- .../payment/handlers/PaymentHandlerSpec.scala | 114 +- ...EndpointsWithOneOffCookieSessionSpec.scala | 13 +- ...EndpointsWithOneOffHeaderSessionSpec.scala | 13 +- ...intsWithRefreshableCookieSessionSpec.scala | 13 +- ...intsWithRefreshableHeaderSessionSpec.scala | 13 +- ...entRoutesWithOneOffCookieSessionSpec.scala | 11 +- ...entRoutesWithOneOffHeaderSessionSpec.scala | 11 +- ...utesWithRefreshableCookieSessionSpec.scala | 11 +- ...utesWithRefreshableHeaderSessionSpec.scala | 11 +- .../payment/service/PaymentServiceSpec.scala | 281 ++- .../service/StripePaymentServiceSpec.scala | 844 +++++++ 94 files changed, 8831 insertions(+), 1816 deletions(-) create mode 100644 client/src/main/scala/app/softnetwork/payment/model/PayInTransactionDecorator.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/config/Payment.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/config/ProviderConfig.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/spi/CardApi.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/spi/DirectDebitApi.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/spi/PayInApi.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/spi/PayOutApi.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/spi/PaymentAccountApi.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/spi/PaymentContext.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/spi/RecurringPaymentApi.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/spi/RefundApi.scala create mode 100644 common/src/main/scala/app/softnetwork/payment/spi/TransferApi.scala create mode 100644 stripe/build.sbt create mode 100644 stripe/src/main/resources/META-INF/services/app.softnetwork.payment.spi.PaymentProviderSpi create mode 100644 stripe/src/main/resources/reference.conf create mode 100644 stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/config/StripeSettings.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksDirectives.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksEndpoints.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/spi/StripeAccountApi.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/spi/StripeCardApi.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/spi/StripeDirectDebitApi.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/spi/StripePayInApi.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/spi/StripePayOutApi.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApi.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/spi/StripeRefundApi.scala create mode 100644 stripe/src/main/scala/app/softnetwork/payment/spi/StripeTransferApi.scala create mode 100644 stripe/src/test/resources/reference.conf create mode 100644 stripe/src/test/scala/app/softnetwork/payment/config/StripeApiSpec.scala create mode 100644 testkit/src/main/scala/app/softnetwork/payment/api/PaymentProviderTestKit.scala delete mode 100644 testkit/src/main/scala/app/softnetwork/payment/api/SoftPayClientTestKit.scala create mode 100644 testkit/src/main/scala/app/softnetwork/payment/scalatest/StripePaymentRouteTestKit.scala create mode 100644 testkit/src/main/scala/app/softnetwork/payment/scalatest/StripePaymentTestKit.scala create mode 100644 testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala diff --git a/api/src/main/scala/app/softnetwork/payment/api/SoftPayApi.scala b/api/src/main/scala/app/softnetwork/payment/api/SoftPayApi.scala index 51585c6..c1d2c87 100644 --- a/api/src/main/scala/app/softnetwork/payment/api/SoftPayApi.scala +++ b/api/src/main/scala/app/softnetwork/payment/api/SoftPayApi.scala @@ -97,7 +97,7 @@ trait SoftPayApi[SD <: SessionData with SessionDataDecorator[SD]] extends SoftPa override implicit def companion: SessionDataCompanion[SD] = self.companion override val applicationVersion: String = systemVersion() override val swaggerUIOptions: SwaggerUIOptions = - SwaggerUIOptions.default.pathPrefix(List("swagger", PaymentSettings.PaymentPath)) + SwaggerUIOptions.default.pathPrefix(List("swagger", PaymentSettings.PaymentConfig.path)) } def accountSwagger: ActorSystem[_] => SwaggerEndpoint = sys => diff --git a/build.sbt b/build.sbt index 54a2781..6fe9639 100644 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,7 @@ ThisBuild / organization := "app.softnetwork" name := "payment" -ThisBuild / version := "0.6.1.1" +ThisBuild / version := "0.7.0-SNAPSHOT" ThisBuild / scalaVersion := "2.12.18" @@ -71,6 +71,9 @@ lazy val api = project.in(file("api")) .dependsOn( mangopay % "compile->compile;test->test;it->it" ) + .dependsOn( + stripe % "compile->compile;test->test;it->it" + ) lazy val testkit = project.in(file("testkit")) .configs(IntegrationTest) @@ -78,6 +81,9 @@ lazy val testkit = project.in(file("testkit")) .dependsOn( mangopay % "compile->compile;test->test;it->it" ) + .dependsOn( + stripe % "compile->compile;test->test;it->it" + ) lazy val root = project.in(file(".")) .aggregate(client, common, core, mangopay, stripe, testkit, api) diff --git a/client/src/main/protobuf/api/payment.proto b/client/src/main/protobuf/api/payment.proto index 7342fee..845f603 100644 --- a/client/src/main/protobuf/api/payment.proto +++ b/client/src/main/protobuf/api/payment.proto @@ -26,6 +26,8 @@ service PaymentServiceApi { rpc CancelMandate (CancelMandateRequest) returns (CancelMandateResponse) {} rpc LoadBankAccountOwner (LoadBankAccountOwnerRequest) returns (LoadBankAccountOwnerResponse) {} rpc LoadLegalUser(LoadLegalUserRequest) returns (LoadLegalUserResponse) {} + rpc LoadPayInTransaction(LoadPayInTransactionRequest) returns (TransactionResponse) {} + rpc LoadPayOutTransaction (LoadPayOutTransactionRequest) returns (TransactionResponse) {} } message CreateOrUpdatePaymentAccountRequest { @@ -49,6 +51,7 @@ enum TransactionStatus{ TRANSACTION_FAILED = 2; TRANSACTION_NOT_SPECIFIED = 4; TRANSACTION_FAILED_FOR_TECHNICAL_REASON = 5; + TRANSACTION_CANCELED = 6; } message TransactionResponse { @@ -85,6 +88,7 @@ message PayOutRequest { string currency = 5; google.protobuf.StringValue externalReference = 6; string clientId = 7; + google.protobuf.StringValue payInTransactionId = 8; } message TransferRequest { @@ -195,3 +199,15 @@ message LoadLegalUserResponse { app.softnetwork.payment.model.Address legalRepresentativeAddress = 4; app.softnetwork.payment.model.Address headQuartersAddress = 5; } + +message LoadPayInTransactionRequest { + string orderUuid = 1; + string payInTransactionId = 2; + string clientId = 3; +} + +message LoadPayOutTransactionRequest { + string orderUuid = 1; + string payOutTransactionId = 2; + string clientId = 3; +} \ No newline at end of file diff --git a/client/src/main/protobuf/model/payment/card.proto b/client/src/main/protobuf/model/payment/card.proto index 383ce37..6c8d39e 100644 --- a/client/src/main/protobuf/model/payment/card.proto +++ b/client/src/main/protobuf/model/payment/card.proto @@ -24,6 +24,8 @@ message Card { required string alias = 5; required string expirationDate = 6; optional bool active = 7; + optional string holderName = 8; + optional string brand = 9; } message CardOwner { diff --git a/client/src/main/protobuf/model/payment/document.proto b/client/src/main/protobuf/model/payment/document.proto index a640b2d..60b08e9 100644 --- a/client/src/main/protobuf/model/payment/document.proto +++ b/client/src/main/protobuf/model/payment/document.proto @@ -47,6 +47,10 @@ message KycDocument { * proof of address */ KYC_ADDRESS_PROOF = 4; + /** + * additional requirement + */ + KYC_ADDITIONAL_REQUIREMENT = 5; } option (scalapb.message).extends = "ProtobufDomainObject"; option (scalapb.message).extends = "KycDocumentDecorator"; @@ -57,6 +61,7 @@ message KycDocument { optional string refusedReasonMessage = 5; optional google.protobuf.Timestamp createdDate = 6 [(scalapb.field).type = "java.util.Date"]; optional google.protobuf.Timestamp lastUpdated = 7 [(scalapb.field).type = "java.util.Date"]; + optional string kycDocumentSubType = 8; } message UboDeclaration { @@ -80,6 +85,9 @@ message UboDeclaration { required string country = 10 [default = "FR"]; required BirthPlace birthPlace = 11; required bool active = 12; + optional double percentOwnership = 13; + optional string email = 14; + optional string phone = 15; } enum UboDeclarationStatus { diff --git a/client/src/main/protobuf/model/payment/paymentUser.proto b/client/src/main/protobuf/model/payment/paymentUser.proto index 7251ebe..76d1d6d 100644 --- a/client/src/main/protobuf/model/payment/paymentUser.proto +++ b/client/src/main/protobuf/model/payment/paymentUser.proto @@ -54,6 +54,8 @@ message BankAccount { optional string mandateId = 12; optional MandateStatus mandateStatus = 13; optional MandateScheme mandateScheme = 14 [default = MANDATE_SEPA]; + optional string countryCode = 15; + optional string currency = 16; } message NaturalUser { @@ -76,6 +78,10 @@ message NaturalUser { optional string profile = 10; optional NaturalUserType naturalUserType = 11; // optional string secondaryWalletId = 12; + optional Address address = 13; + optional string phone = 14; + optional Business business = 15; + optional string title = 16; } message LegalUser { @@ -94,6 +100,9 @@ message LegalUser { required Address headQuartersAddress = 6; optional UboDeclaration uboDeclaration = 7; optional google.protobuf.Timestamp lastAcceptedTermsOfPSP = 8 [(scalapb.field).type = "java.util.Date"]; + optional string vatNumber = 9; + optional string phone = 10; + optional Business business = 11; } /** @@ -125,6 +134,18 @@ message PaymentAccount { optional string clientId = 12; } +message Business { + required string merchantCategoryCode = 1; + required string website = 2; + optional BusinessSupport support = 3; +} + +message BusinessSupport { + required string email = 1; + optional string phone = 2; + optional string url = 3; +} + message MandateResult{ required string id = 1; required BankAccount.MandateStatus status = 2; diff --git a/client/src/main/protobuf/model/payment/transaction.proto b/client/src/main/protobuf/model/payment/transaction.proto index c18df20..5a51f9d 100644 --- a/client/src/main/protobuf/model/payment/transaction.proto +++ b/client/src/main/protobuf/model/payment/transaction.proto @@ -35,6 +35,8 @@ message Transaction { TRANSACTION_FAILED = 2; TRANSACTION_NOT_SPECIFIED = 4; TRANSACTION_FAILED_FOR_TECHNICAL_REASON = 5; + TRANSACTION_CANCELED = 6; + TRANSACTION_PENDING_PAYMENT = 7; } enum PaymentType { @@ -85,6 +87,11 @@ message Transaction { optional string payPalBuyerAccountEmail = 31; optional string idempotencyKey = 32; optional string clientId = 33; + optional string paymentClientSecret = 34; + optional string paymentClientData = 35; + optional string paymentClientReturnUrl = 36; + optional string sourceTransactionId = 37; + optional int32 transferAmount = 38; } message BrowserInfo { @@ -101,6 +108,26 @@ message BrowserInfo { } message PayInTransaction { + option (scalapb.message).extends = "ProtobufDomainObject"; + option (scalapb.message).extends = "PayInTransactionDecorator"; + required string orderUuid = 1; + required int32 debitedAmount = 2; + required int32 feesAmount = 3 [default = 0]; + required string currency = 4 [default = "EUR"]; + required string creditedWalletId = 5; + required string authorId = 6; + required string statementDescriptor = 7; + required Transaction.PaymentType paymentType = 8 [default = CARD]; + optional string cardId = 9; + optional string ipAddress = 10; + optional BrowserInfo browserInfo = 11; + optional bool registerCard = 12; + optional bool printReceipt = 13; + optional string cardPreAuthorizedTransactionId = 14; + optional int32 preAuthorizationDebitedAmount = 15; +} + +message PayInWithCardTransaction { option (scalapb.message).extends = "ProtobufDomainObject"; required string orderUuid = 1; required int32 debitedAmount = 2; @@ -127,6 +154,9 @@ message PayInWithPayPalTransaction { optional string language = 7; optional string statementDescriptor = 8; optional bool printReceipt = 9; + optional string payPalWalletId = 10; + optional string ipAddress = 11; + optional BrowserInfo browserInfo = 12; } message RefundTransaction { @@ -151,6 +181,8 @@ message TransferTransaction { required string debitedWalletId = 7; optional string orderUuid = 8; optional string externalReference = 9; + optional string statementDescriptor = 10; + optional string payInTransactionId = 11; } message PayOutTransaction { @@ -164,6 +196,8 @@ message PayOutTransaction { required string authorId = 7; required string debitedWalletId = 8; optional string externalReference = 9; + optional string payInTransactionId = 10; + optional string statementDescriptor = 11; } message DirectDebitTransaction { @@ -190,6 +224,9 @@ message PreAuthorizationTransaction { optional BrowserInfo browserInfo = 7; optional bool registerCard = 8; optional bool printReceipt = 9; + optional string creditedUserId = 10; + optional int32 feesAmount = 11; + optional string statementDescriptor = 12; } message PayInWithCardPreAuthorizedTransaction { @@ -201,6 +238,8 @@ message PayInWithCardPreAuthorizedTransaction { required string authorId = 5; required string cardPreAuthorizedTransactionId = 6; required int32 preAuthorizationDebitedAmount = 7; + optional int32 feesAmount = 8; + optional string statementDescriptor = 9; } message CardPreRegistration{ diff --git a/client/src/main/scala/app/softnetwork/payment/api/PaymentClient.scala b/client/src/main/scala/app/softnetwork/payment/api/PaymentClient.scala index c25a828..2a725fd 100644 --- a/client/src/main/scala/app/softnetwork/payment/api/PaymentClient.scala +++ b/client/src/main/scala/app/softnetwork/payment/api/PaymentClient.scala @@ -68,6 +68,20 @@ trait PaymentClient extends GrpcClient { ) } + def loadPayInTransaction( + orderUuid: String, + payInTransactionId: String, + token: Option[String] = None + ): Future[TransactionResponse] = { + withAuthorization( + grpcClient.loadPayInTransaction(), + token + ) + .invoke( + LoadPayInTransactionRequest(orderUuid, payInTransactionId, settings.clientId) + ) + } + def cancelPreAuthorization( orderUuid: String, cardPreAuthorizedTransactionId: String, @@ -115,6 +129,7 @@ trait PaymentClient extends GrpcClient { feesAmount: Int, currency: String, externalReference: Option[String], + payInTransactionId: Option[String], token: Option[String] = None ): Future[TransactionResponse] = { withAuthorization( @@ -129,11 +144,26 @@ trait PaymentClient extends GrpcClient { feesAmount, currency, externalReference, - settings.clientId + settings.clientId, + payInTransactionId ) ) } + def loadPayOutTransaction( + orderUuid: String, + payOutTransactionId: String, + token: Option[String] = None + ): Future[TransactionResponse] = { + withAuthorization( + grpcClient.loadPayOutTransaction(), + token + ) + .invoke( + LoadPayOutTransactionRequest(orderUuid, payOutTransactionId, settings.clientId) + ) + } + def transfer( orderUuid: Option[String], debitedAccount: String, diff --git a/client/src/main/scala/app/softnetwork/payment/api/config/ApiKeys.scala b/client/src/main/scala/app/softnetwork/payment/api/config/ApiKeys.scala index 122a1b3..d70acfb 100644 --- a/client/src/main/scala/app/softnetwork/payment/api/config/ApiKeys.scala +++ b/client/src/main/scala/app/softnetwork/payment/api/config/ApiKeys.scala @@ -5,9 +5,8 @@ import java.nio.file.Paths object ApiKeys { private[this] lazy val filePath: String = { - val config = SoftPayClientSettings.SOFT_PAY_HOME + "/config" - Paths.get(config).toFile.mkdirs() - config + "/apiKeys.conf" + Paths.get(SoftPayClientSettings.SP_CONFIG).toFile.mkdirs() + SoftPayClientSettings.SP_CONFIG + "/apiKeys.conf" } private[this] def write(apiKeys: Map[String, String]): Unit = { diff --git a/client/src/main/scala/app/softnetwork/payment/api/config/SoftPayClientSettings.scala b/client/src/main/scala/app/softnetwork/payment/api/config/SoftPayClientSettings.scala index dc25896..d012a01 100644 --- a/client/src/main/scala/app/softnetwork/payment/api/config/SoftPayClientSettings.scala +++ b/client/src/main/scala/app/softnetwork/payment/api/config/SoftPayClientSettings.scala @@ -18,7 +18,7 @@ case class SoftPayClientSettings(clientId: String, apiKey: String) { } private[payment] def select(): Unit = { - val config = SoftPayClientSettings.SOFT_PAY_HOME + "/config" + val config = SoftPayClientSettings.SP_ROOT + "/config" Paths.get(config).toFile.mkdirs() val application = Paths.get(config + "/application.conf").toFile application.createNewFile() @@ -34,14 +34,18 @@ case class SoftPayClientSettings(clientId: String, apiKey: String) { } object SoftPayClientSettings { - lazy val SOFT_PAY_HOME: String = + lazy val SP_ROOT: String = sys.env.getOrElse( - "SOFT_PAY_HOME", + "SP_ROOT", Option(System.getProperty("user.home") + "/soft-pay").getOrElse(".") ) + lazy val SP_CONFIG: String = sys.env.getOrElse("SP_CONFIG", SP_ROOT + "/config") + + lazy val SP_SECRETS: String = sys.env.getOrElse("SP_SECRETS", SP_ROOT + "/secrets") + def apply(system: ActorSystem[_]): SoftPayClientSettings = { - val clientConfigFile: Path = Paths.get(s"$SOFT_PAY_HOME/config/application.conf") + val clientConfigFile: Path = Paths.get(s"$SP_ROOT/config/application.conf") val systemConfig = system.settings.config.getConfig("payment") val clientConfig: Config = { if ( diff --git a/client/src/main/scala/app/softnetwork/payment/model/LegalUserDecorator.scala b/client/src/main/scala/app/softnetwork/payment/model/LegalUserDecorator.scala index e79ce85..8aa283c 100644 --- a/client/src/main/scala/app/softnetwork/payment/model/LegalUserDecorator.scala +++ b/client/src/main/scala/app/softnetwork/payment/model/LegalUserDecorator.scala @@ -6,7 +6,7 @@ import app.softnetwork.validation.RegexValidator import scala.util.matching.Regex trait LegalUserDecorator { self: LegalUser => - lazy val wrongSiret: Boolean = !SiretValidator.check(siret) + lazy val wrongSiret: Boolean = !SiretValidator.check(siret.split(" ").mkString) lazy val wrongLegalRepresentativeAddress: Boolean = legalRepresentativeAddress.wrongAddress @@ -18,6 +18,8 @@ trait LegalUserDecorator { self: LegalUser => uboDeclaration.exists(_.status.isUboDeclarationValidated) lazy val view: LegalUserView = model.LegalUserView(self) + + lazy val siren: String = siret.split(" ").mkString.take(9) } object SiretValidator extends RegexValidator { @@ -28,6 +30,7 @@ case class LegalUserView( legalUserType: LegalUser.LegalUserType, legalName: String, siret: String, + siren: String, legalRepresentative: NaturalUserView, legalRepresentativeAddress: AddressView, headQuartersAddress: AddressView, @@ -42,6 +45,7 @@ object LegalUserView { legalUserType, legalName, siret, + siren, legalRepresentative.view, legalRepresentativeAddress.view, headQuartersAddress.view, diff --git a/client/src/main/scala/app/softnetwork/payment/model/PayInTransactionDecorator.scala b/client/src/main/scala/app/softnetwork/payment/model/PayInTransactionDecorator.scala new file mode 100644 index 0000000..a6f7532 --- /dev/null +++ b/client/src/main/scala/app/softnetwork/payment/model/PayInTransactionDecorator.scala @@ -0,0 +1,72 @@ +package app.softnetwork.payment.model + +trait PayInTransactionDecorator { _: PayInTransaction => + + def cardTransaction: Option[PayInWithCardTransaction] = + paymentType match { + case Transaction.PaymentType.CARD => + Some( + PayInWithCardTransaction.defaultInstance + .withOrderUuid(orderUuid) + .withAuthorId(authorId) + .withDebitedAmount(debitedAmount) + .withCurrency(currency) + .withCreditedWalletId(creditedWalletId) + .withPaymentType(paymentType) + .withStatementDescriptor(statementDescriptor) + .withCardId(cardId.orNull) + .copy( + browserInfo = browserInfo, + ipAddress = ipAddress, + registerCard = registerCard, + printReceipt = printReceipt + ) + ) + case _ => None + } + + def cardPreAuthorizedTransaction: Option[PayInWithCardPreAuthorizedTransaction] = + paymentType match { + case Transaction.PaymentType.PREAUTHORIZED => + Some( + PayInWithCardPreAuthorizedTransaction.defaultInstance + .withOrderUuid(orderUuid) + .withAuthorId(authorId) + .withDebitedAmount(debitedAmount) + .withFeesAmount(feesAmount) + .withCurrency(currency) + .withCreditedWalletId(creditedWalletId) + .withCardPreAuthorizedTransactionId(cardPreAuthorizedTransactionId.orNull) + .withPreAuthorizationDebitedAmount( + preAuthorizationDebitedAmount.getOrElse(debitedAmount) + ) + .copy( + statementDescriptor = + if (statementDescriptor.isEmpty) None else Some(statementDescriptor) + ) + ) + case _ => None + } + + def payPalTransaction: Option[PayInWithPayPalTransaction] = + paymentType match { + case Transaction.PaymentType.PAYPAL => + Some( + PayInWithPayPalTransaction.defaultInstance + .withOrderUuid(orderUuid) + .withAuthorId(authorId) + .withDebitedAmount(debitedAmount) + .withFeesAmount(feesAmount) + .withCurrency(currency) + .withCreditedWalletId(creditedWalletId) + .withStatementDescriptor(statementDescriptor) + .copy( + browserInfo = browserInfo, + ipAddress = ipAddress, + printReceipt = printReceipt, + language = browserInfo.map(_.language) + ) + ) + case _ => None + } +} diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index e995eec..4b28538 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -17,26 +17,23 @@ payment{ path = "payment" path = ${?PAYMENT_PATH} - payIn-route = "payIn" - payIn-statement-descriptor = "SOFTNETWORK" + pay-in-route = "payIn" + pay-in-statement-descriptor = "SOFTNETWORK" pre-authorize-card-route = "preAuthorize" - recurringPayment-route = "recurringPayment" - secure-mode-route = "3ds" + recurring-payment-route = "recurringPayment" + callbacks-route = "callbacks" hooks-route = "hooks" mandate-route = "mandate" card-route = "card" bank-route = "bank" declaration-route = "declaration" kyc-route = "kyc" - payPal-route = "payPal" disable-bank-account-deletion = false akka-node-role = payment - event-streams { - external-to-payment-account-tag = "external-to-payment-account" - } + external-to-payment-account-tag = "external-to-payment-account" } diff --git a/common/src/main/scala/app/softnetwork/payment/config/Payment.scala b/common/src/main/scala/app/softnetwork/payment/config/Payment.scala new file mode 100644 index 0000000..4777597 --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/config/Payment.scala @@ -0,0 +1,23 @@ +package app.softnetwork.payment.config + +object Payment { + + case class Config( + baseUrl: String, + path: String, + payInRoute: String, + payInStatementDescriptor: String, + preAuthorizeCardRoute: String, + recurringPaymentRoute: String, + callbacksRoute: String, + hooksRoute: String, + mandateRoute: String, + cardRoute: String, + bankRoute: String, + declarationRoute: String, + kycRoute: String, + disableBankAccountDeletion: Boolean, + externalToPaymentAccountTag: String, + akkaNodeRole: String + ) +} diff --git a/common/src/main/scala/app/softnetwork/payment/config/PaymentSettings.scala b/common/src/main/scala/app/softnetwork/payment/config/PaymentSettings.scala index b143abe..c938429 100644 --- a/common/src/main/scala/app/softnetwork/payment/config/PaymentSettings.scala +++ b/common/src/main/scala/app/softnetwork/payment/config/PaymentSettings.scala @@ -1,6 +1,7 @@ package app.softnetwork.payment.config import com.typesafe.config.{Config, ConfigFactory} +import configs.Configs /** Created by smanciot on 05/07/2018. */ @@ -8,31 +9,21 @@ trait PaymentSettings { lazy val config: Config = ConfigFactory.load() - val BaseUrl: String = config.getString("payment.baseUrl") + lazy val PaymentConfig: Payment.Config = { + Configs[Payment.Config].get(config, "payment").toEither match { + case Left(configError) => + Console.err.println(s"Something went wrong with the provided arguments $configError") + throw configError.configException + case Right(paymentConfig) => paymentConfig + } + } - val PaymentPath: String = config.getString("payment.path") - - val PayInRoute: String = config.getString("payment.payIn-route") - val PayInStatementDescriptor: String = config.getString("payment.payIn-statement-descriptor") - val PreAuthorizeCardRoute: String = config.getString("payment.pre-authorize-card-route") - val RecurringPaymentRoute: String = config.getString("payment.recurringPayment-route") - val SecureModeRoute: String = config.getString("payment.secure-mode-route") - val HooksRoute: String = config.getString("payment.hooks-route") - val MandateRoute: String = config.getString("payment.mandate-route") - val CardRoute: String = config.getString("payment.card-route") - val BankRoute: String = config.getString("payment.bank-route") - val DeclarationRoute: String = config.getString("payment.declaration-route") - val KycRoute: String = config.getString("payment.kyc-route") - val PayPalRoute: String = config.getString("payment.payPal-route") - - val DisableBankAccountDeletion: Boolean = - config.getBoolean("payment.disable-bank-account-deletion") - - val ExternalToPaymentAccountTag: String = - config.getString("payment.event-streams.external-to-payment-account-tag") +} - val AkkaNodeRole: String = config.getString("payment.akka-node-role") +object PaymentSettings extends PaymentSettings { + def apply(conf: Config): PaymentSettings = new PaymentSettings { + override lazy val config: Config = conf + } + def apply(): PaymentSettings = new PaymentSettings {} } - -object PaymentSettings extends PaymentSettings diff --git a/common/src/main/scala/app/softnetwork/payment/config/ProviderConfig.scala b/common/src/main/scala/app/softnetwork/payment/config/ProviderConfig.scala new file mode 100644 index 0000000..79a8082 --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/config/ProviderConfig.scala @@ -0,0 +1,50 @@ +package app.softnetwork.payment.config + +import app.softnetwork.payment.model.SoftPayAccount +import app.softnetwork.payment.model.SoftPayAccount.Client.Provider + +abstract class ProviderConfig( + val clientId: String, + val apiKey: String, + val baseUrl: String, + val version: String, + val debug: Boolean, + val secureModePath: String, + val hooksPath: String, + val mandatePath: String, + val paypalPath: String +) { + + def `type`: Provider.ProviderType + + def paymentConfig: Payment.Config + + def withPaymentConfig(paymentConfig: Payment.Config): ProviderConfig + + lazy val callbacksUrl = + s"""${paymentConfig.baseUrl}/$secureModePath/${paymentConfig.callbacksRoute}""" + + lazy val preAuthorizeCardReturnUrl = s"$callbacksUrl/${paymentConfig.preAuthorizeCardRoute}" + + lazy val payInReturnUrl = s"$callbacksUrl/${paymentConfig.payInRoute}" + + lazy val recurringPaymentReturnUrl = s"$callbacksUrl/${paymentConfig.recurringPaymentRoute}" + + lazy val hooksBaseUrl = + s"""${paymentConfig.baseUrl}/$hooksPath/${paymentConfig.hooksRoute}/${`type`.name.toLowerCase}""" + + lazy val mandateReturnUrl = + s"""${paymentConfig.baseUrl}/$mandatePath/${paymentConfig.mandateRoute}""" + +// lazy val payPalReturnUrl = +// s"""${paymentConfig.baseUrl}/$paypalPath/${paymentConfig.payPalRoute}""" +// +// lazy val cardReturnUrl = +// s"""${paymentConfig.baseUrl}/$secureModePath/${paymentConfig.cardRoute}/${paymentConfig.payInRoute}""" + + lazy val softPayProvider: SoftPayAccount.Client.Provider = + SoftPayAccount.Client.Provider.defaultInstance + .withProviderType(`type`) + .withProviderId(clientId) + .withProviderApiKey(apiKey) +} 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 971f447..cc7ec92 100644 --- a/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala +++ b/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala @@ -65,6 +65,10 @@ object PaymentMessages { * - payment type * @param printReceipt * - whether or not the client asks to print a receipt + * @param feesAmount + * - optional fees amount + * @param user + * - optional payment user */ case class Payment( orderUuid: String, @@ -80,7 +84,9 @@ object PaymentMessages { screenHeight: Option[Int] = None, statementDescriptor: Option[String] = None, paymentType: Transaction.PaymentType = Transaction.PaymentType.CARD, - printReceipt: Boolean = false + printReceipt: Boolean = false, + feesAmount: Option[Int] = None, + user: Option[NaturalUser] = None ) /** Flow [PreRegisterCard -> ] PreAuthorizeCard [ -> PreAuthorizeCardFor3DS] @@ -105,6 +111,10 @@ object PaymentMessages { * - browser info * @param printReceipt * - whether or not the client asks to print a receipt + * @param creditedAccount + * - account to credit + * @param feesAmount + * - optional fees amount */ case class PreAuthorizeCard( orderUuid: String, @@ -116,12 +126,14 @@ object PaymentMessages { registerCard: Boolean = false, ipAddress: Option[String] = None, browserInfo: Option[BrowserInfo] = None, - printReceipt: Boolean = false + printReceipt: Boolean = false, + creditedAccount: Option[String] = None, + feesAmount: Option[Int] = None ) extends PaymentCommandWithKey { val key: String = debitedAccount } - /** 3ds command + /** card pre authorization return command * * @param orderUuid * - order unique id @@ -133,7 +145,7 @@ object PaymentMessages { * - whether or not the client asks to print a receipt */ @InternalApi - private[payment] case class PreAuthorizeCardFor3DS( + private[payment] case class PreAuthorizeCardCallback( orderUuid: String, preAuthorizationId: String, registerCard: Boolean = true, @@ -217,6 +229,10 @@ object PaymentMessages { * - payment type * @param printReceipt * - whether or not the client asks to print a receipt + * @param feesAmount + * - optional fees amount + * @param user + * - optional payment user */ case class PayIn( orderUuid: String, @@ -231,12 +247,15 @@ object PaymentMessages { browserInfo: Option[BrowserInfo] = None, statementDescriptor: Option[String] = None, paymentType: Transaction.PaymentType = Transaction.PaymentType.CARD, - printReceipt: Boolean = false + printReceipt: Boolean = false, + feesAmount: Option[Int] = None, + user: Option[NaturalUser] = None, + clientId: Option[String] = None ) extends PaymentCommandWithKey { val key: String = debitedAccount } - /** 3ds command + /** pay in return command * * @param orderUuid * - order unique id @@ -248,7 +267,7 @@ object PaymentMessages { * - whether or not the client asks to print a receipt */ @InternalApi - private[payment] case class PayInFor3DS( + private[payment] case class PayInCallback( orderUuid: String, transactionId: String, registerCard: Boolean, @@ -257,24 +276,6 @@ object PaymentMessages { lazy val key: String = transactionId } - /** PayPal return command - * - * @param orderUuid - * - order unique id - * @param transactionId - * - payIn transaction id - * @param printReceipt - * - whether or not the client asks to print a receipt - */ - @InternalApi - private[payment] case class PayInForPayPal( - orderUuid: String, - transactionId: String, - printReceipt: Boolean = false - ) extends PaymentCommandWithKey { - lazy val key: String = transactionId - } - /** @param orderUuid * - order uuid * @param creditedAccount @@ -287,6 +288,8 @@ object PaymentMessages { * - currency * @param externalReference * - optional external reference + * @param payInTransactionId + * - optional payIn transaction id * @param clientId * - optional client id */ @@ -297,11 +300,28 @@ object PaymentMessages { feesAmount: Int = 0, currency: String = "EUR", externalReference: Option[String] = None, + payInTransactionId: Option[String] = None, //TODO should be required clientId: Option[String] = None ) extends PaymentCommandWithKey { val key: String = creditedAccount } + case class LoadPayOutTransaction( + orderUuid: String, + transactionId: String, + clientId: Option[String] = None + ) extends PaymentCommandWithKey { + val key: String = transactionId + } + + case class LoadPayInTransaction( + orderUuid: String, + transactionId: String, + clientId: Option[String] = None + ) extends PaymentCommandWithKey { + val key: String = transactionId + } + /** @param orderUuid * - order uuid * @param payInTransactionId @@ -500,7 +520,7 @@ object PaymentMessages { * - transaction payIn id */ @InternalApi - private[payment] case class PayInFirstRecurringFor3DS( + private[payment] case class FirstRecurringPaymentCallback( recurringPayInRegistrationId: String, transactionId: String ) extends PaymentCommandWithKey { @@ -585,6 +605,10 @@ object PaymentMessages { * - payment user * @param acceptedTermsOfPSP * - whether or not the terms of the psp are accepted + * @param ipAddress + * - ip address + * @param userAgent + * - user agent * @param clientId * - optional client id */ @@ -593,6 +617,8 @@ object PaymentMessages { bankAccount: BankAccount, user: Option[PaymentAccount.User] = None, acceptedTermsOfPSP: Option[Boolean] = None, + ipAddress: Option[String] = None, + userAgent: Option[String], clientId: Option[String] = None ) extends PaymentCommandWithKey { val key: String = creditedAccount @@ -652,6 +678,14 @@ object PaymentMessages { val key: String = creditedAccount } + @InternalApi + private[payment] case class CreateOrUpdateKycDocument( + creditedAccount: String, + kycDocument: KycDocument + ) extends PaymentCommandWithKey { + val key: String = creditedAccount + } + /** hook command * * @param kycDocumentId @@ -696,7 +730,8 @@ object PaymentMessages { /** @param creditedAccount * - account which owns the UBO declaration that would be validated */ - case class ValidateUboDeclaration(creditedAccount: String) extends PaymentCommandWithKey { + case class ValidateUboDeclaration(creditedAccount: String, ipAddress: String, userAgent: String) + extends PaymentCommandWithKey { val key: String = creditedAccount } @@ -816,6 +851,13 @@ object PaymentMessages { case class PaymentRedirection(redirectUrl: String) extends PaidInResult + case class PaymentRequired( + transactionId: String, + paymentClientSecret: String, + paymentClientData: String, + paymentClientReturnUrl: String + ) extends PaidInResult + case class RecurringPaymentRegistered(recurringPaymentRegistrationId: String) extends PaymentResult @@ -866,6 +908,8 @@ object PaymentMessages { case class KycDocumentAdded(kycDocumentId: String) extends PaymentResult + case object KycDocumentCreatedOrUpdated extends PaymentResult + case class KycDocumentStatusUpdated(report: KycDocumentValidationReport) extends PaymentResult case class KycDocumentStatusLoaded(report: KycDocumentValidationReport) extends PaymentResult @@ -896,6 +940,18 @@ object PaymentMessages { case object PaymentAccountUpdated extends PaymentResult + case class PayInTransactionLoaded( + transactionId: String, + transactionStatus: Transaction.TransactionStatus, + error: Option[String] + ) extends PaymentResult + + case class PayOutTransactionLoaded( + transactionId: String, + transactionStatus: Transaction.TransactionStatus, + error: Option[String] + ) extends PaymentResult + class PaymentError(override val message: String) extends ErrorMessage(message) with PaymentResult case object CardNotPreRegistered extends PaymentError("CardNotPreRegistered") diff --git a/common/src/main/scala/app/softnetwork/payment/service/HooksEndpoints.scala b/common/src/main/scala/app/softnetwork/payment/service/HooksEndpoints.scala index 6253d1e..fb0b681 100644 --- a/common/src/main/scala/app/softnetwork/payment/service/HooksEndpoints.scala +++ b/common/src/main/scala/app/softnetwork/payment/service/HooksEndpoints.scala @@ -14,6 +14,6 @@ trait HooksEndpoints extends Tapir with SchemaDerivation with BasicPaymentServic def hooks( rootEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] - ): Full[Unit, Unit, (String, String), Unit, Unit, Any, Future] + ): Full[Unit, Unit, _, Unit, Unit, Any, Future] } diff --git a/common/src/main/scala/app/softnetwork/payment/spi/CardApi.scala b/common/src/main/scala/app/softnetwork/payment/spi/CardApi.scala new file mode 100644 index 0000000..9a1a48e --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/CardApi.scala @@ -0,0 +1,95 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.model.{ + Card, + CardPreRegistration, + PreAuthorizationTransaction, + Transaction +} + +trait CardApi { _: PaymentContext => + + /** @param maybeUserId + * - owner of the card + * @param currency + * - currency + * @param externalUuid + * - external unique id + * @return + * card pre registration + */ + def preRegisterCard( + maybeUserId: Option[String], + currency: String, + externalUuid: String + ): Option[CardPreRegistration] + + /** @param cardPreRegistrationId + * - card registration id + * @param maybeRegistrationData + * - card registration data + * @return + * card id + */ + def createCard( + cardPreRegistrationId: String, + maybeRegistrationData: Option[String] + ): Option[String] + + /** @param cardId + * - card id + * @return + * card + */ + def loadCard(cardId: String): Option[Card] + + /** @param cardId + * - the id of the card to disable + * @return + * the card disabled or none + */ + def disableCard(cardId: String): Option[Card] + + /** @param preAuthorizationTransaction + * - pre authorization transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pre authorization transaction result + */ + def preAuthorizeCard( + preAuthorizationTransaction: PreAuthorizationTransaction, + idempotency: Option[Boolean] = None + ): Option[Transaction] + + /** @param orderUuid + * - order unique id + * @param cardPreAuthorizedTransactionId + * - card pre authorized transaction id + * @return + * card pre authorized transaction + */ + def loadCardPreAuthorized( + orderUuid: String, + cardPreAuthorizedTransactionId: String + ): Option[Transaction] + + /** @param orderUuid + * - order unique id + * @param cardPreAuthorizedTransactionId + * - card pre authorized transaction id + * @return + * whether pre authorization transaction has been cancelled or not + */ + def cancelPreAuthorization(orderUuid: String, cardPreAuthorizedTransactionId: String): Boolean + + /** @param orderUuid + * - order unique id + * @param cardPreAuthorizedTransactionId + * - card pre authorized transaction id + * @return + * whether pre authorization transaction has been validated or not + */ + def validatePreAuthorization(orderUuid: String, cardPreAuthorizedTransactionId: String): Boolean + +} diff --git a/common/src/main/scala/app/softnetwork/payment/spi/DirectDebitApi.scala b/common/src/main/scala/app/softnetwork/payment/spi/DirectDebitApi.scala new file mode 100644 index 0000000..62c182d --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/DirectDebitApi.scala @@ -0,0 +1,75 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.model.{DirectDebitTransaction, MandateResult, Transaction} + +import java.util.Date + +trait DirectDebitApi { _: PaymentContext => + + /** @param externalUuid + * - external unique id + * @param userId + * - Provider user id + * @param bankAccountId + * - Bank account id + * @param idempotencyKey + * - whether to use an idempotency key for this request or not + * @return + * mandate result + */ + def mandate( + externalUuid: String, + userId: String, + bankAccountId: String, + idempotencyKey: Option[String] = None + ): Option[MandateResult] + + /** @param maybeMandateId + * - optional mandate id + * @param userId + * - Provider user id + * @param bankAccountId + * - bank account id + * @return + * mandate associated to this bank account + */ + def loadMandate( + maybeMandateId: Option[String], + userId: String, + bankAccountId: String + ): Option[MandateResult] + + /** @param mandateId + * - Provider mandate id + * @return + * mandate result + */ + def cancelMandate(mandateId: String): Option[MandateResult] + + /** @param maybeDirectDebitTransaction + * - direct debit transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * direct debit transaction result + */ + def directDebit( + maybeDirectDebitTransaction: Option[DirectDebitTransaction], + idempotency: Option[Boolean] = None + ): Option[Transaction] + + /** @param walletId + * - Provider wallet id + * @param transactionId + * - Provider transaction id + * @param transactionDate + * - Provider transaction date + * @return + * transaction if it exists + */ + def loadDirectDebitTransaction( + walletId: String, + transactionId: String, + transactionDate: Date + ): Option[Transaction] +} diff --git a/common/src/main/scala/app/softnetwork/payment/spi/PayInApi.scala b/common/src/main/scala/app/softnetwork/payment/spi/PayInApi.scala new file mode 100644 index 0000000..c92ac7e --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/PayInApi.scala @@ -0,0 +1,88 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.model.{ + PayInTransaction, + PayInWithCardPreAuthorizedTransaction, + PayInWithCardTransaction, + PayInWithPayPalTransaction, + Transaction +} + +trait PayInApi { _: PaymentContext => + + /** @param payInTransaction + * - pay in transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay in transaction result + */ + def payIn( + payInTransaction: Option[PayInTransaction], + idempotency: Option[Boolean] = None + ): Option[Transaction] = { + payInTransaction match { + case Some(payInTransaction) => + payInTransaction.paymentType match { + case Transaction.PaymentType.CARD => + payInWithCard(payInTransaction.cardTransaction, idempotency) + case Transaction.PaymentType.PREAUTHORIZED => + payInWithCardPreAuthorized(payInTransaction.cardPreAuthorizedTransaction, idempotency) + case Transaction.PaymentType.PAYPAL => + payInWithPayPal(payInTransaction.payPalTransaction, idempotency) + case _ => None + } + case None => None + } + } + + /** @param payInWithCardPreAuthorizedTransaction + * - card pre authorized pay in transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay in with card pre authorized transaction result + */ + private[spi] def payInWithCardPreAuthorized( + payInWithCardPreAuthorizedTransaction: Option[PayInWithCardPreAuthorizedTransaction], + idempotency: Option[Boolean] = None + ): Option[Transaction] + + /** @param maybePayInTransaction + * - pay in transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay in transaction result + */ + private[spi] def payInWithCard( + maybePayInTransaction: Option[PayInWithCardTransaction], + idempotency: Option[Boolean] = None + ): Option[Transaction] + + /** @param payInWithPayPalTransaction + * - pay in with PayPal transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay in with PayPal transaction result + */ + private[spi] def payInWithPayPal( + payInWithPayPalTransaction: Option[PayInWithPayPalTransaction], + idempotency: Option[Boolean] = None + ): Option[Transaction] + + /** @param orderUuid + * - order unique id + * @param transactionId + * - transaction id + * @return + * pay in transaction + */ + def loadPayInTransaction( + orderUuid: String, + transactionId: String, + recurringPayInRegistrationId: Option[String] + ): Option[Transaction] + +} diff --git a/common/src/main/scala/app/softnetwork/payment/spi/PayOutApi.scala b/common/src/main/scala/app/softnetwork/payment/spi/PayOutApi.scala new file mode 100644 index 0000000..2c6f67f --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/PayOutApi.scala @@ -0,0 +1,28 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.model.{PayOutTransaction, Transaction} + +trait PayOutApi { _: PaymentContext => + + /** @param maybePayOutTransaction + * - pay out transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay out transaction result + */ + def payOut( + maybePayOutTransaction: Option[PayOutTransaction], + idempotency: Option[Boolean] = None + ): Option[Transaction] + + /** @param orderUuid + * - order unique id + * @param transactionId + * - transaction id + * @return + * pay out transaction + */ + def loadPayOutTransaction(orderUuid: String, transactionId: String): Option[Transaction] + +} diff --git a/common/src/main/scala/app/softnetwork/payment/spi/PaymentAccountApi.scala b/common/src/main/scala/app/softnetwork/payment/spi/PaymentAccountApi.scala new file mode 100644 index 0000000..9f9d6be --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/PaymentAccountApi.scala @@ -0,0 +1,192 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.annotation.InternalApi +import app.softnetwork.payment.model.{ + BankAccount, + KycDocument, + KycDocumentValidationReport, + LegalUser, + NaturalUser, + PaymentAccount, + UboDeclaration +} + +trait PaymentAccountApi { _: PaymentContext => + + /** @param maybePaymentAccount + * - payment account to create or update + * @return + * provider user id + */ + def createOrUpdatePaymentAccount( + maybePaymentAccount: Option[PaymentAccount], + acceptedTermsOfPSP: Boolean, + ipAddress: Option[String], + userAgent: Option[String] + ): Option[String] = { + maybePaymentAccount match { + case Some(paymentAccount) => + import paymentAccount._ + if (user.isLegalUser) { + createOrUpdateLegalUser(user.legalUser, acceptedTermsOfPSP, ipAddress, userAgent) + } else if (user.isNaturalUser) { + createOrUpdateNaturalUser(user.naturalUser, acceptedTermsOfPSP, ipAddress, userAgent) + } else { + None + } + case _ => None + } + } + + /** @param maybeNaturalUser + * - natural user to create + * @return + * provider user id + */ + @InternalApi + private[spi] def createOrUpdateNaturalUser( + maybeNaturalUser: Option[NaturalUser], + acceptedTermsOfPSP: Boolean, + ipAddress: Option[String], + userAgent: Option[String] + ): Option[String] + + /** @param maybeLegalUser + * - legal user to create or update + * @return + * provider user id + */ + @InternalApi + private[spi] def createOrUpdateLegalUser( + maybeLegalUser: Option[LegalUser], + acceptedTermsOfPSP: Boolean, + ipAddress: Option[String], + userAgent: Option[String] + ): Option[String] + + /** @param userId + * - Provider user id + * @return + * Ultimate Beneficial Owner Declaration + */ + def createDeclaration(userId: String): Option[UboDeclaration] + + /** @param userId + * - Provider user id + * @param uboDeclarationId + * - Provider declaration id + * @param ultimateBeneficialOwner + * - Ultimate Beneficial Owner + * @return + * Ultimate Beneficial Owner created or updated + */ + def createOrUpdateUBO( + userId: String, + uboDeclarationId: String, + ultimateBeneficialOwner: UboDeclaration.UltimateBeneficialOwner + ): Option[UboDeclaration.UltimateBeneficialOwner] + + /** @param userId + * - Provider user id + * @param uboDeclarationId + * - Provider declaration id + * @return + * declaration with Ultimate Beneficial Owner(s) + */ + def getDeclaration(userId: String, uboDeclarationId: String): Option[UboDeclaration] + + /** @param userId + * - Provider user id + * @param uboDeclarationId + * - Provider declaration id + * @return + * Ultimate Beneficial Owner declaration + */ + def validateDeclaration( + userId: String, + uboDeclarationId: String, + ipAddress: String, + userAgent: String + ): Option[UboDeclaration] + + /** @param userId + * - Provider user id + * @param externalUuid + * - external unique id + * @param pages + * - document pages + * @param documentType + * - document type + * @return + * Provider document id + */ + def addDocument( + userId: String, + externalUuid: String, + pages: Seq[Array[Byte]], + documentType: KycDocument.KycDocumentType + ): Option[String] + + /** @param userId + * - Provider user id + * @param documentId + * - Provider document id + * @param documentType + * - document type + * @return + * document validation report + */ + def loadDocumentStatus( + userId: String, + documentId: String, + documentType: KycDocument.KycDocumentType + ): KycDocumentValidationReport + + /** @param maybeBankAccount + * - bank account to create + * @return + * bank account id + */ + def createOrUpdateBankAccount(maybeBankAccount: Option[BankAccount]): Option[String] + + /** @param userId + * - provider user id + * @param currency + * - currency + * @return + * the first active bank account + */ + def getActiveBankAccount(userId: String, currency: String): Option[String] + + /** @param userId + * - provider user id + * @param bankAccountId + * - bank account id + * @param currency + * - currency + * @return + * whether this bank account exists and is active + */ + def checkBankAccount(userId: String, bankAccountId: String, currency: String): Boolean = { + getActiveBankAccount(userId, currency).contains(bankAccountId) + } + + /** @param maybeUserId + * - owner of the wallet + * @param currency + * - currency + * @param externalUuid + * - external unique id + * @param maybeWalletId + * - wallet id to update + * @return + * wallet id + */ + def createOrUpdateWallet( + maybeUserId: Option[String], + currency: String, + externalUuid: String, + maybeWalletId: Option[String] + ): Option[String] + +} diff --git a/common/src/main/scala/app/softnetwork/payment/spi/PaymentContext.scala b/common/src/main/scala/app/softnetwork/payment/spi/PaymentContext.scala new file mode 100644 index 0000000..d52172a --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/PaymentContext.scala @@ -0,0 +1,14 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.config.ProviderConfig +import app.softnetwork.payment.model.SoftPayAccount +import com.typesafe.scalalogging.Logger + +trait PaymentContext { + + protected def mlog: Logger + + implicit def provider: SoftPayAccount.Client.Provider + + implicit def config: ProviderConfig +} diff --git a/common/src/main/scala/app/softnetwork/payment/spi/PaymentProvider.scala b/common/src/main/scala/app/softnetwork/payment/spi/PaymentProvider.scala index 8c1bf95..3d76ac0 100644 --- a/common/src/main/scala/app/softnetwork/payment/spi/PaymentProvider.scala +++ b/common/src/main/scala/app/softnetwork/payment/spi/PaymentProvider.scala @@ -4,469 +4,29 @@ import app.softnetwork.payment.model._ import com.typesafe.scalalogging.Logger import org.slf4j.LoggerFactory -import java.util.Date - /** Created by smanciot on 16/08/2018. */ -private[payment] trait PaymentProvider { +private[payment] trait PaymentProvider + extends PaymentContext + with PaymentAccountApi + with CardApi + with DirectDebitApi + with PayInApi + with PayOutApi + with TransferApi + with RefundApi + with RecurringPaymentApi { protected lazy val mlog: Logger = Logger(LoggerFactory.getLogger(getClass.getName)) - implicit def provider: SoftPayAccount.Client.Provider - - /** @param maybePaymentAccount - * - payment account to create or update - * @return - * provider user id - */ - def createOrUpdatePaymentAccount(maybePaymentAccount: Option[PaymentAccount]): Option[String] = { - maybePaymentAccount match { - case Some(paymentAccount) => - import paymentAccount._ - if (user.isLegalUser) { - createOrUpdateLegalUser(user.legalUser) - } else if (user.isNaturalUser) { - createOrUpdateNaturalUser(user.naturalUser) - } else { - None - } - case _ => None - } - } - - /** @param maybeNaturalUser - * - natural user to create - * @return - * provider user id - */ - def createOrUpdateNaturalUser(maybeNaturalUser: Option[NaturalUser]): Option[String] - - /** @param maybeLegalUser - * - legal user to create - * @return - * provider user id - */ - def createOrUpdateLegalUser(maybeLegalUser: Option[LegalUser]): Option[String] - - /** @param maybeUserId - * - owner of the wallet - * @param currency - * - currency - * @param externalUuid - * - external unique id - * @param maybeWalletId - * - wallet id to update - * @return - * wallet id - */ - def createOrUpdateWallet( - maybeUserId: Option[String], - currency: String, - externalUuid: String, - maybeWalletId: Option[String] - ): Option[String] - - /** @param maybeBankAccount - * - bank account to create - * @return - * bank account id - */ - def createOrUpdateBankAccount(maybeBankAccount: Option[BankAccount]): Option[String] - - /** @param userId - * - provider user id - * @return - * the first active bank account - */ - def getActiveBankAccount(userId: String): Option[String] - - /** @param userId - * - provider user id - * @param bankAccountId - * - bank account id - * @return - * whether this bank account exists and is active - */ - def checkBankAccount(userId: String, bankAccountId: String): Boolean - - /** @param maybeUserId - * - owner of the card - * @param currency - * - currency - * @param externalUuid - * - external unique id - * @return - * card pre registration - */ - def preRegisterCard( - maybeUserId: Option[String], - currency: String, - externalUuid: String - ): Option[CardPreRegistration] - - /** @param cardPreRegistrationId - * - card registration id - * @param maybeRegistrationData - * - card registration data - * @return - * card id - */ - def createCard( - cardPreRegistrationId: String, - maybeRegistrationData: Option[String] - ): Option[String] - - /** @param cardId - * - card id - * @return - * card - */ - def loadCard(cardId: String): Option[Card] - - /** @param cardId - * - the id of the card to disable - * @return - * the card disabled or none - */ - def disableCard(cardId: String): Option[Card] - - /** @param preAuthorizationTransaction - * - pre authorization transaction - * @param idempotency - * - whether to use an idempotency key for this request or not - * @return - * pre authorization transaction result - */ - def preAuthorizeCard( - preAuthorizationTransaction: PreAuthorizationTransaction, - idempotency: Option[Boolean] = None - ): Option[Transaction] - - /** @param orderUuid - * - order unique id - * @param cardPreAuthorizedTransactionId - * - card pre authorized transaction id - * @return - * card pre authorized transaction - */ - def loadCardPreAuthorized( - orderUuid: String, - cardPreAuthorizedTransactionId: String - ): Option[Transaction] - - /** @param payInWithCardPreAuthorizedTransaction - * - card pre authorized pay in transaction - * @param idempotency - * - whether to use an idempotency key for this request or not - * @return - * pay in with card pre authorized transaction result - */ - def payInWithCardPreAuthorized( - payInWithCardPreAuthorizedTransaction: PayInWithCardPreAuthorizedTransaction, - idempotency: Option[Boolean] = None - ): Option[Transaction] - - /** @param orderUuid - * - order unique id - * @param cardPreAuthorizedTransactionId - * - card pre authorized transaction id - * @return - * whether pre authorization transaction has been cancelled or not - */ - def cancelPreAuthorization(orderUuid: String, cardPreAuthorizedTransactionId: String): Boolean - - /** @param orderUuid - * - order unique id - * @param cardPreAuthorizedTransactionId - * - card pre authorized transaction id - * @return - * whether pre authorization transaction has been validated or not - */ - def validatePreAuthorization(orderUuid: String, cardPreAuthorizedTransactionId: String): Boolean - - /** @param maybePayInTransaction - * - pay in transaction - * @param idempotency - * - whether to use an idempotency key for this request or not - * @return - * pay in transaction result - */ - def payIn( - maybePayInTransaction: Option[PayInTransaction], - idempotency: Option[Boolean] = None - ): Option[Transaction] - - /** @param payInWithPayPalTransaction - * - pay in with PayPal transaction - * @param idempotency - * - whether to use an idempotency key for this request or not - * @return - * pay in with PayPal transaction result - */ - def payInWithPayPal( - payInWithPayPalTransaction: PayInWithPayPalTransaction, - idempotency: Option[Boolean] = None - ): Option[Transaction] - - /** @param maybeRefundTransaction - * - refund transaction - * @param idempotency - * - whether to use an idempotency key for this request or not - * @return - * refund transaction result - */ - def refund( - maybeRefundTransaction: Option[RefundTransaction], - idempotency: Option[Boolean] = None - ): Option[Transaction] - - /** @param maybeTransferTransaction - * - transfer transaction - * @return - * transfer transaction result - */ - def transfer(maybeTransferTransaction: Option[TransferTransaction]): Option[Transaction] - - /** @param maybePayOutTransaction - * - pay out transaction - * @param idempotency - * - whether to use an idempotency key for this request or not - * @return - * pay out transaction result - */ - def payOut( - maybePayOutTransaction: Option[PayOutTransaction], - idempotency: Option[Boolean] = None - ): Option[Transaction] - - /** @param orderUuid - * - order unique id - * @param transactionId - * - transaction id - * @return - * pay in transaction - */ - def loadPayIn( - orderUuid: String, - transactionId: String, - recurringPayInRegistrationId: Option[String] - ): Option[Transaction] - - /** @param orderUuid - * - order unique id - * @param transactionId - * - transaction id - * @return - * Refund transaction - */ - def loadRefund(orderUuid: String, transactionId: String): Option[Transaction] - - /** @param orderUuid - * - order unique id - * @param transactionId - * - transaction id - * @return - * pay out transaction - */ - def loadPayOut(orderUuid: String, transactionId: String): Option[Transaction] - - /** @param transactionId - * - transaction id - * @return - * transfer transaction - */ - def loadTransfer(transactionId: String): Option[Transaction] - - /** @param userId - * - Provider user id - * @param externalUuid - * - external unique id - * @param pages - * - document pages - * @param documentType - * - document type - * @return - * Provider document id - */ - def addDocument( - userId: String, - externalUuid: String, - pages: Seq[Array[Byte]], - documentType: KycDocument.KycDocumentType - ): Option[String] - - /** @param userId - * - Provider user id - * @param documentId - * - Provider document id - * @return - * document validation report - */ - def loadDocumentStatus(userId: String, documentId: String): KycDocumentValidationReport - - /** @param externalUuid - * - external unique id - * @param userId - * - Provider user id - * @param bankAccountId - * - Bank account id - * @param idempotencyKey - * - whether to use an idempotency key for this request or not - * @return - * mandate result - */ - def mandate( - externalUuid: String, - userId: String, - bankAccountId: String, - idempotencyKey: Option[String] = None - ): Option[MandateResult] - - /** @param maybeMandateId - * - optional mandate id - * @param userId - * - Provider user id - * @param bankAccountId - * - bank account id - * @return - * mandate associated to this bank account - */ - def loadMandate( - maybeMandateId: Option[String], - userId: String, - bankAccountId: String - ): Option[MandateResult] - - /** @param mandateId - * - Provider mandate id - * @return - * mandate result - */ - def cancelMandate(mandateId: String): Option[MandateResult] - - /** @param maybeDirectDebitTransaction - * - direct debit transaction - * @param idempotency - * - whether to use an idempotency key for this request or not - * @return - * direct debit transaction result - */ - def directDebit( - maybeDirectDebitTransaction: Option[DirectDebitTransaction], - idempotency: Option[Boolean] = None - ): Option[Transaction] - - /** @param walletId - * - Provider wallet id - * @param transactionId - * - Provider transaction id - * @param transactionDate - * - Provider transaction date - * @return - * transaction if it exists + /** @return + * client */ - def directDebitTransaction( - walletId: String, - transactionId: String, - transactionDate: Date - ): Option[Transaction] + def client: Option[SoftPayAccount.Client] /** @return * client fees */ - def client: Option[SoftPayAccount.Client] - def clientFees(): Option[Double] - /** @param userId - * - Provider user id - * @return - * Ultimate Beneficial Owner Declaration - */ - def createDeclaration(userId: String): Option[UboDeclaration] - - /** @param userId - * - Provider user id - * @param uboDeclarationId - * - Provider declaration id - * @param ultimateBeneficialOwner - * - Ultimate Beneficial Owner - * @return - * Ultimate Beneficial Owner created or updated - */ - def createOrUpdateUBO( - userId: String, - uboDeclarationId: String, - ultimateBeneficialOwner: UboDeclaration.UltimateBeneficialOwner - ): Option[UboDeclaration.UltimateBeneficialOwner] - - /** @param userId - * - Provider user id - * @param uboDeclarationId - * - Provider declaration id - * @return - * declaration with Ultimate Beneficial Owner(s) - */ - def getDeclaration(userId: String, uboDeclarationId: String): Option[UboDeclaration] - - /** @param userId - * - Provider user id - * @param uboDeclarationId - * - Provider declaration id - * @return - * Ultimate Beneficial Owner declaration - */ - def validateDeclaration(userId: String, uboDeclarationId: String): Option[UboDeclaration] - - /** @param userId - * - Provider user id - * @param walletId - * - Provider wallet id - * @param cardId - * - Provider card id - * @param recurringPayment - * - recurring payment to register - * @return - * recurring card payment registration result - */ - def registerRecurringCardPayment( - userId: String, - walletId: String, - cardId: String, - recurringPayment: RecurringPayment - ): Option[RecurringPayment.RecurringCardPaymentResult] = None - - /** @param recurringPayInRegistrationId - * - recurring payIn registration id - * @param cardId - * - Provider card id - * @param status - * - optional recurring payment status - * @return - * recurring card payment registration updated result - */ - def updateRecurringCardPaymentRegistration( - recurringPayInRegistrationId: String, - cardId: Option[String], - status: Option[RecurringPayment.RecurringCardPaymentStatus] - ): Option[RecurringPayment.RecurringCardPaymentResult] = None - - /** @param recurringPayInRegistrationId - * - recurring payIn registration id - * @return - * recurring card payment registration result - */ - def loadRecurringCardPayment( - recurringPayInRegistrationId: String - ): Option[RecurringPayment.RecurringCardPaymentResult] = None - - /** @param recurringPaymentTransaction - * - recurring payment transaction - * @return - * resulted payIn transaction - */ - def createRecurringCardPayment( - recurringPaymentTransaction: RecurringPaymentTransaction - ): Option[Transaction] = None - } diff --git a/common/src/main/scala/app/softnetwork/payment/spi/PaymentProviderSpi.scala b/common/src/main/scala/app/softnetwork/payment/spi/PaymentProviderSpi.scala index 4ebcd36..e9e89b3 100644 --- a/common/src/main/scala/app/softnetwork/payment/spi/PaymentProviderSpi.scala +++ b/common/src/main/scala/app/softnetwork/payment/spi/PaymentProviderSpi.scala @@ -3,6 +3,7 @@ package app.softnetwork.payment.spi import akka.actor.typed.ActorSystem import app.softnetwork.payment.model.SoftPayAccount import app.softnetwork.payment.service.{HooksDirectives, HooksEndpoints} +import com.typesafe.config.Config import org.json4s.Formats trait PaymentProviderSpi { @@ -10,7 +11,7 @@ trait PaymentProviderSpi { def paymentProvider(p: SoftPayAccount.Client.Provider): PaymentProvider - def softPaymentProvider: SoftPayAccount.Client.Provider + def softPaymentProvider(config: Config): SoftPayAccount.Client.Provider def hooksPath: String = providerType.name.toLowerCase diff --git a/common/src/main/scala/app/softnetwork/payment/spi/PaymentProviders.scala b/common/src/main/scala/app/softnetwork/payment/spi/PaymentProviders.scala index 8e9ef95..9026454 100644 --- a/common/src/main/scala/app/softnetwork/payment/spi/PaymentProviders.scala +++ b/common/src/main/scala/app/softnetwork/payment/spi/PaymentProviders.scala @@ -3,6 +3,7 @@ package app.softnetwork.payment.spi import akka.actor.typed.ActorSystem import app.softnetwork.payment.model.SoftPayAccount import app.softnetwork.payment.service.{HooksDirectives, HooksEndpoints} +import com.typesafe.config.Config import org.json4s.Formats import java.util.ServiceLoader @@ -15,8 +16,8 @@ object PaymentProviders { private[this] var paymentProviders: Map[String, PaymentProvider] = Map.empty - def defaultPaymentProviders: Seq[SoftPayAccount.Client.Provider] = - paymentProviderFactories.iterator().asScala.map(_.softPaymentProvider).toSeq + def defaultPaymentProviders(config: Config): Seq[SoftPayAccount.Client.Provider] = + paymentProviderFactories.iterator().asScala.map(_.softPaymentProvider(config)).toSeq def hooksDirectives(implicit system: ActorSystem[_], diff --git a/common/src/main/scala/app/softnetwork/payment/spi/RecurringPaymentApi.scala b/common/src/main/scala/app/softnetwork/payment/spi/RecurringPaymentApi.scala new file mode 100644 index 0000000..81df70d --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/RecurringPaymentApi.scala @@ -0,0 +1,58 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.model.{RecurringPayment, RecurringPaymentTransaction, Transaction} + +trait RecurringPaymentApi { _: PaymentContext => + + /** @param userId + * - Provider user id + * @param walletId + * - Provider wallet id + * @param cardId + * - Provider card id + * @param recurringPayment + * - recurring payment to register + * @return + * recurring card payment registration result + */ + def registerRecurringCardPayment( + userId: String, + walletId: String, + cardId: String, + recurringPayment: RecurringPayment + ): Option[RecurringPayment.RecurringCardPaymentResult] + + /** @param recurringPayInRegistrationId + * - recurring payIn registration id + * @param cardId + * - Provider card id + * @param status + * - optional recurring payment status + * @return + * recurring card payment registration updated result + */ + def updateRecurringCardPaymentRegistration( + recurringPayInRegistrationId: String, + cardId: Option[String], + status: Option[RecurringPayment.RecurringCardPaymentStatus] + ): Option[RecurringPayment.RecurringCardPaymentResult] + + /** @param recurringPayInRegistrationId + * - recurring payIn registration id + * @return + * recurring card payment registration result + */ + def loadRecurringCardPayment( + recurringPayInRegistrationId: String + ): Option[RecurringPayment.RecurringCardPaymentResult] + + /** @param recurringPaymentTransaction + * - recurring payment transaction + * @return + * resulted payIn transaction + */ + def createRecurringCardPayment( + recurringPaymentTransaction: RecurringPaymentTransaction + ): Option[Transaction] + +} diff --git a/common/src/main/scala/app/softnetwork/payment/spi/RefundApi.scala b/common/src/main/scala/app/softnetwork/payment/spi/RefundApi.scala new file mode 100644 index 0000000..15cbcae --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/RefundApi.scala @@ -0,0 +1,28 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.model.{RefundTransaction, Transaction} + +trait RefundApi { _: PaymentContext => + + /** @param maybeRefundTransaction + * - refund transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * refund transaction result + */ + def refund( + maybeRefundTransaction: Option[RefundTransaction], + idempotency: Option[Boolean] = None + ): Option[Transaction] + + /** @param orderUuid + * - order unique id + * @param transactionId + * - transaction id + * @return + * Refund transaction + */ + def loadRefundTransaction(orderUuid: String, transactionId: String): Option[Transaction] + +} diff --git a/common/src/main/scala/app/softnetwork/payment/spi/TransferApi.scala b/common/src/main/scala/app/softnetwork/payment/spi/TransferApi.scala new file mode 100644 index 0000000..be1ad83 --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/TransferApi.scala @@ -0,0 +1,21 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.model.{Transaction, TransferTransaction} + +trait TransferApi { _: PaymentContext => + + /** @param maybeTransferTransaction + * - transfer transaction + * @return + * transfer transaction result + */ + def transfer(maybeTransferTransaction: Option[TransferTransaction]): Option[Transaction] + + /** @param transactionId + * - transaction id + * @return + * transfer transaction + */ + def loadTransfer(transactionId: String): Option[Transaction] + +} diff --git a/core/src/main/scala/app/softnetwork/payment/api/PaymentServer.scala b/core/src/main/scala/app/softnetwork/payment/api/PaymentServer.scala index 1f3443d..8a926d2 100644 --- a/core/src/main/scala/app/softnetwork/payment/api/PaymentServer.scala +++ b/core/src/main/scala/app/softnetwork/payment/api/PaymentServer.scala @@ -19,7 +19,7 @@ import app.softnetwork.payment.message.PaymentMessages.{ TransferFailed, Transferred } -import app.softnetwork.payment.model.{BankAccount, PaymentAccount, RecurringPayment} +import app.softnetwork.payment.model.{BankAccount, PaymentAccount, RecurringPayment, Transaction} import app.softnetwork.payment.serialization._ import org.slf4j.{Logger, LoggerFactory} @@ -114,6 +114,7 @@ trait PaymentServer extends PaymentServiceApi with PaymentDao { feesAmount, currency, externalReference, + payInTransactionId, Some(clientId) ) map { case Right(r: PaidOut) => @@ -279,6 +280,43 @@ trait PaymentServer extends PaymentServiceApi with PaymentDao { } } + override def loadPayInTransaction( + in: LoadPayInTransactionRequest + ): Future[TransactionResponse] = { + import in._ + loadPayInTransaction(orderUuid, payInTransactionId, Some(clientId)) map { + case Right(r) => + TransactionResponse( + transactionId = Some(r.transactionId), + transactionStatus = r.transactionStatus + ) + case Left(_) => + TransactionResponse( + transactionId = None, + transactionStatus = TransactionStatus.TRANSACTION_NOT_SPECIFIED, + error = Some("transaction not found") + ) + } + } + + override def loadPayOutTransaction( + in: LoadPayOutTransactionRequest + ): Future[TransactionResponse] = { + import in._ + loadPayOutTransaction(orderUuid, payOutTransactionId, Some(clientId)) map { + case Right(r) => + TransactionResponse( + transactionId = Some(r.transactionId), + transactionStatus = r.transactionStatus + ) + case _ => + TransactionResponse( + transactionId = None, + transactionStatus = TransactionStatus.TRANSACTION_NOT_SPECIFIED, + error = Some("transaction nont found") + ) + } + } } object PaymentServer { diff --git a/core/src/main/scala/app/softnetwork/payment/handlers/PaymentDao.scala b/core/src/main/scala/app/softnetwork/payment/handlers/PaymentDao.scala index a379c18..cc6104b 100644 --- a/core/src/main/scala/app/softnetwork/payment/handlers/PaymentDao.scala +++ b/core/src/main/scala/app/softnetwork/payment/handlers/PaymentDao.scala @@ -151,7 +151,9 @@ trait PaymentDao extends PaymentHandler { registerCard: Boolean = false, ipAddress: Option[String] = None, browserInfo: Option[BrowserInfo] = None, - printReceipt: Boolean = false + printReceipt: Boolean = false, + creditedAccount: Option[String] = None, + feesAmount: Option[Int] = None )(implicit system: ActorSystem[_] ): Future[Either[CardPreAuthorizationFailed, Either[PaymentRedirection, CardPreAuthorized]]] = { @@ -167,7 +169,9 @@ trait PaymentDao extends PaymentHandler { registerCard, ipAddress, browserInfo, - printReceipt + printReceipt, + creditedAccount, + feesAmount ) ) map { case result: PaymentRedirection => Right(Left(result)) @@ -215,6 +219,21 @@ trait PaymentDao extends PaymentHandler { } } + @InternalApi + private[payment] def loadPayInTransaction( + orderUuid: String, + transactionId: String, + clientId: Option[String] = None + )(implicit + system: ActorSystem[_] + ): Future[Either[TransactionNotFound.type, PayInTransactionLoaded]] = { + implicit val ec: ExecutionContextExecutor = system.executionContext + !?(LoadPayInTransaction(orderUuid, transactionId, clientId)) map { + case result: PayInTransactionLoaded => Right(result) + case _ => Left(TransactionNotFound) + } + } + @InternalApi private[payment] def refund( orderUuid: String, @@ -256,6 +275,7 @@ trait PaymentDao extends PaymentHandler { feesAmount: Int, currency: String = "EUR", externalReference: Option[String], + payInTransactionId: Option[String] = None, clientId: Option[String] = None )(implicit system: ActorSystem[_] @@ -269,6 +289,7 @@ trait PaymentDao extends PaymentHandler { feesAmount, currency, externalReference, + payInTransactionId, clientId ) ) map { @@ -283,6 +304,21 @@ trait PaymentDao extends PaymentHandler { } } + @InternalApi + private[payment] def loadPayOutTransaction( + orderUuid: String, + transactionId: String, + clientId: Option[String] = None + )(implicit + system: ActorSystem[_] + ): Future[Either[TransactionNotFound.type, PayOutTransactionLoaded]] = { + implicit val ec: ExecutionContextExecutor = system.executionContext + !?(LoadPayOutTransaction(orderUuid, transactionId, clientId)) map { + case result: PayOutTransactionLoaded => Right(result) + case _ => Left(TransactionNotFound) + } + } + @InternalApi private[payment] def transfer( orderUuid: Option[String] = None, diff --git a/core/src/main/scala/app/softnetwork/payment/launch/PaymentGuardian.scala b/core/src/main/scala/app/softnetwork/payment/launch/PaymentGuardian.scala index a99830c..268d2f7 100644 --- a/core/src/main/scala/app/softnetwork/payment/launch/PaymentGuardian.scala +++ b/core/src/main/scala/app/softnetwork/payment/launch/PaymentGuardian.scala @@ -77,15 +77,17 @@ trait PaymentGuardian extends SessionGuardian { _: SchemaProvider with CsrfCheck ) def registerProvidersAccount: ActorSystem[_] => Unit = system => { - PaymentProviders.defaultPaymentProviders.foreach(provider => { - implicit val ec: ExecutionContext = system.executionContext - softPayAccountDao.registerAccountWithProvider(provider)(system) map { - case Some(account) => - system.log.info(s"Registered provider account for ${provider.providerId}: $account") - case _ => - system.log.warn(s"Failed to register provider account for ${provider.providerId}") - } - }) + PaymentProviders + .defaultPaymentProviders(config) + .foreach(provider => { + implicit val ec: ExecutionContext = system.executionContext + softPayAccountDao.registerAccountWithProvider(provider)(system) map { + case Some(account) => + system.log.info(s"Registered provider account for ${provider.providerId}: $account") + case _ => + system.log.warn(s"Failed to register provider account for ${provider.providerId}") + } + }) } override def initSystem: ActorSystem[_] => Unit = system => { diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/query/PaymentCommandProcessorStream.scala b/core/src/main/scala/app/softnetwork/payment/persistence/query/PaymentCommandProcessorStream.scala index 1ac71af..33793e0 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/query/PaymentCommandProcessorStream.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/query/PaymentCommandProcessorStream.scala @@ -14,7 +14,7 @@ import scala.concurrent.Future trait PaymentCommandProcessorStream extends EventProcessorStream[PaymentEventWithCommand] { _: JournalProvider with OffsetProvider with PaymentHandler => - override lazy val tag: String = PaymentSettings.ExternalToPaymentAccountTag + override lazy val tag: String = PaymentSettings.PaymentConfig.externalToPaymentAccountTag /** @return * whether or not the events processed by this processor stream would be published to the main 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 5ab7ea1..d421735 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 @@ -8,7 +8,10 @@ import app.softnetwork.kv.handlers.GenericKeyValueDao import app.softnetwork.payment.annotation.InternalApi import app.softnetwork.payment.api.config.SoftPayClientSettings import app.softnetwork.payment.config.PaymentSettings -import app.softnetwork.payment.config.PaymentSettings.{AkkaNodeRole, PayInStatementDescriptor} +import app.softnetwork.payment.config.PaymentSettings.PaymentConfig.{ + akkaNodeRole, + payInStatementDescriptor +} import app.softnetwork.payment.handlers.{PaymentDao, PaymentKvDao, SoftPayAccountDao} import app.softnetwork.payment.message.PaymentEvents._ import app.softnetwork.payment.message.PaymentMessages._ @@ -62,7 +65,7 @@ trait PaymentBehavior /** @return * node role required to start this actor */ - override lazy val role: String = AkkaNodeRole + override lazy val role: String = akkaNodeRole override def init(system: ActorSystem[_], maybeRole: Option[String] = None)(implicit c: ClassTag[PaymentCommand] @@ -216,7 +219,10 @@ trait PaymentBehavior paymentAccount.withNaturalUser( user.withNaturalUserType(NaturalUserType.PAYER) ) - ) + ), + acceptedTermsOfPSP = false, + None, + None ) case some => some }) match { @@ -358,6 +364,17 @@ trait PaymentBehavior .map(_.id) }) match { case Some(cardId) => + val creditedUserId: Option[String] = + creditedAccount match { + case Some(account) => + paymentDao.loadPaymentAccount(account, clientId) complete () match { + case Success(s) => s.flatMap(_.userId) + case Failure(f) => + log.error(s"Error loading credited account: ${f.getMessage}") + None + } + case None => None + } preAuthorizeCard( PreAuthorizationTransaction.defaultInstance .withCardId(cardId) @@ -368,7 +385,9 @@ trait PaymentBehavior .withPrintReceipt(printReceipt) .copy( ipAddress = ipAddress, - browserInfo = browserInfo + browserInfo = browserInfo, + creditedUserId = creditedUserId, + feesAmount = feesAmount ) ) match { case Some(transaction) => @@ -394,7 +413,7 @@ trait PaymentBehavior Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } - case cmd: PreAuthorizeCardFor3DS => // 3DS + case cmd: PreAuthorizeCardCallback => // 3DS import cmd._ state match { case Some(paymentAccount) => @@ -471,7 +490,9 @@ trait PaymentBehavior .orElse( internalClientId ) - val maybeTransaction = paymentAccount.transactions.find(_.id == preAuthorizationId) + val maybeTransaction = paymentAccount.transactions + .filter(t => t.`type` == Transaction.TransactionType.PRE_AUTHORIZATION) + .find(_.id == preAuthorizationId) maybeTransaction match { case None => handlePayInWithCardPreauthorizedFailure( @@ -534,19 +555,22 @@ trait PaymentBehavior import paymentProvider._ creditedPaymentAccount.walletId match { case Some(creditedWalletId) => - payInWithCardPreAuthorized( - PayInWithCardPreAuthorizedTransaction.defaultInstance - .withCardPreAuthorizedTransactionId(preAuthorizationId) - .withAuthorId(preAuthorizationTransaction.authorId) - .withDebitedAmount( - debitedAmount.getOrElse(preAuthorizationTransaction.amount) - ) - .withCurrency(preAuthorizationTransaction.currency) - .withOrderUuid(preAuthorizationTransaction.orderUuid) - .withCreditedWalletId(creditedWalletId) - .withPreAuthorizationDebitedAmount( - preAuthorizationTransaction.amount - ) + payIn( + Some( + PayInTransaction.defaultInstance + .withPaymentType(Transaction.PaymentType.PREAUTHORIZED) + .withCardPreAuthorizedTransactionId(preAuthorizationId) + .withAuthorId(preAuthorizationTransaction.authorId) + .withDebitedAmount( + debitedAmount.getOrElse(preAuthorizationTransaction.amount) + ) + .withCurrency(preAuthorizationTransaction.currency) + .withOrderUuid(preAuthorizationTransaction.orderUuid) + .withCreditedWalletId(creditedWalletId) + .withPreAuthorizationDebitedAmount( + preAuthorizationTransaction.amount + ) + ) ) match { case Some(transaction) => handlePayIn( @@ -597,95 +621,168 @@ trait PaymentBehavior case cmd: PayIn => import cmd._ - state match { + var registerWallet: Boolean = false + (state match { + case None => + cmd.user match { + case Some(user) => + loadPaymentAccount( + entityId, + state, + PaymentAccount.User.NaturalUser(user), + clientId + ) match { + case Some(paymentAccount) => + val clientId = paymentAccount.clientId + .orElse(cmd.clientId) + .orElse( + internalClientId + ) + val paymentProvider = loadPaymentProvider(clientId) + import paymentProvider._ + val lastUpdated = now() + (paymentAccount.userId match { + case None => + createOrUpdatePaymentAccount( + Some( + paymentAccount.withNaturalUser( + user.withNaturalUserType(NaturalUserType.PAYER) + ) + ), + acceptedTermsOfPSP = false, + None, + None + ) + case some => some + }) match { + case Some(userId) => + keyValueDao.addKeyValue(userId, entityId) + (paymentAccount.walletId match { + case None => + registerWallet = true + createOrUpdateWallet(Some(userId), currency, user.externalUuid, None) + case some => some + }) match { + case Some(walletId) => + keyValueDao.addKeyValue(walletId, entityId) + Some( + paymentAccount + .withPaymentAccountStatus( + PaymentAccount.PaymentAccountStatus.COMPTE_OK + ) + .copy(user = + PaymentAccount.User.NaturalUser( + user + .withUserId(userId) + .withWalletId(walletId) + .withNaturalUserType(NaturalUserType.PAYER) + ) + ) + .withLastUpdated(lastUpdated) + ) + case _ => + Some( + paymentAccount + .withPaymentAccountStatus( + PaymentAccount.PaymentAccountStatus.COMPTE_OK + ) + .copy(user = + PaymentAccount.User.NaturalUser( + user + .withUserId(userId) + .withNaturalUserType(NaturalUserType.PAYER) + ) + ) + .withLastUpdated(lastUpdated) + ) + } + } + } + case _ => + None + } + case some => some + }) match { case Some(paymentAccount) => - val clientId = paymentAccount.clientId.orElse( - internalClientId - ) + val clientId = paymentAccount.clientId + .orElse(cmd.clientId) + .orElse(internalClientId) val paymentProvider = loadPaymentProvider(clientId) import paymentProvider._ paymentType match { case Transaction.PaymentType.CARD => paymentAccount.userId match { case Some(userId) => - (registrationId match { - case Some(id) => - createCard(id, registrationData) - case _ => - paymentAccount.cards - .find(card => card.active.getOrElse(true) && !card.expired) - .map(_.id) - }) match { - case Some(cardId) => - // load credited payment account - paymentDao.loadPaymentAccount(creditedAccount, clientId) complete () match { - case Success(s) => - s match { - case Some(creditedPaymentAccount) => - creditedPaymentAccount.walletId match { - case Some(creditedWalletId) => - payIn( - Some( - PayInTransaction.defaultInstance - .withAuthorId(userId) - .withDebitedAmount(debitedAmount) - .withCurrency(currency) - .withOrderUuid(orderUuid) - .withCreditedWalletId(creditedWalletId) - .withCardId(cardId) - .withPaymentType(paymentType) - .withStatementDescriptor( - statementDescriptor.getOrElse(PayInStatementDescriptor) - ) - .withRegisterCard(registerCard) - .withPrintReceipt(printReceipt) - .copy( - ipAddress = ipAddress, - browserInfo = browserInfo - ) + val cardId = + registrationId match { + case Some(id) => + createCard(id, registrationData) + case _ => + paymentAccount.cards + .find(card => card.active.getOrElse(true) && !card.expired) + .map(_.id) + } + // load credited payment account + paymentDao.loadPaymentAccount(creditedAccount, clientId) complete () match { + case Success(s) => + s match { + case Some(creditedPaymentAccount) => + creditedPaymentAccount.walletId match { + case Some(creditedWalletId) => + payIn( + Some( + PayInTransaction.defaultInstance + .withAuthorId(userId) + .withDebitedAmount(debitedAmount) + .withCurrency(currency) + .withOrderUuid(orderUuid) + .withCreditedWalletId(creditedWalletId) + .withCardId(cardId.orNull) + .withPaymentType(paymentType) + .withStatementDescriptor( + statementDescriptor.getOrElse(payInStatementDescriptor) ) - ) match { - case Some(transaction) => - handlePayIn( - entityId, - orderUuid, - replyTo, - paymentAccount, - registerCard, - printReceipt, - transaction - ) - case _ => - Effect.none.thenRun(_ => - PayInFailed( - "", - Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, - "unknown" - ) ~> replyTo - ) - } + .withRegisterCard(registerCard) + .withPrintReceipt(printReceipt) + .copy( + ipAddress = ipAddress, + browserInfo = browserInfo + ) + ) + ) match { + case Some(transaction) => + handlePayIn( + entityId, + orderUuid, + replyTo, + paymentAccount, + registerCard, + printReceipt, + transaction, + registerWallet + ) case _ => Effect.none.thenRun(_ => PayInFailed( "", Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, - "no credited wallet" + "unknown" ) ~> replyTo ) } - case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) + case _ => + Effect.none.thenRun(_ => + PayInFailed( + "", + Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, + "no credited wallet" + ) ~> replyTo + ) } - case Failure(_) => - Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) + case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } - case _ => - Effect.none.thenRun(_ => - PayInFailed( - "", - Transaction.TransactionStatus.TRANSACTION_NOT_SPECIFIED, - "no card" - ) ~> replyTo - ) + case Failure(_) => + Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } @@ -700,20 +797,25 @@ trait PaymentBehavior case Some(creditedPaymentAccount) => creditedPaymentAccount.walletId match { case Some(creditedWalletId) => - payInWithPayPal( - PayInWithPayPalTransaction.defaultInstance - .withAuthorId(userId) - .withDebitedAmount(debitedAmount) - .withCurrency(currency) - .withOrderUuid(orderUuid) - .withCreditedWalletId(creditedWalletId) - .withStatementDescriptor( - statementDescriptor.getOrElse(PayInStatementDescriptor) - ) - .withPrintReceipt(printReceipt) - .copy( - language = browserInfo.map(_.language) - ) + payIn( + Some( + PayInTransaction.defaultInstance + .withPaymentType(Transaction.PaymentType.PAYPAL) + .withAuthorId(userId) + .withDebitedAmount(debitedAmount) + .withFeesAmount(feesAmount.getOrElse(0)) + .withCurrency(currency) + .withOrderUuid(orderUuid) + .withCreditedWalletId(creditedWalletId) + .withStatementDescriptor( + statementDescriptor.getOrElse(payInStatementDescriptor) + ) + .withPrintReceipt(printReceipt) + .copy( + ipAddress = ipAddress, + browserInfo = browserInfo + ) + ) ) match { case Some(transaction) => handlePayIn( @@ -723,7 +825,8 @@ trait PaymentBehavior paymentAccount, registerCard = false, printReceipt = printReceipt, - transaction + transaction, + registerWallet ) case _ => Effect.none.thenRun(_ => @@ -776,7 +879,7 @@ trait PaymentBehavior case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } - case cmd: PayInFor3DS => + case cmd: PayInCallback => import cmd._ state match { case Some(paymentAccount) => @@ -785,39 +888,14 @@ trait PaymentBehavior ) val paymentProvider = loadPaymentProvider(clientId) import paymentProvider._ - loadPayIn(orderUuid, transactionId, None) match { + loadPayInTransaction(orderUuid, transactionId, None) match { case Some(transaction) => handlePayIn( entityId, orderUuid, replyTo, paymentAccount, - registerCard, - printReceipt, - transaction - ) - case _ => Effect.none.thenRun(_ => TransactionNotFound ~> replyTo) - } - case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) - } - - case cmd: PayInForPayPal => - import cmd._ - state match { - case Some(paymentAccount) => - val clientId = paymentAccount.clientId.orElse( - internalClientId - ) - val paymentProvider = loadPaymentProvider(clientId) - import paymentProvider._ - loadPayIn(orderUuid, transactionId, None) match { - case Some(transaction) => - handlePayIn( - entityId, - orderUuid, - replyTo, - paymentAccount, - registerCard = false, + registerCard = registerCard, printReceipt = printReceipt, transaction ) @@ -838,7 +916,7 @@ trait PaymentBehavior val paymentProvider = loadPaymentProvider(clientId) import paymentProvider._ (paymentAccount.transactions.find(_.id == payInTransactionId) match { - case None => loadPayIn(orderUuid, payInTransactionId, None) + case None => loadPayInTransaction(orderUuid, payInTransactionId, None) case some => some }) match { case None => Effect.none.thenRun(_ => TransactionNotFound ~> replyTo) @@ -997,6 +1075,15 @@ trait PaymentBehavior case Some(walletId) => paymentAccount.bankAccount.flatMap(_.id) match { case Some(bankAccountId) => + val pit = payInTransactionId.orElse( + paymentAccount.transactions + .filter(t => + t.`type` == Transaction.TransactionType.PAYIN && + (t.status.isTransactionCreated || t.status.isTransactionSucceeded) + ) + .find(_.orderUuid == orderUuid) + .map(_.id) + ) payOut( Some( PayOutTransaction.defaultInstance @@ -1008,7 +1095,10 @@ trait PaymentBehavior .withAuthorId(userId) .withCreditedUserId(userId) .withDebitedWalletId(walletId) - .copy(externalReference = externalReference) + .copy( + externalReference = externalReference, + payInTransactionId = pit + ) ) ) match { case Some(transaction) => @@ -1579,6 +1669,192 @@ trait PaymentBehavior case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } + case cmd: LoadPayInTransaction => + import cmd._ + state match { + case Some(paymentAccount) => + paymentAccount.transactions.find(t => + t.id == transactionId && t.orderUuid == orderUuid + ) match { + case Some(transaction) + if transaction.status.isTransactionSucceeded + || transaction.status.isTransactionFailed + || transaction.status.isTransactionCanceled => + Effect.none.thenRun(_ => + PayInTransactionLoaded(transaction.id, transaction.status, None) ~> replyTo + ) + case Some(transaction) => + val clientId = paymentAccount.clientId + .orElse(cmd.clientId) + .orElse( + internalClientId + ) + val paymentProvider = loadPaymentProvider(clientId) + import paymentProvider._ + loadPayInTransaction(orderUuid, transactionId, None) match { + case Some(t) => + val lastUpdated = now() + val updatedTransaction = transaction + .withStatus(t.status) + .withId(t.id) + .withAuthorId(t.authorId) + .withAmount(t.amount) + .withLastUpdated(lastUpdated) + .withPaymentType(t.paymentType) + .withResultCode(t.resultCode) + .withResultMessage(t.resultMessage) + .copy( + cardId = t.cardId.orElse(transaction.cardId) + ) + val updatedPaymentAccount = paymentAccount + .withTransactions( + paymentAccount.transactions.filterNot(_.id == t.id) + :+ updatedTransaction.copy(clientId = clientId) + ) + .withLastUpdated(lastUpdated) + if (t.status.isTransactionSucceeded || t.status.isTransactionCreated) { + Effect + .persist( + broadcastEvent( + PaidInEvent.defaultInstance + .withOrderUuid(orderUuid) + .withTransactionId(t.id) + .withDebitedAccount(t.authorId) + .withDebitedAmount(t.amount) + .withLastUpdated(lastUpdated) + .withCardId(t.cardId.orElse(transaction.cardId).getOrElse("")) + .withPaymentType(t.paymentType) + ) :+ + PaymentAccountUpsertedEvent.defaultInstance + .withLastUpdated(lastUpdated) + .withDocument(updatedPaymentAccount) + ) + .thenRun(_ => + PayInTransactionLoaded( + transaction.id, + transaction.status, + None + ) ~> replyTo + ) + } else { + Effect + .persist( + broadcastEvent( + PayInFailedEvent.defaultInstance + .withOrderUuid(orderUuid) + .withResultMessage(t.resultMessage) + .withTransaction(updatedTransaction) + ) :+ + PaymentAccountUpsertedEvent.defaultInstance + .withLastUpdated(lastUpdated) + .withDocument(updatedPaymentAccount) + ) + .thenRun(_ => + PayInTransactionLoaded( + transaction.id, + transaction.status, + None + ) ~> replyTo + ) + } + case None => Effect.none.thenRun(_ => TransactionNotFound ~> replyTo) + } + case None => Effect.none.thenRun(_ => TransactionNotFound ~> replyTo) + } + case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) + } + + case cmd: LoadPayOutTransaction => + import cmd._ + state match { + case Some(paymentAccount) => + paymentAccount.transactions.find(t => + t.id == transactionId && t.orderUuid == orderUuid + ) match { + case Some(transaction) + if transaction.status.isTransactionSucceeded + || transaction.status.isTransactionFailed + || transaction.status.isTransactionCanceled => + Effect.none.thenRun(_ => + PayOutTransactionLoaded(transaction.id, transaction.status, None) ~> replyTo + ) + case Some(transaction) => + val clientId = paymentAccount.clientId + .orElse(cmd.clientId) + .orElse( + internalClientId + ) + val paymentProvider = loadPaymentProvider(clientId) + import paymentProvider._ + loadPayOutTransaction(orderUuid, transactionId) match { + case Some(t) => + val lastUpdated = now() + val updatedTransaction = transaction + .withStatus(t.status) + .withLastUpdated(lastUpdated) + .withResultCode(t.resultCode) + .withResultMessage(t.resultMessage) + val updatedPaymentAccount = paymentAccount + .withTransactions( + paymentAccount.transactions.filterNot(_.id == t.id) + :+ updatedTransaction.copy(clientId = clientId) + ) + .withLastUpdated(lastUpdated) + if (t.status.isTransactionSucceeded || t.status.isTransactionCreated) { + Effect + .persist( + broadcastEvent( + PaidOutEvent.defaultInstance + .withOrderUuid(orderUuid) + .withLastUpdated(lastUpdated) + .withCreditedAccount(paymentAccount.externalUuid) + .withCreditedAmount(t.amount) + .withFeesAmount(t.fees) + .withCurrency(t.currency) + .withTransactionId(t.id) + .withPaymentType(t.paymentType) + .copy(externalReference = transaction.externalReference) + ) :+ + PaymentAccountUpsertedEvent.defaultInstance + .withLastUpdated(lastUpdated) + .withDocument(updatedPaymentAccount) + ) + .thenRun(_ => + PayOutTransactionLoaded( + transaction.id, + transaction.status, + None + ) ~> replyTo + ) + } else { + Effect + .persist( + broadcastEvent( + PayOutFailedEvent.defaultInstance + .withOrderUuid(orderUuid) + .withResultMessage(updatedTransaction.resultMessage) + .withTransaction(updatedTransaction) + .copy(externalReference = transaction.externalReference) + ) :+ + PaymentAccountUpsertedEvent.defaultInstance + .withLastUpdated(lastUpdated) + .withDocument(updatedPaymentAccount) + ) + .thenRun(_ => + PayOutTransactionLoaded( + transaction.id, + transaction.status, + None + ) ~> replyTo + ) + } + case None => Effect.none.thenRun(_ => TransactionNotFound ~> replyTo) + } + case _ => Effect.none.thenRun(_ => TransactionNotFound ~> replyTo) + } + case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) + } + case cmd: LoadDirectDebitTransaction => import cmd._ state match { @@ -1595,7 +1871,7 @@ trait PaymentBehavior val paymentProvider = loadPaymentProvider(clientId) import paymentProvider._ val transactionDate: LocalDate = Date.from(transaction.createdDate) - directDebitTransaction( + loadDirectDebitTransaction( transaction.creditedWalletId.getOrElse(creditedWalletId), transaction.id, transactionDate.minusDays(1) @@ -1716,7 +1992,7 @@ trait PaymentBehavior case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } - case cmd: PayInFirstRecurringFor3DS => + case cmd: FirstRecurringPaymentCallback => state match { case Some(paymentAccount) => import cmd._ @@ -1727,7 +2003,7 @@ trait PaymentBehavior ) val paymentProvider = loadPaymentProvider(clientId) import paymentProvider._ - loadPayIn("", transactionId, Some(recurringPayInRegistrationId)) match { + loadPayInTransaction("", transactionId, Some(recurringPayInRegistrationId)) match { case Some(transaction) => handleRecurringPayment( entityId, @@ -2188,12 +2464,27 @@ trait PaymentBehavior import paymentProvider._ (paymentAccount.userId match { case None => - createOrUpdatePaymentAccount(Some(updatedPaymentAccount)) + createOrUpdatePaymentAccount( + Some(updatedPaymentAccount), + acceptedTermsOfPSP.getOrElse(false), + ipAddress, + userAgent + ) case Some(_) if shouldUpdateUser => if (shouldUpdateUserType) { - createOrUpdatePaymentAccount(Some(updatedPaymentAccount.resetUserId(None))) + createOrUpdatePaymentAccount( + Some(updatedPaymentAccount.resetUserId(None)), + acceptedTermsOfPSP.getOrElse(false), + ipAddress, + userAgent + ) } else { - createOrUpdatePaymentAccount(Some(updatedPaymentAccount)) + createOrUpdatePaymentAccount( + Some(updatedPaymentAccount), + acceptedTermsOfPSP.getOrElse(false), + ipAddress, + userAgent + ) } case some => some }) match { @@ -2413,6 +2704,63 @@ trait PaymentBehavior } } + case cmd: CreateOrUpdateKycDocument => + import cmd._ + state match { + case Some(paymentAccount) if paymentAccount.hasAcceptedTermsOfPSP => + val documentId = kycDocument.id.getOrElse("") + paymentAccount.documents.find(_.`type` == kycDocument.`type`).flatMap(_.id) match { + case Some(previous) if previous != documentId => + keyValueDao.removeKeyValue(previous) + case _ => + } + keyValueDao.addKeyValue(documentId, entityId) + + val lastUpdated = now() + + val updatedDocument = + paymentAccount.documents + .find(_.`type` == kycDocument.`type`) + .getOrElse( + KycDocument.defaultInstance + .withCreatedDate(lastUpdated) + .withType(kycDocument.`type`) + ) + .withLastUpdated(lastUpdated) + .withId(documentId) + .withStatus(kycDocument.status) + .copy( + refusedReasonType = kycDocument.refusedReasonType, + refusedReasonMessage = kycDocument.refusedReasonMessage + ) + + val newDocuments = + paymentAccount.documents.filterNot(_.`type` == kycDocument.`type`) :+ + updatedDocument + + Effect + .persist( + broadcastEvent( + DocumentsUpdatedEvent.defaultInstance + .withExternalUuid(paymentAccount.externalUuid) + .withLastUpdated(lastUpdated) + .withDocuments(newDocuments) + ) ++ broadcastEvent( + DocumentUpdatedEvent.defaultInstance + .withExternalUuid(paymentAccount.externalUuid) + .withLastUpdated(lastUpdated) + .withDocument(updatedDocument) + ) :+ PaymentAccountUpsertedEvent.defaultInstance + .withDocument( + paymentAccount.withDocuments(newDocuments).withLastUpdated(lastUpdated) + ) + .withLastUpdated(lastUpdated) + ) + .thenRun(_ => KycDocumentCreatedOrUpdated ~> replyTo) + case None => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) + case _ => Effect.none.thenRun(_ => AcceptedTermsOfPSPRequired ~> replyTo) + } + case cmd: AddKycDocument => import cmd._ state match { @@ -2617,9 +2965,10 @@ trait PaymentBehavior case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } - case _: ValidateUboDeclaration => + case cmd: ValidateUboDeclaration => state match { case Some(paymentAccount) => + import cmd._ paymentAccount.getLegalUser.uboDeclaration match { case None => Effect.none.thenRun(_ => UboDeclarationNotFound ~> replyTo) case Some(uboDeclaration) @@ -2630,7 +2979,12 @@ trait PaymentBehavior ) val paymentProvider = loadPaymentProvider(clientId) import paymentProvider._ - validateDeclaration(paymentAccount.userId.getOrElse(""), uboDeclaration.id) match { + validateDeclaration( + paymentAccount.userId.getOrElse(""), + uboDeclaration.id, + ipAddress, + userAgent + ) match { case Some(declaration) => val updatedUbo = declaration.withUbos(uboDeclaration.ubos) val lastUpdated = now() @@ -2882,7 +3236,9 @@ trait PaymentBehavior case cmd: DeleteBankAccount => state match { case Some(_) - if PaymentSettings.DisableBankAccountDeletion && !cmd.force.getOrElse(false) => + if PaymentSettings.PaymentConfig.disableBankAccountDeletion && !cmd.force.getOrElse( + false + ) => Effect.none.thenRun(_ => BankAccountDeletionDisabled ~> replyTo) case Some(paymentAccount) => @@ -3707,7 +4063,8 @@ trait PaymentBehavior paymentAccount: PaymentAccount, registerCard: Boolean, printReceipt: Boolean, - transaction: Transaction + transaction: Transaction, + registerWallet: Boolean = false )(implicit system: ActorSystem[_], log: Logger, @@ -3725,14 +4082,45 @@ trait PaymentBehavior .filterNot(_.id == transaction.id) :+ transaction ) .withLastUpdated(lastUpdated) + val walletEvents: List[ExternalSchedulerEvent] = + if (registerWallet) { + broadcastEvent( + WalletRegisteredEvent.defaultInstance + .withOrderUuid(orderUuid) + .withExternalUuid(paymentAccount.externalUuid) + .withLastUpdated(lastUpdated) + .copy( + userId = paymentAccount.userId.get, + walletId = paymentAccount.walletId.get + ) + ) + } else { + List.empty + } transaction.status match { + case Transaction.TransactionStatus.TRANSACTION_PENDING_PAYMENT + if transaction.paymentClientReturnUrl.isDefined => + Effect + .persist( + PaymentAccountUpsertedEvent.defaultInstance + .withDocument(updatedPaymentAccount) + .withLastUpdated(lastUpdated) +: walletEvents + ) + .thenRun(_ => + PaymentRequired( + transaction.id, + transaction.paymentClientSecret.getOrElse(""), + transaction.paymentClientData.getOrElse(""), + transaction.paymentClientReturnUrl.get + ) ~> replyTo + ) case Transaction.TransactionStatus.TRANSACTION_CREATED if transaction.redirectUrl.isDefined => // 3ds | PayPal Effect .persist( PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) + .withLastUpdated(lastUpdated) +: walletEvents ) .thenRun(_ => PaymentRedirection(transaction.redirectUrl.get) ~> replyTo) case _ => @@ -3811,10 +4199,10 @@ trait PaymentBehavior .withCardId(transaction.cardId.getOrElse("")) .withPaymentType(transaction.paymentType) .withPrintReceipt(printReceipt) - ) :+ - PaymentAccountUpsertedEvent.defaultInstance + ) ++ + (PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) + .withLastUpdated(lastUpdated) +: walletEvents) ) .thenRun(_ => PaidIn(transaction.id, transaction.status) ~> replyTo) } else { @@ -3831,10 +4219,10 @@ trait PaymentBehavior .withOrderUuid(orderUuid) .withResultMessage(transaction.resultMessage) .withTransaction(transaction) - ) :+ - PaymentAccountUpsertedEvent.defaultInstance + ) ++ + (PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) - .withLastUpdated(lastUpdated) + .withLastUpdated(lastUpdated) +: walletEvents) ) .thenRun(_ => PayInFailed(transaction.id, transaction.status, transaction.resultMessage) ~> replyTo @@ -3984,7 +4372,7 @@ trait PaymentBehavior val clientId = paymentAccount.clientId.orElse(Option(softPayClientSettings.clientId)) val paymentProvider = loadPaymentProvider(clientId) import paymentProvider._ - val report = loadDocumentStatus(userId, documentId) + val report = loadDocumentStatus(userId, documentId, document.`type`) val internalStatus = if (environment != "prod") { diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentKvBehavior.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentKvBehavior.scala index b8bab74..fc14760 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentKvBehavior.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentKvBehavior.scala @@ -1,7 +1,7 @@ package app.softnetwork.payment.persistence.typed import app.softnetwork.kv.persistence.typed.KeyValueBehavior -import app.softnetwork.payment.config.PaymentSettings.AkkaNodeRole +import app.softnetwork.payment.config.PaymentSettings.PaymentConfig.akkaNodeRole trait PaymentKvBehavior extends KeyValueBehavior { override def persistenceId: String = "PaymentKeys" @@ -9,7 +9,7 @@ trait PaymentKvBehavior extends KeyValueBehavior { /** @return * node role required to start this actor */ - override lazy val role: String = AkkaNodeRole + override lazy val role: String = akkaNodeRole } 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 e14650a..083ffc9 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/BankAccountEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/BankAccountEndpoints.scala @@ -7,7 +7,7 @@ import app.softnetwork.payment.model.{BankAccountView, PaymentAccount} import app.softnetwork.session.model.{SessionData, SessionDataDecorator} import sttp.capabilities import sttp.capabilities.akka.AkkaStreams -import sttp.model.StatusCode +import sttp.model.{HeaderNames, StatusCode} import sttp.tapir.json.json4s.jsonBody import sttp.tapir.server.ServerEndpoint @@ -20,14 +20,16 @@ trait BankAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val createOrUpdateBankAccount: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.post - .in(PaymentSettings.BankRoute) + .in(PaymentSettings.PaymentConfig.bankRoute) + .in(clientIp) + .in(header[Option[String]](HeaderNames.UserAgent)) .in(jsonBody[BankAccountCommand].description("Legal or natural user bank account")) .out( statusCode(StatusCode.Ok) .and(jsonBody[BankAccountCreatedOrUpdated].description("Bank account created or updated")) ) - .serverLogic { case (client, session) => - bank => + .serverLogic { + case (client, session) => { case (ipAddress, userAgent, bank) => import bank._ var externalUuid: String = "" val updatedUser: Option[PaymentAccount.User] = { @@ -72,18 +74,21 @@ trait BankAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { bankAccount.withExternalUuid(externalUuid), updatedUser, acceptedTermsOfPSP, - clientId = client.map(_.clientId).orElse(session.clientId) + clientId = client.map(_.clientId).orElse(session.clientId), + ipAddress = ipAddress, + userAgent = userAgent ) ).map { case r: BankAccountCreatedOrUpdated => Right(r) case other => Left(error(other)) } + } } .description("Create or update legal or natural user bank account") val loadBankAccount: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get - .in(PaymentSettings.BankRoute) + .in(PaymentSettings.PaymentConfig.bankRoute) .out( statusCode(StatusCode.Ok).and( jsonBody[BankAccountView] @@ -107,7 +112,7 @@ trait BankAccountEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val deleteBankAccount: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.delete - .in(PaymentSettings.BankRoute) + .in(PaymentSettings.PaymentConfig.bankRoute) .out( statusCode(StatusCode.Ok).and(jsonBody[BankAccountDeleted.type]) ) diff --git a/core/src/main/scala/app/softnetwork/payment/service/CardEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/CardEndpoints.scala index 86b94a1..34dc99c 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/CardEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/CardEndpoints.scala @@ -20,7 +20,7 @@ trait CardEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val loadCards: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get - .in(PaymentSettings.CardRoute) + .in(PaymentSettings.PaymentConfig.cardRoute) .out( statusCode(StatusCode.Ok).and( jsonBody[Seq[CardView]].description("Authenticated user cards") @@ -38,7 +38,7 @@ trait CardEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val preRegisterCard: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.post - .in(PaymentSettings.CardRoute) + .in(PaymentSettings.PaymentConfig.cardRoute) .in(jsonBody[PreRegisterCard]) .out( statusCode(StatusCode.Ok).and( @@ -74,7 +74,7 @@ trait CardEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val disableCard: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.delete - .in(PaymentSettings.CardRoute) + .in(PaymentSettings.PaymentConfig.cardRoute) .in(query[String]("cardId").description("Card id to disable")) .out( statusCode(StatusCode.Ok).and(jsonBody[CardDisabled.type]) diff --git a/core/src/main/scala/app/softnetwork/payment/service/CardPaymentEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/CardPaymentEndpoints.scala index 8e7746d..e389e87 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/CardPaymentEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/CardPaymentEndpoints.scala @@ -51,7 +51,8 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { printReceipt = true ) ) - .in(PaymentSettings.PreAuthorizeCardRoute) + .in(PaymentSettings.PaymentConfig.preAuthorizeCardRoute) + .in(paths.description("optional credited account")) .post .out( oneOf[PaymentResult]( @@ -69,41 +70,48 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) ) - .serverLogic(principal => { case (language, accept, userAgent, ipAddress, payment) => - val browserInfo = extractBrowserInfo(language, accept, userAgent, payment) - import payment._ - run( - PreAuthorizeCard( - orderUuid, - externalUuidWithProfile(principal._2), - debitedAmount, - currency, - registrationId, - registrationData, - registerCard, - if (browserInfo.isDefined) ipAddress else None, - browserInfo, - printReceipt - ) - ).map { - case result: CardPreAuthorized => Right(result) - case result: PaymentRedirection => Right(result) - case other => Left(error(other)) - } + .serverLogic(principal => { + case (language, accept, userAgent, ipAddress, payment, creditedAccount) => + val browserInfo = extractBrowserInfo(language, accept, userAgent, payment) + import payment._ + run( + PreAuthorizeCard( + orderUuid, + externalUuidWithProfile(principal._2), + debitedAmount, + currency, + registrationId, + registrationData, + registerCard, + if (browserInfo.isDefined) ipAddress else None, + browserInfo, + printReceipt, + creditedAccount.headOption, + feesAmount + ) + ).map { + case result: CardPreAuthorized => Right(result) + case result: PaymentRedirection => Right(result) + case other => Left(error(other)) + } }) .description("Pre authorize card") - val preAuthorizeCardFor3DS: ServerEndpoint[Any with AkkaStreams, Future] = + val preAuthorizeCardCallback: ServerEndpoint[Any with AkkaStreams, Future] = rootEndpoint - .in(PaymentSettings.SecureModeRoute / PaymentSettings.PreAuthorizeCardRoute) + .in( + PaymentSettings.PaymentConfig.callbacksRoute / PaymentSettings.PaymentConfig.preAuthorizeCardRoute + ) .in(path[String].description("Order uuid")) - .in(query[String]("preAuthorizationId").description("Pre authorization transaction id")) + .in(queryParams) + .description("Pre authorization query parameters") + /*.in(query[String]("preAuthorizationId").description("Pre authorization transaction id")) .in( query[Boolean]("registerCard").description( "Whether to register or not the card after successfully pre authorization" ) ) - .in(query[Boolean]("printReceipt").description("Whether or not a receipt should be printed")) + .in(query[Boolean]("printReceipt").description("Whether or not a receipt should be printed"))*/ .out( oneOf[PaymentResult]( oneOfVariant[CardPreAuthorized]( @@ -121,9 +129,14 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) .description("Pre authorize card for 3D secure") - .serverLogic { case (orderUuid, preAuthorizationId, registerCard, printReceipt) => + .serverLogic { case (orderUuid, params) => + val preAuthorizationIdParameter = + params.get("preAuthorizationIdParameter").getOrElse("preAuthorizationId") + val preAuthorizationId = params.get(preAuthorizationIdParameter).getOrElse("") + val registerCard = params.get("registerCard").getOrElse("false").toBoolean + val printReceipt = params.get("printReceipt").getOrElse("false").toBoolean run( - PreAuthorizeCardFor3DS(orderUuid, preAuthorizationId, registerCard, printReceipt) + PreAuthorizeCardCallback(orderUuid, preAuthorizationId, registerCard, printReceipt) ).map { case result: CardPreAuthorized => Right(result) case result: PaymentRedirection => Right(result) @@ -142,7 +155,7 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { printReceipt = true ) ) - .in(PaymentSettings.PayInRoute) + .in(PaymentSettings.PaymentConfig.payInRoute) .in(path[String].description("credited account")) .post .out( @@ -153,7 +166,11 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ), oneOfVariant[PaymentRedirection]( statusCode(StatusCode.Accepted) - .and(jsonBody[PaymentRedirection].description("Payment redirection to 3D secure")) + .and(jsonBody[PaymentRedirection].description("Payment redirection")) + ), + oneOfVariant[PaymentRequired]( + statusCode(StatusCode.PaymentRequired) + .and(jsonBody[PaymentRequired].description("Payment required")) ) ) ) @@ -175,27 +192,28 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { browserInfo, statementDescriptor, paymentType, - printReceipt + printReceipt, + feesAmount, + user = user, // required for Pay in without registered card (eg PayPal) + clientId = principal._1.map(_.clientId).orElse(principal._2.clientId) ) ).map { case result: PaidIn => Right(result) case result: PaymentRedirection => Right(result) + case result: PaymentRequired => Right(result) case other => Left(error(other)) } }) .description("Pay in") - val payInFor3DS: ServerEndpoint[Any with AkkaStreams, Future] = + val payInCallback: ServerEndpoint[Any with AkkaStreams, Future] = rootEndpoint - .in(PaymentSettings.SecureModeRoute / PaymentSettings.PayInRoute) + .in(PaymentSettings.PaymentConfig.callbacksRoute / PaymentSettings.PaymentConfig.payInRoute) .in(path[String].description("Order uuid")) - .in(query[String]("transactionId").description("Payment transaction id")) - .in( - query[Boolean]("registerCard").description( - "Whether to register or not the card after successfully pay in" - ) - ) - .in(query[Boolean]("printReceipt").description("Whether or not a receipt should be printed")) + .in(queryParams) + .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"))*/ .out( oneOf[PaymentResult]( oneOfVariant[PaidIn]( @@ -205,52 +223,33 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { oneOfVariant[PaymentRedirection]( statusCode(StatusCode.Accepted) .and(jsonBody[PaymentRedirection].description("Payment redirection to 3D secure")) - ) - ) - ) - .description("Pay in for 3D secure") - .serverLogic { case (orderUuid, transactionId, registerCard, printReceipt) => - run( - PayInFor3DS(orderUuid, transactionId, registerCard, printReceipt) - ).map { - case result: PaidIn => Right(result) - case result: PaymentRedirection => Right(result) - case other => Left(error(other)) - } - } - - val payInForPayPal: ServerEndpoint[Any with AkkaStreams, Future] = - rootEndpoint - .in(PaymentSettings.PayPalRoute) - .in(path[String].description("Order uuid")) - .in(query[String]("transactionId").description("Payment transaction id")) - .in(query[Boolean]("printReceipt").description("Whether or not a receipt should be printed")) - .out( - oneOf[PaymentResult]( - oneOfVariant[PaidIn]( - statusCode(StatusCode.Ok) - .and(jsonBody[PaidIn].description("Payment transaction result")) ), - oneOfVariant[PaymentRedirection]( - statusCode(StatusCode.Accepted) - .and(jsonBody[PaymentRedirection].description("Payment redirection to 3D secure")) + oneOfVariant[PaymentRequired]( + statusCode(StatusCode.PaymentRequired) + .and(jsonBody[PaymentRequired].description("Payment required")) ) ) ) - .description("Pay in for PayPal") - .serverLogic { case (orderUuid, transactionId, printReceipt) => + .description("Pay in with card") + .serverLogic { case (orderUuid, params) => + val transactionIdParameter = + params.get("transactionIdParameter").getOrElse("transactionId") + val transactionId = params.get(transactionIdParameter).getOrElse("") + val registerCard = params.get("registerCard").getOrElse("false").toBoolean + val printReceipt = params.get("printReceipt").getOrElse("false").toBoolean run( - PayInForPayPal(orderUuid, transactionId, printReceipt) + PayInCallback(orderUuid, transactionId, registerCard, printReceipt) ).map { case result: PaidIn => Right(result) case result: PaymentRedirection => Right(result) + case result: PaymentRequired => Right(result) case other => Left(error(other)) } } val executeFirstRecurringCardPayment: ServerEndpoint[Any with AkkaStreams, Future] = payment(Payment("", 0)).post - .in(PaymentSettings.RecurringPaymentRoute) + .in(PaymentSettings.PaymentConfig.recurringPaymentRoute) .in(path[String].description("Recurring payment registration Id")) .out( oneOf[PaymentResult]( @@ -290,9 +289,11 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { }) .description("Execute first recurring payment") - val executeFirstRecurringCardPaymentFor3DS: ServerEndpoint[Any with AkkaStreams, Future] = + val executeFirstRecurringCardPaymentCallback: ServerEndpoint[Any with AkkaStreams, Future] = rootEndpoint - .in(PaymentSettings.SecureModeRoute / PaymentSettings.RecurringPaymentRoute) + .in( + PaymentSettings.PaymentConfig.callbacksRoute / PaymentSettings.PaymentConfig.recurringPaymentRoute + ) .in(path[String].description("Recurring payment registration Id")) .in(query[String]("transactionId").description("First recurring payment transaction Id")) .out( @@ -314,7 +315,7 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { .description("Execute first recurring payment for 3D secure") .serverLogic { case (recurringPayInRegistrationId, transactionId) => run( - PayInFirstRecurringFor3DS(recurringPayInRegistrationId, transactionId) + FirstRecurringPaymentCallback(recurringPayInRegistrationId, transactionId) ).map { case result: PaidIn => Right(result) case result: PaymentRedirection => Right(result) @@ -325,11 +326,10 @@ trait CardPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val cardPaymentEndpoints: List[ServerEndpoint[AkkaStreams with capabilities.WebSockets, Future]] = List( preAuthorizeCard, - preAuthorizeCardFor3DS, + preAuthorizeCardCallback, payIn, - payInFor3DS, - payInForPayPal, + payInCallback, executeFirstRecurringCardPayment, - executeFirstRecurringCardPaymentFor3DS + executeFirstRecurringCardPaymentCallback ) } 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 e8c6c26..d90c2a1 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/KycDocumentEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/KycDocumentEndpoints.scala @@ -21,7 +21,7 @@ trait KycDocumentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val loadKycDocument: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get - .in(PaymentSettings.KycRoute) + .in(PaymentSettings.PaymentConfig.kycRoute) .in( query[String]("documentType") .description( @@ -53,7 +53,7 @@ trait KycDocumentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val addKycDocument: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.post - .in(PaymentSettings.KycRoute) + .in(PaymentSettings.PaymentConfig.kycRoute) .in( query[String]("documentType") .description( 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 7d70681..0df53db 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/MandateEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/MandateEndpoints.scala @@ -20,7 +20,7 @@ trait MandateEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val createMandate: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.post - .in(PaymentSettings.MandateRoute) + .in(PaymentSettings.PaymentConfig.mandateRoute) .out( oneOf[PaymentResult]( oneOfVariant[MandateCreated.type]( @@ -51,7 +51,7 @@ trait MandateEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val cancelMandate: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.delete - .in(PaymentSettings.MandateRoute) + .in(PaymentSettings.PaymentConfig.mandateRoute) .out( statusCode(StatusCode.Ok) .and(jsonBody[MandateCanceled.type].description("Mandate canceled")) @@ -72,7 +72,7 @@ trait MandateEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val updateMandateStatus: ServerEndpoint[Any with AkkaStreams, Future] = rootEndpoint - .in(PaymentSettings.MandateRoute) + .in(PaymentSettings.PaymentConfig.mandateRoute) .get .in(query[String]("MandateId").description("Mandate Id")) .out( diff --git a/core/src/main/scala/app/softnetwork/payment/service/PaymentService.scala b/core/src/main/scala/app/softnetwork/payment/service/PaymentService.scala index b4025d7..464d411 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/PaymentService.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/PaymentService.scala @@ -22,7 +22,7 @@ import de.heikoseeberger.akkahttpjson4s.Json4sSupport import org.json4s.{jackson, Formats} import org.json4s.jackson.Serialization import app.softnetwork.api.server._ -import app.softnetwork.payment.config.PaymentSettings._ +import app.softnetwork.payment.config.PaymentSettings.PaymentConfig._ import app.softnetwork.payment.model._ import app.softnetwork.payment.spi.PaymentProviders import app.softnetwork.session.model.{SessionData, SessionDataDecorator} @@ -50,13 +50,12 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] override implicit def ts: ActorSystem[_] = system val route: Route = { - pathPrefix(PaymentSettings.PaymentPath) { + pathPrefix(PaymentSettings.PaymentConfig.path) { hooks ~ card ~ - payInFor3ds ~ - payInForPayPal ~ - preAuthorizeCardFor3ds ~ - payInFirstRecurringFor3ds ~ + payInCallback ~ + preAuthorizeCardCallback ~ + firstRecurringPaymentCallback ~ bank ~ declaration ~ kyc ~ @@ -66,7 +65,7 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] } } - lazy val card: Route = pathPrefix(CardRoute) { + lazy val card: Route = pathPrefix(cardRoute) { // check anti CSRF token hmacTokenCsrfProtection(checkHeader) { // check if a session exists @@ -160,38 +159,75 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] userAgent.map(_.value()), payment ) - pathPrefix(PreAuthorizeCardRoute) { - run( - PreAuthorizeCard( - orderUuid, - externalUuidWithProfile(session), - debitedAmount, - currency, - registrationId, - registrationData, - registerCard, - if (browserInfo.isDefined) Some(ipAddress) else None, - browserInfo, - printReceipt - ) - ) completeWith { - case r: CardPreAuthorized => - complete( - HttpResponse( - StatusCodes.OK, - entity = r - ) + pathPrefix(preAuthorizeCardRoute) { + pathEnd { + run( + PreAuthorizeCard( + orderUuid, + externalUuidWithProfile(session), + debitedAmount, + currency, + registrationId, + registrationData, + registerCard, + if (browserInfo.isDefined) Some(ipAddress) else None, + browserInfo, + printReceipt, + None, + feesAmount ) - case r: PaymentRedirection => - complete( - HttpResponse( - StatusCodes.Accepted, - entity = r + ) completeWith { + case r: CardPreAuthorized => + complete( + HttpResponse( + StatusCodes.OK, + entity = r + ) + ) + case r: PaymentRedirection => + complete( + HttpResponse( + StatusCodes.Accepted, + entity = r + ) ) + case other => error(other) + } + } ~ pathSuffix(Segment) { creditedAccount => + run( + PreAuthorizeCard( + orderUuid, + externalUuidWithProfile(session), + debitedAmount, + currency, + registrationId, + registrationData, + registerCard, + if (browserInfo.isDefined) Some(ipAddress) else None, + browserInfo, + printReceipt, + Some(creditedAccount), + feesAmount ) - case other => error(other) + ) completeWith { + case r: CardPreAuthorized => + complete( + HttpResponse( + StatusCodes.OK, + entity = r + ) + ) + case r: PaymentRedirection => + complete( + HttpResponse( + StatusCodes.Accepted, + entity = r + ) + ) + case other => error(other) + } } - } ~ pathPrefix(PayInRoute) { + } ~ pathPrefix(payInRoute) { pathPrefix(Segment) { creditedAccount => run( PayIn( @@ -207,7 +243,9 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] browserInfo, statementDescriptor, paymentType, - printReceipt + printReceipt, + user = user, // required for Pay in without registered card (eg PayPal) + clientId = client.map(_.clientId).orElse(session.clientId) ) ) completeWith { case r: PaidIn => @@ -224,10 +262,17 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] entity = r ) ) + case r: PaymentRequired => + complete( + HttpResponse( + StatusCodes.PaymentRequired, + entity = r + ) + ) case other => error(other) } } - } ~ pathPrefix(RecurringPaymentRoute) { + } ~ pathPrefix(recurringPaymentRoute) { pathPrefix(Segment) { recurringPaymentRegistrationId => run( PayInFirstRecurring( @@ -266,16 +311,59 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] } } - lazy val payInFor3ds: Route = pathPrefix(SecureModeRoute / PayInRoute) { + lazy val payInCallback: Route = pathPrefix(callbacksRoute / payInRoute) { pathPrefix(Segment) { orderUuid => - parameters("transactionId", "registerCard".as[Boolean], "printReceipt".as[Boolean]) { - (transactionId, registerCard, printReceipt) => - run(PayInFor3DS(orderUuid, transactionId, registerCard, printReceipt)) completeWith { - case r: PaidIn => + parameterMap { params => + val transactionIdParameter = + params.getOrElse("transactionIdParameter", "transactionId") + parameters(transactionIdParameter, "registerCard".as[Boolean], "printReceipt".as[Boolean]) { + (transactionId, registerCard, printReceipt) => + run(PayInCallback(orderUuid, transactionId, registerCard, printReceipt)) completeWith { + case r: PaidIn => + complete( + HttpResponse( + StatusCodes.OK, + entity = r + ) + ) + case r: PaymentRedirection => + complete( + HttpResponse( + StatusCodes.Accepted, + entity = r + ) + ) + case r: PaymentRequired => + complete( + HttpResponse( + StatusCodes.PaymentRequired, + entity = r + ) + ) + case other => error(other) + } + } + } + } + } + + lazy val preAuthorizeCardCallback: Route = pathPrefix(callbacksRoute / preAuthorizeCardRoute) { + pathPrefix(Segment) { orderUuid => + parameterMap { params => + val preAuthorizationIdParameter = + params.getOrElse("preAuthorizationIdParameter", "preAuthorizationId") + parameters( + preAuthorizationIdParameter, + "registerCard".as[Boolean], + "printReceipt".as[Boolean] + ) { (preAuthorizationId, registerCard, printReceipt) => + run( + PreAuthorizeCardCallback(orderUuid, preAuthorizationId, registerCard, printReceipt) + ) completeWith { + case _: CardPreAuthorized => complete( HttpResponse( - StatusCodes.OK, - entity = r + StatusCodes.OK ) ) case r: PaymentRedirection => @@ -287,45 +375,23 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] ) case other => error(other) } - } - } - } - - lazy val payInForPayPal: Route = pathPrefix(PayPalRoute) { - pathPrefix(Segment) { orderUuid => - parameters("transactionId", "printReceipt".as[Boolean]) { (transactionId, printReceipt) => - run(PayInForPayPal(orderUuid, transactionId, printReceipt)) completeWith { - case r: PaidIn => - complete( - HttpResponse( - StatusCodes.OK, - entity = r - ) - ) - case r: PaymentRedirection => - complete( - HttpResponse( - StatusCodes.Accepted, - entity = r - ) - ) - case other => error(other) } } } } - lazy val preAuthorizeCardFor3ds: Route = pathPrefix(SecureModeRoute / PreAuthorizeCardRoute) { - pathPrefix(Segment) { orderUuid => - parameters("preAuthorizationId", "registerCard".as[Boolean], "printReceipt".as[Boolean]) { - (preAuthorizationId, registerCard, printReceipt) => + lazy val firstRecurringPaymentCallback: Route = + pathPrefix(callbacksRoute / recurringPaymentRoute) { + pathPrefix(Segment) { recurringPayInRegistrationId => + parameter("transactionId") { transactionId => run( - PreAuthorizeCardFor3DS(orderUuid, preAuthorizationId, registerCard, printReceipt) + FirstRecurringPaymentCallback(recurringPayInRegistrationId, transactionId) ) completeWith { - case _: CardPreAuthorized => + case r: PaidIn => complete( HttpResponse( - StatusCodes.OK + StatusCodes.OK, + entity = r ) ) case r: PaymentRedirection => @@ -337,45 +403,22 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] ) case other => error(other) } - } - } - } - - lazy val payInFirstRecurringFor3ds: Route = pathPrefix(SecureModeRoute / RecurringPaymentRoute) { - pathPrefix(Segment) { recurringPayInRegistrationId => - parameter("transactionId") { transactionId => - run(PayInFirstRecurringFor3DS(recurringPayInRegistrationId, transactionId)) completeWith { - case r: PaidIn => - complete( - HttpResponse( - StatusCodes.OK, - entity = r - ) - ) - case r: PaymentRedirection => - complete( - HttpResponse( - StatusCodes.Accepted, - entity = r - ) - ) - case other => error(other) } } } - } private lazy val hooksRoutes: List[Route] = PaymentProviders.hooksDirectives.map { case (k, v) => + log.info("registering hooks for provider: {}", k) pathPrefix(k) { v.hooks } }.toList - lazy val hooks: Route = pathPrefix(HooksRoute) { + lazy val hooks: Route = pathPrefix(hooksRoute) { concat(hooksRoutes: _*) } - lazy val bank: Route = pathPrefix(BankRoute) { + lazy val bank: Route = pathPrefix(bankRoute) { // check anti CSRF token hmacTokenCsrfProtection(checkHeader) { // check if a session exists @@ -399,56 +442,62 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] } } ~ post { - entity(as[BankAccountCommand]) { bank => - import bank._ - var externalUuid: String = "" - val updatedUser: Option[PaymentAccount.User] = - user match { - case Left(naturalUser) => - var updatedNaturalUser = { - if (naturalUser.externalUuid.trim.isEmpty) { - naturalUser.withExternalUuid(session.id) - } else { - naturalUser - } - } - session.profile match { - case Some(profile) if updatedNaturalUser.profile.isEmpty => - updatedNaturalUser = updatedNaturalUser.withProfile(profile) - case _ => - } - externalUuid = updatedNaturalUser.externalUuid - Some(PaymentAccount.User.NaturalUser(updatedNaturalUser)) - case Right(legalUser) => - var updatedLegalRepresentative = legalUser.legalRepresentative - if (updatedLegalRepresentative.externalUuid.trim.isEmpty) { - updatedLegalRepresentative = - updatedLegalRepresentative.withExternalUuid(session.id) - } - session.profile match { - case Some(profile) if updatedLegalRepresentative.profile.isEmpty => - updatedLegalRepresentative = updatedLegalRepresentative.withProfile(profile) - case _ => + optionalHeaderValueByType[UserAgent]((): Unit) { userAgent => + extractClientIP { ipAddress => + entity(as[BankAccountCommand]) { bank => + import bank._ + var externalUuid: String = "" + val updatedUser: Option[PaymentAccount.User] = + user match { + case Left(naturalUser) => + var updatedNaturalUser = { + if (naturalUser.externalUuid.trim.isEmpty) { + naturalUser.withExternalUuid(session.id) + } else { + naturalUser + } + } + session.profile match { + case Some(profile) if updatedNaturalUser.profile.isEmpty => + updatedNaturalUser = updatedNaturalUser.withProfile(profile) + case _ => + } + externalUuid = updatedNaturalUser.externalUuid + Some(PaymentAccount.User.NaturalUser(updatedNaturalUser)) + case Right(legalUser) => + var updatedLegalRepresentative = legalUser.legalRepresentative + if (updatedLegalRepresentative.externalUuid.trim.isEmpty) { + updatedLegalRepresentative = + updatedLegalRepresentative.withExternalUuid(session.id) + } + session.profile match { + case Some(profile) if updatedLegalRepresentative.profile.isEmpty => + updatedLegalRepresentative = + updatedLegalRepresentative.withProfile(profile) + case _ => + } + externalUuid = updatedLegalRepresentative.externalUuid + Some( + PaymentAccount.User.LegalUser( + legalUser.withLegalRepresentative(updatedLegalRepresentative) + ) + ) } - externalUuid = updatedLegalRepresentative.externalUuid - Some( - PaymentAccount.User.LegalUser( - legalUser.withLegalRepresentative(updatedLegalRepresentative) - ) + run( + CreateOrUpdateBankAccount( + externalUuidWithProfile(session), + bankAccount.withExternalUuid(externalUuid), + updatedUser, + acceptedTermsOfPSP, + Some(ipAddress.value), + userAgent.map(_.name()) ) + ) completeWith { + case r: BankAccountCreatedOrUpdated => + complete(HttpResponse(StatusCodes.OK, entity = r)) + case other => error(other) + } } - run( - CreateOrUpdateBankAccount( - externalUuidWithProfile(session), - bankAccount.withExternalUuid(externalUuid), - updatedUser, - acceptedTermsOfPSP, - client.map(_.clientId).orElse(session.clientId) - ) - ) completeWith { - case r: BankAccountCreatedOrUpdated => - complete(HttpResponse(StatusCodes.OK, entity = r)) - case other => error(other) } } } ~ @@ -463,7 +512,7 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] } } - lazy val declaration: Route = pathPrefix(DeclarationRoute) { + lazy val declaration: Route = pathPrefix(declarationRoute) { // check anti CSRF token hmacTokenCsrfProtection(checkHeader) { // check if a session exists @@ -484,10 +533,20 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] } } } ~ put { - run(ValidateUboDeclaration(externalUuidWithProfile(session))) completeWith { - case _: UboDeclarationAskedForValidation.type => - complete(HttpResponse(StatusCodes.OK)) - case other => error(other) + optionalHeaderValueByType[UserAgent]((): Unit) { userAgent => + extractClientIP { ipAddress => + run( + ValidateUboDeclaration( + externalUuidWithProfile(session), + ipAddress.value, + userAgent.map(_.name()) + ) + ) completeWith { + case _: UboDeclarationAskedForValidation.type => + complete(HttpResponse(StatusCodes.OK)) + case other => error(other) + } + } } } } @@ -496,7 +555,7 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] } lazy val kyc: Route = { - pathPrefix(KycRoute) { + pathPrefix(kycRoute) { parameter("documentType") { documentType => val maybeKycDocumentType: Option[KycDocument.KycDocumentType] = KycDocument.KycDocumentType.enumCompanion.fromName(documentType) @@ -555,7 +614,7 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] } lazy val mandate: Route = - pathPrefix(MandateRoute) { + pathPrefix(mandateRoute) { parameter("MandateId") { mandateId => run(UpdateMandateStatus(mandateId)) completeWith { case r: MandateStatusUpdated => complete(HttpResponse(StatusCodes.OK, entity = r.result)) @@ -594,7 +653,7 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] } } - lazy val recurringPayment: Route = pathPrefix(RecurringPaymentRoute) { + lazy val recurringPayment: Route = pathPrefix(recurringPaymentRoute) { // check anti CSRF token hmacTokenCsrfProtection(checkHeader) { // check if a session exists diff --git a/core/src/main/scala/app/softnetwork/payment/service/PaymentServiceEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/PaymentServiceEndpoints.scala index 2e7cd8c..f21b8dd 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/PaymentServiceEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/PaymentServiceEndpoints.scala @@ -32,9 +32,9 @@ trait PaymentServiceEndpoints[SD <: SessionData with SessionDataDecorator[SD]] /** should be implemented by each payment provider */ - def hooks: List[Full[Unit, Unit, (String, String), Unit, Unit, Any, Future]] = { + def hooks: List[Full[Unit, Unit, _, Unit, Unit, Any, Future]] = { PaymentProviders.hooksEndpoints.map { case (k, v) => - v.hooks(rootEndpoint.in(PaymentSettings.HooksRoute).in(k)) + v.hooks(rootEndpoint.in(PaymentSettings.PaymentConfig.hooksRoute).in(k)) }.toList } 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 0a4e8a4..98b9b46 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/RecurringPaymentEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/RecurringPaymentEndpoints.scala @@ -20,7 +20,7 @@ trait RecurringPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] val registerRecurringPayment: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.post - .in(PaymentSettings.RecurringPaymentRoute) + .in(PaymentSettings.PaymentConfig.recurringPaymentRoute) .in(jsonBody[RegisterRecurringPayment].description("Recurring payment to register")) .out( oneOf[PaymentResult]( @@ -57,7 +57,7 @@ trait RecurringPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] val loadRecurringPayment: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get - .in(PaymentSettings.RecurringPaymentRoute) + .in(PaymentSettings.PaymentConfig.recurringPaymentRoute) .in(path[String]) .out( statusCode(StatusCode.Ok).and( @@ -81,7 +81,7 @@ trait RecurringPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] val updateRecurringCardPaymentRegistration: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.put - .in(PaymentSettings.RecurringPaymentRoute) + .in(PaymentSettings.PaymentConfig.recurringPaymentRoute) .in( jsonBody[UpdateRecurringCardPaymentRegistration].description( "Recurring card payment update" @@ -107,7 +107,7 @@ trait RecurringPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] val deleteRecurringPayment: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.delete - .in(PaymentSettings.RecurringPaymentRoute) + .in(PaymentSettings.PaymentConfig.recurringPaymentRoute) .in(path[String]) .out( statusCode(StatusCode.Ok) diff --git a/core/src/main/scala/app/softnetwork/payment/service/RootPaymentEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/RootPaymentEndpoints.scala index cb0be8c..dda7264 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/RootPaymentEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/RootPaymentEndpoints.scala @@ -32,7 +32,7 @@ trait RootPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] lazy val rootEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] = endpoint - .in(PaymentSettings.PaymentPath) + .in(PaymentSettings.PaymentConfig.path) lazy val requiredSessionEndpoint: PartialServerEndpointWithSecurityOutput[ (Seq[Option[String]], Option[String], Method, Option[String]), @@ -50,7 +50,7 @@ trait RootPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] requiredClientSession } ) - .in(PaymentSettings.PaymentPath) + .in(PaymentSettings.PaymentConfig.path) lazy val optionalSessionEndpoint: PartialServerEndpointWithSecurityOutput[ (Seq[Option[String]], Option[String], Method, Option[String]), @@ -68,6 +68,6 @@ trait RootPaymentEndpoints[SD <: SessionData with SessionDataDecorator[SD]] optionalClientSession } ) - .in(PaymentSettings.PaymentPath) + .in(PaymentSettings.PaymentConfig.path) } 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 c7878f7..24431f7 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/UboDeclarationEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/UboDeclarationEndpoints.scala @@ -7,7 +7,7 @@ import app.softnetwork.payment.model.{UboDeclaration, UboDeclarationView} import app.softnetwork.session.model.{SessionData, SessionDataDecorator} import sttp.capabilities import sttp.capabilities.akka.AkkaStreams -import sttp.model.StatusCode +import sttp.model.{HeaderNames, StatusCode} import sttp.tapir.json.json4s.jsonBody import sttp.tapir.server.ServerEndpoint @@ -20,7 +20,7 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val addUboDeclaration: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.post - .in(PaymentSettings.DeclarationRoute) + .in(PaymentSettings.PaymentConfig.declarationRoute) .in( jsonBody[UboDeclaration.UltimateBeneficialOwner] .description("The UBO to declare for the authenticated legal payment account") @@ -42,7 +42,7 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val loadUboDeclaration: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.get - .in(PaymentSettings.DeclarationRoute) + .in(PaymentSettings.PaymentConfig.declarationRoute) .out( statusCode(StatusCode.Ok) .and( @@ -61,7 +61,9 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { val validateUboDeclaration: ServerEndpoint[Any with AkkaStreams, Future] = requiredSessionEndpoint.put - .in(PaymentSettings.DeclarationRoute) + .in(clientIp) + .in(header[Option[String]](HeaderNames.UserAgent)) + .in(PaymentSettings.PaymentConfig.declarationRoute) .out( statusCode(StatusCode.Ok).and( jsonBody[UboDeclarationAskedForValidation.type].description( @@ -69,13 +71,13 @@ trait UboDeclarationEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { ) ) ) - .serverLogic(principal => - _ => - run(ValidateUboDeclaration(externalUuidWithProfile(principal._2))).map { + .serverLogic(principal => { case (ipAddress, userAgent) => + run(ValidateUboDeclaration(externalUuidWithProfile(principal._2), ipAddress, userAgent)) + .map { case UboDeclarationAskedForValidation => Right(UboDeclarationAskedForValidation) case other => Left(error(other)) } - ) + }) .description("Validate the Ubo declaration of the authenticated legal payment account") val uboDeclarationEndpoints diff --git a/mangopay/src/main/scala/app/softnetwork/payment/config/MangoPay.scala b/mangopay/src/main/scala/app/softnetwork/payment/config/MangoPay.scala index ad73d0f..f6a547e 100644 --- a/mangopay/src/main/scala/app/softnetwork/payment/config/MangoPay.scala +++ b/mangopay/src/main/scala/app/softnetwork/payment/config/MangoPay.scala @@ -1,45 +1,52 @@ package app.softnetwork.payment.config -import MangoPaySettings._ import app.softnetwork.payment.model.SoftPayAccount import app.softnetwork.payment.model.SoftPayAccount.Client.Provider import com.mangopay.MangoPayApi import com.mangopay.core.enumerations.{EventType, HookStatus} import com.mangopay.entities.Hook +import java.net.URL import scala.util.{Failure, Success, Try} /** Created by smanciot on 16/08/2018. */ object MangoPay { + trait MangoPayConfig extends ProviderConfig { + val technicalErrors: Set[String] + override def withPaymentConfig(paymentConfig: Payment.Config): MangoPayConfig + } + case class Config( - clientId: String, - apiKey: String, - baseUrl: String, - version: String, - debug: Boolean, + override val clientId: String, + override val apiKey: String, + override val baseUrl: String, + override val version: String, + override val debug: Boolean, technicalErrors: Set[String], - secureModePath: String, - hooksPath: String, - mandatePath: String, - paypalPath: String - ) { - - lazy val secureModeReturnUrl = s"""$BaseUrl/$secureModePath/$SecureModeRoute""" - - lazy val preAuthorizeCardFor3DS = s"$secureModeReturnUrl/$PreAuthorizeCardRoute" - - lazy val payInFor3DS = s"$secureModeReturnUrl/$PayInRoute" - - lazy val recurringPaymentFor3DS = s"$secureModeReturnUrl/$RecurringPaymentRoute" - - lazy val hooksBaseUrl = - s"""$BaseUrl/$hooksPath/$HooksRoute/${Provider.ProviderType.MANGOPAY.name.toLowerCase}""" + override val secureModePath: String, + override val hooksPath: String, + override val mandatePath: String, + override val paypalPath: String, + paymentConfig: Payment.Config = PaymentSettings.PaymentConfig + ) extends ProviderConfig( + clientId, + apiKey, + baseUrl, + version, + debug, + secureModePath, + hooksPath, + mandatePath, + paypalPath + ) + with MangoPayConfig { - lazy val mandateReturnUrl = s"""$BaseUrl/$mandatePath/$MandateRoute""" + override def `type`: Provider.ProviderType = Provider.ProviderType.MANGOPAY - lazy val payPalReturnUrl = s"""$BaseUrl/$paypalPath/$PayPalRoute""" + override def withPaymentConfig(paymentConfig: Payment.Config): Config = + this.copy(paymentConfig = paymentConfig) } var mangoPayApis: Map[String, MangoPayApi] = Map.empty @@ -50,7 +57,10 @@ object MangoPay { .withProviderId(MangoPaySettings.MangoPayConfig.clientId) .withProviderApiKey(MangoPaySettings.MangoPayConfig.apiKey) - def apply(provider: SoftPayAccount.Client.Provider): MangoPayApi = { + def apply()(implicit + provider: SoftPayAccount.Client.Provider, + config: MangoPayConfig + ): MangoPayApi = { mangoPayApis.get(provider.providerId) match { case Some(mangoPayApi) => mangoPayApi case _ => @@ -61,30 +71,33 @@ object MangoPay { mangoPayApi.getConfig.setClientId(provider.providerId) mangoPayApi.getConfig.setClientPassword(provider.providerApiKey) mangoPayApi.getConfig.setDebugMode(debug) - // init MangoPay hooks - import scala.collection.JavaConverters._ - val hooks: List[Hook] = - Try(mangoPayApi.getHookApi.getAll) match { - case Success(s) => s.asScala.toList - case Failure(f) => - Console.err.println(f.getMessage, f.getCause) - List.empty - } - createOrUpdateHook(mangoPayApi, EventType.KYC_SUCCEEDED, hooks) - createOrUpdateHook(mangoPayApi, EventType.KYC_FAILED, hooks) - createOrUpdateHook(mangoPayApi, EventType.KYC_OUTDATED, hooks) - createOrUpdateHook(mangoPayApi, EventType.TRANSFER_NORMAL_SUCCEEDED, hooks) - createOrUpdateHook(mangoPayApi, EventType.TRANSFER_NORMAL_FAILED, hooks) - createOrUpdateHook(mangoPayApi, EventType.UBO_DECLARATION_REFUSED, hooks) - createOrUpdateHook(mangoPayApi, EventType.UBO_DECLARATION_VALIDATED, hooks) - createOrUpdateHook(mangoPayApi, EventType.UBO_DECLARATION_INCOMPLETE, hooks) - createOrUpdateHook(mangoPayApi, EventType.USER_KYC_REGULAR, hooks) - createOrUpdateHook(mangoPayApi, EventType.USER_KYC_LIGHT, hooks) - createOrUpdateHook(mangoPayApi, EventType.MANDATE_FAILED, hooks) - createOrUpdateHook(mangoPayApi, EventType.MANDATE_SUBMITTED, hooks) - createOrUpdateHook(mangoPayApi, EventType.MANDATE_CREATED, hooks) - createOrUpdateHook(mangoPayApi, EventType.MANDATE_ACTIVATED, hooks) - createOrUpdateHook(mangoPayApi, EventType.MANDATE_EXPIRED, hooks) + val url = new URL(s"${config.hooksBaseUrl}") + if (!Seq("localhost", "127.0.0.1").contains(url.getHost)) { + // init MangoPay hooks + import scala.collection.JavaConverters._ + val hooks: List[Hook] = + Try(mangoPayApi.getHookApi.getAll) match { + case Success(s) => s.asScala.toList + case Failure(f) => + Console.err.println(f.getMessage, f.getCause) + List.empty + } + createOrUpdateHook(mangoPayApi, EventType.KYC_SUCCEEDED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.KYC_FAILED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.KYC_OUTDATED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.TRANSFER_NORMAL_SUCCEEDED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.TRANSFER_NORMAL_FAILED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.UBO_DECLARATION_REFUSED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.UBO_DECLARATION_VALIDATED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.UBO_DECLARATION_INCOMPLETE, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.USER_KYC_REGULAR, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.USER_KYC_LIGHT, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.MANDATE_FAILED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.MANDATE_SUBMITTED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.MANDATE_CREATED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.MANDATE_ACTIVATED, hooks, config) + createOrUpdateHook(mangoPayApi, EventType.MANDATE_EXPIRED, hooks, config) + } mangoPayApis = mangoPayApis.updated(provider.providerId, mangoPayApi) mangoPayApi } @@ -93,21 +106,21 @@ object MangoPay { private[payment] def createOrUpdateHook( mangoPayApi: MangoPayApi, eventType: EventType, - hooks: List[Hook] + hooks: List[Hook], + config: MangoPayConfig ): Unit = { - import MangoPaySettings.MangoPayConfig._ Try { hooks.find(_.getEventType == eventType) match { case Some(previousHook) => previousHook.setStatus(HookStatus.ENABLED) - previousHook.setUrl(s"$hooksBaseUrl") + previousHook.setUrl(s"${config.hooksBaseUrl}") Console.println(s"Updating Mangopay Hook ${previousHook.getId}") mangoPayApi.getHookApi.update(previousHook) case _ => val hook = new Hook() hook.setEventType(eventType) hook.setStatus(HookStatus.ENABLED) - hook.setUrl(s"$hooksBaseUrl") + hook.setUrl(s"${config.hooksBaseUrl}") mangoPayApi.getHookApi.create(hook) } } match { diff --git a/mangopay/src/main/scala/app/softnetwork/payment/config/MangoPaySettings.scala b/mangopay/src/main/scala/app/softnetwork/payment/config/MangoPaySettings.scala index 385d2e2..3ea8eb1 100644 --- a/mangopay/src/main/scala/app/softnetwork/payment/config/MangoPaySettings.scala +++ b/mangopay/src/main/scala/app/softnetwork/payment/config/MangoPaySettings.scala @@ -1,15 +1,25 @@ package app.softnetwork.payment.config +import com.typesafe.config.{Config, ConfigFactory} import configs.Configs -object MangoPaySettings extends PaymentSettings { +trait MangoPaySettings { + + lazy val config: Config = ConfigFactory.load() lazy val MangoPayConfig: MangoPay.Config = Configs[MangoPay.Config].get(config, "payment.mangopay").toEither match { case Left(configError) => Console.err.println(s"Something went wrong with the provided arguments $configError") throw configError.configException - case Right(mangoPayConfig) => mangoPayConfig + case Right(mangoPayConfig) => + mangoPayConfig.withPaymentConfig(PaymentSettings(config).PaymentConfig) } } + +object MangoPaySettings extends MangoPaySettings { + def apply(conf: Config): MangoPaySettings = new MangoPaySettings { + override lazy val config: Config = conf + } +} diff --git a/mangopay/src/main/scala/app/softnetwork/payment/service/MangoPayHooksEndpoints.scala b/mangopay/src/main/scala/app/softnetwork/payment/service/MangoPayHooksEndpoints.scala index e02897a..6d16c1e 100644 --- a/mangopay/src/main/scala/app/softnetwork/payment/service/MangoPayHooksEndpoints.scala +++ b/mangopay/src/main/scala/app/softnetwork/payment/service/MangoPayHooksEndpoints.scala @@ -14,9 +14,9 @@ trait MangoPayHooksEndpoints extends HooksEndpoints with PaymentHandler { /** should be implemented by each payment provider */ def hooks( - hooksEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] - ): Full[Unit, Unit, (String, String), Unit, Unit, Any, Future] = - hooksEndpoint + rootEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] + ): Full[Unit, Unit, _, Unit, Unit, Any, Future] = + rootEndpoint .description("MangoPay Payment Hooks") .in(query[String]("EventType").description("MangoPay Event Type")) .in(query[String]("RessourceId").description("MangoPay Resource Id related to this event")) diff --git a/mangopay/src/main/scala/app/softnetwork/payment/spi/MangoPayProvider.scala b/mangopay/src/main/scala/app/softnetwork/payment/spi/MangoPayProvider.scala index 73a6518..d314e72 100644 --- a/mangopay/src/main/scala/app/softnetwork/payment/spi/MangoPayProvider.scala +++ b/mangopay/src/main/scala/app/softnetwork/payment/spi/MangoPayProvider.scala @@ -1,6 +1,8 @@ package app.softnetwork.payment.spi import akka.actor.typed.ActorSystem +import app.softnetwork.payment.annotation.InternalApi +import app.softnetwork.payment.config.MangoPay.MangoPayConfig import java.text.SimpleDateFormat import java.util @@ -24,7 +26,6 @@ import com.mangopay.entities.{ import com.mangopay.entities.subentities.{BrowserInfo => MangoPayBrowserInfo, _} import app.softnetwork.payment.model.{RecurringPayment, _} import app.softnetwork.payment.config.{MangoPay, MangoPaySettings} -import app.softnetwork.payment.config.MangoPaySettings.MangoPayConfig._ import app.softnetwork.payment.model.NaturalUser.NaturalUserType import app.softnetwork.payment.model.SoftPayAccount.Client.Provider import app.softnetwork.payment.service.{ @@ -38,6 +39,7 @@ import scala.util.{Failure, Success, Try} import app.softnetwork.persistence._ import app.softnetwork.serialization.asJson import app.softnetwork.time.{epochSecondToDate, epochSecondToLocalDate, DateExtensions} +import com.typesafe.config.Config import org.json4s.Formats import java.time.format.DateTimeFormatter @@ -50,6 +52,8 @@ trait MangoPayProvider extends PaymentProvider { import scala.language.implicitConversions import scala.collection.JavaConverters._ + override implicit def config: MangoPayConfig + implicit def computeBirthday(epochSecond: Long): String = { epochSecondToLocalDate(epochSecond).format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) } @@ -214,7 +218,13 @@ trait MangoPayProvider extends PaymentProvider { * @return * provider user id */ - def createOrUpdateNaturalUser(maybeNaturalUser: Option[NaturalUser]): Option[String] = { + @InternalApi + private[spi] def createOrUpdateNaturalUser( + maybeNaturalUser: Option[NaturalUser], + acceptedTermsOfPSP: Boolean, + ipAddress: Option[String], + userAgent: Option[String] + ): Option[String] = { maybeNaturalUser match { case Some(naturalUser) => import naturalUser._ @@ -244,7 +254,7 @@ trait MangoPayProvider extends PaymentProvider { (if (userId.getOrElse("").trim.isEmpty) None else - Try(MangoPay(provider).getUserApi.getNatural(userId.getOrElse(""))) match { + Try(MangoPay().getUserApi.getNatural(userId.getOrElse(""))) match { case Success(u) => Option(u) case Failure(f) => mlog.error(f.getMessage, f) @@ -252,14 +262,14 @@ trait MangoPayProvider extends PaymentProvider { }) match { case Some(u) => user.setId(u.getId) - Try(MangoPay(provider).getUserApi.update(user).getId) match { + Try(MangoPay().getUserApi.update(user).getId) match { case Success(id) => Some(id) case Failure(f) => mlog.error(f.getMessage, f) None } case None => - Try(MangoPay(provider).getUserApi.create(user).getId) match { + Try(MangoPay().getUserApi.create(user).getId) match { case Success(id) => Some(id) case Failure(f) => mlog.error(f.getMessage, f) @@ -279,7 +289,13 @@ trait MangoPayProvider extends PaymentProvider { * @return * provider user id */ - def createOrUpdateLegalUser(maybeLegalUser: Option[LegalUser]): Option[String] = { + @InternalApi + private[spi] def createOrUpdateLegalUser( + maybeLegalUser: Option[LegalUser], + acceptedTermsOfPSP: Boolean, + ipAddress: Option[String], + userAgent: Option[String] + ): Option[String] = { maybeLegalUser match { case Some(legalUser) => import legalUser._ @@ -333,7 +349,7 @@ trait MangoPayProvider extends PaymentProvider { None else Try( - MangoPay(provider).getUserApi.getLegal(legalRepresentative.userId.getOrElse("")) + MangoPay().getUserApi.getLegal(legalRepresentative.userId.getOrElse("")) ) match { case Success(u) => Option(u) case Failure(f) => @@ -342,14 +358,14 @@ trait MangoPayProvider extends PaymentProvider { }) match { case Some(u) => user.setId(u.getId) - Try(MangoPay(provider).getUserApi.update(user).getId) match { + Try(MangoPay().getUserApi.update(user).getId) match { case Success(id) => Some(id) case Failure(f) => mlog.error(f.getMessage, f) None } case None => - Try(MangoPay(provider).getUserApi.create(user).getId) match { + Try(MangoPay().getUserApi.create(user).getId) match { case Success(id) => Some(id) case Failure(f) => mlog.error(f.getMessage, f) @@ -389,7 +405,7 @@ trait MangoPayProvider extends PaymentProvider { wallet.setDescription(s"wallet for $externalUuid") wallet.setTag(externalUuid) Try( - MangoPay(provider).getUserApi + MangoPay().getUserApi .getWallets(userId) .asScala .find(w => @@ -400,14 +416,14 @@ trait MangoPayProvider extends PaymentProvider { maybeWallet match { case Some(w) => wallet.setId(w.getId) - Try(MangoPay(provider).getWalletApi.update(wallet).getId) match { + Try(MangoPay().getWalletApi.update(wallet).getId) match { case Success(id) => Some(id) case Failure(f) => mlog.error(f.getMessage, f) None } case None => - Try(MangoPay(provider).getWalletApi.create(wallet).getId) match { + Try(MangoPay().getWalletApi.create(wallet).getId) match { case Success(id) => Some(id) case Failure(f) => mlog.error(f.getMessage, f) @@ -453,7 +469,7 @@ trait MangoPayProvider extends PaymentProvider { bankAccount.setType(BankAccountType.IBAN) bankAccount.setUserId(userId) (if (id.isDefined) - Try(MangoPay(provider).getUserApi.getBankAccount(userId, getId)) match { + Try(MangoPay().getUserApi.getBankAccount(userId, getId)) match { case Success(b) => Option(b) case Failure(f) => mlog.error(f.getMessage, f) @@ -462,7 +478,7 @@ trait MangoPayProvider extends PaymentProvider { else None) match { case Some(previousBankAccount) => Try( - MangoPay(provider).getUserApi + MangoPay().getUserApi .updateBankAccount(userId, bankAccount, previousBankAccount.getId) .getId ) match { @@ -472,7 +488,7 @@ trait MangoPayProvider extends PaymentProvider { None } case _ => - Try(MangoPay(provider).getUserApi.createBankAccount(userId, bankAccount).getId) match { + Try(MangoPay().getUserApi.createBankAccount(userId, bankAccount).getId) match { case Success(id) => Option(id) case Failure(f) => mlog.error(f.getMessage, f) @@ -488,9 +504,9 @@ trait MangoPayProvider extends PaymentProvider { * @return * the first active bank account */ - override def getActiveBankAccount(userId: String): Option[String] = { + override def getActiveBankAccount(userId: String, currency: String): Option[String] = { Try( - MangoPay(provider).getUserApi + MangoPay().getUserApi .getBankAccounts(userId) .asScala .filter(bankAccount => bankAccount.isActive) @@ -507,31 +523,6 @@ trait MangoPayProvider extends PaymentProvider { } } - /** @param userId - * - provider user id - * @param bankAccountId - * - bank account id - * @return - * whether this bank account exists and is active - */ - def checkBankAccount(userId: String, bankAccountId: String): Boolean = { - Try( - MangoPay(provider).getUserApi - .getBankAccounts(userId) - .asScala - .find(bankAccount => bankAccount.getId == bankAccountId) - ) match { - case Success(maybeBankAccount) => - maybeBankAccount match { - case Some(bankAccount) => bankAccount.isActive - case None => false - } - case Failure(f) => - mlog.error(f.getMessage, f) - false - } - } - /** @param maybeUserId * - owner of the card * @param externalUuid @@ -550,7 +541,7 @@ trait MangoPayProvider extends PaymentProvider { cardPreRegistration.setCurrency(CurrencyIso.valueOf(currency)) cardPreRegistration.setTag(externalUuid) cardPreRegistration.setUserId(userId) - Try(MangoPay(provider).getCardRegistrationApi.create(cardPreRegistration)) match { + Try(MangoPay().getCardRegistrationApi.create(cardPreRegistration)) match { case Success(cardRegistration) => Some( CardPreRegistration.defaultInstance @@ -581,11 +572,11 @@ trait MangoPayProvider extends PaymentProvider { ): Option[String] = { maybeRegistrationData match { case Some(registrationData) => - Try(MangoPay(provider).getCardRegistrationApi.get(cardPreRegistrationId)) match { + Try(MangoPay().getCardRegistrationApi.get(cardPreRegistrationId)) match { case Success(cardRegistration) => cardRegistration.setRegistrationData(registrationData) Try( - MangoPay(provider).getCardRegistrationApi.update(cardRegistration).getCardId + MangoPay().getCardRegistrationApi.update(cardRegistration).getCardId ) match { case Success(cardId) => Option(cardId) @@ -608,7 +599,7 @@ trait MangoPayProvider extends PaymentProvider { * card */ def loadCard(cardId: String): Option[Card] = { - Try(MangoPay(provider).getCardApi.get(cardId)) match { + Try(MangoPay().getCardApi.get(cardId)) match { case Success(card) => Some( Card.defaultInstance @@ -629,9 +620,9 @@ trait MangoPayProvider extends PaymentProvider { * the card disabled or none */ override def disableCard(cardId: String): Option[Card] = { - Try(MangoPay(provider).getCardApi.get(cardId)) match { + Try(MangoPay().getCardApi.get(cardId)) match { case Success(card) => - Try(MangoPay(provider).getCardApi.disable(card)) match { + Try(MangoPay().getCardApi.disable(card)) match { case Success(disabledCard) => Some( Card.defaultInstance @@ -690,7 +681,7 @@ trait MangoPayProvider extends PaymentProvider { } cardPreAuthorization.setSecureMode(SecureMode.DEFAULT) cardPreAuthorization.setSecureModeReturnUrl( - s"$preAuthorizeCardFor3DS/$orderUuid?registerCard=${registerCard + s"${config.preAuthorizeCardReturnUrl}/$orderUuid?registerCard=${registerCard .getOrElse(false)}&printReceipt=${printReceipt.getOrElse(false)}" ) cardPreAuthorization.setPaymentStatus(PaymentStatus.WAITING) @@ -702,8 +693,8 @@ trait MangoPayProvider extends PaymentProvider { Try( idempotencyKey match { case Some(s) => - MangoPay(provider).getCardPreAuthorizationApi.create(s, cardPreAuthorization) - case _ => MangoPay(provider).getCardPreAuthorizationApi.create(cardPreAuthorization) + MangoPay().getCardPreAuthorizationApi.create(s, cardPreAuthorization) + case _ => MangoPay().getCardPreAuthorizationApi.create(cardPreAuthorization) } ) match { case Success(result) => @@ -716,7 +707,7 @@ trait MangoPayProvider extends PaymentProvider { `type` = Transaction.TransactionType.PRE_AUTHORIZATION, status = result.getStatus match { case PreAuthorizationStatus.FAILED if Option(result.getResultCode).isDefined => - if (MangoPaySettings.MangoPayConfig.technicalErrors.contains(result.getResultCode)) + if (config.technicalErrors.contains(result.getResultCode)) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON else Transaction.TransactionStatus.TRANSACTION_FAILED @@ -777,7 +768,7 @@ trait MangoPayProvider extends PaymentProvider { cardPreAuthorizedTransactionId: String ): Option[Transaction] = { Try( - MangoPay(provider).getCardPreAuthorizationApi.get(cardPreAuthorizedTransactionId) + MangoPay().getCardPreAuthorizationApi.get(cardPreAuthorizedTransactionId) ) match { case Success(result) => Some( @@ -788,7 +779,7 @@ trait MangoPayProvider extends PaymentProvider { `type` = Transaction.TransactionType.PRE_AUTHORIZATION, status = result.getStatus match { case PreAuthorizationStatus.FAILED if Option(result.getResultCode).isDefined => - if (MangoPaySettings.MangoPayConfig.technicalErrors.contains(result.getResultCode)) + if (config.technicalErrors.contains(result.getResultCode)) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON else Transaction.TransactionStatus.TRANSACTION_FAILED @@ -821,97 +812,106 @@ trait MangoPayProvider extends PaymentProvider { * @return * pay in with card pre authorized transaction result */ - override def payInWithCardPreAuthorized( - payInWithCardPreAuthorizedTransaction: PayInWithCardPreAuthorizedTransaction, + private[spi] override def payInWithCardPreAuthorized( + payInWithCardPreAuthorizedTransaction: Option[PayInWithCardPreAuthorizedTransaction], idempotency: Option[Boolean] ): Option[Transaction] = { - import payInWithCardPreAuthorizedTransaction._ - val payIn = new PayIn() - payIn.setTag(orderUuid) - payIn.setCreditedWalletId(creditedWalletId) - payIn.setAuthorId(authorId) - payIn.setDebitedFunds(new Money) - payIn.getDebitedFunds.setAmount(debitedAmount) - payIn.getDebitedFunds.setCurrency(CurrencyIso.valueOf(currency)) - payIn.setExecutionType(PayInExecutionType.DIRECT) - payIn.setFees(new Money) - payIn.getFees.setAmount(0) // fees are only set during transfer or payOut - payIn.getFees.setCurrency(CurrencyIso.valueOf(currency)) - payIn.setPaymentType(PayInPaymentType.PREAUTHORIZED) - val paymentDetails = new PayInPaymentDetailsPreAuthorized - paymentDetails.setPreauthorizationId(cardPreAuthorizedTransactionId) - payIn.setPaymentDetails(paymentDetails) - val idempotencyKey = - idempotency match { - case Some(s) if s => Some(generateUUID()) - case _ => None - } - Try( - idempotencyKey match { - case Some(s) => MangoPay(provider).getPayInApi.create(s, payIn) - case _ => MangoPay(provider).getPayInApi.create(payIn) - } - ) match { - case Success(result) => - Some( - Transaction() - .copy( - id = result.getId, - orderUuid = orderUuid, - nature = Transaction.TransactionNature.REGULAR, - `type` = Transaction.TransactionType.PAYIN, - status = result.getStatus match { - case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => - if ( - MangoPaySettings.MangoPayConfig.technicalErrors - .contains(result.getResultCode) - ) - Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON - else - Transaction.TransactionStatus.TRANSACTION_FAILED - case other => other - }, - amount = debitedAmount, - cardId = None, - fees = 0, - resultCode = Option(result.getResultCode).getOrElse(""), - resultMessage = Option(result.getResultMessage).getOrElse(""), - redirectUrl = None, - authorId = result.getAuthorId, - creditedWalletId = Option(result.getCreditedWalletId), - idempotencyKey = idempotencyKey + payInWithCardPreAuthorizedTransaction match { + case Some(payInWithCardPreAuthorizedTransaction) + if Option( + payInWithCardPreAuthorizedTransaction.cardPreAuthorizedTransactionId + ).isDefined => + import payInWithCardPreAuthorizedTransaction._ + val payIn = new PayIn() + payIn.setTag(orderUuid) + payIn.setCreditedWalletId(creditedWalletId) + payIn.setAuthorId(authorId) + payIn.setDebitedFunds(new Money) + payIn.getDebitedFunds.setAmount(debitedAmount) + payIn.getDebitedFunds.setCurrency(CurrencyIso.valueOf(currency)) + payIn.setExecutionType(PayInExecutionType.DIRECT) + payIn.setFees(new Money) + payIn.getFees.setAmount(0) // fees are only set during transfer or payOut + payIn.getFees.setCurrency(CurrencyIso.valueOf(currency)) + payIn.setPaymentType(PayInPaymentType.PREAUTHORIZED) + val paymentDetails = new PayInPaymentDetailsPreAuthorized + paymentDetails.setPreauthorizationId(cardPreAuthorizedTransactionId) + payIn.setPaymentDetails(paymentDetails) + val idempotencyKey = + idempotency match { + case Some(s) if s => Some(generateUUID()) + case _ => None + } + Try( + idempotencyKey match { + case Some(s) => MangoPay().getPayInApi.create(s, payIn) + case _ => MangoPay().getPayInApi.create(payIn) + } + ) match { + case Success(result) => + Some( + Transaction() + .copy( + id = result.getId, + orderUuid = orderUuid, + nature = Transaction.TransactionNature.REGULAR, + `type` = Transaction.TransactionType.PAYIN, + status = result.getStatus match { + case MangoPayTransactionStatus.FAILED + if Option(result.getResultCode).isDefined => + if ( + config.technicalErrors + .contains(result.getResultCode) + ) + Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON + else + Transaction.TransactionStatus.TRANSACTION_FAILED + case other => other + }, + amount = debitedAmount, + cardId = None, + fees = 0, + resultCode = Option(result.getResultCode).getOrElse(""), + resultMessage = Option(result.getResultMessage).getOrElse(""), + redirectUrl = None, + authorId = result.getAuthorId, + creditedWalletId = Option(result.getCreditedWalletId), + idempotencyKey = idempotencyKey + ) + .withPaymentType(Transaction.PaymentType.PREAUTHORIZED) + .withPreAuthorizationId(cardPreAuthorizedTransactionId) + .withPreAuthorizationDebitedAmount(preAuthorizationDebitedAmount) ) - .withPaymentType(Transaction.PaymentType.PREAUTHORIZED) - .withPreAuthorizationId(cardPreAuthorizedTransactionId) - .withPreAuthorizationDebitedAmount(preAuthorizationDebitedAmount) - ) - case Failure(f) => - mlog.error(f.getMessage, f) + case Failure(f) => + mlog.error(f.getMessage, f) + None + /* + f match { + case r: ResponseException => + Some( + Transaction().copy( + uuid = r.getId, + id = r.getId, + orderUuid = orderUuid, + nature = Transaction.TransactionNature.REGULAR, + `type` = Transaction.TransactionType.PAYIN, + status = MangoPayTransactionStatus.NotSpecified, + amount = debitedAmount, + cardId = Some(cardId), + fees = 0, + resultCode = r.getType, + resultMessage = r.getApiMessage, + redirectUrl = None + ) + ) + case _ => + mlog.error(f.getMessage, f) + None + } + */ + } + case None => None - /* - f match { - case r: ResponseException => - Some( - Transaction().copy( - uuid = r.getId, - id = r.getId, - orderUuid = orderUuid, - nature = Transaction.TransactionNature.REGULAR, - `type` = Transaction.TransactionType.PAYIN, - status = MangoPayTransactionStatus.NotSpecified, - amount = debitedAmount, - cardId = Some(cardId), - fees = 0, - resultCode = r.getType, - resultMessage = r.getApiMessage, - redirectUrl = None - ) - ) - case _ => - mlog.error(f.getMessage, f) - None - } - */ } } @@ -927,12 +927,12 @@ trait MangoPayProvider extends PaymentProvider { cardPreAuthorizedTransactionId: String ): Boolean = { Try( - MangoPay(provider).getCardPreAuthorizationApi.get(cardPreAuthorizedTransactionId) + MangoPay().getCardPreAuthorizationApi.get(cardPreAuthorizedTransactionId) ) match { case Success(result) => result.setPaymentStatus(PaymentStatus.CANCELED) Try( - MangoPay(provider).getCardPreAuthorizationApi.update(result) + MangoPay().getCardPreAuthorizationApi.update(result) ) match { case Success(_) => true case Failure(f) => @@ -957,12 +957,12 @@ trait MangoPayProvider extends PaymentProvider { cardPreAuthorizedTransactionId: String ): Boolean = { Try( - MangoPay(provider).getCardPreAuthorizationApi.get(cardPreAuthorizedTransactionId) + MangoPay().getCardPreAuthorizationApi.get(cardPreAuthorizedTransactionId) ) match { case Success(result) => result.setPaymentStatus(PaymentStatus.VALIDATED) Try( - MangoPay(provider).getCardPreAuthorizationApi.update(result) + MangoPay().getCardPreAuthorizationApi.update(result) ) match { case Success(_) => true case Failure(f) => @@ -982,13 +982,129 @@ trait MangoPayProvider extends PaymentProvider { * @return * pay in transaction result */ - def payIn( - maybePayInTransaction: Option[PayInTransaction], + private[spi] def payInWithCard( + maybePayInTransaction: Option[PayInWithCardTransaction], idempotency: Option[Boolean] = None ): Option[Transaction] = { maybePayInTransaction match { case Some(payInTransaction) => import payInTransaction._ + Option(cardId) match { + case None => + mlog.error("cardId is required") + // TODO pre registration ? + None + case Some(cardId) => + val payIn = new PayIn() + payIn.setTag(orderUuid) + payIn.setCreditedWalletId(creditedWalletId) + payIn.setAuthorId(authorId) + payIn.setDebitedFunds(new Money) + payIn.getDebitedFunds.setAmount(debitedAmount) + payIn.getDebitedFunds.setCurrency(CurrencyIso.valueOf(currency)) + payIn.setFees(new Money) + payIn.getFees.setAmount(0) // fees are only set during transfer or payOut + payIn.getFees.setCurrency(CurrencyIso.valueOf(currency)) + payIn.setPaymentType(PayInPaymentType.CARD) + val paymentDetails = new PayInPaymentDetailsCard + paymentDetails.setCardId(cardId) + paymentDetails.setCardType(CardType.CB_VISA_MASTERCARD) + paymentDetails.setStatementDescriptor(statementDescriptor) + if (ipAddress.isDefined) { + paymentDetails.setIpAddress(ipAddress.get) + } + if (browserInfo.isDefined) { + val bi = browserInfo.get + import bi._ + val mangoPayBrowserInfo = new MangoPayBrowserInfo() + mangoPayBrowserInfo.setAcceptHeader(acceptHeader) + mangoPayBrowserInfo.setColorDepth(colorDepth) + mangoPayBrowserInfo.setJavaEnabled(javaEnabled) + mangoPayBrowserInfo.setJavascriptEnabled(javascriptEnabled) + mangoPayBrowserInfo.setLanguage(language) + mangoPayBrowserInfo.setScreenHeight(screenHeight) + mangoPayBrowserInfo.setScreenWidth(screenWidth) + mangoPayBrowserInfo.setTimeZoneOffset(timeZoneOffset) + mangoPayBrowserInfo.setUserAgent(userAgent) + paymentDetails.setBrowserInfo(mangoPayBrowserInfo) + } + payIn.setPaymentDetails(paymentDetails) + payIn.setExecutionType(PayInExecutionType.DIRECT) + val executionDetails = new PayInExecutionDetailsDirect + executionDetails.setCardId(cardId) + // Secured Mode is activated from €100. + executionDetails.setSecureMode(SecureMode.DEFAULT) + executionDetails.setSecureModeReturnUrl( + s"${config.payInReturnUrl}/$orderUuid?registerCard=${registerCard + .getOrElse(false)}&printReceipt=${printReceipt.getOrElse(false)}" + ) + payIn.setExecutionDetails(executionDetails) + val idempotencyKey = + idempotency match { + case Some(s) if s => Some(generateUUID()) + case _ => None + } + Try( + idempotencyKey match { + case Some(s) => MangoPay().getPayInApi.create(s, payIn) + case _ => MangoPay().getPayInApi.create(payIn) + } + ) match { + case Success(result) => + Some( + Transaction().copy( + id = result.getId, + orderUuid = orderUuid, + nature = Transaction.TransactionNature.REGULAR, + `type` = Transaction.TransactionType.PAYIN, + status = result.getStatus match { + case MangoPayTransactionStatus.FAILED + if Option(result.getResultCode).isDefined => + if (config.technicalErrors.contains(result.getResultCode)) + Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON + else + Transaction.TransactionStatus.TRANSACTION_FAILED + case other => other + }, + amount = debitedAmount, + cardId = Option(cardId), + fees = 0, + resultCode = Option(result.getResultCode).getOrElse(""), + resultMessage = Option(result.getResultMessage).getOrElse(""), + redirectUrl = Option( // for 3D Secure + result.getExecutionDetails + .asInstanceOf[PayInExecutionDetailsDirect] + .getSecureModeRedirectUrl + ), + authorId = result.getAuthorId, + creditedWalletId = Option(result.getCreditedWalletId), + idempotencyKey = idempotencyKey + ) + ) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + case None => + None + } + } + + /** @param payInWithPayPalTransaction + * - pay in with PayPal transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay in with PayPal transaction result + */ + private[spi] override def payInWithPayPal( + payInWithPayPalTransaction: Option[PayInWithPayPalTransaction], + idempotency: Option[Boolean] + ): Option[Transaction] = { + payInWithPayPalTransaction match { + case Some(payInWithPayPalTransaction) => + import payInWithPayPalTransaction._ val payIn = new PayIn() payIn.setTag(orderUuid) payIn.setCreditedWalletId(creditedWalletId) @@ -999,50 +1115,16 @@ trait MangoPayProvider extends PaymentProvider { payIn.setFees(new Money) payIn.getFees.setAmount(0) // fees are only set during transfer or payOut payIn.getFees.setCurrency(CurrencyIso.valueOf(currency)) - payIn.setPaymentType(PayInPaymentType.CARD) - val paymentDetails = new PayInPaymentDetailsCard - paymentDetails.setCardId(cardId) - paymentDetails.setCardType(CardType.CB_VISA_MASTERCARD) - paymentDetails.setStatementDescriptor(statementDescriptor) - if (ipAddress.isDefined) { - paymentDetails.setIpAddress(ipAddress.get) - } - if (browserInfo.isDefined) { - val bi = browserInfo.get - import bi._ - val mangoPayBrowserInfo = new MangoPayBrowserInfo() - mangoPayBrowserInfo.setAcceptHeader(acceptHeader) - mangoPayBrowserInfo.setColorDepth(colorDepth) - mangoPayBrowserInfo.setJavaEnabled(javaEnabled) - mangoPayBrowserInfo.setJavascriptEnabled(javascriptEnabled) - mangoPayBrowserInfo.setLanguage(language) - mangoPayBrowserInfo.setScreenHeight(screenHeight) - mangoPayBrowserInfo.setScreenWidth(screenWidth) - mangoPayBrowserInfo.setTimeZoneOffset(timeZoneOffset) - mangoPayBrowserInfo.setUserAgent(userAgent) - paymentDetails.setBrowserInfo(mangoPayBrowserInfo) - } - payIn.setPaymentDetails(paymentDetails) - payIn.setExecutionType(PayInExecutionType.DIRECT) - val executionDetails = new PayInExecutionDetailsDirect - executionDetails.setCardId(cardId) - // Secured Mode is activated from €100. - executionDetails.setSecureMode(SecureMode.DEFAULT) - executionDetails.setSecureModeReturnUrl( - s"$payInFor3DS/$orderUuid?registerCard=${registerCard - .getOrElse(false)}&printReceipt=${printReceipt.getOrElse(false)}" + payIn.setPaymentType(PayInPaymentType.PAYPAL) + val executionDetails = new PayInExecutionDetailsWeb() + executionDetails.setCulture(language) + executionDetails.setReturnUrl( + s"${config.payInReturnUrl}/$orderUuid?registerCard=false&printReceipt=${printReceipt.getOrElse(false)}" ) payIn.setExecutionDetails(executionDetails) - val idempotencyKey = - idempotency match { - case Some(s) if s => Some(generateUUID()) - case _ => None - } + payIn.setExecutionType(PayInExecutionType.WEB) Try( - idempotencyKey match { - case Some(s) => MangoPay(provider).getPayInApi.create(s, payIn) - case _ => MangoPay(provider).getPayInApi.create(payIn) - } + MangoPay().getPayInApi.create(payIn) ) match { case Success(result) => Some( @@ -1053,132 +1135,36 @@ trait MangoPayProvider extends PaymentProvider { `type` = Transaction.TransactionType.PAYIN, status = result.getStatus match { case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => - if ( - MangoPaySettings.MangoPayConfig.technicalErrors.contains(result.getResultCode) - ) + if (config.technicalErrors.contains(result.getResultCode)) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON else Transaction.TransactionStatus.TRANSACTION_FAILED case other => other }, amount = debitedAmount, - cardId = Option(cardId), fees = 0, resultCode = Option(result.getResultCode).getOrElse(""), resultMessage = Option(result.getResultMessage).getOrElse(""), - redirectUrl = Option( // for 3D Secure + redirectUrl = Option( result.getExecutionDetails - .asInstanceOf[PayInExecutionDetailsDirect] - .getSecureModeRedirectUrl + .asInstanceOf[PayInExecutionDetailsWeb] + .getRedirectUrl + ), + returnUrl = Option( + result.getExecutionDetails + .asInstanceOf[PayInExecutionDetailsWeb] + .getReturnUrl ), authorId = result.getAuthorId, creditedWalletId = Option(result.getCreditedWalletId), - idempotencyKey = idempotencyKey + paymentType = Transaction.PaymentType.PAYPAL ) ) case Failure(f) => mlog.error(f.getMessage, f) None - /* - f match { - case r: ResponseException => - Some( - Transaction().copy( - uuid = r.getId, - id = r.getId, - orderUuid = orderUuid, - nature = Transaction.TransactionNature.REGULAR, - `type` = Transaction.TransactionType.PAYIN, - status = MangoPayTransactionStatus.NotSpecified, - amount = debitedAmount, - cardId = Some(cardId), - fees = 0, - resultCode = r.getType, - resultMessage = r.getApiMessage, - redirectUrl = None - ) - ) - case _ => - mlog.error(f.getMessage, f) - None - } - */ } - case None => - None - } - } - - /** @param payInWithPayPalTransaction - * - pay in with PayPal transaction - * @param idempotency - * - whether to use an idempotency key for this request or not - * @return - * pay in with PayPal transaction result - */ - override def payInWithPayPal( - payInWithPayPalTransaction: PayInWithPayPalTransaction, - idempotency: Option[Boolean] - ): Option[Transaction] = { - import payInWithPayPalTransaction._ - val payIn = new PayIn() - payIn.setTag(orderUuid) - payIn.setCreditedWalletId(creditedWalletId) - payIn.setAuthorId(authorId) - payIn.setDebitedFunds(new Money) - payIn.getDebitedFunds.setAmount(debitedAmount) - payIn.getDebitedFunds.setCurrency(CurrencyIso.valueOf(currency)) - payIn.setFees(new Money) - payIn.getFees.setAmount(0) // fees are only set during transfer or payOut - payIn.getFees.setCurrency(CurrencyIso.valueOf(currency)) - payIn.setPaymentType(PayInPaymentType.PAYPAL) - val executionDetails = new PayInExecutionDetailsWeb() - executionDetails.setCulture(language) - executionDetails.setReturnUrl( - s"$payPalReturnUrl/$orderUuid?printReceipt=${printReceipt.getOrElse(false)}" - ) - payIn.setExecutionDetails(executionDetails) - payIn.setExecutionType(PayInExecutionType.WEB) - Try( - MangoPay(provider).getPayInApi.create(payIn) - ) match { - case Success(result) => - Some( - Transaction().copy( - id = result.getId, - orderUuid = orderUuid, - nature = Transaction.TransactionNature.REGULAR, - `type` = Transaction.TransactionType.PAYIN, - status = result.getStatus match { - case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => - if (MangoPaySettings.MangoPayConfig.technicalErrors.contains(result.getResultCode)) - Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON - else - Transaction.TransactionStatus.TRANSACTION_FAILED - case other => other - }, - amount = debitedAmount, - fees = 0, - resultCode = Option(result.getResultCode).getOrElse(""), - resultMessage = Option(result.getResultMessage).getOrElse(""), - redirectUrl = Option( - result.getExecutionDetails - .asInstanceOf[PayInExecutionDetailsWeb] - .getRedirectUrl - ), - returnUrl = Option( - result.getExecutionDetails - .asInstanceOf[PayInExecutionDetailsWeb] - .getReturnUrl - ), - authorId = result.getAuthorId, - creditedWalletId = Option(result.getCreditedWalletId), - paymentType = Transaction.PaymentType.PAYPAL - ) - ) - case Failure(f) => - mlog.error(f.getMessage, f) - None + case None => None } } @@ -1222,8 +1208,8 @@ trait MangoPayProvider extends PaymentProvider { Try( idempotencyKey match { case Some(s) => - MangoPay(provider).getPayInApi.createRefund(s, payInTransactionId, refund) - case _ => MangoPay(provider).getPayInApi.createRefund(payInTransactionId, refund) + MangoPay().getPayInApi.createRefund(s, payInTransactionId, refund) + case _ => MangoPay().getPayInApi.createRefund(payInTransactionId, refund) } ) match { case Success(result) => @@ -1235,9 +1221,7 @@ trait MangoPayProvider extends PaymentProvider { `type` = Transaction.TransactionType.PAYIN, status = result.getStatus match { case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => - if ( - MangoPaySettings.MangoPayConfig.technicalErrors.contains(result.getResultCode) - ) + if (config.technicalErrors.contains(result.getResultCode)) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON else Transaction.TransactionStatus.TRANSACTION_FAILED @@ -1306,7 +1290,7 @@ trait MangoPayProvider extends PaymentProvider { transfer.getFees.setAmount(feesAmount) transfer.getFees.setCurrency(CurrencyIso.valueOf(currency)) transfer.setDebitedWalletId(debitedWalletId) - Try(MangoPay(provider).getTransferApi.create(transfer)) match { + Try(MangoPay().getTransferApi.create(transfer)) match { case Success(result) => Some( Transaction() @@ -1318,7 +1302,7 @@ trait MangoPayProvider extends PaymentProvider { case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => if ( - MangoPaySettings.MangoPayConfig.technicalErrors + config.technicalErrors .contains(result.getResultCode) ) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON @@ -1328,6 +1312,7 @@ trait MangoPayProvider extends PaymentProvider { }, amount = debitedAmount, fees = feesAmount, + currency = currency, resultCode = Option(result.getResultCode).getOrElse(""), resultMessage = Option(result.getResultMessage).getOrElse(""), authorId = result.getAuthorId, @@ -1405,8 +1390,8 @@ trait MangoPayProvider extends PaymentProvider { } Try( idempotencyKey match { - case Some(s) => MangoPay(provider).getPayOutApi.create(s, payOut) - case _ => MangoPay(provider).getPayOutApi.create(payOut) + case Some(s) => MangoPay().getPayOutApi.create(s, payOut) + case _ => MangoPay().getPayOutApi.create(payOut) } ) match { case Success(result) => @@ -1421,7 +1406,7 @@ trait MangoPayProvider extends PaymentProvider { case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => if ( - MangoPaySettings.MangoPayConfig.technicalErrors + config.technicalErrors .contains(result.getResultCode) ) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON @@ -1431,6 +1416,7 @@ trait MangoPayProvider extends PaymentProvider { }, amount = debitedAmount, fees = feesAmount, + currency = currency, resultCode = Option(result.getResultCode).getOrElse(""), resultMessage = Option(result.getResultMessage).getOrElse(""), authorId = result.getAuthorId, @@ -1480,12 +1466,12 @@ trait MangoPayProvider extends PaymentProvider { * @return * pay in transaction */ - def loadPayIn( + def loadPayInTransaction( orderUuid: String, transactionId: String, recurringPayInRegistrationId: Option[String] ): Option[Transaction] = { - Try(MangoPay(provider).getPayInApi.get(transactionId)) match { + Try(MangoPay().getPayInApi.get(transactionId)) match { case Success(result) => val `type` = if (result.getPaymentType == PayInPaymentType.DIRECT_DEBIT) { @@ -1587,8 +1573,11 @@ trait MangoPayProvider extends PaymentProvider { * @return * Refund transaction */ - override def loadRefund(orderUuid: String, transactionId: String): Option[Transaction] = { - Try(MangoPay(provider).getPayInApi.getRefund(transactionId)) match { + override def loadRefundTransaction( + orderUuid: String, + transactionId: String + ): Option[Transaction] = { + Try(MangoPay().getPayInApi.getRefund(transactionId)) match { case Success(result) => Some( Transaction().copy( @@ -1619,8 +1608,11 @@ trait MangoPayProvider extends PaymentProvider { * @return * pay out transaction */ - override def loadPayOut(orderUuid: String, transactionId: String): Option[Transaction] = { - Try(MangoPay(provider).getPayOutApi.get(transactionId)) match { + override def loadPayOutTransaction( + orderUuid: String, + transactionId: String + ): Option[Transaction] = { + Try(MangoPay().getPayOutApi.get(transactionId)) match { case Success(result) => Some( Transaction().copy( @@ -1649,7 +1641,7 @@ trait MangoPayProvider extends PaymentProvider { * transfer transaction */ override def loadTransfer(transactionId: String): Option[Transaction] = { - Try(MangoPay(provider).getTransferApi.get(transactionId)) match { + Try(MangoPay().getTransferApi.get(transactionId)) match { case Success(result) => Some( Transaction().copy( @@ -1711,19 +1703,19 @@ trait MangoPayProvider extends PaymentProvider { documentType: KycDocument.KycDocumentType ): Option[String] = { // create document - Try(MangoPay(provider).getUserApi.createKycDocument(userId, documentType, externalUuid)) match { + Try(MangoPay().getUserApi.createKycDocument(userId, documentType, externalUuid)) match { case Success(s) => mlog.info(s"""Create $documentType for $externalUuid""") // ask for document creation s.setTag(externalUuid) s.setStatus(KycStatus.CREATED) - Try(MangoPay(provider).getUserApi.updateKycDocument(userId, s)) match { + Try(MangoPay().getUserApi.updateKycDocument(userId, s)) match { case Success(s2) => mlog.info(s"""Update $documentType for $externalUuid""") // add document pages if ( pages.forall { page => - Try(MangoPay(provider).getUserApi.createKycPage(userId, s2.getId, page)) match { + Try(MangoPay().getUserApi.createKycPage(userId, s2.getId, page)) match { case Success(_) => mlog.info(s"""Add document page for $externalUuid""") true @@ -1736,7 +1728,7 @@ trait MangoPayProvider extends PaymentProvider { // ask for document validation s2.setTag(externalUuid) s2.setStatus(KycStatus.VALIDATION_ASKED) - Try(MangoPay(provider).getUserApi.updateKycDocument(userId, s2)) match { + Try(MangoPay().getUserApi.updateKycDocument(userId, s2)) match { case Success(s3) => mlog.info(s"""Ask document ${s3.getId} validation for $externalUuid""") Some(s3.getId) @@ -1764,9 +1756,13 @@ trait MangoPayProvider extends PaymentProvider { * @return * document validation report */ - override def loadDocumentStatus(userId: String, documentId: String): KycDocumentValidationReport = + override def loadDocumentStatus( + userId: String, + documentId: String, + documentType: KycDocument.KycDocumentType + ): KycDocumentValidationReport = // load document - Try(MangoPay(provider).getUserApi.getKycDocument(userId, documentId)) match { + Try(MangoPay().getUserApi.getKycDocument(userId, documentId)) match { case Success(s) => KycDocumentValidationReport.defaultInstance .withId(documentId) @@ -1807,14 +1803,14 @@ trait MangoPayProvider extends PaymentProvider { mandate.setExecutionType(MandateExecutionType.WEB) mandate.setMandateType(MandateType.DIRECT_DEBIT) mandate.setReturnUrl( - s"$mandateReturnUrl?externalUuid=$externalUuid&idempotencyKey=${idempotencyKey.getOrElse("")}" + s"${config.mandateReturnUrl}?externalUuid=$externalUuid&idempotencyKey=${idempotencyKey.getOrElse("")}" ) mandate.setScheme(MandateScheme.SEPA) mandate.setUserId(userId) Try( idempotencyKey match { - case Some(key) => MangoPay(provider).getMandateApi.create(key, mandate) - case _ => MangoPay(provider).getMandateApi.create(mandate) + case Some(key) => MangoPay().getMandateApi.create(key, mandate) + case _ => MangoPay().getMandateApi.create(mandate) } ) match { case Success(s) => @@ -1855,11 +1851,11 @@ trait MangoPayProvider extends PaymentProvider { ): Option[MandateResult] = { Try( maybeMandateId match { - case Some(mandateId) => Option(MangoPay(provider).getMandateApi.get(mandateId)) + case Some(mandateId) => Option(MangoPay().getMandateApi.get(mandateId)) case None => val sorting = new Sorting() sorting.addField("creationDate", SortDirection.desc) - MangoPay(provider).getMandateApi + MangoPay().getMandateApi .getForBankAccount( userId, bankAccountId, @@ -1902,7 +1898,7 @@ trait MangoPayProvider extends PaymentProvider { * mandate result */ override def cancelMandate(mandateId: String): Option[MandateResult] = { - Try(MangoPay(provider).getMandateApi.cancel(mandateId)) match { + Try(MangoPay().getMandateApi.cancel(mandateId)) match { case Success(mandate) => Some( MandateResult.defaultInstance @@ -1977,8 +1973,8 @@ trait MangoPayProvider extends PaymentProvider { } Try( idempotencyKey match { - case Some(s) => MangoPay(provider).getPayInApi.create(s, payIn) - case _ => MangoPay(provider).getPayInApi.create(payIn) + case Some(s) => MangoPay().getPayInApi.create(s, payIn) + case _ => MangoPay().getPayInApi.create(payIn) } ) match { case Success(result) => @@ -1992,7 +1988,7 @@ trait MangoPayProvider extends PaymentProvider { case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => if ( - MangoPaySettings.MangoPayConfig.technicalErrors + config.technicalErrors .contains(result.getResultCode) ) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON @@ -2035,7 +2031,7 @@ trait MangoPayProvider extends PaymentProvider { * @return * transaction if it exists */ - override def directDebitTransaction( + override def loadDirectDebitTransaction( walletId: String, transactionId: String, transactionDate: Date @@ -2047,7 +2043,7 @@ trait MangoPayProvider extends PaymentProvider { filters.setType(MangoPayTransactionType.PAYIN) filters.setAfterDate(transactionDate.toEpochSecond) Try( - MangoPay(provider).getWalletApi + MangoPay().getWalletApi .getTransactions( walletId, new Pagination(1, 100), @@ -2067,9 +2063,7 @@ trait MangoPayProvider extends PaymentProvider { `type` = Transaction.TransactionType.PAYIN, status = result.getStatus match { case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => - if ( - MangoPaySettings.MangoPayConfig.technicalErrors.contains(result.getResultCode) - ) + if (config.technicalErrors.contains(result.getResultCode)) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON else Transaction.TransactionStatus.TRANSACTION_FAILED @@ -2093,7 +2087,7 @@ trait MangoPayProvider extends PaymentProvider { } override def client: Option[SoftPayAccount.Client] = { - Try(MangoPay(provider).getClientApi.get()) match { + Try(MangoPay().getClientApi.get()) match { case Success(client) => Some( SoftPayAccount.Client.defaultInstance @@ -2129,7 +2123,7 @@ trait MangoPayProvider extends PaymentProvider { * client fees */ override def clientFees(): Option[Double] = { - Try(MangoPay(provider).getClientApi.getWallet(FundsType.FEES, CurrencyIso.EUR)) match { + Try(MangoPay().getClientApi.getWallet(FundsType.FEES, CurrencyIso.EUR)) match { case Success(wallet) => Some(wallet.getBalance.getAmount.toDouble / 100) case Failure(f) => mlog.error(f.getMessage, f) @@ -2143,7 +2137,7 @@ trait MangoPayProvider extends PaymentProvider { * Ultimate Beneficial Owner Declaration */ override def createDeclaration(userId: String): Option[UboDeclaration] = { - Try(MangoPay(provider).getUboDeclarationApi.create(userId)) match { + Try(MangoPay().getUboDeclarationApi.create(userId)) match { case Success(declaration) => Some( UboDeclaration.defaultInstance @@ -2202,9 +2196,9 @@ trait MangoPayProvider extends PaymentProvider { { if (id.isEmpty) { - Try(MangoPay(provider).getUboDeclarationApi.createUbo(userId, uboDeclarationId, ubo)) + Try(MangoPay().getUboDeclarationApi.createUbo(userId, uboDeclarationId, ubo)) } else { - Try(MangoPay(provider).getUboDeclarationApi.updateUbo(userId, uboDeclarationId, ubo)) + Try(MangoPay().getUboDeclarationApi.updateUbo(userId, uboDeclarationId, ubo)) } } match { case Success(s2) => @@ -2227,7 +2221,7 @@ trait MangoPayProvider extends PaymentProvider { * declaration with Ultimate Beneficial Owner(s) */ override def getDeclaration(userId: String, uboDeclarationId: String): Option[UboDeclaration] = { - Try(MangoPay(provider).getUboDeclarationApi.get(userId, uboDeclarationId)) match { + Try(MangoPay().getUboDeclarationApi.get(userId, uboDeclarationId)) match { case Success(s) => import scala.collection.JavaConverters._ Some( @@ -2274,10 +2268,12 @@ trait MangoPayProvider extends PaymentProvider { */ override def validateDeclaration( userId: String, - uboDeclarationId: String + uboDeclarationId: String, + ipAddress: String, + userAgent: String ): Option[UboDeclaration] = { Try( - MangoPay(provider).getUboDeclarationApi.submitForValidation(userId, uboDeclarationId) + MangoPay().getUboDeclarationApi.submitForValidation(userId, uboDeclarationId) ) match { case Success(s) => Some( @@ -2375,7 +2371,7 @@ trait MangoPayProvider extends PaymentProvider { case _ => } Try( - MangoPay(provider).getPayInApi + MangoPay().getPayInApi .createRecurringPayment(generateUUID(), createRecurringPayment) ) match { case Success(s) => @@ -2419,7 +2415,7 @@ trait MangoPayProvider extends PaymentProvider { case _ => } Try( - MangoPay(provider).getPayInApi + MangoPay().getPayInApi .updateRecurringPayment(recurringPayInRegistrationId, recurringPaymentUpdate) ) match { case Success(s) => @@ -2447,7 +2443,7 @@ trait MangoPayProvider extends PaymentProvider { recurringPayInRegistrationId: String ): Option[RecurringPayment.RecurringCardPaymentResult] = { Try( - MangoPay(provider).getPayInApi.getRecurringPayment(recurringPayInRegistrationId) + MangoPay().getPayInApi.getRecurringPayment(recurringPayInRegistrationId) ) match { case Success(s) => Some( @@ -2508,10 +2504,10 @@ trait MangoPayProvider extends PaymentProvider { recurringPayInCIT.setTag(externalUuid) recurringPayInCIT.setStatementDescriptor(statementDescriptor) recurringPayInCIT.setSecureModeReturnURL( - s"$recurringPaymentFor3DS/$recurringPayInRegistrationId" + s"${config.recurringPaymentReturnUrl}/$recurringPayInRegistrationId" ) Try( - MangoPay(provider).getPayInApi.createRecurringPayInCIT(generateUUID(), recurringPayInCIT) + MangoPay().getPayInApi.createRecurringPayInCIT(generateUUID(), recurringPayInCIT) ) match { case Success(result) => Some( @@ -2521,9 +2517,7 @@ trait MangoPayProvider extends PaymentProvider { `type` = Transaction.TransactionType.PAYIN, status = result.getStatus match { case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => - if ( - MangoPaySettings.MangoPayConfig.technicalErrors.contains(result.getResultCode) - ) + if (config.technicalErrors.contains(result.getResultCode)) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON else Transaction.TransactionStatus.TRANSACTION_FAILED @@ -2564,7 +2558,7 @@ trait MangoPayProvider extends PaymentProvider { recurringPayInMIT.setFees(fees) recurringPayInMIT.setTag(externalUuid) Try( - MangoPay(provider).getPayInApi.createRecurringPayInMIT(generateUUID(), recurringPayInMIT) + MangoPay().getPayInApi.createRecurringPayInMIT(generateUUID(), recurringPayInMIT) ) match { case Success(result) => Some( @@ -2574,9 +2568,7 @@ trait MangoPayProvider extends PaymentProvider { `type` = Transaction.TransactionType.PAYIN, status = result.getStatus match { case MangoPayTransactionStatus.FAILED if Option(result.getResultCode).isDefined => - if ( - MangoPaySettings.MangoPayConfig.technicalErrors.contains(result.getResultCode) - ) + if (config.technicalErrors.contains(result.getResultCode)) Transaction.TransactionStatus.TRANSACTION_FAILED_FOR_TECHNICAL_REASON else Transaction.TransactionStatus.TRANSACTION_FAILED @@ -2603,16 +2595,23 @@ trait MangoPayProvider extends PaymentProvider { } class MangoPayProviderFactory extends PaymentProviderSpi { + @volatile private[this] var _config: Option[MangoPayConfig] = None + override val providerType: Provider.ProviderType = Provider.ProviderType.MANGOPAY override def paymentProvider(p: SoftPayAccount.Client.Provider): MangoPayProvider = new MangoPayProvider { override implicit val provider: SoftPayAccount.Client.Provider = p + override implicit val config: MangoPayConfig = + _config.getOrElse(MangoPaySettings.MangoPayConfig) } - override def softPaymentProvider: SoftPayAccount.Client.Provider = - MangoPay.softPayProvider + override def softPaymentProvider(config: Config): SoftPayAccount.Client.Provider = { + val mangoPayConfig = MangoPaySettings(config).MangoPayConfig + _config = Some(mangoPayConfig) + mangoPayConfig.softPayProvider + } override def hooksDirectives(implicit _system: ActorSystem[_], diff --git a/project/Versions.scala b/project/Versions.scala index 230372d..1f2d54a 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -15,4 +15,6 @@ object Versions { val jinja = "2.7.1" val scalate = "1.9.8" + + val selenium = "4.13.0" } diff --git a/stripe/build.sbt b/stripe/build.sbt new file mode 100644 index 0000000..95f8e41 --- /dev/null +++ b/stripe/build.sbt @@ -0,0 +1,8 @@ +organization := "app.softnetwork.payment" + +name := "stripe-core" + +libraryDependencies ++= Seq( + // stripe + "com.stripe" % "stripe-java" % "26.2.0" +) diff --git a/stripe/src/main/resources/META-INF/services/app.softnetwork.payment.spi.PaymentProviderSpi b/stripe/src/main/resources/META-INF/services/app.softnetwork.payment.spi.PaymentProviderSpi new file mode 100644 index 0000000..bce834d --- /dev/null +++ b/stripe/src/main/resources/META-INF/services/app.softnetwork.payment.spi.PaymentProviderSpi @@ -0,0 +1 @@ +app.softnetwork.payment.spi.StripeProviderFactory diff --git a/stripe/src/main/resources/reference.conf b/stripe/src/main/resources/reference.conf new file mode 100644 index 0000000..f1f8360 --- /dev/null +++ b/stripe/src/main/resources/reference.conf @@ -0,0 +1,14 @@ +payment { + stripe { + client-id: "YourStripeClientId" + client-id: ${?STRIPE_CLIENT_ID} + api-key: "YourStripeApiKey" + api-key: ${?STRIPE_API_KEY} + baseUrl = "https://api.stripe.com" + debug = false + secure-mode-path = ${payment.path} + hooks-path = ${payment.path} + mandate-path = ${payment.path} + paypal-path = ${payment.path} + } +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala new file mode 100644 index 0000000..a092475 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala @@ -0,0 +1,176 @@ +package app.softnetwork.payment.config + +import app.softnetwork.payment.api.config.SoftPayClientSettings +import app.softnetwork.payment.model.SoftPayAccount +import app.softnetwork.payment.model.SoftPayAccount.Client.Provider +import app.softnetwork.security.sha256 +import com.stripe.Stripe +import com.stripe.model.WebhookEndpoint +import com.stripe.net.RequestOptions +import com.stripe.net.RequestOptions.RequestOptionsBuilder +import com.stripe.param.{ + WebhookEndpointCreateParams, + WebhookEndpointListParams, + WebhookEndpointUpdateParams +} + +import java.nio.file.Paths +import scala.util.{Failure, Success, Try} + +case class StripeApi( + requestOptionsBuilder: RequestOptionsBuilder +) { + lazy val requestOptions: RequestOptions = requestOptionsBuilder.clearStripeAccount().build() +} + +object StripeApi { + + case class Config( + override val clientId: String, + override val apiKey: String, + override val baseUrl: String, + override val version: String = Stripe.VERSION, + override val debug: Boolean, + override val secureModePath: String, + override val hooksPath: String, + override val mandatePath: String, + override val paypalPath: String, + paymentConfig: Payment.Config = PaymentSettings.PaymentConfig + ) extends ProviderConfig( + clientId, + apiKey, + baseUrl, + version, + debug, + secureModePath, + hooksPath, + mandatePath, + paypalPath + ) { + override val `type`: Provider.ProviderType = Provider.ProviderType.STRIPE + + override def withPaymentConfig(paymentConfig: Payment.Config): Config = + this.copy(paymentConfig = paymentConfig) + } + + private[this] var stripeApis: Map[String, StripeApi] = Map.empty + + private[this] var stripeWebHooks: Map[String, String] = Map.empty + + private[this] lazy val STRIPE_SECRETS_DIR: String = s"${SoftPayClientSettings.SP_SECRETS}/stripe" + + private[this] def addSecret(hash: String, secret: String): Unit = { + val dir = s"$STRIPE_SECRETS_DIR/$hash" + Paths.get(dir).toFile.mkdirs() + val file = Paths.get(dir, "webhook-secret").toFile + file.createNewFile() + val secretWriter = new java.io.BufferedWriter(new java.io.FileWriter(file)) + secretWriter.write(secret) + secretWriter.close() + stripeWebHooks = stripeWebHooks.updated(hash, secret) + } + + private[this] def loadSecret(hash: String): Option[String] = { + val dir = s"$STRIPE_SECRETS_DIR/$hash" + val file = Paths.get(dir, "webhook-secret").toFile + Console.println(s"Loading secret from: ${file.getAbsolutePath}") + if (file.exists()) { + import scala.io.Source + val source = Source.fromFile(file) + val secret = source.getLines().mkString + source.close() + stripeWebHooks = stripeWebHooks.updated(hash, secret) + Some(secret) + } else { + None + } + } + + def apply()(implicit provider: SoftPayAccount.Client.Provider, config: Config): StripeApi = { + stripeApis.get(provider.providerId) match { + case Some(stripeApi) => stripeApi + case _ => + // init Stripe request options + val requestOptions = RequestOptions + .builder() + .setBaseUrl(config.baseUrl) + .setClientId(provider.providerId) + .setApiKey(provider.providerApiKey) + + // create / update stripe webhook endpoint + + val hash = sha256(provider.clientId) + + import collection.JavaConverters._ + + Try { + ((Option( + WebhookEndpoint + .list( + WebhookEndpointListParams.builder().setLimit(3L).build(), + requestOptions.build() + ) + .getData + ) match { + case Some(data) => + data.asScala.headOption + case _ => + None + }) match { + case Some(webhookEndpoint) => + Console.println(s"Webhook endpoint found: ${webhookEndpoint.getId}") + loadSecret(hash) match { + case None => + Try(webhookEndpoint.delete(requestOptions.build())) + None + case value => + val url = s"${config.hooksBaseUrl}?hash=$hash" + Try( + webhookEndpoint.update( + WebhookEndpointUpdateParams + .builder() + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.ACCOUNT__UPDATED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.PERSON__UPDATED + ) + .setUrl(url) + .build(), + requestOptions.build() + ) + ) + value + } + case _ => + None + }).getOrElse { + WebhookEndpoint + .create( + WebhookEndpointCreateParams + .builder() + .addAllEnabledEvent(List(WebhookEndpointCreateParams.EnabledEvent.ALL).asJava) + .setUrl(s"${config.hooksBaseUrl}?hash=$hash") + .setApiVersion(WebhookEndpointCreateParams.ApiVersion.VERSION_2024_06_20) + .build(), + requestOptions.build() + ) + .getSecret + } + + } match { + case Success(secret) => + val ret = StripeApi(requestOptions) + stripeApis = stripeApis.updated(provider.providerId, ret) + addSecret(hash, secret) + ret + case Failure(f) => + Console.err.println(s"Error creating stripe webhook endpoint: ${f.getMessage}") + throw f + } + } + } + + def webHookSecret(hash: String): Option[String] = + stripeWebHooks.get(hash).orElse(loadSecret(hash)) +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/config/StripeSettings.scala b/stripe/src/main/scala/app/softnetwork/payment/config/StripeSettings.scala new file mode 100644 index 0000000..da320ee --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/config/StripeSettings.scala @@ -0,0 +1,27 @@ +package app.softnetwork.payment.config + +import com.typesafe.config.{Config, ConfigFactory} +import configs.Configs + +trait StripeSettings { + + lazy val config: Config = ConfigFactory.load() + + lazy val StripeApiConfig: StripeApi.Config = + Configs[StripeApi.Config].get(config, "payment.stripe").toEither match { + case Left(configError) => + Console.err.println(s"Something went wrong with the provided arguments $configError") + throw configError.configException + case Right(stripeConfig) => + stripeConfig.withPaymentConfig(PaymentSettings(config).PaymentConfig) + } + +} + +object StripeSettings extends StripeSettings { + def apply(conf: Config): StripeSettings = new StripeSettings { + override lazy val config: Config = conf + } + + def apply(): StripeSettings = new StripeSettings {} +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala new file mode 100644 index 0000000..66a304a --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala @@ -0,0 +1,290 @@ +package app.softnetwork.payment.service + +import app.softnetwork.payment.handlers.PaymentHandler +import app.softnetwork.payment.message.PaymentMessages.{ + CreateOrUpdateKycDocument, + InvalidateRegularUser, + KycDocumentCreatedOrUpdated, + RegularUserInvalidated, + RegularUserValidated, + ValidateRegularUser +} +import app.softnetwork.payment.model.KycDocument +import com.stripe.model.{Account, Event, Person, StripeObject} +import com.stripe.net.Webhook + +import scala.util.{Failure, Success, Try} +import scala.language.implicitConversions +import collection.JavaConverters._ + +/** Created by smanciot on 27/04/2021. + */ +trait StripeEventHandler { _: BasicPaymentService with PaymentHandler => + + def toStripeEvent(payload: String, sigHeader: String, secret: String): Option[Event] = { + Try { + Webhook.constructEvent(payload, sigHeader, secret) + } match { + case Success(event) => + Some(event) + case Failure(f) => + log.error(s"[Payment Hooks] Stripe Webhook verification failed: ${f.getMessage}") + None + } + } + + def handleStripeEvent(event: Event): Unit = { + event.getType match { + + case "account.updated" => + log.info(s"[Payment Hooks] Stripe Webhook received: Account Updated") + val maybeAccount: Option[Account] = event + maybeAccount match { + case Some(account) => + val accountId = account.getId + log.info( + s"[Payment Hooks] Stripe Webhook received: Account Updated -> $accountId" + ) + if (!account.getChargesEnabled || !account.getPayoutsEnabled) { + Option(account.getRequirements.getDisabledReason) match { + case Some(reason) => + log.info( + s"[Payment Hooks] Stripe Webhook received: Account Updated -> Charges and/or Payouts are disabled for $accountId -> $reason" + ) + case None => + log.info( + s"[Payment Hooks] Stripe Webhook received: Account Updated -> Charges and/or Payouts are disabled for $accountId" + ) + } + //disable account + run(InvalidateRegularUser(accountId)).map { + case RegularUserInvalidated => + log.info( + s"[Payment Hooks] Stripe Webhook received: Account Updated -> Account disabled for $accountId" + ) + // update KYC document(s) status for individual + Option(account.getRequirements) + .map(_.getErrors.asScala.toSeq) + .getOrElse(Seq.empty) + .foreach { error => + val requirement = error.getRequirement + val code = error.getCode + val reason = error.getReason + log.warn( + s"[Payment Hooks] Stripe Webhook received: Account Updated $requirement : $code -> $reason" + ) + requirement match { + case "verification.document.front" | "verification.document.back" => + Option(account.getIndividual).map(_.getVerification.getDocument) match { + case Some(document) if Option(document.getFront).isDefined => + val front = document.getFront + val documentId = Option( + document.getBack + ) match { + case Some(back) => s"$front#$back" + case _ => front + } + refuseDocument( + accountId, + documentId, + KycDocument.KycDocumentType.KYC_IDENTITY_PROOF, + code, + reason + ) + case _ => + } + case "verification.additional_document.front" | + "verification.additional_document.back" => + Option(account.getIndividual).flatMap(individual => + Option(individual.getVerification.getAdditionalDocument) + ) match { + case Some(document) if Option(document.getFront).isDefined => + val front = document.getFront + val documentId = Option( + document.getBack + ) match { + case Some(back) => s"$front#$back" + case _ => front + } + refuseDocument( + accountId, + documentId, + KycDocument.KycDocumentType.KYC_ADDRESS_PROOF, + code, + reason + ) + case _ => + } + case "company.verification.document" => + Option(account.getCompany) + .flatMap(company => Option(company.getVerification)) + .map(_.getDocument) match { + case Some(document) if Option(document.getFront).isDefined => + val front = document.getFront + val documentId = Option( + document.getBack + ) match { + case Some(back) => s"$front#$back" + case _ => front + } + refuseDocument( + accountId, + documentId, + KycDocument.KycDocumentType.KYC_REGISTRATION_PROOF, + code, + reason + ) + case _ => + } + case _ => + } + } + + case _ => + log.warn( + s"[Payment Hooks] Stripe Webhook received: Account Updated -> Account not disabled for $accountId" + ) + } + } else if (account.getChargesEnabled && account.getPayoutsEnabled) { + log.info( + s"[Payment Hooks] Stripe Webhook received: Account Updated -> Charges and Payouts are enabled for $accountId" + ) + //enable account + run(ValidateRegularUser(account.getId)).map { + case RegularUserValidated => + log.info( + s"[Payment Hooks] Stripe Webhook received: Account Updated -> Account enabled for $accountId" + ) + case _ => + log.warn( + s"[Payment Hooks] Stripe Webhook received: Account Updated -> Account not enabled for $accountId" + ) + } + } + + case None => + log.warn(s"[Payment Hooks] Stripe Webhook received: Account Updated -> No data") + } + + case "person.updated" => + log.info(s"[Payment Hooks] Stripe Webhook received: Person Updated") + val maybePerson: Option[Person] = event + maybePerson match { + case Some(person) => + val personId = person.getId + val accountId = person.getAccount + log.info( + s"[Payment Hooks] Stripe Webhook received: Person Updated -> $personId" + ) + val verification = person.getVerification + // update KYC document(s) status for person + verification.getStatus match { + case "unverified" => + log.info( + s"[Payment Hooks] Stripe Webhook received: Person Updated -> Person unverified -> $personId" + ) + Option(person.getRequirements) + .map(_.getErrors.asScala.toSeq) + .getOrElse(Seq.empty) + .foreach { error => + val requirement = error.getRequirement + val code = error.getCode + val reason = error.getReason + log.warn( + s"[Payment Hooks] Stripe Webhook received: Person Updated $requirement : $code -> $reason" + ) + requirement match { + case "verification.document.front" | "verification.document.back" => + Option(verification.getDocument) match { + case Some(document) if Option(document.getFront).isDefined => + val front = document.getFront + val documentId = Option( + document.getBack + ) match { + case Some(back) => s"$front#$back" + case _ => front + } + refuseDocument( + accountId, + documentId, + KycDocument.KycDocumentType.KYC_IDENTITY_PROOF, + code, + reason + ) + case _ => + } + case "verification.additional_document.front" | + "verification.additional_document.back" => + Option(verification.getAdditionalDocument) match { + case Some(document) if Option(document.getFront).isDefined => + val front = document.getFront + val documentId = Option( + document.getBack + ) match { + case Some(back) => s"$front#$back" + case _ => front + } + refuseDocument( + accountId, + documentId, + KycDocument.KycDocumentType.KYC_ADDRESS_PROOF, + code, + reason + ) + case _ => + } + case _ => + } + } + case "verified" => + log.info( + s"[Payment Hooks] Stripe Webhook received: Person Updated -> Person verified -> $personId" + ) + case _ => + } + case None => + log.warn(s"[Payment Hooks] Stripe Webhook received: Person Updated -> No data") + } + + case _ => + log.info(s"[Payment Hooks] Stripe Webhook received: ${event.getType}") + } + } + + implicit def toStripeObject[T <: StripeObject](event: Event): Option[T] = { + Option(event.getDataObjectDeserializer.getObject.orElse(null)).map(_.asInstanceOf[T]) + } + + private[this] def refuseDocument( + accountId: String, + documentId: String, + documentType: KycDocument.KycDocumentType, + code: String, + reason: String + ): Unit = { + log.warn( + s"[Payment Hooks] Stripe Webhook received: Document ID: $documentId refused" + ) + val document = KycDocument.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.KYC_DOCUMENT_REFUSED) + .withRefusedReasonType(code) + .withRefusedReasonMessage(reason) + run( + CreateOrUpdateKycDocument( + accountId, + document + ) + ).map { + case KycDocumentCreatedOrUpdated => + log.info( + s"[Payment Hooks] Stripe Webhook received: Document ID: $documentId updated" + ) + case _ => + log.warn( + s"[Payment Hooks] Stripe Webhook received: Document ID: $documentId not updated" + ) + } + } +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksDirectives.scala b/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksDirectives.scala new file mode 100644 index 0000000..d46100e --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksDirectives.scala @@ -0,0 +1,37 @@ +package app.softnetwork.payment.service + +import akka.http.scaladsl.model.{HttpResponse, StatusCodes} +import akka.http.scaladsl.server.Route +import app.softnetwork.payment.config.StripeApi +import app.softnetwork.payment.handlers.PaymentHandler + +trait StripeHooksDirectives extends HooksDirectives with PaymentHandler with StripeEventHandler { + + override def hooks: Route = pathEnd { + parameter("hash") { hash => + StripeApi.webHookSecret(hash) match { + case Some(secret) => + optionalHeaderValueByName("Stripe-Signature") { + case Some(signature) => + entity(as[String]) { payload => + if (log.isDebugEnabled) { + log.debug(s"[Payment Hooks] Stripe Webhook received: $payload") + } + toStripeEvent(payload, signature, secret) match { + case Some(event) => + handleStripeEvent(event) + complete(HttpResponse(StatusCodes.OK)) + case None => + complete(HttpResponse(StatusCodes.NotFound)) + } + } + case None => + complete(HttpResponse(StatusCodes.NotFound)) + } + case None => + complete(HttpResponse(StatusCodes.NotFound)) + } + } + } + +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksEndpoints.scala b/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksEndpoints.scala new file mode 100644 index 0000000..1427b84 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeHooksEndpoints.scala @@ -0,0 +1,42 @@ +package app.softnetwork.payment.service + +import app.softnetwork.payment.config.StripeApi +import app.softnetwork.payment.handlers.PaymentHandler +import sttp.tapir.Endpoint +import sttp.tapir.server.ServerEndpoint.Full + +import scala.concurrent.Future + +trait StripeHooksEndpoints extends HooksEndpoints with PaymentHandler with StripeEventHandler { + + override def hooks( + rootEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] + ): Full[Unit, Unit, _, Unit, Unit, Any, Future] = + rootEndpoint + .description("Stripe Payment Hooks") + .in(query[String]("hash").description("Hash")) + .in(header[Option[String]]("Stripe-Signature").description("Stripe signature")) + .in(stringBody.description("Payload")) + .post + .serverLogic { case (hash, sig, payload) => + StripeApi.webHookSecret(hash) match { + case Some(secret) => + sig match { + case Some(signature) => + if (log.isDebugEnabled) { + log.debug(s"[Payment Hooks] Stripe Webhook received: $payload") + } + toStripeEvent(payload, signature, secret) match { + case Some(event) => + handleStripeEvent(event) + Future.successful(Right(())) + case None => + Future.successful(Left(())) + } + } + case None => + Future.successful(Left(())) + } + } + +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeAccountApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeAccountApi.scala new file mode 100644 index 0000000..b54be49 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeAccountApi.scala @@ -0,0 +1,2176 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.annotation.InternalApi +import app.softnetwork.payment.config.StripeApi +import app.softnetwork.payment.model +import app.softnetwork.payment.model.UboDeclaration.UltimateBeneficialOwner +import app.softnetwork.payment.model.{ + BankAccount, + KycDocument, + KycDocumentValidationReport, + LegalUser, + NaturalUser, + UboDeclaration +} +import app.softnetwork.persistence +import app.softnetwork.time +import app.softnetwork.time.dateToInstant +import com.google.gson.Gson +import com.stripe.Stripe +import com.stripe.model.{Customer, Token} +import com.stripe.param.{ + AccountListParams, + CustomerCreateParams, + CustomerListParams, + CustomerUpdateParams, + ExternalAccountCollectionCreateParams, + ExternalAccountCollectionListParams, + TokenCreateParams +} + +//import com.stripe.model.identity.VerificationReport +import com.stripe.model.{Account, File, Person} +//import com.stripe.param.identity.VerificationReportListParams +import com.stripe.param.{ + AccountCreateParams, + AccountUpdateParams, + FileCreateParams, + PersonCollectionCreateParams, + PersonCollectionListParams, + PersonUpdateParams +} + +import java.io.ByteArrayInputStream +import java.text.SimpleDateFormat +import java.util.{Calendar, TimeZone} +import scala.util.{Failure, Success, Try} +import collection.JavaConverters._ +import scala.language.implicitConversions + +trait StripeAccountApi extends PaymentAccountApi { _: StripeContext => + + implicit def personToUbo(person: Person): UboDeclaration.UltimateBeneficialOwner = { + UboDeclaration.UltimateBeneficialOwner.defaultInstance + .withId(person.getId) + .withFirstName(person.getFirstName) + .withLastName(person.getLastName) + .withAddress(person.getAddress.getLine1) + .withCity(person.getAddress.getCity) + .withCountry(person.getAddress.getCountry) + .withPostalCode(person.getAddress.getPostalCode) + .withBirthday( + s"${person.getDob.getMonth}/${person.getDob.getDay}/${person.getDob.getYear}" + ) + .copy( + percentOwnership = Option(person.getRelationship.getPercentOwnership).map(_.doubleValue()) + ) + } + + private[this] def pagesToFiles( + pages: Seq[Array[Byte]], + documentType: KycDocument.KycDocumentType + ): Try[Seq[File]] = { + val filesCreateParams = pages.map(page => { + FileCreateParams + .builder() + .setPurpose( + documentType match { + case KycDocument.KycDocumentType.KYC_IDENTITY_PROOF => + FileCreateParams.Purpose.IDENTITY_DOCUMENT + case KycDocument.KycDocumentType.KYC_ADDRESS_PROOF => + FileCreateParams.Purpose.ADDITIONAL_VERIFICATION + /*case KycDocument.KycDocumentType.KYC_SHAREHOLDER_DECLARATION => //ubo declaration + FileCreateParams.Purpose.ACCOUNT_REQUIREMENT + case KycDocument.KycDocumentType.KYC_ARTICLES_OF_ASSOCIATION => //legal representative document + FileCreateParams.Purpose.ACCOUNT_REQUIREMENT*/ + case KycDocument.KycDocumentType.KYC_REGISTRATION_PROOF => //company registration document + FileCreateParams.Purpose.ACCOUNT_REQUIREMENT + case _ => FileCreateParams.Purpose.ADDITIONAL_VERIFICATION + } + ) + .setFile(new ByteArrayInputStream(page)) + .build() + }) + Try( + filesCreateParams.map(fileCreateParams => + File.create(fileCreateParams, StripeApi().requestOptions) + ) + ) + } + + /** @param maybeNaturalUser + * - natural user to create + * @return + * provider user id + */ + @InternalApi + private[spi] override def createOrUpdateNaturalUser( + maybeNaturalUser: Option[NaturalUser], + acceptedTermsOfPSP: Boolean, + ipAddress: Option[String], + userAgent: Option[String] + ): Option[String] = { + maybeNaturalUser match { + case Some(naturalUser) => + // create or update natural user + val birthday = naturalUser.birthday + val sdf = new SimpleDateFormat("dd/MM/yyyy") + sdf.setTimeZone(TimeZone.getTimeZone("UTC")) + Try(sdf.parse(birthday)) match { + case Success(date) => + val customer = + naturalUser.naturalUserType.contains(model.NaturalUser.NaturalUserType.PAYER) + if (customer) { + createOrUpdateCustomer(naturalUser) + } else { + val tos_shown_and_accepted = + acceptedTermsOfPSP && ipAddress.isDefined && userAgent.isDefined + val c = Calendar.getInstance() + c.setTime(date) + Try( + (naturalUser.userId match { + case Some(userId) => + Option(Account.retrieve(userId, StripeApi().requestOptions)) + case _ => + Account + .list( + AccountListParams + .builder() + .setLimit(100) + .build(), + StripeApi().requestOptions + ) + .getData + .asScala + .find(acc => acc.getMetadata.get("external_uuid") == naturalUser.externalUuid) + }) match { + case Some(account) => + mlog.info(s"account -> ${new Gson().toJson(account)}") + + // update account + val individual = + TokenCreateParams.Account.Individual + .builder() + .setFirstName(naturalUser.firstName) + .setLastName(naturalUser.lastName) + .setDob( + TokenCreateParams.Account.Individual.Dob + .builder() + .setDay(c.get(Calendar.DAY_OF_MONTH)) + .setMonth(c.get(Calendar.MONTH)) + .setYear(c.get(Calendar.YEAR)) + .build() + ) + .setEmail(naturalUser.email) + naturalUser.phone match { + case Some(phone) => + individual.setPhone(phone) + case _ => + } + naturalUser.address match { + case Some(address) => + individual.setAddress( + TokenCreateParams.Account.Individual.Address + .builder() + .setCity(address.city) + .setCountry(address.country) + .setLine1(address.addressLine) + .setPostalCode(address.postalCode) + .build() + ) + case _ => + } + val token = Token.create( + TokenCreateParams + .builder() + .setAccount( + TokenCreateParams.Account + .builder() + .setBusinessType(TokenCreateParams.Account.BusinessType.INDIVIDUAL) + .setIndividual(individual.build()) + .setTosShownAndAccepted(tos_shown_and_accepted) + .build + ) + .build(), + StripeApi().requestOptions + ) + val params = + AccountUpdateParams + .builder() + .setAccountToken(token.getId) + .setCapabilities( + AccountUpdateParams.Capabilities + .builder() + .setBankTransferPayments( + AccountUpdateParams.Capabilities.BankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setCardPayments( + AccountUpdateParams.Capabilities.CardPayments + .builder() + .setRequested(true) + .build() + ) + .setCartesBancairesPayments( + AccountUpdateParams.Capabilities.CartesBancairesPayments + .builder() + .setRequested(true) + .build() + ) + .setTransfers( + AccountUpdateParams.Capabilities.Transfers + .builder() + .setRequested(true) + .build() + ) + .setSepaBankTransferPayments( + AccountUpdateParams.Capabilities.SepaBankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setSepaDebitPayments( + AccountUpdateParams.Capabilities.SepaDebitPayments + .builder() + .setRequested(true) + .build() + ) + .build() + ) + .putMetadata("external_uuid", naturalUser.externalUuid) + naturalUser.business match { + case Some(business) => + val businessProfile = + AccountUpdateParams.BusinessProfile + .builder() + .setMcc(business.merchantCategoryCode) + .setUrl(business.website) + business.support match { + case Some(support) => + businessProfile.setSupportEmail(support.email) + support.phone match { + case Some(phone) => + businessProfile.setSupportPhone(phone) + case _ => + } + support.url match { + case Some(url) => + businessProfile.setSupportUrl(url) + case _ => + } + case _ => + } + params.setBusinessProfile(businessProfile.build()) + case _ => + } + account.update( + params.build(), + StripeApi().requestOptions + ) + + case _ => + // create account + val individual = + TokenCreateParams.Account.Individual + .builder() + .setFirstName(naturalUser.firstName) + .setLastName(naturalUser.lastName) + .setDob( + TokenCreateParams.Account.Individual.Dob + .builder() + .setDay(c.get(Calendar.DAY_OF_MONTH)) + .setMonth(c.get(Calendar.MONTH)) + .setYear(c.get(Calendar.YEAR)) + .build() + ) + .setEmail(naturalUser.email) + naturalUser.phone match { + case Some(phone) => + individual.setPhone(phone) + case _ => + } + naturalUser.address match { + case Some(address) => + individual.setAddress( + TokenCreateParams.Account.Individual.Address + .builder() + .setCity(address.city) + .setCountry(address.country) + .setLine1(address.addressLine) + .setPostalCode(address.postalCode) + .build() + ) + case _ => + } + val token = Token.create( + TokenCreateParams + .builder() + .setAccount( + TokenCreateParams.Account + .builder() + .setBusinessType(TokenCreateParams.Account.BusinessType.INDIVIDUAL) + .setIndividual(individual.build()) + .setTosShownAndAccepted(tos_shown_and_accepted) + .build() + ) + .build(), + StripeApi().requestOptions + ) + val params = + AccountCreateParams + .builder() + .setAccountToken(token.getId) + .setCapabilities( + AccountCreateParams.Capabilities + .builder() + .setBankTransferPayments( + AccountCreateParams.Capabilities.BankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setCardPayments( + AccountCreateParams.Capabilities.CardPayments + .builder() + .setRequested(true) + .build() + ) + .setCartesBancairesPayments( + AccountCreateParams.Capabilities.CartesBancairesPayments + .builder() + .setRequested(true) + .build() + ) + .setTransfers( + AccountCreateParams.Capabilities.Transfers + .builder() + .setRequested(true) + .build() + ) + .setSepaBankTransferPayments( + AccountCreateParams.Capabilities.SepaBankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setSepaDebitPayments( + AccountCreateParams.Capabilities.SepaDebitPayments + .builder() + .setRequested(true) + .build() + ) + .build() + ) + .setController( + AccountCreateParams.Controller + .builder() + .setFees( + AccountCreateParams.Controller.Fees + .builder() + .setPayer(AccountCreateParams.Controller.Fees.Payer.APPLICATION) + .build() + ) + .setLosses( + AccountCreateParams.Controller.Losses + .builder() + .setPayments( + AccountCreateParams.Controller.Losses.Payments.APPLICATION + ) + .build() + ) + .setRequirementCollection( + AccountCreateParams.Controller.RequirementCollection.APPLICATION + ) + .setStripeDashboard( + AccountCreateParams.Controller.StripeDashboard + .builder() + .setType(AccountCreateParams.Controller.StripeDashboard.Type.NONE) + .build() + ) + .build() + ) + .setCountry(naturalUser.countryOfResidence) + .setMetadata(Map("external_uuid" -> naturalUser.externalUuid).asJava) + + naturalUser.business match { + case Some(business) => + val businessProfile = + AccountCreateParams.BusinessProfile + .builder() + .setMcc(business.merchantCategoryCode) + .setUrl(business.website) + business.support match { + case Some(support) => + businessProfile.setSupportEmail(support.email) + support.phone match { + case Some(phone) => + businessProfile.setSupportPhone(phone) + case _ => + } + support.url match { + case Some(url) => + businessProfile.setSupportUrl(url) + case _ => + } + case _ => + } + params.setBusinessProfile(businessProfile.build()) + case _ => + } + + Account.create( + params.build(), + StripeApi().requestOptions + ) + } + ) match { + case Success(account) => + if (tos_shown_and_accepted) { + mlog.info(s"****** tos_shown_and_accepted -> $tos_shown_and_accepted") + val params = + AccountUpdateParams + .builder() + .setTosAcceptance( + AccountUpdateParams.TosAcceptance + .builder() + .setIp(ipAddress.get) + .setUserAgent(userAgent.get) + .setDate(persistence.now().getEpochSecond) + .build() + ) + .build() + Try( + account.update( + params, + StripeApi().requestOptions + ) + ) + } + Some(account.getId) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case _ => None + } + } + + /** @param maybeLegalUser + * - legal user to create or update + * @return + * legal user created or updated + */ + @InternalApi + private[spi] override def createOrUpdateLegalUser( + maybeLegalUser: Option[LegalUser], + acceptedTermsOfPSP: Boolean, + ipAddress: Option[String], + userAgent: Option[String] + ): Option[String] = { + maybeLegalUser match { + case Some(legalUser) => + // create or update legal user + val birthday = legalUser.legalRepresentative.birthday + val sdf = new SimpleDateFormat("dd/MM/yyyy") + sdf.setTimeZone(TimeZone.getTimeZone("UTC")) + Try(sdf.parse(birthday)) match { + case Success(date) => + val tos_shown_and_accepted = + ipAddress.isDefined && userAgent.isDefined && acceptedTermsOfPSP + val soleTrader = legalUser.legalUserType.isSoletrader + val c = Calendar.getInstance() + c.setTime(date) + Try( + (legalUser.legalRepresentative.userId match { + case Some(userId) => + Option(Account.retrieve(userId, StripeApi().requestOptions)) + case _ => + Account + .list( + AccountListParams + .builder() + .setLimit(100) + .build(), + StripeApi().requestOptions + ) + .getData + .asScala + .find(acc => + acc.getMetadata + .get("external_uuid") == legalUser.legalRepresentative.externalUuid + ) + }) match { + case Some(account) => + // update account legal representative + + val requestOptions = StripeApi().requestOptionsBuilder + .setStripeAccount(account.getId) + .build() + + mlog.info(s"options -> ${new Gson().toJson(requestOptions)}") + + // FIXME we shouldn't have to do this but the stripe api seems to not take into account the request options + Stripe.apiKey = provider.providerApiKey + + account + .persons() + .list( + PersonCollectionListParams + .builder() + .setRelationship( + PersonCollectionListParams.Relationship + .builder() + .setRepresentative(true) + .build() + ) + .build(), + requestOptions + ) + .getData + .asScala + .headOption + .map(person => { + val params = + PersonUpdateParams + .builder() + .setAddress( + PersonUpdateParams.Address + .builder() + .setCity(legalUser.legalRepresentativeAddress.city) + .setCountry(legalUser.legalRepresentativeAddress.country) + .setLine1(legalUser.legalRepresentativeAddress.addressLine) + .setPostalCode(legalUser.legalRepresentativeAddress.postalCode) + .build() + ) + .setFirstName(legalUser.legalRepresentative.firstName) + .setLastName(legalUser.legalRepresentative.lastName) + .setDob( + PersonUpdateParams.Dob + .builder() + .setDay(c.get(Calendar.DAY_OF_MONTH)) + .setMonth(c.get(Calendar.MONTH)) + .setYear(c.get(Calendar.YEAR)) + .build() + ) + .setEmail(legalUser.legalRepresentative.email) + .setNationality(legalUser.legalRepresentative.nationality) + + val relationship = + PersonUpdateParams.Relationship + .builder() + .setRepresentative(true) + .setDirector(true) + .setExecutive(true) + + if (soleTrader) { + relationship + .setTitle(legalUser.legalRepresentative.title.getOrElse("Owner")) + .setOwner(true) + .setPercentOwnership(new java.math.BigDecimal(100.0)) + } else { + relationship + .setOwner(false) + .setTitle( + legalUser.legalRepresentative.title.getOrElse("Representative") + ) + } + + mlog.info(s"relationship -> ${new Gson().toJson(relationship.build())}") + + params.setRelationship(relationship.build()) + + legalUser.legalRepresentative.phone match { + case Some(phone) => + params.setPhone(phone) + case _ => + } + + mlog.info(s"person -> ${new Gson().toJson(params.build())}") + + person.update( + params.build(), + requestOptions + ) + }) + .getOrElse { + val params = + PersonCollectionCreateParams + .builder() + .setAddress( + PersonCollectionCreateParams.Address + .builder() + .setCity(legalUser.legalRepresentativeAddress.city) + .setCountry(legalUser.legalRepresentativeAddress.country) + .setLine1(legalUser.legalRepresentativeAddress.addressLine) + .setPostalCode(legalUser.legalRepresentativeAddress.postalCode) + .build() + ) + .setFirstName(legalUser.legalRepresentative.firstName) + .setLastName(legalUser.legalRepresentative.lastName) + .setDob( + PersonCollectionCreateParams.Dob + .builder() + .setDay(c.get(Calendar.DAY_OF_MONTH)) + .setMonth(c.get(Calendar.MONTH)) + .setYear(c.get(Calendar.YEAR)) + .build() + ) + .setEmail(legalUser.legalRepresentative.email) + .setNationality(legalUser.legalRepresentative.nationality) + val relationship = + PersonCollectionCreateParams.Relationship + .builder() + .setRepresentative(true) + .setDirector(true) + .setExecutive(true) + if (soleTrader) { + relationship + .setTitle(legalUser.legalRepresentative.title.getOrElse("Owner")) + .setOwner(true) + .setPercentOwnership(new java.math.BigDecimal(100.0)) + } else { + relationship + .setOwner(false) + .setTitle( + legalUser.legalRepresentative.title.getOrElse("Representative") + ) + } + + mlog.info(s"relationship -> ${new Gson().toJson(relationship.build())}") + + params.setRelationship(relationship.build()) + + legalUser.legalRepresentative.phone match { + case Some(phone) => + params.setPhone(phone) + case _ => + } + + mlog.info(s"person -> ${new Gson().toJson(params.build())}") + + account + .persons() + .create( + params.build(), + requestOptions + ) + } + + // update account company + val company = + TokenCreateParams.Account.Company + .builder() + .setName(legalUser.legalName) + .setAddress( + TokenCreateParams.Account.Company.Address + .builder() + .setCity(legalUser.headQuartersAddress.city) + .setCountry(legalUser.headQuartersAddress.country) + .setLine1(legalUser.headQuartersAddress.addressLine) + .setPostalCode(legalUser.headQuartersAddress.postalCode) + .build() + ) + .setTaxId(legalUser.siren) + .setDirectorsProvided(true) + .setExecutivesProvided(true) + + if (soleTrader) { + company + .setOwnersProvided(true) + } + + legalUser.vatNumber match { + case Some(vatNumber) => + company.setVatId(vatNumber) + case _ => + } + + legalUser.phone.orElse(legalUser.legalRepresentative.phone) match { + case Some(phone) => + company.setPhone(phone) + case _ => + } + + mlog.info(s"company -> ${new Gson().toJson(company.build())}") + + val token = + Token.create( + TokenCreateParams + .builder() + .setAccount( + TokenCreateParams.Account + .builder() + .setBusinessType(TokenCreateParams.Account.BusinessType.COMPANY) + .setCompany(company.build()) + .setTosShownAndAccepted(tos_shown_and_accepted) + .build + ) + .build(), + requestOptions + ) + + val params = + AccountUpdateParams + .builder() + .setAccountToken(token.getId) + .setCapabilities( + AccountUpdateParams.Capabilities + .builder() + .setBankTransferPayments( + AccountUpdateParams.Capabilities.BankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setCardPayments( + AccountUpdateParams.Capabilities.CardPayments + .builder() + .setRequested(true) + .build() + ) + .setCartesBancairesPayments( + AccountUpdateParams.Capabilities.CartesBancairesPayments + .builder() + .setRequested(true) + .build() + ) + .setTransfers( + AccountUpdateParams.Capabilities.Transfers + .builder() + .setRequested(true) + .build() + ) + .setSepaBankTransferPayments( + AccountUpdateParams.Capabilities.SepaBankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setSepaDebitPayments( + AccountUpdateParams.Capabilities.SepaDebitPayments + .builder() + .setRequested(true) + .build() + ) + .build() + ) + .setEmail(legalUser.legalRepresentative.email) + .putMetadata("external_uuid", legalUser.legalRepresentative.externalUuid) + + /*if (tos_shown_and_accepted) { + //FIXME Parameter 'tos_acceptance' cannot be used in conjunction with an account token. + params + .setTosAcceptance( + AccountUpdateParams.TosAcceptance + .builder() + .setIp(ipAddress.get) + .setUserAgent(userAgent.get) + .setDate(persistence.now().getEpochSecond) + .build() + ) + }*/ + + legalUser.business.orElse(legalUser.legalRepresentative.business) match { + case Some(business) => + val businessProfile = + AccountUpdateParams.BusinessProfile + .builder() + .setMcc(business.merchantCategoryCode) + .setUrl(business.website) + business.support match { + case Some(support) => + businessProfile.setSupportEmail(support.email) + support.phone match { + case Some(phone) => + businessProfile.setSupportPhone(phone) + case _ => + } + support.url match { + case Some(url) => + businessProfile.setSupportUrl(url) + case _ => + } + case _ => + } + params.setBusinessProfile(businessProfile.build()) + case _ => + } + + account.update( + params.build(), + requestOptions + ) + + /*val person = + TokenCreateParams.Person + .builder() + .setFirstName(legalUser.legalRepresentative.firstName) + .setLastName(legalUser.legalRepresentative.lastName) + .setDob( + TokenCreateParams.Person.Dob + .builder() + .setDay(c.get(Calendar.DAY_OF_MONTH)) + .setMonth(c.get(Calendar.MONTH)) + .setYear(c.get(Calendar.YEAR)) + .build() + ) + .setEmail(legalUser.legalRepresentative.email) + .setNationality(legalUser.legalRepresentative.nationality) + .setRelationship( + TokenCreateParams.Person.Relationship + .builder() + .setRepresentative(true) + .build() + ) + if(tos_shown_and_accepted) + person.setAdditionalTosAcceptances( + TokenCreateParams.Person.AdditionalTosAcceptances.builder() + .setAccount(TokenCreateParams.Person.AdditionalTosAcceptances.Account + .builder() + .setIp(ipAddress.get) + .setUserAgent(userAgent.get) + .setDate(persistence.now().getEpochSecond) + .build() + ) + .build() + ) + legalUser.legalRepresentative.phone match { + case Some(phone) => + person.setPhone(phone) + case _ => + } + + mlog.info(s"person -> ${new Gson().toJson(person.build())}") + + val token2 = + Token.create( + TokenCreateParams.builder.setPerson(person.build()).build(), + requestOptions + ) + + val params2 = + PersonCollectionCreateParams + .builder() + .setPersonToken(token2.getId) + + account + .persons() + .create( + params2.build(), + requestOptions + )*/ + + account + case _ => //TODO if tos_shown_and_accepted is true + // create company account + + val company = + TokenCreateParams.Account.Company + .builder() + .setName(legalUser.legalName) + .setAddress( + TokenCreateParams.Account.Company.Address + .builder() + .setCity(legalUser.headQuartersAddress.city) + .setCountry(legalUser.headQuartersAddress.country) + .setLine1(legalUser.headQuartersAddress.addressLine) + .setPostalCode(legalUser.headQuartersAddress.postalCode) + .build() + ) + .setTaxId(legalUser.siren) + .setDirectorsProvided(true) + .setExecutivesProvided(true) + + if (soleTrader) { + company + .setOwnersProvided(true) + } + + legalUser.vatNumber match { + case Some(vatNumber) => + company.setVatId(vatNumber) + case _ => + } + + legalUser.phone.orElse(legalUser.legalRepresentative.phone) match { + case Some(phone) => + company.setPhone(phone) + case _ => + } + + mlog.info(s"company -> ${new Gson().toJson(company.build())}") + + val accountParams = + TokenCreateParams.Account + .builder() + .setBusinessType(TokenCreateParams.Account.BusinessType.COMPANY) + .setCompany(company.build()) + .setTosShownAndAccepted(tos_shown_and_accepted) + + mlog.info(s"token -> ${new Gson().toJson(accountParams.build())}") + + val token = Token.create( + TokenCreateParams.builder.setAccount(accountParams.build()).build(), + StripeApi().requestOptions + ) + + val params = + AccountCreateParams + .builder() + .setAccountToken(token.getId) + .setCapabilities( + AccountCreateParams.Capabilities + .builder() + .setBankTransferPayments( + AccountCreateParams.Capabilities.BankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setCardPayments( + AccountCreateParams.Capabilities.CardPayments + .builder() + .setRequested(true) + .build() + ) + .setCartesBancairesPayments( + AccountCreateParams.Capabilities.CartesBancairesPayments + .builder() + .setRequested(true) + .build() + ) + .setTransfers( + AccountCreateParams.Capabilities.Transfers + .builder() + .setRequested(true) + .build() + ) + .setSepaBankTransferPayments( + AccountCreateParams.Capabilities.SepaBankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setSepaDebitPayments( + AccountCreateParams.Capabilities.SepaDebitPayments + .builder() + .setRequested(true) + .build() + ) + .build() + ) + .setController( + AccountCreateParams.Controller + .builder() + .setFees( + AccountCreateParams.Controller.Fees + .builder() + .setPayer(AccountCreateParams.Controller.Fees.Payer.APPLICATION) + .build() + ) + .setLosses( + AccountCreateParams.Controller.Losses + .builder() + .setPayments( + AccountCreateParams.Controller.Losses.Payments.APPLICATION + ) + .build() + ) + .setRequirementCollection( + AccountCreateParams.Controller.RequirementCollection.APPLICATION + ) + .setStripeDashboard( + AccountCreateParams.Controller.StripeDashboard + .builder() + .setType(AccountCreateParams.Controller.StripeDashboard.Type.NONE) + .build() + ) + .build() + ) + .setCountry(legalUser.legalRepresentative.countryOfResidence) + .setEmail(legalUser.legalRepresentative.email) + .putMetadata("external_uuid", legalUser.legalRepresentative.externalUuid) + + /*if (tos_shown_and_accepted) { + //FIXME Parameter 'tos_acceptance' cannot be used in conjunction with an account token. + params + .setTosAcceptance( + AccountCreateParams.TosAcceptance + .builder() + .setIp(ipAddress.get) + .setUserAgent(userAgent.get) + .setDate(persistence.now().getEpochSecond) + .build() + ) + }*/ + + legalUser.business.orElse(legalUser.legalRepresentative.business) match { + case Some(business) => + val businessProfile = + AccountCreateParams.BusinessProfile + .builder() + .setMcc(business.merchantCategoryCode) + .setUrl(business.website) + business.support match { + case Some(support) => + businessProfile.setSupportEmail(support.email) + support.phone match { + case Some(phone) => + businessProfile.setSupportPhone(phone) + case _ => + } + support.url match { + case Some(url) => + businessProfile.setSupportUrl(url) + case _ => + } + case _ => + } + params.setBusinessProfile(businessProfile.build()) + case _ => + } + + mlog.info(s"account -> ${new Gson().toJson(params.build())}") + + val account = Account.create( + params.build(), + StripeApi().requestOptions + ) + + val requestOptions = StripeApi().requestOptionsBuilder + .setStripeAccount(account.getId) + .build() + + mlog.info(s"options -> ${new Gson().toJson(requestOptions)}") + + // FIXME we shouldn't have to do this + // but the stripe api does not seem to take into account the request options + Stripe.apiKey = provider.providerApiKey + + // create legal representative + account + .persons() + .create( + { + val params = + PersonCollectionCreateParams + .builder() + .setAddress( + PersonCollectionCreateParams.Address + .builder() + .setCity(legalUser.legalRepresentativeAddress.city) + .setCountry(legalUser.legalRepresentativeAddress.country) + .setLine1(legalUser.legalRepresentativeAddress.addressLine) + .setPostalCode(legalUser.legalRepresentativeAddress.postalCode) + .build() + ) + .setFirstName(legalUser.legalRepresentative.firstName) + .setLastName(legalUser.legalRepresentative.lastName) + .setDob( + PersonCollectionCreateParams.Dob + .builder() + .setDay(c.get(Calendar.DAY_OF_MONTH)) + .setMonth(c.get(Calendar.MONTH)) + .setYear(c.get(Calendar.YEAR)) + .build() + ) + .setEmail(legalUser.legalRepresentative.email) + .setNationality(legalUser.legalRepresentative.nationality) + + val relationship = + PersonCollectionCreateParams.Relationship + .builder() + .setRepresentative(true) + .setDirector(true) + .setExecutive(true) + + if (soleTrader) { + relationship + .setTitle(legalUser.legalRepresentative.title.getOrElse("Owner")) + .setOwner(true) + .setPercentOwnership(new java.math.BigDecimal(100.0)) + } else { + relationship + .setOwner(false) + .setTitle( + legalUser.legalRepresentative.title.getOrElse("Representative") + ) + } + + mlog.info(s"relationship -> ${new Gson().toJson(relationship.build())}") + + params.setRelationship(relationship.build()) + + legalUser.legalRepresentative.phone match { + case Some(phone) => + params.setPhone(phone) + case _ => + } + + mlog.info(s"person -> ${new Gson().toJson(params.build())}") + + params.build() + }, + requestOptions + ) + account + } + ) match { + case Success(account) => + Some(account.getId) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case _ => None + } + } + + /** @param userId + * - Provider user id + * @return + * Ultimate Beneficial Owner Declaration + */ + override def createDeclaration(userId: String): Option[UboDeclaration] = { + Some( + model.UboDeclaration.defaultInstance + .withId(userId) + .withCreatedDate(persistence.now()) + .withStatus(UboDeclaration.UboDeclarationStatus.UBO_DECLARATION_CREATED) + ) + } + + /** @param userId + * - Provider user id + * @param uboDeclarationId + * - Provider declaration id + * @param ultimateBeneficialOwner + * - Ultimate Beneficial Owner + * @return + * Ultimate Beneficial Owner created or updated + */ + override def createOrUpdateUBO( + userId: String, + uboDeclarationId: String, + ultimateBeneficialOwner: UboDeclaration.UltimateBeneficialOwner + ): Option[UboDeclaration.UltimateBeneficialOwner] = { + Try(Account.retrieve(userId, StripeApi().requestOptions)) match { + case Success(account) => + createOrUpdateOwner(account, ultimateBeneficialOwner) match { + case Some(ownerId) => + Some(ultimateBeneficialOwner.withId(ownerId)) + case _ => + None + } + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + /** @param userId + * - Provider user id + * @param uboDeclarationId + * - Provider declaration id + * @return + * declaration with Ultimate Beneficial Owner(s) + */ + override def getDeclaration(userId: String, uboDeclarationId: String): Option[UboDeclaration] = { + Try(Account.retrieve(userId, StripeApi().requestOptions)) match { + case Success(account) => + account + .persons() + .list( + PersonCollectionListParams + .builder() + .setRelationship( + PersonCollectionListParams.Relationship + .builder() + .setOwner(true) + .build() + ) + .build(), + StripeApi().requestOptions + ) + .getData + .asScala match { + case persons => + Some( + model.UboDeclaration.defaultInstance + .withId(uboDeclarationId) + .withCreatedDate( + Option(account.getCompany.getOwnershipDeclaration) + .map(o => time.epochSecondToDate(o.getDate)) + .getOrElse(persistence.now()) + ) + .withStatus( + if (Option(account.getCompany.getOwnershipDeclaration).isDefined) + UboDeclaration.UboDeclarationStatus.UBO_DECLARATION_VALIDATED + else UboDeclaration.UboDeclarationStatus.UBO_DECLARATION_CREATED + ) + .withUbos(persons.map(personToUbo)) + ) + } + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + /** @param userId + * - Provider user id + * @param uboDeclarationId + * - Provider declaration id + * @return + * Ultimate Beneficial Owner declaration + */ + override def validateDeclaration( + userId: String, + uboDeclarationId: String, + ipAddress: String, + userAgent: String + ): Option[UboDeclaration] = { + Try { + val account = Account.retrieve(userId, StripeApi().requestOptions) + val persons = + account + .persons() + .list( + PersonCollectionListParams + .builder() + .setRelationship( + PersonCollectionListParams.Relationship + .builder() + .setOwner(true) + .build() + ) + .build(), + StripeApi().requestOptions + ) + .getData + .asScala + + val ownersProvided = + persons.map(_.getRelationship.getPercentOwnership.doubleValue()).sum == 100.0 + + val company = + TokenCreateParams.Account.Company + .builder() + .setOwnershipDeclaration( + TokenCreateParams.Account.Company.OwnershipDeclaration + .builder() + .setDate(persistence.now().getEpochSecond) + .setIp(ipAddress) + .setUserAgent(userAgent) + .build() + ) + .setOwnershipDeclarationShownAndSigned(ownersProvided) + .setOwnersProvided(ownersProvided) + + mlog.info(s"company -> ${new Gson().toJson(company.build())}") + + val token = Token.create( + TokenCreateParams.builder + .setAccount( + TokenCreateParams.Account + .builder() + .setBusinessType(TokenCreateParams.Account.BusinessType.COMPANY) + .setCompany(company.build()) + .setTosShownAndAccepted(true) + .build() + ) + .build(), + StripeApi().requestOptions + ) + + account.update( + AccountUpdateParams + .builder() + .setAccountToken(token.getId) + .build(), + StripeApi().requestOptions + ) + + Some( + model.UboDeclaration.defaultInstance + .withId(uboDeclarationId) + .withCreatedDate(persistence.now()) + .withStatus( + if (ownersProvided) UboDeclaration.UboDeclarationStatus.UBO_DECLARATION_VALIDATED + else UboDeclaration.UboDeclarationStatus.UBO_DECLARATION_INCOMPLETE + ) + .withUbos(persons.map(personToUbo)) + ) + } match { + case Success(declaration) => + declaration + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + /** @param maybeUserId + * - owner of the wallet + * @param currency + * - currency + * @param externalUuid + * - external unique id + * @param maybeWalletId + * - wallet id to update + * @return + * wallet id + */ + override def createOrUpdateWallet( + maybeUserId: Option[String], + currency: String, + externalUuid: String, + maybeWalletId: Option[String] + ): Option[String] = { + maybeUserId match { + case Some(userId) if userId.startsWith("acct") => // account + Try { + val account = Account.retrieve(userId, StripeApi().requestOptions) + val walletId = maybeWalletId.getOrElse(java.util.UUID.randomUUID().toString) + account + .update( + AccountUpdateParams + .builder() + .setDefaultCurrency(currency) + .putMetadata("external_uuid", externalUuid) + .putMetadata("wallet_id", walletId) + .build(), + StripeApi().requestOptions + ) + walletId + } match { + case Success(walletId) => Some(walletId) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case Some(userId) if userId.startsWith("cus") => // customer + Try { + val customer = Customer.retrieve(userId, StripeApi().requestOptions) + val walletId = maybeWalletId.getOrElse(java.util.UUID.randomUUID().toString) + customer + .update( + CustomerUpdateParams + .builder() + .putMetadata("external_uuid", externalUuid) + .putMetadata("wallet_id", walletId) + .build(), + StripeApi().requestOptions + ) + walletId + } match { + case Success(walletId) => Some(walletId) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case _ => + None + } + } + + /** @param userId + * - Provider user id + * @param externalUuid + * - external unique id + * @param pages + * - document pages + * @param documentType + * - document type + * @return + * Provider document id + */ + override def addDocument( + userId: String, + externalUuid: String, + pages: Seq[Array[Byte]], + documentType: KycDocument.KycDocumentType + ): Option[String] = { + Try(Account.retrieve(userId, StripeApi().requestOptions)) match { + case Success(account) => + pagesToFiles(pages, documentType) match { + case Success(files) => + Try { + val documentId = files.map(_.getId).mkString("#") + account.getBusinessType match { + case "individual" => + val verification = + documentType match { + case KycDocument.KycDocumentType.KYC_IDENTITY_PROOF => + val documentBuilder = AccountUpdateParams.Individual.Verification.Document + .builder() + .setFront(files.head.getId) + if (files.size > 1) { //TODO add additional document if more than 2 files ? + documentBuilder.setBack(files(1).getId) + } + val document = documentBuilder.build() + AccountUpdateParams.Individual.Verification + .builder() + .setDocument(document) + .build() + case KycDocument.KycDocumentType.KYC_ADDRESS_PROOF => + val documentBuilder = + AccountUpdateParams.Individual.Verification.AdditionalDocument + .builder() + .setFront(files.head.getId) + if (files.size > 1) { //TODO add additional document if more than 2 files ? + documentBuilder.setBack(files(1).getId) + } + val document = documentBuilder.build() + AccountUpdateParams.Individual.Verification + .builder() + .setAdditionalDocument(document) + .build() + case other => + throw new Exception(s"Invalid document type $other") + } + account + .update( + AccountUpdateParams + .builder() + .setIndividual( + AccountUpdateParams.Individual + .builder() + .setVerification(verification) + .build() + ) + .build(), + StripeApi().requestOptions + ) + Some(documentId) + case "company" => + def verify(verification: PersonUpdateParams.Verification) = + account + .persons() + .list( + PersonCollectionListParams + .builder() + .setRelationship( + PersonCollectionListParams.Relationship + .builder() + .setRepresentative(true) + .build() + ) + .build(), + StripeApi().requestOptions + ) + .getData + .asScala + .headOption match { + case Some(person) => + person.update( + PersonUpdateParams + .builder() + .setVerification(verification) + .build(), + StripeApi().requestOptions + ) + case _ => + throw new Exception("Representative not found") + } + documentType match { + case KycDocument.KycDocumentType.KYC_IDENTITY_PROOF => + val documentBuilder = PersonUpdateParams.Verification.Document + .builder() + .setFront(files.head.getId) + if (files.size > 1) { //TODO add additional document if more than 2 files ? + documentBuilder.setBack(files(1).getId) + } + val document = documentBuilder.build() + verify( + PersonUpdateParams.Verification + .builder() + .setDocument(document) + .build() + ) + Some(documentId) + case KycDocument.KycDocumentType.KYC_ADDRESS_PROOF => + val documentBuilder = PersonUpdateParams.Verification.AdditionalDocument + .builder() + .setFront(files.head.getId) + if (files.size > 1) { //TODO add additional document if more than 2 files ? + documentBuilder.setBack(files(1).getId) + } + val document = documentBuilder.build() + verify( + PersonUpdateParams.Verification + .builder() + .setAdditionalDocument(document) + .build() + ) + Some(documentId) + case KycDocument.KycDocumentType.KYC_REGISTRATION_PROOF => // TODO verify this document + val documentBuilder = AccountUpdateParams.Documents + .builder() + .setProofOfRegistration( + AccountUpdateParams.Documents.ProofOfRegistration + .builder() + .addAllFile(files.map(_.getId).asJava) + .build() + ) + val document = documentBuilder.build() + account.update( + AccountUpdateParams.builder().setDocuments(document).build(), + StripeApi().requestOptions + ) + Some(documentId) + case _ => + val documentBuilder = AccountUpdateParams.Company.Verification.Document + .builder() + .setFront(files.head.getId) + if (files.size > 1) { //TODO add additional document if more than 2 files ? + documentBuilder.setBack(files(1).getId) + } + val document = documentBuilder.build() + account + .update( + AccountUpdateParams + .builder() + .setCompany( + AccountUpdateParams.Company + .builder() + .setVerification( + AccountUpdateParams.Company.Verification + .builder() + .setDocument(document) + .build() + ) + .build() + ) + .build(), + StripeApi().requestOptions + ) + Some(documentId) + } + case _ => None + } + } match { + case Success(s) => s + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + /** @param userId + * - Provider user id + * @param documentId + * - Provider document id + * @param documentType + * - document type + * @return + * document validation report + */ + override def loadDocumentStatus( + userId: String, + documentId: String, + documentType: KycDocument.KycDocumentType + ): KycDocumentValidationReport = { + /*documentType match { + case KycDocument.KycDocumentType.KYC_IDENTITY_PROOF => + Try( + VerificationReport.list( + VerificationReportListParams + .builder() + .setType(VerificationReportListParams.Type.DOCUMENT) + .build(), + StripeApi().requestOptions + ) + ) match { + case Success(reports) => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus( + reports.getData.asScala.headOption match { + case Some(report) => + Option(report.getDocument.getStatus).getOrElse("pending") match { + case "pending" => KycDocument.KycDocumentStatus.KYC_DOCUMENT_VALIDATION_ASKED + case "verified" => KycDocument.KycDocumentStatus.KYC_DOCUMENT_VALIDATED + case "unverified" => + KycDocument.KycDocumentStatus.KYC_DOCUMENT_REFUSED // not necessarily refused, but it does mean that Stripe might request more information soon. + case _ => KycDocument.KycDocumentStatus.KYC_DOCUMENT_NOT_SPECIFIED + } + case _ => + KycDocument.KycDocumentStatus.KYC_DOCUMENT_NOT_SPECIFIED + } + ) + case Failure(f) => + mlog.error(f.getMessage, f) + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.Unrecognized(-1)) + } + case _ => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.KYC_DOCUMENT_NOT_SPECIFIED) + }*/ + Try(Account.retrieve(userId, StripeApi().requestOptions)) match { + case Success(account) => + /*account.getRequirements.getErrors.asScala.find(_.getRequirement == documentId /*FIXME*/) match { + case Some(error) => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.KYC_DOCUMENT_REFUSED) + .withRefusedReasonType(error.getCode) + .withRefusedReasonMessage(error.getReason) + case _ => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.KYC_DOCUMENT_NOT_SPECIFIED) + }*/ + account.getBusinessType match { + case "individual" => + verifyDocumentValidationReport( + documentId, + documentType, + account.getIndividual.getVerification + ) + case "company" => + documentType match { + case KycDocument.KycDocumentType.KYC_IDENTITY_PROOF | + KycDocument.KycDocumentType.KYC_ADDRESS_PROOF => + account + .persons() + .list( + PersonCollectionListParams + .builder() + .setRelationship( + PersonCollectionListParams.Relationship + .builder() + .setRepresentative(true) + .build() + ) + .build(), + StripeApi().requestOptions + ) + .getData + .asScala + .headOption match { + case Some(person) => + verifyDocumentValidationReport( + documentId, + documentType, + person.getVerification + ) + case _ => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.Unrecognized(-1)) + } + case _ => + Option(account.getCompany.getVerification) match { + case Some(verification) => + Option(verification.getDocument.getDetailsCode) match { + case Some(detailsCode) => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.KYC_DOCUMENT_REFUSED) + .withRefusedReasonType(detailsCode) + .copy( + refusedReasonMessage = Option(verification.getDocument.getDetails) + ) + case _ => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.KYC_DOCUMENT_VALIDATED) + } + case _ => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.Unrecognized(-1)) + } + + } + + case _ => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.Unrecognized(-1)) + } + case Failure(f) => + mlog.error(f.getMessage, f) + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(KycDocument.KycDocumentStatus.Unrecognized(-1)) + } + } + + /** @param maybeBankAccount + * - bank account to create + * @return + * bank account id + */ + override def createOrUpdateBankAccount(maybeBankAccount: Option[BankAccount]): Option[String] = { + maybeBankAccount match { + case Some(bankAccount) => + Try(Account.retrieve(bankAccount.userId, StripeApi().requestOptions)) match { + case Success(account) => + Try { + val bank_account = + TokenCreateParams.BankAccount + .builder() + .setAccountNumber(bankAccount.iban) + .setRoutingNumber(bankAccount.bic) + .setCountry(bankAccount.countryCode.getOrElse("FR")) + .setCurrency(bankAccount.currency.getOrElse("EUR")) + .setAccountHolderName(bankAccount.ownerName) + .setAccountHolderType(account.getBusinessType match { + case "individual" => TokenCreateParams.BankAccount.AccountHolderType.INDIVIDUAL + case _ => TokenCreateParams.BankAccount.AccountHolderType.COMPANY + }) + .build() + val token = Token.create( + TokenCreateParams.builder().setBankAccount(bank_account).build(), + StripeApi().requestOptions + ) + val params = + ExternalAccountCollectionCreateParams + .builder() + .setExternalAccount(token.getId) + .setDefaultForCurrency(true) + .putMetadata("external_uuid", bankAccount.externalUuid) + .putMetadata("active", "true") + .putMetadata("default_for_currency", bank_account.getCurrency) + .build() + account.getExternalAccounts.create( + params, + StripeApi().requestOptions + ) + } match { + case Success(externalAccount) => + // FIXME we shouldn't have to do this + // but the stripe api does not seem to take into account the request options + Stripe.apiKey = provider.providerApiKey + Try( + account.getExternalAccounts + .list( + ExternalAccountCollectionListParams + .builder() + .setObject("bank_account") + .build() + ) + .getData + .asScala + .filter(_.getId != externalAccount.getId) + .map( + _.delete( + StripeApi().requestOptions + ) + ) + ) match { + case Failure(f) => + mlog.error(f.getMessage, f) + case _ => + } + /*bankAccount.id match { + case Some(id) => + Try( + account.getExternalAccounts + .retrieve(id, StripeApi().requestOptions) + .delete(StripeApi().requestOptions) + ) match { + case Failure(f) => + mlog.error(f.getMessage, f) + case _ => + } + case _ => + }*/ + Some(externalAccount.getId) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case _ => None + } + } + + /** @param userId + * - provider user id + * @return + * the first active bank account + */ + override def getActiveBankAccount(userId: String, currency: String): Option[String] = { + Try(Account.retrieve(userId, StripeApi().requestOptions)) match { + case Success(account) => + account.getExternalAccounts + .list( + ExternalAccountCollectionListParams + .builder() + .setObject("bank_account") + .build() + ) + .getData + .asScala + .find(externalAccount => + externalAccount + .asInstanceOf[com.stripe.model.BankAccount] + .getMetadata + .get("default_for_currency") + .contains(currency) + && + externalAccount + .asInstanceOf[com.stripe.model.BankAccount] + .getMetadata + .get("active") + .contains("true") + ) match { + case Some(externalAccount) => + Some(externalAccount.getId) + case _ => + None + } + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + private[this] def verifyDocumentValidationReport( + documentId: String, + documentType: KycDocument.KycDocumentType, + verification: Person.Verification + ) = { + val status = + Option(verification.getStatus).getOrElse("pending") match { + case "pending" => KycDocument.KycDocumentStatus.KYC_DOCUMENT_VALIDATION_ASKED + case "verified" => KycDocument.KycDocumentStatus.KYC_DOCUMENT_VALIDATED + case "unverified" => + KycDocument.KycDocumentStatus.KYC_DOCUMENT_REFUSED // not necessarily refused, but it does mean that Stripe might request more information soon. + case _ => KycDocument.KycDocumentStatus.KYC_DOCUMENT_NOT_SPECIFIED + } + status match { + case KycDocument.KycDocumentStatus.KYC_DOCUMENT_REFUSED => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(status) + .copy( + refusedReasonType = documentType match { + case KycDocument.KycDocumentType.KYC_IDENTITY_PROOF => + Option(verification.getDocument) + .map(_.getDetailsCode) + .orElse(Option(verification.getDetailsCode)) + case KycDocument.KycDocumentType.KYC_ADDRESS_PROOF => + Option(verification.getAdditionalDocument) + .map(_.getDetailsCode) + .orElse(Option(verification.getDetailsCode)) + case _ => None + }, + refusedReasonMessage = documentType match { + case KycDocument.KycDocumentType.KYC_IDENTITY_PROOF => + Option(verification.getDocument) + .map(_.getDetails) + .orElse(Option(verification.getDetails)) + case KycDocument.KycDocumentType.KYC_ADDRESS_PROOF => + Option(verification.getAdditionalDocument) + .map(_.getDetails) + .orElse(Option(verification.getDetails)) + case _ => None + } + ) + .withRefusedReasonType(verification.getDetailsCode) + .withRefusedReasonMessage(verification.getDetails) + case _ => + KycDocumentValidationReport.defaultInstance + .withId(documentId) + .withType(documentType) + .withStatus(status) + } + } + + private[this] def createOrUpdateOwner( + account: Account, + owner: UltimateBeneficialOwner + ): Option[String] = { + val birthday = owner.birthday + val sdf = new SimpleDateFormat("dd/MM/yyyy") + sdf.setTimeZone(TimeZone.getTimeZone("UTC")) + Try(sdf.parse(birthday)) match { + case Success(date) => + val c = Calendar.getInstance() + c.setTime(date) + Try( + (owner.id match { + case Some(id) => + Option(account.persons().retrieve(id, StripeApi().requestOptions)) + case _ => + account + .persons() + .list( + PersonCollectionListParams + .builder() + .setRelationship( + PersonCollectionListParams.Relationship + .builder() + .setOwner(true) + .build() + ) + .build(), + StripeApi().requestOptions + ) + .getData + .asScala + .find { person => + person.getFirstName == owner.firstName && person.getLastName == owner.lastName + } + }) match { + case Some(person) => + // update owner + val params = + PersonUpdateParams + .builder() + .setAddress( + PersonUpdateParams.Address + .builder() + .setCity(owner.city) + .setCountry(owner.country) + .setLine1(owner.address) + .setPostalCode(owner.postalCode) + .build() + ) + .setFirstName(owner.firstName) + .setLastName(owner.lastName) + .setDob( + PersonUpdateParams.Dob + .builder() + .setDay(c.get(Calendar.DAY_OF_MONTH)) + .setMonth(c.get(Calendar.MONTH)) + .setYear(c.get(Calendar.YEAR)) + .build() + ) + .setRelationship( + owner.percentOwnership match { + case Some(ownership) => + PersonUpdateParams.Relationship + .builder() + .setPercentOwnership(new java.math.BigDecimal(ownership)) + .setOwner(true) + .build() + case _ => + PersonUpdateParams.Relationship + .builder() + .setOwner(true) + .build() + } + ) + + owner.email match { + case Some(email) => + params.setEmail(email) + case _ => + } + + owner.phone match { + case Some(phone) => + params.setPhone(phone) + case _ => + } + + mlog.info(s"owner -> ${new Gson().toJson(params.build())}") + + person.update(params.build(), StripeApi().requestOptions) + case _ => + // create owner + val params = + PersonCollectionCreateParams + .builder() + .setAddress( + PersonCollectionCreateParams.Address + .builder() + .setCity(owner.city) + .setCountry(owner.country) + .setLine1(owner.address) + .setPostalCode(owner.postalCode) + .build() + ) + .setFirstName(owner.firstName) + .setLastName(owner.lastName) + .setDob( + PersonCollectionCreateParams.Dob + .builder() + .setDay(c.get(Calendar.DAY_OF_MONTH)) + .setMonth(c.get(Calendar.MONTH)) + .setYear(c.get(Calendar.YEAR)) + .build() + ) + .setRelationship( + owner.percentOwnership match { + case Some(ownership) => + PersonCollectionCreateParams.Relationship + .builder() + .setPercentOwnership(new java.math.BigDecimal(ownership)) + .setOwner(true) + .build() + case _ => + PersonCollectionCreateParams.Relationship + .builder() + .setOwner(true) + .build() + } + ) + + owner.email match { + case Some(email) => + params.setEmail(email) + case _ => + } + + owner.phone match { + case Some(phone) => + params.setPhone(phone) + case _ => + } + + mlog.info(s"owner -> ${new Gson().toJson(params.build())}") + + account + .persons() + .create(params.build(), StripeApi().requestOptions) + } + ) match { + case Success(person) => + Some(person.getId) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + private[this] def createOrUpdateCustomer(naturalUser: NaturalUser): Option[String] = { + Try { + (naturalUser.userId match { + case Some(userId) => + Option(Customer.retrieve(userId, StripeApi().requestOptions)) + case _ => + Customer + .list( + CustomerListParams.builder().setEmail(naturalUser.email).build(), + StripeApi().requestOptions + ) + .getData + .asScala + .headOption + }) match { + // we should not allow a customer to update his email address + case Some(customer) if customer.getEmail == naturalUser.email => + val params = CustomerUpdateParams + .builder() + //.setEmail(naturalUser.email) + .setName(s"${naturalUser.firstName} ${naturalUser.lastName}") + .setMetadata( + Map( + "external_uuid" -> naturalUser.externalUuid + ).asJava + ) + + naturalUser.phone match { + case Some(phone) => + params.setPhone(phone) + case _ => + } + + naturalUser.address match { + case Some(address) => + params.setAddress( + CustomerUpdateParams.Address + .builder() + .setCity(address.city) + .setCountry(address.country) + .setLine1(address.addressLine) + .setPostalCode(address.postalCode) + .build() + ) + case _ => + } + + mlog.info(s"customer -> ${new Gson().toJson(params.build())}") + + customer.update(params.build(), StripeApi().requestOptions) + + case _ => + val params = CustomerCreateParams + .builder() + .setEmail(naturalUser.email) + .setName(s"${naturalUser.firstName} ${naturalUser.lastName}") + .setMetadata( + Map( + "external_uuid" -> naturalUser.externalUuid + ).asJava + ) + + naturalUser.phone match { + case Some(phone) => + params.setPhone(phone) + case _ => + } + + naturalUser.address match { + case Some(address) => + params.setAddress( + CustomerCreateParams.Address + .builder() + .setCity(address.city) + .setCountry(address.country) + .setLine1(address.addressLine) + .setPostalCode(address.postalCode) + .build() + ) + case _ => + } + + mlog.info(s"customer -> ${new Gson().toJson(params.build())}") + + Customer.create(params.build(), StripeApi().requestOptions) + } + } match { + case Success(customer) => Some(customer.getId) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeCardApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeCardApi.scala new file mode 100644 index 0000000..5153f0c --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeCardApi.scala @@ -0,0 +1,391 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.config.StripeApi +import app.softnetwork.payment.model.{ + Card, + CardPreRegistration, + PreAuthorizationTransaction, + Transaction +} +import app.softnetwork.serialization.asJson +import com.google.gson.Gson +import com.stripe.model.{Customer, PaymentIntent, PaymentMethod, SetupIntent} +import com.stripe.param.{ + PaymentIntentCancelParams, + PaymentIntentConfirmParams, + PaymentIntentCreateParams, + PaymentMethodAttachParams, + PaymentMethodDetachParams, + SetupIntentCreateParams +} + +import scala.util.{Failure, Success, Try} + +trait StripeCardApi extends CardApi { _: StripeContext => + + /** @param maybeUserId + * - owner of the card + * @param currency + * - currency + * @param externalUuid + * - external unique id + * @return + * card pre registration + */ + override def preRegisterCard( + maybeUserId: Option[String], + currency: String, + externalUuid: String + ): Option[CardPreRegistration] = { + maybeUserId match { + case Some(userId) => + Try { + val customer = + Customer.retrieve(userId, StripeApi().requestOptions) + + val params = + SetupIntentCreateParams + .builder() + .addPaymentMethodType("card") + .setCustomer(customer.getId) + .setUsage(SetupIntentCreateParams.Usage.OFF_SESSION) + .addFlowDirection(SetupIntentCreateParams.FlowDirection.INBOUND) + .putMetadata("currency", currency) + .putMetadata("external_uuid", externalUuid) + // TODO check if return url is required + + SetupIntent.create(params.build(), StripeApi().requestOptions) + } match { + case Success(setupIntent) => + mlog.info(s"Card pre registered for user $userId -> ${new Gson().toJson(setupIntent)}") + Some( + CardPreRegistration.defaultInstance + .withId(setupIntent.getId) + .withAccessKey(setupIntent.getClientSecret) + ) + case Failure(f) => + mlog.error(s"Error while pre registering card for user $userId", f) + None + } + case _ => None + } + } + + /** @param cardPreRegistrationId + * - card registration id + * @param maybeRegistrationData + * - card registration data + * @return + * card id + */ + override def createCard( + cardPreRegistrationId: String, + maybeRegistrationData: Option[String] + ): Option[String] = { + Try { + // retrieve setup intent + val setupIntent = + SetupIntent.retrieve(cardPreRegistrationId, StripeApi().requestOptions) + // attach payment method to customer + PaymentMethod + .retrieve(setupIntent.getPaymentMethod, StripeApi().requestOptions) + .attach( + PaymentMethodAttachParams.builder().setCustomer(setupIntent.getCustomer).build(), + StripeApi().requestOptions + ) + } match { + case Success(paymentMethod) => + mlog.info(s"Card ${paymentMethod.getId} attached to customer ${paymentMethod.getCustomer}") + Some(paymentMethod.getId) + case Failure(f) => + mlog.error(s"Error while creating card for $cardPreRegistrationId", f) + None + } + } + + /** @param cardId + * - card id + * @return + * card + */ + override def loadCard(cardId: String): Option[Card] = { + Try { + PaymentMethod.retrieve(cardId, StripeApi().requestOptions) + } match { + case Success(paymentMethod) => + Option(paymentMethod.getCard) match { + case Some(card) => + Some( + Card.defaultInstance + .withId(cardId) + .withExpirationDate(s"${card.getExpMonth}/${card.getExpYear}") + .withAlias(card.getLast4) + .withBrand(card.getBrand) + .withActive( + Option(paymentMethod.getCustomer).isDefined + ) // if detached from customer, it is disabled + ) + case _ => None + } + case Failure(f) => + mlog.error(s"Error while loading card $cardId", f) + None + } + } + + /** @param cardId + * - the id of the card to disable + * @return + * the card disabled or none + */ + override def disableCard(cardId: String): Option[Card] = { + Try { + val requestOptions = StripeApi().requestOptions + PaymentMethod + .retrieve(cardId, requestOptions) + .detach(PaymentMethodDetachParams.builder().build(), requestOptions) + } match { + case Success(paymentMethod) => + mlog.info(s"Card $cardId detached from customer ${paymentMethod.getCustomer}") + val card = paymentMethod.getCard + Some( + Card.defaultInstance + .withId(cardId) + .withExpirationDate(s"${card.getExpMonth}/${card.getExpYear}") + .withAlias(card.getLast4) + .withBrand(card.getBrand) + .withActive(false) + ) + case Failure(f) => + mlog.error(s"Error while disabling card $cardId", f) + None + } + } + + /** @param preAuthorizationTransaction + * - pre authorization transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pre authorization transaction result + */ + override def preAuthorizeCard( + preAuthorizationTransaction: PreAuthorizationTransaction, + idempotency: Option[Boolean] + ): Option[Transaction] = { + Try { + val params = + PaymentIntentCreateParams + .builder() + .setAmount(preAuthorizationTransaction.debitedAmount) + .setCurrency(preAuthorizationTransaction.currency) + .setCustomer(preAuthorizationTransaction.authorId) + .setPaymentMethod(preAuthorizationTransaction.cardId) + .setCaptureMethod( + PaymentIntentCreateParams.CaptureMethod.MANUAL + ) // To capture funds later + .setConfirm(true) // Confirm the PaymentIntent immediately + //.setOffSession(true) // For off-session payments + .setReturnUrl( + s"${config.preAuthorizeCardReturnUrl}/${preAuthorizationTransaction.orderUuid}?preAuthorizationIdParameter=payment_intent®isterCard=${preAuthorizationTransaction.registerCard + .getOrElse(false)}&printReceipt=${preAuthorizationTransaction.printReceipt.getOrElse(false)}" + ) + .setTransferGroup(preAuthorizationTransaction.orderUuid) + .putMetadata("order_uuid", preAuthorizationTransaction.orderUuid) + .putMetadata("transaction_type", "pre_authorization") + .putMetadata("payment_type", "card") + preAuthorizationTransaction.creditedUserId match { + case Some(creditedUserId) => + params.setTransferData( + PaymentIntentCreateParams.TransferData.builder().setDestination(creditedUserId).build() + ) + preAuthorizationTransaction.feesAmount match { + case Some(feesAmount) => + params.setApplicationFeeAmount(feesAmount) + case _ => + } + case _ => + } + + preAuthorizationTransaction.statementDescriptor match { + case Some(statementDescriptor) => + params.setStatementDescriptor(statementDescriptor) + case _ => + } + + mlog.info( + s"Creating card pre authorization for order ${preAuthorizationTransaction.orderUuid} -> ${new Gson() + .toJson(params.build())}" + ) + PaymentIntent.create(params.build(), StripeApi().requestOptions) + } match { + case Success(paymentIntent) => + mlog.info( + s"Card pre authorization created for order -> ${preAuthorizationTransaction.orderUuid}" + ) + val status = paymentIntent.getStatus + var transaction = + Transaction() + .withId(paymentIntent.getId) + .withOrderUuid(preAuthorizationTransaction.orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.PRE_AUTHORIZATION) + .withAmount(preAuthorizationTransaction.debitedAmount) + .withCardId(preAuthorizationTransaction.cardId) + .withFees(0) + .withResultCode(status) + .withAuthorId(paymentIntent.getCustomer) + .withPaymentType(Transaction.PaymentType.CARD) + .withCurrency(paymentIntent.getCurrency) + .copy( + creditedUserId = preAuthorizationTransaction.creditedUserId, + fees = preAuthorizationTransaction.feesAmount.getOrElse(0) + ) + + if ( + status == "requires_action" && paymentIntent.getNextAction.getType == "redirect_to_url" + ) { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_CREATED, + //The URL you must redirect your customer to in order to authenticate the payment. + redirectUrl = Option(paymentIntent.getNextAction.getRedirectToUrl.getUrl) + ) + } else if (status == "requires_payment_method") { + transaction = transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_FAILED) + } else if (status == "succeeded" || status == "requires_capture") { + transaction = + transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + } else { + transaction = transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_CREATED) + } + + mlog.info( + s"Card pre authorization created for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + /** @param orderUuid + * - order unique id + * @param cardPreAuthorizedTransactionId + * - card pre authorized transaction id + * @return + * card pre authorized transaction + */ + override def loadCardPreAuthorized( + orderUuid: String, + cardPreAuthorizedTransactionId: String + ): Option[Transaction] = { + Try { + PaymentIntent.retrieve(cardPreAuthorizedTransactionId, StripeApi().requestOptions) + } match { + case Success(paymentIntent) => + val status = paymentIntent.getStatus + var transaction = + Transaction() + .withId(paymentIntent.getId) + .withOrderUuid(orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.PRE_AUTHORIZATION) + .withAmount(paymentIntent.getAmount.intValue()) + .withCardId(paymentIntent.getPaymentMethod) + .withFees(0) + .withResultCode(status) + .withAuthorId(paymentIntent.getCustomer) + .withPaymentType(Transaction.PaymentType.CARD) + .withCurrency(paymentIntent.getCurrency) + + if ( + status == "requires_action" && paymentIntent.getNextAction.getType == "redirect_to_url" + ) { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_CREATED, + //The URL you must redirect your customer to in order to authenticate the payment. + returnUrl = Option(paymentIntent.getNextAction.getRedirectToUrl.getUrl) + ) + } else if (status == "succeeded") { + transaction = + transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + } else if (status == "requires_payment_method") { + transaction = transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_FAILED) + } else { + transaction = transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_CREATED) + } + + // TODO retrieve preAuthorizationCanceled, preAuthorizationValidated, preAuthorizationExpired + Some(transaction) + + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + /** @param orderUuid + * - order unique id + * @param cardPreAuthorizedTransactionId + * - card pre authorized transaction id + * @return + * whether pre authorization transaction has been cancelled or not + */ + override def cancelPreAuthorization( + orderUuid: String, + cardPreAuthorizedTransactionId: String + ): Boolean = { + Try { + PaymentIntent + .retrieve(cardPreAuthorizedTransactionId, StripeApi().requestOptions) + .cancel(PaymentIntentCancelParams.builder().build(), StripeApi().requestOptions) + } match { + case Success(_) => + mlog.info(s"Card pre authorization canceled for order -> $orderUuid") + true + case Failure(f) => + mlog.error(f.getMessage, f) + false + } + } + + /** @param orderUuid + * - order unique id + * @param cardPreAuthorizedTransactionId + * - card pre authorized transaction id + * @return + * whether pre authorization transaction has been validated or not + */ + override def validatePreAuthorization( + orderUuid: String, + cardPreAuthorizedTransactionId: String + ): Boolean = { + Try { + val resource = + PaymentIntent + .retrieve(cardPreAuthorizedTransactionId, StripeApi().requestOptions) + resource.getStatus match { + case "requires_confirmation" => + resource.confirm( + PaymentIntentConfirmParams.builder().build(), + StripeApi().requestOptions + ) + case _ => resource + } + } match { + case Success(paymentIntent) => + paymentIntent.getStatus match { + case "succeeded" => + mlog.info(s"Card pre authorization validated for order -> $orderUuid") + true + case _ => false + } + case Failure(f) => + mlog.error(f.getMessage, f) + false + } + } +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeDirectDebitApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeDirectDebitApi.scala new file mode 100644 index 0000000..93d5524 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeDirectDebitApi.scala @@ -0,0 +1,219 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.config.StripeApi +import app.softnetwork.payment.model.{ + BankAccount, + DirectDebitTransaction, + MandateResult, + Transaction +} +import app.softnetwork.persistence +import app.softnetwork.time.dateToInstant +import com.stripe.model.SetupIntent +import com.stripe.param.SetupIntentCreateParams + +import java.util.Date +import scala.util.{Failure, Success, Try} + +trait StripeDirectDebitApi extends DirectDebitApi { _: StripeContext => + + /** @param externalUuid + * - external unique id + * @param userId + * - Provider user id + * @param bankAccountId + * - Bank account id + * @param idempotencyKey + * - whether to use an idempotency key for this request or not + * @return + * mandate result + */ + override def mandate( + externalUuid: String, + userId: String, + bankAccountId: String, + idempotencyKey: Option[String] + ): Option[MandateResult] = { + Try { + val params = + SetupIntentCreateParams + .builder() + // sepa direct debit + .setPaymentMethodOptions( + SetupIntentCreateParams.PaymentMethodOptions + .builder() + .setSepaDebit( + SetupIntentCreateParams.PaymentMethodOptions.SepaDebit + .builder() + .setMandateOptions( + SetupIntentCreateParams.PaymentMethodOptions.SepaDebit.MandateOptions + .builder() + .build() + ) + .build() + ) + .build() + ) + // payment method + .setPaymentMethod( + bankAccountId + ) + /*.setAutomaticPaymentMethods( + SetupIntentCreateParams.AutomaticPaymentMethods.builder().setEnabled(true).build() + )*/ + // automatic confirmation + .setConfirm(true) + .setMandateData( + SetupIntentCreateParams.MandateData + .builder() + .setCustomerAcceptance( + SetupIntentCreateParams.MandateData.CustomerAcceptance + .builder() + .setAcceptedAt(persistence.now().getEpochSecond) + .setOffline( + SetupIntentCreateParams.MandateData.CustomerAcceptance.Offline + .builder() + .build() + ) + .build() + ) + .build() + ) + // on behalf of + .setOnBehalfOf(userId) + // off session usage + .setUsage(SetupIntentCreateParams.Usage.OFF_SESSION) + // the mandate will be attached to the in-context stripe account + .setAttachToSelf(true) + // return url + .setReturnUrl( + s"${config.mandateReturnUrl}?externalUuid=$externalUuid&idempotencyKey=${idempotencyKey + .getOrElse("")}" + ) + .build() + + SetupIntent.create(params, StripeApi().requestOptions) + + } match { + case Success(setupIntent) => + val status = setupIntent.getStatus + var mandate = + MandateResult.defaultInstance + .withId(setupIntent.getId) + .withResultCode(status) + status match { + case "succeeded" => + mandate = mandate.withStatus(BankAccount.MandateStatus.MANDATE_ACTIVATED) + case "requires_action" => + mandate = mandate + .withStatus(BankAccount.MandateStatus.MANDATE_CREATED) + .withRedirectUrl(setupIntent.getNextAction.getRedirectToUrl.getUrl) + .withResultMessage(setupIntent.getNextAction.getType) + case "processing" => + mandate = mandate + .withStatus(BankAccount.MandateStatus.MANDATE_SUBMITTED) + case "canceled" => + mandate = mandate + .withStatus(BankAccount.MandateStatus.MANDATE_EXPIRED) + .withResultMessage("the confirmation limit has been reached") + case _ => + mandate = mandate + .withStatus(BankAccount.MandateStatus.MANDATE_FAILED) + .withResultMessage("the mandate has failed") + } + + Some(mandate) + + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + /** @param maybeMandateId + * - optional mandate id + * @param userId + * - Provider user id + * @param bankAccountId + * - bank account id + * @return + * mandate associated to this bank account + */ + override def loadMandate( + maybeMandateId: Option[String], + userId: String, + bankAccountId: String + ): Option[MandateResult] = { + maybeMandateId match { + case Some(mandateId) => + Try(SetupIntent.retrieve(mandateId, StripeApi().requestOptions)) match { + case Success(setupIntent) => + Some( + MandateResult.defaultInstance + .withId(setupIntent.getId) + .withStatus( + setupIntent.getStatus match { + case "succeeded" => BankAccount.MandateStatus.MANDATE_ACTIVATED + case "processing" => BankAccount.MandateStatus.MANDATE_SUBMITTED + case "canceled" => BankAccount.MandateStatus.MANDATE_EXPIRED + case _ => BankAccount.MandateStatus.MANDATE_FAILED + } + ) + ) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case _ => None + } + } + + /** @param mandateId + * - Provider mandate id + * @return + * mandate result + */ + override def cancelMandate(mandateId: String): Option[MandateResult] = { + Try { + val requestOptions = StripeApi().requestOptions + SetupIntent.retrieve(mandateId, requestOptions).cancel(requestOptions) + } match { + case Success(setupIntent) => + Some( + MandateResult.defaultInstance + .withId(setupIntent.getId) + .withStatus(BankAccount.MandateStatus.MANDATE_EXPIRED) + ) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } + + /** @param maybeDirectDebitTransaction + * - direct debit transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * direct debit transaction result + */ + override def directDebit( + maybeDirectDebitTransaction: Option[DirectDebitTransaction], + idempotency: Option[Boolean] + ): Option[Transaction] = None //TODO + + /** @param walletId + * - Provider wallet id + * @param transactionId + * - Provider transaction id + * @param transactionDate + * - Provider transaction date + * @return + * transaction if it exists + */ + override def loadDirectDebitTransaction( + walletId: String, + transactionId: String, + transactionDate: Date + ): Option[Transaction] = None //TODO +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayInApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayInApi.scala new file mode 100644 index 0000000..faf4643 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayInApi.scala @@ -0,0 +1,619 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.config.StripeApi +import app.softnetwork.payment.model.{ + PayInWithCardPreAuthorizedTransaction, + PayInWithCardTransaction, + PayInWithPayPalTransaction, + Transaction +} +import app.softnetwork.serialization.asJson +import com.google.gson.Gson +import com.stripe.model.PaymentIntent +import com.stripe.param.{ + PaymentIntentCaptureParams, + PaymentIntentCreateParams, + PaymentIntentUpdateParams +} + +import scala.util.{Failure, Success, Try} +import collection.JavaConverters._ + +trait StripePayInApi extends PayInApi { _: StripeContext => + + /** @param payInWithCardPreAuthorizedTransaction + * - card pre authorized pay in transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay in with card pre authorized transaction result + */ + private[spi] override def payInWithCardPreAuthorized( + payInWithCardPreAuthorizedTransaction: Option[PayInWithCardPreAuthorizedTransaction], + idempotency: Option[Boolean] + ): Option[Transaction] = { + payInWithCardPreAuthorizedTransaction match { + case Some(payInWithCardPreAuthorizedTransaction) => + Try { + mlog.info( + s"Capturing payment intent for order: ${payInWithCardPreAuthorizedTransaction.orderUuid}" + ) + + val requestOptions = StripeApi().requestOptions + + var resource = PaymentIntent + .retrieve( + payInWithCardPreAuthorizedTransaction.cardPreAuthorizedTransactionId, + requestOptions + ) + + // optionally update fees amount + resource = + Option(resource.getTransferData).flatMap(td => Option(td.getDestination)) match { + case Some(_) => + payInWithCardPreAuthorizedTransaction.feesAmount match { + case Some(feesAmount) + if feesAmount != Option(resource.getApplicationFeeAmount) + .map(_.intValue()) + .getOrElse(0) => + resource.update( + PaymentIntentUpdateParams + .builder() + .setApplicationFeeAmount(feesAmount) + .build(), + requestOptions + ) + case _ => resource + } + case _ => resource + } + + resource.getStatus match { + case "requires_capture" => + val params = + PaymentIntentCaptureParams + .builder() + .setAmountToCapture( + Math.min( + resource.getAmountCapturable.intValue(), + payInWithCardPreAuthorizedTransaction.debitedAmount + ) + ) + .putMetadata("transaction_type", "pay_in") + .putMetadata("payment_type", "card") + .putMetadata( + "pre_authorization_id", + payInWithCardPreAuthorizedTransaction.cardPreAuthorizedTransactionId + ) + .putMetadata("order_uuid", payInWithCardPreAuthorizedTransaction.orderUuid) + /*.setTransferData( // transfer destination has to be set in the payment intent creation + PaymentIntentCaptureParams.TransferData + .builder() + .setAmount(payInWithCardPreAuthorizedTransaction.debitedAmount) + .build() + )*/ + + payInWithCardPreAuthorizedTransaction.statementDescriptor match { + case Some(statementDescriptor) => + params.setStatementDescriptor(statementDescriptor) + case _ => + } + + resource.capture( + params.build(), + requestOptions + ) + case _ => resource + } + } match { + case Success(payment) => + val status = payment.getStatus + var transaction = + Transaction() + .withId(payment.getId) + .withOrderUuid(payInWithCardPreAuthorizedTransaction.orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.PAYIN) + .withAmount(payment.getAmountReceived.intValue()) + .withFees(Option(payment.getApplicationFeeAmount).map(_.intValue()).getOrElse(0)) + .withResultCode(status) + .withPaymentType(Transaction.PaymentType.PREAUTHORIZED) + .withPreAuthorizationId( + payInWithCardPreAuthorizedTransaction.cardPreAuthorizedTransactionId + ) + .withPreAuthorizationDebitedAmount( + payment.getAmountCapturable + .intValue() //payInWithCardPreAuthorizedTransaction.preAuthorizationDebitedAmount + ) + .withCardId(payment.getPaymentMethod) + .withAuthorId(payment.getCustomer) + // .withCreditedUserId( + // payInWithCardPreAuthorizedTransaction.creditedWalletId + // ) // the credited wallet id is not the user id + .withCreditedWalletId(payInWithCardPreAuthorizedTransaction.creditedWalletId) + .withSourceTransactionId( + payInWithCardPreAuthorizedTransaction.cardPreAuthorizedTransactionId + ) + .copy( + creditedUserId = + Option(payment.getTransferData).flatMap(td => Option(td.getDestination)) + ) + if (status == "succeeded") { + transaction = + transaction.withStatus(Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + } else { + transaction = transaction.withStatus(Transaction.TransactionStatus.TRANSACTION_FAILED) + } + + mlog.info( + s"Payment intent captured for order: ${payInWithCardPreAuthorizedTransaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + case Failure(f) => + mlog.error(s"Failed to capture payment intent: ${f.getMessage}") + None + } + case _ => None + } + } + + /** @param maybePayInTransaction + * - pay in transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay in transaction result + */ + private[spi] override def payInWithCard( + maybePayInTransaction: Option[PayInWithCardTransaction], + idempotency: Option[Boolean] + ): Option[Transaction] = { + maybePayInTransaction match { + case Some(payInTransaction) => + Try { + val requestOptions = StripeApi().requestOptions + + val params = + PaymentIntentCreateParams + .builder() + .setAmount(payInTransaction.debitedAmount) + .setCurrency(payInTransaction.currency) + .setCustomer(payInTransaction.authorId) + + /*.setTransferData( + PaymentIntentCreateParams.TransferData.builder().setDestination(payInTransaction.creditedWalletId).build() + )*/ + .setCaptureMethod(PaymentIntentCreateParams.CaptureMethod.MANUAL) + //.setOffSession(true) // For off-session payments + .setStatementDescriptorSuffix(payInTransaction.statementDescriptor) + .setTransferGroup(payInTransaction.orderUuid) + .putMetadata("order_uuid", payInTransaction.orderUuid) + .putMetadata("transaction_type", "pay_in") + .putMetadata("payment_type", "card") + .putMetadata("print_receipt", payInTransaction.printReceipt.getOrElse(false).toString) + .putMetadata("register_card", payInTransaction.registerCard.getOrElse(false).toString) + + Option(payInTransaction.cardId) match { + case Some(cardId) => + params + .setPaymentMethod(cardId) + .setConfirm(true) // Confirm the PaymentIntent immediately + .setReturnUrl( + s"${config.payInReturnUrl}/${payInTransaction.orderUuid}?transactionIdParameter=payment_intent®isterCard=${payInTransaction.registerCard + .getOrElse(false)}&printReceipt=${payInTransaction.printReceipt.getOrElse(false)}" + ) + case _ => + params + .addPaymentMethodType("card") + .setPaymentMethodOptions( + PaymentIntentCreateParams.PaymentMethodOptions + .builder() + .setCard( + PaymentIntentCreateParams.PaymentMethodOptions.Card + .builder() + .setCaptureMethod( + PaymentIntentCreateParams.PaymentMethodOptions.Card.CaptureMethod.MANUAL + ) + .setRequestThreeDSecure( + PaymentIntentCreateParams.PaymentMethodOptions.Card.RequestThreeDSecure.AUTOMATIC + ) + .build() + ) + .build() + ) + } + + payInTransaction.registerCard match { + case Some(true) => + params.setSetupFutureUsage(PaymentIntentCreateParams.SetupFutureUsage.OFF_SESSION) + case _ => + } + + payInTransaction.ipAddress match { + case Some(ipAddress) => + + var onlineParams = + PaymentIntentCreateParams.MandateData.CustomerAcceptance.Online + .builder() + .setIpAddress(ipAddress) + + payInTransaction.browserInfo.map(_.userAgent) match { + case Some(userAgent) => + onlineParams = onlineParams.setUserAgent(userAgent) + case _ => + } + + params + .setMandateData( + PaymentIntentCreateParams.MandateData + .builder() + .setCustomerAcceptance( + PaymentIntentCreateParams.MandateData.CustomerAcceptance + .builder() + .setAcceptedAt((System.currentTimeMillis() / 1000).toInt) + .setOnline(onlineParams.build()) + .build() + ) + .build() + ) + + case _ => + + } + + mlog.info( + s"Creating pay in for order ${payInTransaction.orderUuid} -> ${new Gson() + .toJson(params.build())}" + ) + + val resource = PaymentIntent.create(params.build(), requestOptions) + + resource.getStatus match { + case "requires_capture" => // we capture the funds now + resource.capture( + PaymentIntentCaptureParams + .builder() + .setAmountToCapture( + Math.min(resource.getAmountCapturable, payInTransaction.debitedAmount) + ) + .build(), + requestOptions + ) + case _ => resource + } + } match { + case Success(payment) => + val status = payment.getStatus + var transaction = + Transaction() + .withId(payment.getId) + .withOrderUuid(payInTransaction.orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.PAYIN) + .withPaymentType(Transaction.PaymentType.CARD) + .withAmount(payment.getAmountReceived.intValue()) + .withFees(0) + .withCurrency(payment.getCurrency) + .withResultCode(status) + .withAuthorId(payment.getCustomer) + .copy( + cardId = Option(payment.getPaymentMethod), + creditedWalletId = Some(payInTransaction.creditedWalletId) + ) + + if (status == "requires_action" && payment.getNextAction.getType == "redirect_to_url") { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_CREATED, + //The URL you must redirect your customer to in order to authenticate the payment. + redirectUrl = Option(payment.getNextAction.getRedirectToUrl.getUrl) + ) + } else if (status == "requires_payment_method") { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_PENDING_PAYMENT, + paymentClientSecret = Option(payment.getClientSecret), + paymentClientReturnUrl = Option( + s"${config.payInReturnUrl}/${payInTransaction.orderUuid}?transactionIdParameter=payment_intent®isterCard=${payInTransaction.registerCard + .getOrElse(false)}&printReceipt=${payInTransaction.printReceipt + .getOrElse(false)}&payment_intent=${payment.getId}" + ) + ) + } else if (status == "succeeded" || status == "requires_capture") { + transaction = + transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + } else { + transaction = + transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_CREATED) + } + + mlog.info( + s"Pay in created for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + case Failure(f) => + mlog.error(s"Failed to create pay in: ${f.getMessage}", f) + None + } + case _ => None + } + } + + /** @param payInWithPayPalTransaction + * - pay in with PayPal transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay in with PayPal transaction result + */ + private[spi] override def payInWithPayPal( + payInWithPayPalTransaction: Option[PayInWithPayPalTransaction], + idempotency: Option[Boolean] + ): Option[Transaction] = { + payInWithPayPalTransaction match { + case Some(payInTransaction) => + Try { + val requestOptions = StripeApi().requestOptions + + val params = + PaymentIntentCreateParams + .builder() + .setAmount(payInTransaction.debitedAmount) + .setCurrency(payInTransaction.currency) + .addPaymentMethodType("paypal") + .setPaymentMethodOptions( + PaymentIntentCreateParams.PaymentMethodOptions + .builder() + .setPaypal( + PaymentIntentCreateParams.PaymentMethodOptions.Paypal + .builder() + .setCaptureMethod( + PaymentIntentCreateParams.PaymentMethodOptions.Paypal.CaptureMethod.MANUAL + ) + .build() + ) + .build() + ) + .setPaymentMethodData( + PaymentIntentCreateParams.PaymentMethodData + .builder() + .setType(PaymentIntentCreateParams.PaymentMethodData.Type.PAYPAL) + .setPaypal( + PaymentIntentCreateParams.PaymentMethodData.Paypal.builder().build() + ) + .build() + ) + .setCustomer(payInTransaction.authorId) + .setCaptureMethod(PaymentIntentCreateParams.CaptureMethod.MANUAL) + .setTransferGroup(payInTransaction.orderUuid) + .putMetadata("order_uuid", payInTransaction.orderUuid) + .putMetadata("transaction_type", "pay_in") + .putMetadata("payment_type", "paypal") + .putMetadata( + "print_receipt", + payInTransaction.printReceipt.getOrElse(false).toString + ) + + payInTransaction.statementDescriptor match { + case Some(statementDescriptor) => + params.setStatementDescriptor(statementDescriptor) + case _ => + } + + payInTransaction.ipAddress match { + case Some(ipAddress) => + + var onlineParams = + PaymentIntentCreateParams.MandateData.CustomerAcceptance.Online + .builder() + .setIpAddress(ipAddress) + + payInTransaction.browserInfo.map(_.userAgent) match { + case Some(userAgent) => + onlineParams = onlineParams.setUserAgent(userAgent) + case _ => + } + + params + //.setOffSession(true) // For off-session payments + .setMandateData( + PaymentIntentCreateParams.MandateData + .builder() + .setCustomerAcceptance( + PaymentIntentCreateParams.MandateData.CustomerAcceptance + .builder() + .setAcceptedAt((System.currentTimeMillis() / 1000).toInt) + .setOnline(onlineParams.build()) + .build() + ) + .build() + ) + + case _ => + + } + + payInTransaction.payPalWalletId match { + case Some(payPalWalletId) => + params + .setConfirm(true) // Confirm the PaymentIntent immediately + .setPaymentMethod(payPalWalletId) + .setReturnUrl( + s"${config.payInReturnUrl}/${payInTransaction.orderUuid}?transactionIdParameter=payment_intent®isterCard=false&&printReceipt=${payInTransaction.printReceipt + .getOrElse(false)}" + ) + case _ => + } + + mlog.info( + s"Creating pay in with PayPal for order ${payInTransaction.orderUuid} -> ${new Gson() + .toJson(params.build())}" + ) + + val resource = PaymentIntent.create(params.build(), requestOptions) + + resource.getStatus match { + case "requires_capture" => // we capture the funds now + resource.capture( + PaymentIntentCaptureParams + .builder() + .setAmountToCapture( + Math.min(resource.getAmountCapturable, payInTransaction.debitedAmount) + ) + .build(), + requestOptions + ) + case _ => resource + } + } match { + case Success(payment) => + mlog.info(s"Pay in with PayPal -> ${new Gson().toJson(payment)}") + + val status = payment.getStatus + var transaction = + Transaction() + .withId(payment.getId) + .withOrderUuid(payInTransaction.orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.PAYIN) + .withPaymentType(Transaction.PaymentType.PAYPAL) + .withAmount(payment.getAmountReceived.intValue()) + .withFees(0) + .withCurrency(payment.getCurrency) + .withResultCode(status) + .withAuthorId(payment.getCustomer) + .copy( + creditedWalletId = Some(payInTransaction.creditedWalletId) + ) + + if (status == "requires_action" && payment.getNextAction.getType == "redirect_to_url") { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_CREATED, + //The URL you must redirect your customer to in order to authenticate the payment. + redirectUrl = Option(payment.getNextAction.getRedirectToUrl.getUrl), + returnUrl = Option(payment.getNextAction.getRedirectToUrl.getReturnUrl) + ) + } else if (status == "requires_payment_method" || status == "requires_confirmation") { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_PENDING_PAYMENT, + paymentClientSecret = Option(payment.getClientSecret), + paymentClientReturnUrl = Option( + s"${config.payInReturnUrl}/${payInTransaction.orderUuid}?transactionIdParameter=payment_intent®isterCard=false&printReceipt=${payInTransaction.printReceipt + .getOrElse(false)}&payment_intent=${payment.getId}" + ) + ) + } else if (status == "succeeded" || status == "requires_capture") { + transaction = + transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + } else { + transaction = + transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_CREATED) + } + + mlog.info( + s"Pay in with PayPal created for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + case Failure(f) => + mlog.error(s"Failed to create pay in with PayPal: ${f.getMessage}", f) + None + } + case _ => None + } + } + + /** @param orderUuid + * - order unique id + * @param transactionId + * - transaction id + * @return + * pay in transaction + */ + override def loadPayInTransaction( + orderUuid: String, + transactionId: String, + recurringPayInRegistrationId: Option[String] + ): Option[Transaction] = { + Try { + PaymentIntent.retrieve(transactionId, StripeApi().requestOptions) + } match { + case Success(payment) => + val status = payment.getStatus + val metadata = payment.getMetadata.asScala + val `type` = + metadata.get("transaction_type") match { + case Some("pre_authorization") => Transaction.TransactionType.PRE_AUTHORIZATION + case Some("direct_debit") => Transaction.TransactionType.DIRECT_DEBIT + case _ => Transaction.TransactionType.PAYIN + } + val paymentType = + metadata.get("payment_type") match { + case Some("paypal") => Transaction.PaymentType.PAYPAL + case _ => Transaction.PaymentType.CARD + } + val cardId = + paymentType match { + case Transaction.PaymentType.CARD => Option(payment.getPaymentMethod) + case _ => None + } + val preAuthorizationId = metadata.get("pre_authorization_id") + val redirectUrl = + if (status == "requires_action" && payment.getNextAction.getType == "redirect_to_url") { + Option(payment.getNextAction.getRedirectToUrl.getUrl) + } else { + None + } + + var transaction = + Transaction() + .withId(payment.getId) + .withOrderUuid(orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(`type`) + .withPaymentType(paymentType) + .withAmount(payment.getAmount.intValue()) + .withFees(Option(payment.getApplicationFeeAmount).map(_.intValue()).getOrElse(0)) + .withResultCode(status) + .withAuthorId(payment.getCustomer) + .withCurrency(payment.getCurrency) + .copy( + cardId = cardId, + preAuthorizationId = preAuthorizationId, + redirectUrl = redirectUrl + ) + + if (status == "requires_action" && payment.getNextAction.getType == "redirect_to_url") { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_CREATED, + //The URL you must redirect your customer to in order to authenticate the payment. + redirectUrl = Option(payment.getNextAction.getRedirectToUrl.getUrl) + ) + } else if (status == "requires_payment_method" || status == "requires_confirmation") { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_PENDING_PAYMENT, + paymentClientSecret = Option(payment.getClientSecret), + paymentClientReturnUrl = Option( + s"${config.payInReturnUrl}/$orderUuid?transactionIdParameter=payment_intent®isterCard=${metadata + .getOrElse("register_card", false)}&printReceipt=${metadata + .getOrElse("print_receipt", false)}&payment_intent=${payment.getId}" + ) + ) + } else if (status == "succeeded" || status == "requires_capture") { + transaction = + transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + } else { + transaction = transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_CREATED) + } + + mlog.info( + s"Pay in with PayPal created for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + case Failure(f) => + mlog.error(s"Failed to load pay in transaction: ${f.getMessage}", f) + None + } + } +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayOutApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayOutApi.scala new file mode 100644 index 0000000..bb608a2 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripePayOutApi.scala @@ -0,0 +1,283 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.config.StripeApi +import app.softnetwork.payment.model.{PayOutTransaction, Transaction} +import app.softnetwork.serialization.asJson +import com.google.gson.Gson +import com.stripe.model.{Charge, PaymentIntent, Transfer} +import com.stripe.param.{ + PaymentIntentCaptureParams, + PaymentIntentUpdateParams, + TransferCreateParams +} + +import scala.util.{Failure, Success, Try} +import collection.JavaConverters._ + +trait StripePayOutApi extends PayOutApi { _: StripeContext => + + /** @param maybePayOutTransaction + * - pay out transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * pay out transaction result + */ + override def payOut( + maybePayOutTransaction: Option[PayOutTransaction], + idempotency: Option[Boolean] + ): Option[Transaction] = { + maybePayOutTransaction match { + case Some(payOutTransaction) => + mlog.info( + s"Processing pay out transaction for order(s): ${payOutTransaction.orderUuid} -> ${asJson(payOutTransaction)}" + ) + + var debitedAmount = payOutTransaction.debitedAmount + + var feesAmount = payOutTransaction.feesAmount + + Try { + val requestOptions = StripeApi().requestOptions + + var resource = + PaymentIntent + .retrieve( + payOutTransaction.payInTransactionId.getOrElse( + throw new Exception("No pay in transaction id found") + ), + requestOptions + ) + + // optionally update fees amount + resource = + Option(resource.getTransferData).flatMap(td => Option(td.getDestination)) match { + case Some(_) + if feesAmount != Option(resource.getApplicationFeeAmount) + .map(_.intValue()) + .getOrElse(0) => + resource.update( + PaymentIntentUpdateParams.builder().setApplicationFeeAmount(feesAmount).build(), + requestOptions + ) + case _ => resource + } + + val payment = + resource.getStatus match { + case "requires_capture" => // should never happen + val params = + PaymentIntentCaptureParams + .builder() + .setAmountToCapture(resource.getAmountCapturable) + resource.capture( + params.build(), + requestOptions + ) + case _ => resource + } + + Option(payment.getTransferData).flatMap(td => Option(td.getDestination)) match { + case Some(destination) if destination != payOutTransaction.creditedUserId => + throw new Exception( + s"Destination account does not match the credited user id: $destination != ${payOutTransaction.creditedUserId}" + ) + case Some(_) => // no need for transfer + debitedAmount = payment.getAmountReceived.intValue() + feesAmount = Option(payment.getApplicationFeeAmount).map(_.intValue()).getOrElse(0) + payment + case None => + debitedAmount = payment.getAmountReceived.intValue() + val amountToTransfer = debitedAmount - feesAmount + val params = + TransferCreateParams + .builder() + .setAmount(amountToTransfer) + .setCurrency(payOutTransaction.currency) + .setDestination(payOutTransaction.creditedUserId) + .setSourceTransaction(payment.getLatestCharge) + .setTransferGroup( + Option(payment.getTransferGroup).getOrElse(payOutTransaction.orderUuid) + ) + .putMetadata("order_uuid", payOutTransaction.orderUuid) + .putMetadata("debited_amount", debitedAmount.toString) + .putMetadata("fees_amount", feesAmount.toString) + .putMetadata("amount_to_transfer", amountToTransfer.toString) + payOutTransaction.externalReference match { + case Some(externalReference) => + params.putMetadata("external_reference", externalReference) + case _ => + } + + mlog.info(s"Creating transfer for order ${payOutTransaction.orderUuid} -> ${new Gson() + .toJson(params.build())}") + + Transfer.create(params.build(), requestOptions) + } + } match { + case Success(payment: PaymentIntent) => + val status = payment.getStatus + + val creditedUserId = Option(payment.getTransferData).map(_.getDestination) + + var transaction = + Transaction() + .withId(payment.getId) + .withOrderUuid(payOutTransaction.orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.PAYOUT) + .withPaymentType(Transaction.PaymentType.BANK_WIRE) + .withAmount(debitedAmount) + .withFees(feesAmount) + .withCurrency(payment.getCurrency) + .withAuthorId( + Option(payment.getCustomer).getOrElse(payOutTransaction.authorId) + ) + .withDebitedWalletId( + Option(payment.getCustomer).getOrElse(payOutTransaction.debitedWalletId) + ) + .copy( + creditedUserId = creditedUserId, + sourceTransactionId = payOutTransaction.payInTransactionId + ) + + if (status == "succeeded") { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_SUCCEEDED + ) + } else if (status == "processing") { + transaction = + transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_CREATED) + } else { //canceled, requires_action, requires_capture, requires_confirmation, requires_payment_method + transaction = + transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_FAILED) + } + + mlog.info( + s"Pay out executed for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + + case Success(transfer: Transfer) => + val transaction = + Transaction() + .withId(transfer.getId) + .withOrderUuid(payOutTransaction.orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.TRANSFER) + .withPaymentType(Transaction.PaymentType.BANK_WIRE) + .withAmount(debitedAmount) + .withFees(feesAmount) + .withTransferAmount( + transfer.getAmount.intValue() + ) // should be equal to debitedAmount - feesAmount + .withCurrency(transfer.getCurrency) + .withAuthorId(payOutTransaction.authorId) + .withCreditedUserId(transfer.getDestination) + .withDebitedWalletId(payOutTransaction.debitedWalletId) + .withStatus(Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + .copy( + sourceTransactionId = payOutTransaction.payInTransactionId + ) + + mlog.info( + s"Transfer transaction created for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + + case Failure(f) => + mlog.error(s"Error while processing pay out transaction: ${f.getMessage}", f) + None + } + case _ => None + } + } + + /** @param orderUuid + * - order unique id + * @param transactionId + * - transaction id + * @return + * pay out transaction + */ + override def loadPayOutTransaction( + orderUuid: String, + transactionId: String + ): Option[Transaction] = { + Try { + val requestOptions = StripeApi().requestOptions + if (transactionId.startsWith("tr_")) { + Transfer.retrieve(transactionId, requestOptions) + } else { + PaymentIntent.retrieve(transactionId, requestOptions) + } + } match { + case Success(transfer: Transfer) => + val metadata = transfer.getMetadata.asScala + val feesAmount = metadata.get("fees_amount").map(_.toInt).getOrElse(0) + + val transaction = + Transaction() + .withId(transfer.getId) + .withOrderUuid(orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.TRANSFER) + .withPaymentType(Transaction.PaymentType.BANK_WIRE) + .withAmount(transfer.getAmount.intValue() + feesAmount) + .withFees(feesAmount) + .withCurrency(transfer.getCurrency) + .withCreditedUserId(transfer.getDestination) + .withStatus(Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + .withTransferAmount(transfer.getAmount.intValue()) + + mlog.info( + s"Pay out transaction retrieved for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + + case Success(payment: PaymentIntent) => + val status = payment.getStatus + + val creditedUserId = Option(payment.getTransferData).map(_.getDestination) + + var transaction = + Transaction() + .withId(payment.getId) + .withOrderUuid(orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.PAYOUT) + .withPaymentType(Transaction.PaymentType.BANK_WIRE) + .withAmount(payment.getAmount.intValue()) + .withFees(Option(payment.getApplicationFeeAmount.intValue()).getOrElse(0)) + .withCurrency(payment.getCurrency) + .withAuthorId(payment.getCustomer) + .withDebitedWalletId(payment.getCustomer) + .copy( + creditedUserId = creditedUserId + ) + + if (status == "succeeded") { + transaction = transaction.copy( + status = Transaction.TransactionStatus.TRANSACTION_SUCCEEDED + ) + } else if (status == "processing") { + transaction = transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_CREATED) + } else { //canceled, requires_action, requires_capture, requires_confirmation, requires_payment_method + transaction = transaction.copy(status = Transaction.TransactionStatus.TRANSACTION_FAILED) + } + + mlog.info( + s"Pay out transaction retrieved for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + + case Failure(f) => + mlog.error(s"Error while loading pay out transaction: ${f.getMessage}", f) + None + } + } +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala new file mode 100644 index 0000000..d972032 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala @@ -0,0 +1,113 @@ +package app.softnetwork.payment.spi + +import akka.actor.typed.ActorSystem +import app.softnetwork.payment.config.{StripeApi, StripeSettings} +import app.softnetwork.payment.model.SoftPayAccount +import app.softnetwork.payment.model.SoftPayAccount.Client +import app.softnetwork.payment.model.SoftPayAccount.Client.Provider +import app.softnetwork.payment.service.{ + HooksDirectives, + HooksEndpoints, + StripeHooksDirectives, + StripeHooksEndpoints +} +import com.stripe.model.Balance +import com.typesafe.config.Config +import org.json4s.Formats +import org.slf4j.Logger + +import collection.JavaConverters._ +import scala.util.{Failure, Success, Try} + +trait StripeContext extends PaymentContext { + + override implicit def config: StripeApi.Config + +} + +trait StripeProvider + extends PaymentProvider + with StripeContext + with StripeAccountApi + with StripeDirectDebitApi + with StripeCardApi + with StripePayInApi + with StripePayOutApi + with StripeRecurringPaymentApi + with StripeRefundApi + with StripeTransferApi { + + /** @return + * client + */ + override def client: Option[Client] = Some( + SoftPayAccount.Client.defaultInstance + .withProvider(provider) + .withClientId(provider.clientId) + ) + + /** @return + * client fees + */ + override def clientFees(): Option[Double] = { + Try( + Balance + .retrieve(StripeApi().requestOptions) + .getAvailable + .asScala + .head + .getAmount + .toDouble / 100 + ) match { + case Success(value) => Some(value) + case Failure(f) => + mlog.error(s"Error while retrieving client fees: ${f.getMessage}") + None + } + } +} + +class StripeProviderFactory extends PaymentProviderSpi { + + @volatile private[this] var _config: Option[StripeApi.Config] = None + + override def providerType: Provider.ProviderType = Provider.ProviderType.STRIPE + + override def paymentProvider(p: Client.Provider): StripeProvider = { + new StripeProvider { + override implicit def provider: Provider = p + override implicit def config: StripeApi.Config = _config + .getOrElse(StripeSettings.StripeApiConfig) + } + } + + override def softPaymentProvider(config: Config): Client.Provider = { + val stripeConfig = StripeSettings(config).StripeApiConfig + _config = Some(stripeConfig) + stripeConfig.softPayProvider + } + + override def hooksDirectives(implicit + _system: ActorSystem[_], + _formats: Formats + ): HooksDirectives = { + new StripeHooksDirectives { + override def log: Logger = org.slf4j.LoggerFactory.getLogger(getClass) + + override implicit def formats: Formats = _formats + + override implicit def system: ActorSystem[_] = _system + } + } + + override def hooksEndpoints(implicit + _system: ActorSystem[_], + formats: Formats + ): HooksEndpoints = { + new StripeHooksEndpoints { + override def log: Logger = org.slf4j.LoggerFactory.getLogger(getClass) + + override implicit def system: ActorSystem[_] = _system + } + } +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApi.scala new file mode 100644 index 0000000..047eff2 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApi.scala @@ -0,0 +1,70 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.model.{RecurringPayment, RecurringPaymentTransaction, Transaction} +import com.stripe.param.SubscriptionScheduleCreateParams + +trait StripeRecurringPaymentApi extends RecurringPaymentApi { _: StripeContext => + + /** @param userId + * - Provider user id + * @param walletId + * - Provider wallet id + * @param cardId + * - Provider card id + * @param recurringPayment + * - recurring payment to register + * @return + * recurring card payment registration result + */ + override def registerRecurringCardPayment( + userId: String, + walletId: String, + cardId: String, + recurringPayment: RecurringPayment + ): Option[RecurringPayment.RecurringCardPaymentResult] = { + SubscriptionScheduleCreateParams + .builder() + .addPhase( + SubscriptionScheduleCreateParams.Phase + .builder() + .setBillingCycleAnchor( + SubscriptionScheduleCreateParams.Phase.BillingCycleAnchor.AUTOMATIC + ) + .build() + ) + ??? + } + + /** @param recurringPayInRegistrationId + * - recurring payIn registration id + * @param cardId + * - Provider card id + * @param status + * - optional recurring payment status + * @return + * recurring card payment registration updated result + */ + override def updateRecurringCardPaymentRegistration( + recurringPayInRegistrationId: String, + cardId: Option[String], + status: Option[RecurringPayment.RecurringCardPaymentStatus] + ): Option[RecurringPayment.RecurringCardPaymentResult] = ??? + + /** @param recurringPayInRegistrationId + * - recurring payIn registration id + * @return + * recurring card payment registration result + */ + override def loadRecurringCardPayment( + recurringPayInRegistrationId: String + ): Option[RecurringPayment.RecurringCardPaymentResult] = ??? + + /** @param recurringPaymentTransaction + * - recurring payment transaction + * @return + * resulted payIn transaction + */ + override def createRecurringCardPayment( + recurringPaymentTransaction: RecurringPaymentTransaction + ): Option[Transaction] = ??? +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRefundApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRefundApi.scala new file mode 100644 index 0000000..a17b351 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRefundApi.scala @@ -0,0 +1,284 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.config.StripeApi +import app.softnetwork.payment.model.{RefundTransaction, Transaction} +import app.softnetwork.serialization.asJson +import com.google.gson.Gson +import com.stripe.model.{Charge, PaymentIntent, Refund, Transfer, TransferReversal} +import com.stripe.param.{RefundCreateParams, TransferReversalCollectionCreateParams} + +import scala.util.{Failure, Success, Try} +import collection.JavaConverters._ + +trait StripeRefundApi extends RefundApi { _: StripeContext => + + /** @param maybeRefundTransaction + * - refund transaction + * @param idempotency + * - whether to use an idempotency key for this request or not + * @return + * refund transaction result + */ + override def refund( + maybeRefundTransaction: Option[RefundTransaction], + idempotency: Option[Boolean] + ): Option[Transaction] = { + maybeRefundTransaction match { + case Some(refundTransaction) => + mlog.info( + s"Processing refund transaction for order: ${refundTransaction.orderUuid} -> ${asJson(refundTransaction)}" + ) + + Try { + val requestOptions = StripeApi().requestOptions + + val transactionId = refundTransaction.payInTransactionId // TODO rename this field + + (if (transactionId.startsWith("tr_")) { + + val transfer = Transfer.retrieve(transactionId, requestOptions) + + val params = + TransferReversalCollectionCreateParams + .builder() + .setAmount(Math.min(transfer.getAmount.intValue(), refundTransaction.refundAmount)) + .setRefundApplicationFee(true) + .putMetadata("order_uuid", refundTransaction.orderUuid) + .putMetadata("author_id", refundTransaction.authorId) + .putMetadata("reason_message", refundTransaction.reasonMessage) + + mlog.info( + s"Processing transfer reversal for order: ${refundTransaction.orderUuid} -> ${new Gson() + .toJson(params)}" + ) + + val transferReversal = transfer.getReversals.create(params.build(), requestOptions) + + Option(transfer.getSourceTransaction) match { + case Some(sourceTransaction) => + if (sourceTransaction.startsWith("pi_")) + PaymentIntent.retrieve(sourceTransaction, requestOptions) + else if (sourceTransaction.startsWith("ch_")) + Charge.retrieve(sourceTransaction, requestOptions) + else + transferReversal + case _ => + transferReversal + } + } else { + PaymentIntent.retrieve(transactionId, requestOptions) + }) match { + case payment: PaymentIntent => + val params = + RefundCreateParams + .builder() + .setAmount( + Math.min(payment.getAmountReceived.intValue(), refundTransaction.refundAmount) + ) + .setCharge(payment.getLatestCharge) + .setReverseTransfer(false) + .putMetadata("order_uuid", refundTransaction.orderUuid) + .putMetadata("author_id", refundTransaction.authorId) + .putMetadata("reason_message", refundTransaction.reasonMessage) + + mlog.info( + s"Processing refund payment for order: ${refundTransaction.orderUuid} -> ${new Gson() + .toJson(params)}" + ) + + if (refundTransaction.initializedByClient) { + params.setReason(RefundCreateParams.Reason.REQUESTED_BY_CUSTOMER) + } + + Refund.create(params.build(), requestOptions) + case charge: Charge => + val params = + RefundCreateParams + .builder() + .setAmount(Math.min(charge.getAmount.intValue(), refundTransaction.refundAmount)) + .setCharge(charge.getId) + .setReverseTransfer(false) + .putMetadata("order_uuid", refundTransaction.orderUuid) + .putMetadata("author_id", refundTransaction.authorId) + .putMetadata("reason_message", refundTransaction.reasonMessage) + + mlog.info( + s"Processing refund payment for order: ${refundTransaction.orderUuid} -> ${new Gson() + .toJson(params)}" + ) + + if (refundTransaction.initializedByClient) { + params.setReason(RefundCreateParams.Reason.REQUESTED_BY_CUSTOMER) + } + + Refund.create(params.build(), requestOptions) + case transferReversal: TransferReversal => transferReversal + } + + } match { + case Success(transferReversal: TransferReversal) => + val transaction = + Transaction() + .withId(transferReversal.getId) + .withOrderUuid(refundTransaction.orderUuid) + .withNature(Transaction.TransactionNature.REFUND) + .withType(Transaction.TransactionType.TRANSFER) + .withAmount(transferReversal.getAmount.intValue()) + .withFees(0) + .withCurrency(transferReversal.getCurrency) + .withReasonMessage(refundTransaction.reasonMessage) + .withAuthorId(refundTransaction.authorId) + .withSourceTransactionId(transferReversal.getTransfer) + + mlog.info( + s"Refund transaction for order: ${refundTransaction.orderUuid} processed successfully -> ${asJson(transferReversal)}" + ) + + Some(transaction) + + case Success(refund: Refund) => + val status = refund.getStatus + + var transaction = + Transaction() + .withId(refund.getId) + .withOrderUuid(refundTransaction.orderUuid) + .withNature(Transaction.TransactionNature.REFUND) + .withType(Transaction.TransactionType.PAYIN) + .withAmount(refund.getAmount.intValue()) + .withFees(0) + .withCurrency(refund.getCurrency) + .withResultCode(status) + .withReasonMessage(refundTransaction.reasonMessage) + .withAuthorId(refundTransaction.authorId) + .withSourceTransactionId( + Option(refund.getPaymentIntent).getOrElse(refund.getCharge) + ) + + Option(refund.getDestinationDetails) match { + case Some(destinationDetails) => + destinationDetails.getType match { + case "card" => + transaction = transaction.withPaymentType(Transaction.PaymentType.CARD) + case "bank_account" => + transaction = transaction.withPaymentType(Transaction.PaymentType.BANK_WIRE) + case "paypal" => + transaction = transaction.withPaymentType(Transaction.PaymentType.PAYPAL) + case _ => + } + case _ => + } + + status match { + case "succeeded" => + transaction = + transaction.withStatus(Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + case "failed" => + transaction = transaction + .withStatus(Transaction.TransactionStatus.TRANSACTION_FAILED) + .copy( + resultMessage = refund.getFailureReason + ) + case "canceled" => + transaction = + transaction.withStatus(Transaction.TransactionStatus.TRANSACTION_CANCELED) + case _ => + transaction = + transaction.withStatus(Transaction.TransactionStatus.TRANSACTION_CREATED) + } + + mlog.info( + s"Refund transaction for order: ${refundTransaction.orderUuid} processed successfully -> ${asJson(refund)}" + ) + + Some(transaction) + + case Failure(f) => + mlog.error( + s"Error processing refund transaction for order: ${refundTransaction.orderUuid}", + f + ) + None + } + } + } + + /** @param orderUuid + * - order unique id + * @param transactionId + * - transaction id + * @return + * Refund transaction + */ + override def loadRefundTransaction( + orderUuid: String, + transactionId: String + ): Option[Transaction] = { + Try { + Refund.retrieve(transactionId, StripeApi().requestOptions) + } match { + case Success(refund: Refund) => + val status = refund.getStatus + + val metadata = refund.getMetadata.asScala + + val reasonMessage = metadata.getOrElse("reason_message", "") + + val authorId = metadata.getOrElse("author_id", "") + + var transaction = // TODO payment type + Transaction() + .withId(refund.getId) + .withOrderUuid(orderUuid) + .withNature(Transaction.TransactionNature.REFUND) + .withType(Transaction.TransactionType.PAYIN) + .withAmount(refund.getAmount.intValue()) + .withFees(0) + .withCurrency(refund.getCurrency) + .withResultCode(status) + .withReasonMessage(reasonMessage) + .withAuthorId(authorId) + .withSourceTransactionId(refund.getPaymentIntent) + + Option(refund.getDestinationDetails) match { + case Some(destinationDetails) => + destinationDetails.getType match { + case "card" => + transaction = transaction.withPaymentType(Transaction.PaymentType.CARD) + case "bank_account" => + transaction = transaction.withPaymentType(Transaction.PaymentType.BANK_WIRE) + case "paypal" => + transaction = transaction.withPaymentType(Transaction.PaymentType.PAYPAL) + case _ => + } + case _ => + } + + status match { + case "succeeded" => + transaction = + transaction.withStatus(Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + case "failed" => + transaction = transaction + .withStatus(Transaction.TransactionStatus.TRANSACTION_FAILED) + .copy( + resultMessage = refund.getFailureReason + ) + case "canceled" => + transaction = transaction.withStatus(Transaction.TransactionStatus.TRANSACTION_CANCELED) + case _ => + transaction = transaction.withStatus(Transaction.TransactionStatus.TRANSACTION_CREATED) + } + + mlog.info( + s"Refund transaction for order: $orderUuid loaded successfully -> ${asJson(refund)}" + ) + + Some(transaction) + + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + } +} diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeTransferApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeTransferApi.scala new file mode 100644 index 0000000..322b987 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeTransferApi.scala @@ -0,0 +1,140 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.config.StripeApi +import app.softnetwork.payment.model.{Transaction, TransferTransaction} +import app.softnetwork.serialization.asJson +import com.stripe.model.{Balance, Transfer} +import com.stripe.param.TransferCreateParams + +import collection.JavaConverters._ +import scala.util.{Failure, Success, Try} + +trait StripeTransferApi extends TransferApi { _: StripeContext => + + /** @param maybeTransferTransaction + * - transfer transaction + * @return + * transfer transaction result + */ + override def transfer( + maybeTransferTransaction: Option[TransferTransaction] + ): Option[Transaction] = { + maybeTransferTransaction match { + case Some(transferTransaction) => + mlog.info( + s"Processing transfer transaction for order: ${transferTransaction.orderUuid} -> ${asJson(transferTransaction)}" + ) + Try { + val requestOptions = StripeApi().requestOptions + + val amountToTransfer = + transferTransaction.debitedAmount - transferTransaction.feesAmount + + val availableAmount = + Balance + .retrieve(requestOptions) + .getAvailable + .asScala + .find(_.getCurrency == transferTransaction.currency) match { + case Some(balance) => + balance.getAmount.intValue() + case None => + 0 + } + + val params = + TransferCreateParams + .builder() + .setAmount(Math.min(amountToTransfer, availableAmount)) + .setDestination(transferTransaction.creditedUserId) + .setCurrency(transferTransaction.currency) + .putMetadata("available_amount", availableAmount.toString) + .putMetadata("debited_amount", transferTransaction.debitedAmount.toString) + .putMetadata("fees_amount", transferTransaction.feesAmount.toString) + .putMetadata("amount_to_transfer", amountToTransfer.toString) + + transferTransaction.orderUuid match { + case Some(orderUuid) => + params.setTransferGroup(orderUuid) + params.putMetadata("order_uuid", orderUuid) + case _ => + } + + Transfer.create(params.build(), requestOptions) + + } match { + case Success(transfer: Transfer) => + val transaction = + Transaction() + .withId(transfer.getId) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.TRANSFER) + .withPaymentType(Transaction.PaymentType.BANK_WIRE) + .withAmount(transferTransaction.debitedAmount) + .withFees(transferTransaction.feesAmount) + .withTransferAmount(transfer.getAmount.intValue()) + .withCurrency(transfer.getCurrency) + .withAuthorId(transferTransaction.authorId) + .withCreditedUserId(transfer.getDestination) + .withDebitedWalletId(transferTransaction.debitedWalletId) + .withStatus(Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + .copy( + orderUuid = transferTransaction.orderUuid.getOrElse(""), + sourceTransactionId = transferTransaction.payInTransactionId + ) + + mlog.info( + s"Transfer transaction created for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + + case Failure(f) => + mlog.error( + s"Error processing transfer transaction for order: ${transferTransaction.orderUuid} -> ${asJson(transferTransaction)}", + f + ) + None + } + case _ => None + } + } + + /** @param transactionId + * - transaction id + * @return + * transfer transaction + */ + override def loadTransfer(transactionId: String): Option[Transaction] = { + Try { + Transfer.retrieve(transactionId, StripeApi().requestOptions) + } match { + case Success(transfer: Transfer) => + val metadata = transfer.getMetadata.asScala + val orderUuid = + metadata.get("order_uuid").orElse(Option(transfer.getTransferGroup)).getOrElse("") + val amountToTransfer = metadata.get("amount_to_transfer").map(_.toInt).getOrElse(0) + val feesAmount = metadata.get("fees_amount").map(_.toInt).getOrElse(0) + + val transaction = + Transaction() + .withId(transfer.getId) + .withOrderUuid(orderUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.TRANSFER) + .withPaymentType(Transaction.PaymentType.BANK_WIRE) + .withAmount(amountToTransfer + feesAmount) + .withFees(feesAmount) + .withCurrency(transfer.getCurrency) + .withCreditedUserId(transfer.getDestination) + .withStatus(Transaction.TransactionStatus.TRANSACTION_SUCCEEDED) + .withTransferAmount(transfer.getAmount.intValue()) + + mlog.info( + s"Pay out transaction retrieved for order ${transaction.orderUuid} -> ${asJson(transaction)}" + ) + + Some(transaction) + } + } +} diff --git a/stripe/src/test/resources/reference.conf b/stripe/src/test/resources/reference.conf new file mode 100644 index 0000000..94270ae --- /dev/null +++ b/stripe/src/test/resources/reference.conf @@ -0,0 +1,3 @@ +payment{ + baseUrl = "http://www.softnetwork.fr:"${softnetwork.api.server.port}"/"${softnetwork.api.server.root-path} +} \ No newline at end of file diff --git a/stripe/src/test/scala/app/softnetwork/payment/config/StripeApiSpec.scala b/stripe/src/test/scala/app/softnetwork/payment/config/StripeApiSpec.scala new file mode 100644 index 0000000..6a1d6a8 --- /dev/null +++ b/stripe/src/test/scala/app/softnetwork/payment/config/StripeApiSpec.scala @@ -0,0 +1,18 @@ +package app.softnetwork.payment.config + +import app.softnetwork.payment.model.SoftPayAccount.Client +import app.softnetwork.security.sha256 +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class StripeApiSpec extends AnyWordSpec with Matchers { + + "StripeApi" should { + "be able to create a new instance" in { + implicit def config: StripeApi.Config = StripeSettings.StripeApiConfig + implicit def provider: Client.Provider = config.softPayProvider + StripeApi() should not be null + StripeApi.webHookSecret(sha256(provider.clientId)).isDefined shouldBe true + } + } +} diff --git a/testkit/build.sbt b/testkit/build.sbt index 5cdb86d..07ce05e 100644 --- a/testkit/build.sbt +++ b/testkit/build.sbt @@ -10,5 +10,7 @@ libraryDependencies ++= Seq( "app.softnetwork.session" %% "session-testkit" % Versions.genericPersistence, "app.softnetwork.persistence" %% "persistence-core-testkit" % Versions.genericPersistence, "app.softnetwork.account" %% "account-testkit" % Versions.account, - "org.scalatest" %% "scalatest" % Versions.scalatest + "org.scalatest" %% "scalatest" % Versions.scalatest, + "org.seleniumhq.selenium" % "selenium-java" % Versions.selenium, + "org.seleniumhq.selenium" % "htmlunit-driver" % Versions.selenium ) diff --git a/testkit/src/main/scala/app/softnetwork/payment/api/PaymentGrpcServicesTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/api/PaymentGrpcServicesTestKit.scala index 40735a8..289df15 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/api/PaymentGrpcServicesTestKit.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/api/PaymentGrpcServicesTestKit.scala @@ -4,11 +4,12 @@ import akka.actor.typed.ActorSystem import app.softnetwork.api.server.GrpcService import app.softnetwork.api.server.scalatest.ServerTestKit import app.softnetwork.payment.launch.PaymentGuardian +import app.softnetwork.persistence.scalatest.PersistenceTestKit import app.softnetwork.scheduler.api.SchedulerGrpcServicesTestKit import app.softnetwork.scheduler.launch.SchedulerGuardian -trait PaymentGrpcServicesTestKit extends SchedulerGrpcServicesTestKit with SoftPayClientTestKit { - _: PaymentGuardian with SchedulerGuardian with ServerTestKit => +trait PaymentGrpcServicesTestKit extends SchedulerGrpcServicesTestKit with PaymentProviderTestKit { + _: PaymentGuardian with SchedulerGuardian with ServerTestKit with PersistenceTestKit => override def grpcServices: ActorSystem[_] => Seq[GrpcService] = system => paymentGrpcServices(system) ++ schedulerGrpcServices(system) @@ -28,5 +29,6 @@ trait PaymentGrpcServicesTestKit extends SchedulerGrpcServicesTestKit with SoftP | port = $port | use-tls = false |} - |""".stripMargin + softPayClientSettings + |payment.baseUrl = "http://$interface:$port/api" + |""".stripMargin + providerSettings } diff --git a/testkit/src/main/scala/app/softnetwork/payment/api/PaymentProviderTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/api/PaymentProviderTestKit.scala new file mode 100644 index 0000000..09e5db8 --- /dev/null +++ b/testkit/src/main/scala/app/softnetwork/payment/api/PaymentProviderTestKit.scala @@ -0,0 +1,23 @@ +package app.softnetwork.payment.api + +import app.softnetwork.payment.config.{MangoPay, MangoPaySettings, ProviderConfig} +import app.softnetwork.payment.model.SoftPayAccount +import app.softnetwork.payment.spi.MockMangoPayConfig +import app.softnetwork.persistence.scalatest.PersistenceTestKit +import app.softnetwork.security.sha256 + +trait PaymentProviderTestKit { _: PersistenceTestKit => + + implicit lazy val provider: SoftPayAccount.Client.Provider = providerConfig.softPayProvider + + implicit lazy val providerConfig: ProviderConfig = MockMangoPayConfig( + MangoPaySettings.MangoPayConfig + ) + + def providerSettings: String = + s""" + |payment.test = true + |payment.client-id = "${provider.clientId}" + |payment.api-key = "${sha256(provider.providerApiKey)}" + |""".stripMargin +} diff --git a/testkit/src/main/scala/app/softnetwork/payment/api/SoftPayClientTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/api/SoftPayClientTestKit.scala deleted file mode 100644 index f2f56f1..0000000 --- a/testkit/src/main/scala/app/softnetwork/payment/api/SoftPayClientTestKit.scala +++ /dev/null @@ -1,20 +0,0 @@ -package app.softnetwork.payment.api - -import app.softnetwork.payment.config.MangoPay -import app.softnetwork.payment.model.SoftPayAccount -import app.softnetwork.security.sha256 - -trait SoftPayClientTestKit { - - def provider: SoftPayAccount.Client.Provider = - MangoPay.softPayProvider.withProviderType( - SoftPayAccount.Client.Provider.ProviderType.MOCK - ) - - def softPayClientSettings: String = - s""" - |payment.test = true - |payment.client-id = "${provider.clientId}" - |payment.api-key = "${sha256(provider.providerApiKey)}" - |""".stripMargin -} diff --git a/testkit/src/main/scala/app/softnetwork/payment/data/package.scala b/testkit/src/main/scala/app/softnetwork/payment/data/package.scala index a244b32..5ce2321 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/data/package.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/data/package.scala @@ -1,6 +1,13 @@ package app.softnetwork.payment -import app.softnetwork.payment.model.{Address, CardPreRegistration, LegalUser, NaturalUser} +import app.softnetwork.payment.model.{ + Address, + Business, + BusinessSupport, + CardPreRegistration, + LegalUser, + NaturalUser +} import app.softnetwork.payment.model.UboDeclaration.UltimateBeneficialOwner import app.softnetwork.payment.model.UboDeclaration.UltimateBeneficialOwner.BirthPlace @@ -25,45 +32,63 @@ package object data { val lastName = "lastName" val birthday = "26/12/1972" val email = "demo@softnetwork.fr" + val phone = "+33102030405" + + val business: Business = Business.defaultInstance + .withMerchantCategoryCode("5817") + .withWebsite("https://www.softnetwork.fr") + .withSupport(BusinessSupport.defaultInstance.withEmail(email).withPhone(phone)) + + val ownerName = s"$firstName $lastName" + val ownerAddress: Address = Address.defaultInstance + .withAddressLine("addressLine") + .withCity("Paris") + .withPostalCode("75002") + .withCountry("FR") + val naturalUser: NaturalUser = NaturalUser.defaultInstance .withExternalUuid(customerUuid) .withFirstName(firstName) .withLastName(lastName) .withBirthday(birthday) + .withAddress(ownerAddress) .withEmail(email) + .withPhone(phone) + .withBusiness(business) /** bank account */ var sellerBankAccountId: String = _ var vendorBankAccountId: String = _ - val ownerName = s"$firstName $lastName" - val ownerAddress: Address = Address.defaultInstance - .withAddressLine("addressLine") - .withCity("Paris") - .withPostalCode("75002") - .withCountry("FR") val iban = "FR1420041010050500013M02606" val bic = "SOGEFRPPPSZ" /** legal user */ - val siret = "12345678901234" + val siret = "732 829 320 00074" + val vatNumber = "FR49732829320" val legalUser: LegalUser = LegalUser.defaultInstance .withSiret(siret) + .withVatNumber(vatNumber) .withLegalName(ownerName) .withLegalUserType(LegalUser.LegalUserType.SOLETRADER) .withLegalRepresentative(naturalUser.withExternalUuid(sellerUuid)) .withLegalRepresentativeAddress(ownerAddress) .withHeadQuartersAddress(ownerAddress) + .withPhone(phone) + .withBusiness(business) /** ultimate beneficial owner */ val ubo: UltimateBeneficialOwner = UltimateBeneficialOwner.defaultInstance - .withFirstName(firstName) - .withLastName(lastName) + .withFirstName(s"owner$firstName") + .withLastName(s"owner$lastName") .withBirthday(birthday) .withBirthPlace(BirthPlace.defaultInstance.withCity("city")) .withAddress(ownerAddress.addressLine) .withCity(ownerAddress.city) .withPostalCode(ownerAddress.postalCode) + .withPercentOwnership(100.0) + .withPhone(phone) + .withEmail(email) var uboDeclarationId: String = _ diff --git a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentEndpointsTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentEndpointsTestKit.scala index d5f4494..a15bf15 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentEndpointsTestKit.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentEndpointsTestKit.scala @@ -6,13 +6,21 @@ import app.softnetwork.payment.handlers.{MockSoftPayAccountDao, SoftPayAccountDa import app.softnetwork.payment.launch.PaymentEndpoints import app.softnetwork.payment.service.{MockPaymentServiceEndpoints, PaymentServiceEndpoints} import app.softnetwork.persistence.schema.SchemaProvider -import app.softnetwork.session.CsrfCheck +import app.softnetwork.session.{CsrfCheck, CsrfCheckHeader} import app.softnetwork.session.model.{SessionData, SessionDataCompanion, SessionDataDecorator} -import app.softnetwork.session.scalatest.{SessionEndpointsRoutes, SessionTestKit} -import app.softnetwork.session.service.SessionMaterials +import app.softnetwork.session.scalatest.{ + OneOffCookieSessionEndpointsTestKit, + OneOffHeaderSessionEndpointsTestKit, + RefreshableCookieSessionEndpointsTestKit, + RefreshableHeaderSessionEndpointsTestKit, + SessionEndpointsRoutes, + SessionTestKit +} +import app.softnetwork.session.service.{JwtClaimsSessionMaterials, SessionMaterials} import com.softwaremill.session.{RefreshTokenStorage, SessionConfig, SessionManager} +import org.scalatest.wordspec.AnyWordSpecLike import org.slf4j.{Logger, LoggerFactory} -import org.softnetwork.session.model.Session +import org.softnetwork.session.model.{JwtClaims, Session} import scala.concurrent.ExecutionContext @@ -48,3 +56,43 @@ trait PaymentEndpointsTestKit[SD <: SessionData with SessionDataDecorator[SD]] system => super.endpoints(system) :+ sessionServiceEndpoints(system) } + +trait PaymentEndpointsWithOneOffCookieSessionSpecTestKit + extends AnyWordSpecLike + with PaymentRouteTestKit[JwtClaims] + with OneOffCookieSessionEndpointsTestKit[JwtClaims] + with PaymentEndpointsTestKit[JwtClaims] + with CsrfCheckHeader + with JwtClaimsSessionMaterials { + override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims +} + +trait PaymentEndpointsWithOneOffHeaderSessionSpecTestKit + extends AnyWordSpecLike + with PaymentRouteTestKit[JwtClaims] + with OneOffHeaderSessionEndpointsTestKit[JwtClaims] + with PaymentEndpointsTestKit[JwtClaims] + with CsrfCheckHeader + with JwtClaimsSessionMaterials { + override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims +} + +trait PaymentEndpointsWithRefreshableCookieSessionSpecTestKit + extends AnyWordSpecLike + with PaymentRouteTestKit[JwtClaims] + with RefreshableCookieSessionEndpointsTestKit[JwtClaims] + with PaymentEndpointsTestKit[JwtClaims] + with CsrfCheckHeader + with JwtClaimsSessionMaterials { + override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims +} + +trait PaymentEndpointsWithRefreshableHeaderSessionSpecTestKit + extends AnyWordSpecLike + with PaymentRouteTestKit[JwtClaims] + with RefreshableHeaderSessionEndpointsTestKit[JwtClaims] + with PaymentEndpointsTestKit[JwtClaims] + with CsrfCheckHeader + with JwtClaimsSessionMaterials { + override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims +} diff --git a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRouteTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRouteTestKit.scala index 2e86bf4..06be14a 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRouteTestKit.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRouteTestKit.scala @@ -5,9 +5,10 @@ import akka.http.scaladsl.model.headers.RawHeader import akka.http.scaladsl.model.{ContentTypes, Multipart, StatusCodes} import app.softnetwork.api.server.ApiRoutes import app.softnetwork.api.server.config.ServerSettings.RootPath -import app.softnetwork.payment.api.PaymentGrpcServicesTestKit -import app.softnetwork.payment.config.MangoPay -import app.softnetwork.payment.config.PaymentSettings._ +import app.softnetwork.payment.api.{PaymentClient, PaymentGrpcServicesTestKit} +import app.softnetwork.payment.config.PaymentSettings +import app.softnetwork.payment.config.PaymentSettings.PaymentConfig._ +import app.softnetwork.payment.data.{customerUuid, sellerUuid} import app.softnetwork.payment.model.SoftPayAccount.Client.Provider import app.softnetwork.payment.model._ import app.softnetwork.session.model.{SessionData, SessionDataDecorator} @@ -23,6 +24,16 @@ trait PaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] with PaymentGrpcServicesTestKit { _: Suite with ApiRoutes with SessionMaterials[SD] => + lazy val paymentClient: PaymentClient = PaymentClient(ts) + + lazy val customerSession: SD with SessionDataDecorator[SD] = + companion.newSession.withId(customerUuid).withProfile("customer").withClientId(clientId) + + var externalUserId: String = "individual" + + def sellerSession(id: String = sellerUuid): SD with SessionDataDecorator[SD] = + companion.newSession.withId(id).withProfile("seller").withClientId(clientId) + import app.softnetwork.serialization._ override lazy val additionalConfig: String = paymentGrpcConfig @@ -37,7 +48,7 @@ trait PaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] def loadPaymentAccount(): PaymentAccountView = { withHeaders( - Get(s"/$RootPath/$PaymentPath") + Get(s"/$RootPath/${PaymentSettings.PaymentConfig.path}") ) ~> routes ~> check { status shouldEqual StatusCodes.OK responseAs[PaymentAccountView] @@ -46,7 +57,7 @@ trait PaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] def loadCards(): Seq[Card] = { withHeaders( - Get(s"/$RootPath/$PaymentPath/$CardRoute") + Get(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute") ) ~> routes ~> check { status shouldEqual StatusCodes.OK responseAs[Seq[Card]] @@ -55,7 +66,7 @@ trait PaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] def loadBankAccount(): BankAccountView = { withHeaders( - Get(s"/$RootPath/$PaymentPath/$BankRoute") + Get(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute") ) ~> routes ~> check { status shouldEqual StatusCodes.OK responseAs[BankAccountView] @@ -71,7 +82,7 @@ trait PaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] .foreach { documentType => withHeaders( Post( - s"/$RootPath/$PaymentPath/$KycRoute?documentType=$documentType", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$kycRoute?documentType=$documentType", entity = Multipart.FormData .fromPath( "pages", @@ -99,7 +110,7 @@ trait PaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] documentType: KycDocument.KycDocumentType ): KycDocumentValidationReport = { withHeaders( - Get(s"/$RootPath/$PaymentPath/$KycRoute?documentType=$documentType") + Get(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$kycRoute?documentType=$documentType") ) ~> routes ~> check { status shouldEqual StatusCodes.OK responseAs[KycDocumentValidationReport] @@ -112,13 +123,15 @@ trait PaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] .map(_.documentType) .foreach { documentType => withHeaders( - Get(s"/$RootPath/$PaymentPath/$KycRoute?documentType=$documentType") + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$kycRoute?documentType=$documentType" + ) ) ~> routes ~> check { status shouldEqual StatusCodes.OK val report = responseAs[KycDocumentValidationReport] assert(report.status == KycDocument.KycDocumentStatus.KYC_DOCUMENT_VALIDATION_ASKED) Get( - s"/$RootPath/$PaymentPath/$HooksRoute/${Provider.ProviderType.MOCK.name.toLowerCase}?EventType=KYC_SUCCEEDED&RessourceId=${report.id}" + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$hooksRoute/${Provider.ProviderType.MOCK.name.toLowerCase}?EventType=KYC_SUCCEEDED&RessourceId=${report.id}" ) ~> routes ~> check { status shouldEqual StatusCodes.OK assert( @@ -133,7 +146,7 @@ trait PaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] def loadDeclaration(): UboDeclarationView = { withHeaders( - Get(s"/$RootPath/$PaymentPath/$DeclarationRoute") + Get(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$declarationRoute") ) ~> routes ~> check { status shouldEqual StatusCodes.OK responseAs[UboDeclarationView] diff --git a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRoutesTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRoutesTestKit.scala index 0fcd47a..14af22e 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRoutesTestKit.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRoutesTestKit.scala @@ -7,11 +7,19 @@ import app.softnetwork.payment.launch.PaymentRoutes import app.softnetwork.payment.service.{MockPaymentService, PaymentService} import app.softnetwork.persistence.schema.SchemaProvider import app.softnetwork.session.model.{SessionData, SessionDataCompanion, SessionDataDecorator} -import app.softnetwork.session.scalatest.{SessionServiceRoutes, SessionTestKit} -import app.softnetwork.session.service.SessionMaterials +import app.softnetwork.session.scalatest.{ + OneOffCookieSessionServiceTestKit, + OneOffHeaderSessionServiceTestKit, + RefreshableCookieSessionServiceTestKit, + RefreshableHeaderSessionServiceTestKit, + SessionServiceRoutes, + SessionTestKit +} +import app.softnetwork.session.service.{JwtClaimsSessionMaterials, SessionMaterials} import com.softwaremill.session.{RefreshTokenStorage, SessionConfig, SessionManager} +import org.scalatest.wordspec.AnyWordSpecLike import org.slf4j.{Logger, LoggerFactory} -import org.softnetwork.session.model.Session +import org.softnetwork.session.model.{JwtClaims, Session} import scala.concurrent.ExecutionContext @@ -43,3 +51,39 @@ trait PaymentRoutesTestKit[SD <: SessionData with SessionDataDecorator[SD]] system => super.apiRoutes(system) :+ sessionServiceRoute(system) } + +trait PaymentRoutesWithOneOffCookieSessionSpecTestKit + extends AnyWordSpecLike + with PaymentRouteTestKit[JwtClaims] + with OneOffCookieSessionServiceTestKit[JwtClaims] + with PaymentRoutesTestKit[JwtClaims] + with JwtClaimsSessionMaterials { + override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims +} + +trait PaymentRoutesWithOneOffHeaderSessionSpecTestKit + extends AnyWordSpecLike + with PaymentRouteTestKit[JwtClaims] + with OneOffHeaderSessionServiceTestKit[JwtClaims] + with PaymentRoutesTestKit[JwtClaims] + with JwtClaimsSessionMaterials { + override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims +} + +trait PaymentRoutesWithRefreshableCookieSessionSpecTestKit + extends AnyWordSpecLike + with PaymentRouteTestKit[JwtClaims] + with RefreshableCookieSessionServiceTestKit[JwtClaims] + with PaymentRoutesTestKit[JwtClaims] + with JwtClaimsSessionMaterials { + override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims +} + +trait PaymentRoutesWithRefreshableHeaderSessionSpecTestKit + extends AnyWordSpecLike + with PaymentRouteTestKit[JwtClaims] + with RefreshableHeaderSessionServiceTestKit[JwtClaims] + with PaymentRoutesTestKit[JwtClaims] + with JwtClaimsSessionMaterials { + override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims +} diff --git a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentTestKit.scala index 2ad2e12..9d04b5a 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentTestKit.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentTestKit.scala @@ -4,7 +4,7 @@ import akka.actor.typed.ActorSystem import app.softnetwork.account.config.AccountSettings import app.softnetwork.notification.scalatest.AllNotificationsTestKit import app.softnetwork.payment.api._ -import app.softnetwork.payment.config.PaymentSettings._ +import app.softnetwork.payment.config.PaymentSettings.PaymentConfig._ import app.softnetwork.payment.handlers.{ MockPaymentHandler, MockSoftPayAccountDao, @@ -42,13 +42,13 @@ trait PaymentTestKit extends SchedulerTestKit with AllNotificationsTestKit with PaymentGuardian - with SoftPayClientTestKit { + with PaymentProviderTestKit { _: Suite => /** @return * roles associated with this node */ - override def roles: Seq[String] = super.roles :+ AkkaNodeRole :+ AccountSettings.AkkaNodeRole + override def roles: Seq[String] = super.roles :+ akkaNodeRole :+ AccountSettings.AkkaNodeRole override def paymentBehavior: ActorSystem[_] => PaymentBehavior = _ => MockPaymentBehavior @@ -67,7 +67,7 @@ trait PaymentTestKit override lazy val config: Config = akkaConfig .withFallback(ConfigFactory.load("softnetwork-in-memory-persistence.conf")) .withFallback( - ConfigFactory.parseString(softPayClientSettings) + ConfigFactory.parseString(providerSettings) ) .withFallback(ConfigFactory.load()) @@ -94,7 +94,7 @@ trait PaymentTestKit override implicit def system: ActorSystem[_] = sys } - def payInFor3DS( + def payInCallback( orderUuid: String, transactionId: String, registerCard: Boolean, @@ -102,7 +102,7 @@ trait PaymentTestKit )(implicit ec: ExecutionContext ): Future[Either[PayInFailed, Either[PaymentRedirection, PaidIn]]] = { - MockPaymentHandler !? PayInFor3DS(orderUuid, transactionId, registerCard, printReceipt) map { + MockPaymentHandler !? PayInCallback(orderUuid, transactionId, registerCard, printReceipt) map { case result: PaymentRedirection => Right(Left(result)) case result: PaidIn => Right(Right(result)) case error: PayInFailed => Left(error) @@ -111,7 +111,7 @@ trait PaymentTestKit } } - def preAuthorizeCardFor3DS( + def preAuthorizeCardCallback( orderUuid: String, preAuthorizationId: String, registerCard: Boolean = true, @@ -119,7 +119,7 @@ trait PaymentTestKit )(implicit ec: ExecutionContext ): Future[Either[CardPreAuthorizationFailed, Either[PaymentRedirection, CardPreAuthorized]]] = { - MockPaymentHandler !? PreAuthorizeCardFor3DS( + MockPaymentHandler !? PreAuthorizeCardCallback( orderUuid, preAuthorizationId, registerCard, @@ -132,13 +132,13 @@ trait PaymentTestKit } } - def payInFirstRecurringFor3DS( + def payInFirstRecurringPaymentCallback( recurringPayInRegistrationId: String, transactionId: String )(implicit ec: ExecutionContext): Future[ Either[FirstRecurringCardPaymentFailed, Either[PaymentRedirection, FirstRecurringPaidIn]] ] = { - MockPaymentHandler !? PayInFirstRecurringFor3DS( + MockPaymentHandler !? FirstRecurringPaymentCallback( recurringPayInRegistrationId, transactionId ) map { diff --git a/testkit/src/main/scala/app/softnetwork/payment/scalatest/SoftPayTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/scalatest/SoftPayTestKit.scala index aaa8eee..9f774b9 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/scalatest/SoftPayTestKit.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/scalatest/SoftPayTestKit.scala @@ -3,7 +3,7 @@ package app.softnetwork.payment.scalatest import akka.actor.typed.ActorSystem import app.softnetwork.account.config.AccountSettings import app.softnetwork.account.persistence.query.AccountEventProcessorStreams.InternalAccountEvents2AccountProcessorStream -import app.softnetwork.payment.config.PaymentSettings._ +import app.softnetwork.payment.config.PaymentSettings.PaymentConfig._ import app.softnetwork.payment.handlers.MockSoftPayAccountHandler import app.softnetwork.payment.launch.SoftPayGuardian import app.softnetwork.payment.persistence.typed.MockSoftPayAccountBehavior @@ -22,7 +22,7 @@ trait SoftPayTestKit extends PaymentTestKit with SoftPayGuardian { /** @return * roles associated with this node */ - override def roles: Seq[String] = super.roles :+ AkkaNodeRole :+ AccountSettings.AkkaNodeRole + override def roles: Seq[String] = super.roles :+ akkaNodeRole :+ AccountSettings.AkkaNodeRole override def internalAccountEvents2AccountProcessorStream : ActorSystem[_] => InternalAccountEvents2AccountProcessorStream = sys => diff --git a/testkit/src/main/scala/app/softnetwork/payment/scalatest/StripePaymentRouteTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/scalatest/StripePaymentRouteTestKit.scala new file mode 100644 index 0000000..3108014 --- /dev/null +++ b/testkit/src/main/scala/app/softnetwork/payment/scalatest/StripePaymentRouteTestKit.scala @@ -0,0 +1,31 @@ +package app.softnetwork.payment.scalatest + +import app.softnetwork.api.server.ApiRoutes +import app.softnetwork.security.sha256 +import app.softnetwork.session.model.{SessionData, SessionDataDecorator} +import app.softnetwork.session.service.SessionMaterials +import org.scalatest.Suite + +import sys.process._ + +trait StripePaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] + extends PaymentRouteTestKit[SD] + with StripePaymentTestKit { _: Suite with ApiRoutes with SessionMaterials[SD] => + + private[this] var stripeCLi: Process = _ + + override def beforeAll(): Unit = { + super.beforeAll() + val hash = sha256(clientId) + stripeCLi = Process( + s"stripe listen --forward-to ${providerConfig.hooksBaseUrl.replace("9000", s"$port").replace("localhost", interface)}?hash=$hash" + ).run() + } + + override def afterAll(): Unit = { + super.afterAll() + if (stripeCLi.isAlive()) + stripeCLi.destroy() + } + +} diff --git a/testkit/src/main/scala/app/softnetwork/payment/scalatest/StripePaymentTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/scalatest/StripePaymentTestKit.scala new file mode 100644 index 0000000..a88a1df --- /dev/null +++ b/testkit/src/main/scala/app/softnetwork/payment/scalatest/StripePaymentTestKit.scala @@ -0,0 +1,10 @@ +package app.softnetwork.payment.scalatest + +import app.softnetwork.payment.config.{StripeApi, StripeSettings} +import org.scalatest.Suite + +trait StripePaymentTestKit extends PaymentTestKit { _: Suite => + + override implicit lazy val providerConfig: StripeApi.Config = StripeSettings.StripeApiConfig + +} diff --git a/testkit/src/main/scala/app/softnetwork/payment/spi/MockMangoPayProvider.scala b/testkit/src/main/scala/app/softnetwork/payment/spi/MockMangoPayProvider.scala index 4b14d07..b30f36e 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/spi/MockMangoPayProvider.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/spi/MockMangoPayProvider.scala @@ -1,8 +1,9 @@ package app.softnetwork.payment.spi import akka.actor.typed.ActorSystem -import app.softnetwork.payment.config.MangoPay -import app.softnetwork.payment.config.MangoPaySettings.MangoPayConfig._ +import app.softnetwork.payment.annotation.InternalApi +import app.softnetwork.payment.config.MangoPay.MangoPayConfig +import app.softnetwork.payment.config.{MangoPaySettings, Payment, ProviderConfig} import app.softnetwork.payment.handlers.MockPaymentHandler import app.softnetwork.payment.model.NaturalUser.NaturalUserType import app.softnetwork.payment.model.RecurringPayment.RecurringCardPaymentState @@ -28,6 +29,7 @@ import com.mangopay.entities.{ UboDeclaration => _, _ } +import com.typesafe.config.Config import org.json4s.Formats import java.text.SimpleDateFormat @@ -53,7 +55,13 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * provider user id */ - override def createOrUpdateNaturalUser(maybeNaturalUser: Option[NaturalUser]): Option[String] = + @InternalApi + private[spi] override def createOrUpdateNaturalUser( + maybeNaturalUser: Option[NaturalUser], + acceptedTermsOfPSP: Boolean, + ipAddress: Option[String], + userAgent: Option[String] + ): Option[String] = maybeNaturalUser match { case Some(naturalUser) => import naturalUser._ @@ -101,7 +109,13 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * provider user id */ - override def createOrUpdateLegalUser(maybeLegalUser: Option[LegalUser]): Option[String] = { + @InternalApi + private[spi] override def createOrUpdateLegalUser( + maybeLegalUser: Option[LegalUser], + acceptedTermsOfPSP: Boolean, + ipAddress: Option[String], + userAgent: Option[String] + ): Option[String] = { maybeLegalUser match { case Some(legalUser) => import legalUser._ @@ -427,7 +441,7 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * pay in transaction */ - override def loadPayIn( + override def loadPayInTransaction( orderUuid: String, transactionId: String, recurringPayInRegistrationId: Option[String] @@ -538,7 +552,10 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * Refund transaction */ - override def loadRefund(orderUuid: String, transactionId: String): Option[Transaction] = None + override def loadRefundTransaction( + orderUuid: String, + transactionId: String + ): Option[Transaction] = None /** @param orderUuid * - order unique id @@ -547,7 +564,10 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * pay out transaction */ - override def loadPayOut(orderUuid: String, transactionId: String): Option[Transaction] = None + override def loadPayOutTransaction( + orderUuid: String, + transactionId: String + ): Option[Transaction] = None /** @param transactionId * - transaction id @@ -628,7 +648,7 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * the first active bank account */ - override def getActiveBankAccount(userId: String): Option[String] = + override def getActiveBankAccount(userId: String, currency: String): Option[String] = BankAccounts.values.filter(bankAccount => bankAccount.getUserId == userId && bankAccount.isActive ) match { @@ -637,21 +657,6 @@ trait MockMangoPayProvider extends MangoPayProvider { case _ => None } - /** @param userId - * - provider user id - * @param bankAccountId - * - bank account id - * @return - * whether this bank account exists and is active - */ - override def checkBankAccount(userId: String, bankAccountId: String): Boolean = - BankAccounts.values.find(bankAccount => - bankAccount.getUserId == userId && bankAccount.getId == bankAccountId - ) match { - case Some(ba) => ba.isActive - case _ => false - } - /** @param preAuthorizationTransaction * - pre authorization transaction * @param idempotency @@ -675,7 +680,7 @@ trait MockMangoPayProvider extends MangoPayProvider { cardPreAuthorization.setExecutionType(PreAuthorizationExecutionType.DIRECT) cardPreAuthorization.setSecureMode(SecureMode.DEFAULT) cardPreAuthorization.setSecureModeReturnUrl( - s"$preAuthorizeCardFor3DS/$orderUuid?registerCard=${registerCard + s"${config.preAuthorizeCardReturnUrl}/$orderUuid?registerCard=${registerCard .getOrElse(false)}&printReceipt=${printReceipt.getOrElse(false)}" ) @@ -756,53 +761,57 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * pay in with card pre authorized transaction result */ - override def payInWithCardPreAuthorized( - payInWithCardPreAuthorizedTransaction: PayInWithCardPreAuthorizedTransaction, + private[spi] override def payInWithCardPreAuthorized( + payInWithCardPreAuthorizedTransaction: Option[PayInWithCardPreAuthorizedTransaction], idempotency: Option[Boolean] ): Option[Transaction] = { - import payInWithCardPreAuthorizedTransaction._ - val payIn = new PayIn() - payIn.setTag(orderUuid) - payIn.setCreditedWalletId(creditedWalletId) - payIn.setAuthorId(authorId) - payIn.setDebitedFunds(new Money) - payIn.getDebitedFunds.setAmount(debitedAmount) - payIn.getDebitedFunds.setCurrency(CurrencyIso.valueOf(currency)) - payIn.setExecutionType(PayInExecutionType.DIRECT) - payIn.setFees(new Money) - payIn.getFees.setAmount(0) // fees are only set during transfer or payOut - payIn.getFees.setCurrency(CurrencyIso.valueOf(currency)) - payIn.setPaymentType(PayInPaymentType.PREAUTHORIZED) - val paymentDetails = new PayInPaymentDetailsPreAuthorized - paymentDetails.setPreauthorizationId(cardPreAuthorizedTransactionId) - payIn.setPaymentDetails(paymentDetails) - - payIn.setId(generateUUID()) - payIn.setStatus(MangoPayTransactionStatus.SUCCEEDED) - payIn.setResultCode(OK) - payIn.setResultMessage(SUCCEEDED) - PayIns = PayIns.updated(payIn.getId, payIn) - Some( - Transaction() - .copy( - id = payIn.getId, - orderUuid = orderUuid, - nature = Transaction.TransactionNature.REGULAR, - `type` = Transaction.TransactionType.PAYIN, - status = payIn.getStatus, - amount = debitedAmount, - cardId = None, - fees = 0, - resultCode = Option(payIn.getResultCode).getOrElse(""), - resultMessage = Option(payIn.getResultMessage).getOrElse(""), - redirectUrl = None, - authorId = payIn.getAuthorId, - creditedWalletId = Option(payIn.getCreditedWalletId) + payInWithCardPreAuthorizedTransaction match { + case Some(payInWithCardPreAuthorizedTransaction) => + import payInWithCardPreAuthorizedTransaction._ + val payIn = new PayIn() + payIn.setTag(orderUuid) + payIn.setCreditedWalletId(creditedWalletId) + payIn.setAuthorId(authorId) + payIn.setDebitedFunds(new Money) + payIn.getDebitedFunds.setAmount(debitedAmount) + payIn.getDebitedFunds.setCurrency(CurrencyIso.valueOf(currency)) + payIn.setExecutionType(PayInExecutionType.DIRECT) + payIn.setFees(new Money) + payIn.getFees.setAmount(0) // fees are only set during transfer or payOut + payIn.getFees.setCurrency(CurrencyIso.valueOf(currency)) + payIn.setPaymentType(PayInPaymentType.PREAUTHORIZED) + val paymentDetails = new PayInPaymentDetailsPreAuthorized + paymentDetails.setPreauthorizationId(cardPreAuthorizedTransactionId) + payIn.setPaymentDetails(paymentDetails) + + payIn.setId(generateUUID()) + payIn.setStatus(MangoPayTransactionStatus.SUCCEEDED) + payIn.setResultCode(OK) + payIn.setResultMessage(SUCCEEDED) + PayIns = PayIns.updated(payIn.getId, payIn) + Some( + Transaction() + .copy( + id = payIn.getId, + orderUuid = orderUuid, + nature = Transaction.TransactionNature.REGULAR, + `type` = Transaction.TransactionType.PAYIN, + status = payIn.getStatus, + amount = debitedAmount, + cardId = None, + fees = 0, + resultCode = Option(payIn.getResultCode).getOrElse(""), + resultMessage = Option(payIn.getResultMessage).getOrElse(""), + redirectUrl = None, + authorId = payIn.getAuthorId, + creditedWalletId = Option(payIn.getCreditedWalletId) + ) + .withPaymentType(Transaction.PaymentType.PREAUTHORIZED) + .withPreAuthorizationId(cardPreAuthorizedTransactionId) + .withPreAuthorizationDebitedAmount(preAuthorizationDebitedAmount) ) - .withPaymentType(Transaction.PaymentType.PREAUTHORIZED) - .withPreAuthorizationId(cardPreAuthorizedTransactionId) - .withPreAuthorizationDebitedAmount(preAuthorizationDebitedAmount) - ) + case _ => None + } } /** @param orderUuid @@ -852,8 +861,8 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * pay in transaction result */ - override def payIn( - maybePayInTransaction: Option[PayInTransaction], + private[spi] override def payInWithCard( + maybePayInTransaction: Option[PayInWithCardTransaction], idempotency: Option[Boolean] = None ): Option[Transaction] = maybePayInTransaction match { @@ -881,7 +890,7 @@ trait MockMangoPayProvider extends MangoPayProvider { // Secured Mode is activated from €100. executionDetails.setSecureMode(SecureMode.DEFAULT) executionDetails.setSecureModeReturnUrl( - s"$payInFor3DS/$orderUuid?registerCard=${registerCard + s"${config.payInReturnUrl}/$orderUuid?registerCard=${registerCard .getOrElse(false)}&printReceipt=${printReceipt.getOrElse(false)}" ) payIn.setExecutionDetails(executionDetails) @@ -923,65 +932,69 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * pay in with PayPal transaction result */ - override def payInWithPayPal( - payInWithPayPalTransaction: PayInWithPayPalTransaction, + private[spi] override def payInWithPayPal( + payInWithPayPalTransaction: Option[PayInWithPayPalTransaction], idempotency: Option[Boolean] ): Option[Transaction] = { - import payInWithPayPalTransaction._ - val payIn = new PayIn() - payIn.setTag(orderUuid) - payIn.setCreditedWalletId(creditedWalletId) - payIn.setAuthorId(authorId) - payIn.setDebitedFunds(new Money) - payIn.getDebitedFunds.setAmount(debitedAmount) - payIn.getDebitedFunds.setCurrency(CurrencyIso.valueOf(currency)) - payIn.setFees(new Money) - payIn.getFees.setAmount(0) // fees are only set during transfer or payOut - payIn.getFees.setCurrency(CurrencyIso.valueOf(currency)) - payIn.setPaymentType(PayInPaymentType.PAYPAL) - val executionDetails = new PayInExecutionDetailsWeb() - executionDetails.setCulture(language) - executionDetails.setReturnUrl( - s"$payPalReturnUrl/$orderUuid?printReceipt=${printReceipt.getOrElse(false)}" - ) - payIn.setExecutionDetails(executionDetails) - payIn.setExecutionType(PayInExecutionType.WEB) - - payIn.setId(generateUUID()) - payIn.setStatus(MangoPayTransactionStatus.CREATED) - payIn.setResultCode(OK) - payIn.setResultMessage(CREATED) - executionDetails.setRedirectUrl( - s"${executionDetails.getReturnUrl}&transactionId=${payIn.getId}" - ) - PayIns = PayIns.updated(payIn.getId, payIn) + payInWithPayPalTransaction match { + case Some(payInWithPayPalTransaction) => + import payInWithPayPalTransaction._ + val payIn = new PayIn() + payIn.setTag(orderUuid) + payIn.setCreditedWalletId(creditedWalletId) + payIn.setAuthorId(authorId) + payIn.setDebitedFunds(new Money) + payIn.getDebitedFunds.setAmount(debitedAmount) + payIn.getDebitedFunds.setCurrency(CurrencyIso.valueOf(currency)) + payIn.setFees(new Money) + payIn.getFees.setAmount(0) // fees are only set during transfer or payOut + payIn.getFees.setCurrency(CurrencyIso.valueOf(currency)) + payIn.setPaymentType(PayInPaymentType.PAYPAL) + val executionDetails = new PayInExecutionDetailsWeb() + executionDetails.setCulture(language) + executionDetails.setReturnUrl( + s"${config.payInReturnUrl}/$orderUuid?printReceipt=${printReceipt.getOrElse(false)}" + ) + payIn.setExecutionDetails(executionDetails) + payIn.setExecutionType(PayInExecutionType.WEB) - Some( - Transaction().copy( - id = payIn.getId, - orderUuid = orderUuid, - nature = Transaction.TransactionNature.REGULAR, - `type` = Transaction.TransactionType.PAYIN, - status = payIn.getStatus, - amount = debitedAmount, - fees = 0, - resultCode = Option(payIn.getResultCode).getOrElse(""), - resultMessage = Option(payIn.getResultMessage).getOrElse(""), - redirectUrl = Option( - payIn.getExecutionDetails - .asInstanceOf[PayInExecutionDetailsWeb] - .getRedirectUrl - ), - returnUrl = Option( - payIn.getExecutionDetails - .asInstanceOf[PayInExecutionDetailsWeb] - .getReturnUrl - ), - authorId = payIn.getAuthorId, - creditedWalletId = Option(payIn.getCreditedWalletId), - paymentType = Transaction.PaymentType.PAYPAL - ) - ) + payIn.setId(generateUUID()) + payIn.setStatus(MangoPayTransactionStatus.CREATED) + payIn.setResultCode(OK) + payIn.setResultMessage(CREATED) + executionDetails.setRedirectUrl( + s"${executionDetails.getReturnUrl}&transactionId=${payIn.getId}" + ) + PayIns = PayIns.updated(payIn.getId, payIn) + + Some( + Transaction().copy( + id = payIn.getId, + orderUuid = orderUuid, + nature = Transaction.TransactionNature.REGULAR, + `type` = Transaction.TransactionType.PAYIN, + status = payIn.getStatus, + amount = debitedAmount, + fees = 0, + resultCode = Option(payIn.getResultCode).getOrElse(""), + resultMessage = Option(payIn.getResultMessage).getOrElse(""), + redirectUrl = Option( + payIn.getExecutionDetails + .asInstanceOf[PayInExecutionDetailsWeb] + .getRedirectUrl + ), + returnUrl = Option( + payIn.getExecutionDetails + .asInstanceOf[PayInExecutionDetailsWeb] + .getReturnUrl + ), + authorId = payIn.getAuthorId, + creditedWalletId = Option(payIn.getCreditedWalletId), + paymentType = Transaction.PaymentType.PAYPAL + ) + ) + case None => None + } } /** @param maybeUserId @@ -1055,7 +1068,11 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * document validation report */ - override def loadDocumentStatus(userId: String, documentId: String): KycDocumentValidationReport = + override def loadDocumentStatus( + userId: String, + documentId: String, + documentType: KycDocument.KycDocumentType + ): KycDocumentValidationReport = Documents.getOrElse( documentId, KycDocumentValidationReport.defaultInstance @@ -1087,7 +1104,7 @@ trait MockMangoPayProvider extends MangoPayProvider { mandate.setExecutionType(MandateExecutionType.WEB) mandate.setMandateType(MandateType.DIRECT_DEBIT) mandate.setReturnUrl( - s"$mandateReturnUrl?externalUuid=$externalUuid&idempotencyKey=${idempotencyKey.getOrElse("")}" + s"${config.mandateReturnUrl}?externalUuid=$externalUuid&idempotencyKey=${idempotencyKey.getOrElse("")}" ) mandate.setScheme(MandateScheme.SEPA) mandate.setStatus(MandateStatus.SUBMITTED) @@ -1221,7 +1238,7 @@ trait MockMangoPayProvider extends MangoPayProvider { * @return * transaction if it exists */ - override def directDebitTransaction( + override def loadDirectDebitTransaction( walletId: String, transactionId: String, transactionDate: Date @@ -1331,7 +1348,9 @@ trait MockMangoPayProvider extends MangoPayProvider { */ override def validateDeclaration( userId: String, - uboDeclarationId: String + uboDeclarationId: String, + ipAddress: String, + userAgent: String ): Option[UboDeclaration] = { UboDeclarations.get(uboDeclarationId) match { case Some(uboDeclaration) => @@ -1541,7 +1560,7 @@ trait MockMangoPayProvider extends MangoPayProvider { recurringPayInCIT.setTag(externalUuid) recurringPayInCIT.setStatementDescriptor(statementDescriptor) recurringPayInCIT.setSecureModeReturnURL( - s"$recurringPaymentFor3DS/$recurringPayInRegistrationId" + s"${config.recurringPaymentReturnUrl}/$recurringPayInRegistrationId" ) import recurringPaymentRegistration._ @@ -1730,17 +1749,46 @@ case class RecurringCardPaymentRegistration( registration: CreateRecurringPayment ) +case class MockMangoPayConfig(config: MangoPayConfig) + extends ProviderConfig( + config.clientId, + config.apiKey, + config.baseUrl, + config.version, + config.debug, + config.secureModePath, + config.hooksPath, + config.mandatePath, + config.paypalPath + ) + with MangoPayConfig { + override def `type`: Provider.ProviderType = Provider.ProviderType.MOCK + override val technicalErrors: Set[String] = config.technicalErrors + + override def paymentConfig: Payment.Config = config.paymentConfig + + override def withPaymentConfig(paymentConfig: Payment.Config): MangoPayConfig = + this.copy(config = config.withPaymentConfig(paymentConfig)) +} + class MockMangoPayProviderFactory extends PaymentProviderSpi { + @volatile private[this] var _config: Option[MangoPayConfig] = None + override val providerType: Provider.ProviderType = Provider.ProviderType.MOCK override def paymentProvider(p: Client.Provider): MockMangoPayProvider = new MockMangoPayProvider { override implicit def provider: Provider = p + override implicit def config: MangoPayConfig = + _config.getOrElse(MockMangoPayConfig(MangoPaySettings.MangoPayConfig)) } - override def softPaymentProvider: Provider = - MangoPay.softPayProvider.withProviderType(providerType) + override def softPaymentProvider(config: Config): Provider = { + val mangoPayConfig = MockMangoPayConfig(MangoPaySettings(config).MangoPayConfig) + _config = Some(mangoPayConfig) + mangoPayConfig.softPayProvider.withProviderType(providerType) + } override def hooksDirectives(implicit _system: ActorSystem[_], 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 cda60ad..1f78915 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/handlers/PaymentHandlerSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/handlers/PaymentHandlerSpec.scala @@ -75,7 +75,8 @@ class PaymentHandlerSpec "EUR", Some(cardPreRegistration.id), Some(cardPreRegistration.preregistrationData), - registerCard = true + registerCard = true, + creditedAccount = Some(computeExternalUuidWithProfile(sellerUuid, Some("seller"))) ) ) await { case result: PaymentRedirection => @@ -93,7 +94,7 @@ class PaymentHandlerSpec "update card pre authorization" in { !?( - PreAuthorizeCardFor3DS( + PreAuthorizeCardCallback( orderUuid, preAuthorizationId ) @@ -134,7 +135,9 @@ class PaymentHandlerSpec !?( CreateOrUpdateBankAccount( computeExternalUuidWithProfile(sellerUuid, Some("seller")), - BankAccount(None, ownerName, ownerAddress, "", bic) + BankAccount(None, ownerName, ownerAddress, "", bic), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case WrongIban => @@ -146,7 +149,9 @@ class PaymentHandlerSpec !?( CreateOrUpdateBankAccount( computeExternalUuidWithProfile(sellerUuid, Some("seller")), - BankAccount(None, ownerName, ownerAddress, iban, "WRONG") + BankAccount(None, ownerName, ownerAddress, iban, "WRONG"), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case WrongBic => @@ -160,7 +165,9 @@ class PaymentHandlerSpec computeExternalUuidWithProfile(sellerUuid, Some("seller")), BankAccount(None, ownerName, ownerAddress, iban, ""), Some(User.NaturalUser(naturalUser.withExternalUuid(sellerUuid).withProfile("seller"))), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -207,7 +214,9 @@ class PaymentHandlerSpec .withProfile("seller") ) ), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -254,7 +263,9 @@ class PaymentHandlerSpec .withProfile("seller") ) ), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -282,7 +293,9 @@ class PaymentHandlerSpec .withProfile("seller") ) ), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -314,7 +327,9 @@ class PaymentHandlerSpec .withProfile("seller") ) ), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => assert(!r.kycUpdated && !r.documentsUpdated && r.userUpdated) @@ -342,7 +357,9 @@ class PaymentHandlerSpec .withProfile("seller") ) ), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => assert(!r.kycUpdated && !r.documentsUpdated && r.userUpdated) @@ -360,7 +377,9 @@ class PaymentHandlerSpec iban, bic ), - Some(User.LegalUser(legalUser.withSiret(""))) + Some(User.LegalUser(legalUser.withSiret(""))), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case WrongSiret => @@ -379,7 +398,9 @@ class PaymentHandlerSpec iban, bic ), - Some(User.LegalUser(legalUser.withLegalName(""))) + Some(User.LegalUser(legalUser.withLegalName(""))), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case LegalNameRequired => @@ -398,7 +419,9 @@ class PaymentHandlerSpec iban, bic ), - Some(User.LegalUser(legalUser)) + Some(User.LegalUser(legalUser)), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case AcceptedTermsOfPSPRequired => @@ -423,7 +446,9 @@ class PaymentHandlerSpec ) ), Some(true), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -474,7 +499,9 @@ class PaymentHandlerSpec ) ), Some(true), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -551,7 +578,9 @@ class PaymentHandlerSpec updatedBankAccount, Some(User.LegalUser(updatedLegalUser)), Some(true), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -568,7 +597,9 @@ class PaymentHandlerSpec computeExternalUuidWithProfile(sellerUuid, Some("seller")), updatedBankAccount, Some(User.LegalUser(updatedLegalUser)), - Some(true) + Some(true), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -584,7 +615,9 @@ class PaymentHandlerSpec computeExternalUuidWithProfile(sellerUuid, Some("seller")), updatedBankAccount, Some(User.LegalUser(updatedLegalUser)), - Some(true) + Some(true), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -601,7 +634,9 @@ class PaymentHandlerSpec updatedBankAccount, Some(User.LegalUser(updatedLegalUser)), Some(true), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -617,7 +652,9 @@ class PaymentHandlerSpec updatedBankAccount.withBic(""), Some(User.LegalUser(updatedLegalUser)), Some(true), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -637,7 +674,9 @@ class PaymentHandlerSpec updatedBankAccount, Some(User.LegalUser(updatedLegalUser)), Some(true), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => @@ -726,7 +765,13 @@ class PaymentHandlerSpec } "ask for declaration validation" in { - !?(ValidateUboDeclaration(computeExternalUuidWithProfile(sellerUuid, Some("seller")))) await { + !?( + ValidateUboDeclaration( + computeExternalUuidWithProfile(sellerUuid, Some("seller")), + "127.0.0.1", + Some("UserAgent") + ) + ) await { case UboDeclarationAskedForValidation => !?(GetUboDeclaration(computeExternalUuidWithProfile(sellerUuid, Some("seller")))) await { case result: UboDeclarationLoaded => @@ -777,7 +822,8 @@ class PaymentHandlerSpec "EUR", Some(cardPreRegistration.id), Some(cardPreRegistration.preregistrationData), - registerCard = true + registerCard = true, + creditedAccount = Some(computeExternalUuidWithProfile(sellerUuid, Some("seller"))) ) ) await { case result: CardPreAuthorized => @@ -801,7 +847,8 @@ class PaymentHandlerSpec "EUR", Some(cardPreRegistration.id), Some(cardPreRegistration.preregistrationData), - registerCard = true + registerCard = true, + creditedAccount = Some(computeExternalUuidWithProfile(sellerUuid, Some("seller"))) ) ) await { case result: CardPreAuthorized => @@ -844,7 +891,8 @@ class PaymentHandlerSpec 100, 0, "EUR", - None + None, + result.transactionId ) complete () match { case Success(s) => assert(s.transactionId.isDefined) @@ -867,14 +915,15 @@ class PaymentHandlerSpec computeExternalUuidWithProfile(sellerUuid, Some("seller")) ) ) await { - case _: PaidIn => + case result: PaidIn => paymentClient.payOut( orderUuid, computeExternalUuidWithProfile(sellerUuid, Some("seller")), 100, 0, "EUR", - None + None, + Option(result.transactionId) ) complete () match { case Success(s) => assert(s.transactionId.isDefined) @@ -909,20 +958,21 @@ class PaymentHandlerSpec val printReceipt = params.getOrElse("printReceipt", "") assert(printReceipt == "true") !?( - PayInForPayPal( + PayInCallback( orderUuid, transactionId, printReceipt.toBoolean ) ) await { - case _: PaidIn => + case result: PaidIn => paymentClient.payOut( orderUuid, computeExternalUuidWithProfile(sellerUuid, Some("seller")), 100, 0, "EUR", - None + None, + Option(result.transactionId) ) complete () match { case Success(s) => assert(s.transactionId.isDefined) @@ -983,7 +1033,9 @@ class PaymentHandlerSpec computeExternalUuidWithProfile(vendorUuid, Some("vendor")), BankAccount(None, ownerName, ownerAddress, iban, bic), Some(User.NaturalUser(naturalUser.withExternalUuid(vendorUuid).withProfile("vendor"))), - clientId = Some(clientId) + clientId = Some(clientId), + ipAddress = Some("127.0.0.1"), + userAgent = Some("UserAgent") ) ) await { case r: BankAccountCreatedOrUpdated => diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithOneOffCookieSessionSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithOneOffCookieSessionSpec.scala index 6a6c9e9..7bca170 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithOneOffCookieSessionSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithOneOffCookieSessionSpec.scala @@ -1,17 +1,8 @@ package app.softnetwork.payment.service -import app.softnetwork.payment.scalatest.PaymentEndpointsTestKit -import app.softnetwork.session.scalatest.OneOffCookieSessionEndpointsTestKit -import app.softnetwork.session.CsrfCheckHeader -import app.softnetwork.session.model.SessionDataCompanion -import app.softnetwork.session.service.JwtClaimsSessionMaterials +import app.softnetwork.payment.scalatest.PaymentEndpointsWithOneOffCookieSessionSpecTestKit import org.softnetwork.session.model.JwtClaims class PaymentEndpointsWithOneOffCookieSessionSpec extends PaymentServiceSpec[JwtClaims] - with OneOffCookieSessionEndpointsTestKit[JwtClaims] - with PaymentEndpointsTestKit[JwtClaims] - with CsrfCheckHeader - with JwtClaimsSessionMaterials { - override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims -} + with PaymentEndpointsWithOneOffCookieSessionSpecTestKit diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithOneOffHeaderSessionSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithOneOffHeaderSessionSpec.scala index ed81055..db38a5e 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithOneOffHeaderSessionSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithOneOffHeaderSessionSpec.scala @@ -1,17 +1,8 @@ package app.softnetwork.payment.service -import app.softnetwork.payment.scalatest.PaymentEndpointsTestKit -import app.softnetwork.session.scalatest.OneOffHeaderSessionEndpointsTestKit -import app.softnetwork.session.CsrfCheckHeader -import app.softnetwork.session.model.SessionDataCompanion -import app.softnetwork.session.service.JwtClaimsSessionMaterials +import app.softnetwork.payment.scalatest.PaymentEndpointsWithOneOffHeaderSessionSpecTestKit import org.softnetwork.session.model.JwtClaims class PaymentEndpointsWithOneOffHeaderSessionSpec extends PaymentServiceSpec[JwtClaims] - with OneOffHeaderSessionEndpointsTestKit[JwtClaims] - with PaymentEndpointsTestKit[JwtClaims] - with CsrfCheckHeader - with JwtClaimsSessionMaterials { - override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims -} + with PaymentEndpointsWithOneOffHeaderSessionSpecTestKit diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithRefreshableCookieSessionSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithRefreshableCookieSessionSpec.scala index 00fc3f9..0df090a 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithRefreshableCookieSessionSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithRefreshableCookieSessionSpec.scala @@ -1,17 +1,8 @@ package app.softnetwork.payment.service -import app.softnetwork.payment.scalatest.PaymentEndpointsTestKit -import app.softnetwork.session.scalatest.RefreshableCookieSessionEndpointsTestKit -import app.softnetwork.session.CsrfCheckHeader -import app.softnetwork.session.model.SessionDataCompanion -import app.softnetwork.session.service.JwtClaimsSessionMaterials +import app.softnetwork.payment.scalatest.PaymentEndpointsWithRefreshableCookieSessionSpecTestKit import org.softnetwork.session.model.JwtClaims class PaymentEndpointsWithRefreshableCookieSessionSpec extends PaymentServiceSpec[JwtClaims] - with RefreshableCookieSessionEndpointsTestKit[JwtClaims] - with PaymentEndpointsTestKit[JwtClaims] - with CsrfCheckHeader - with JwtClaimsSessionMaterials { - override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims -} + with PaymentEndpointsWithRefreshableCookieSessionSpecTestKit diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithRefreshableHeaderSessionSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithRefreshableHeaderSessionSpec.scala index 029388e..2fff99f 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithRefreshableHeaderSessionSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentEndpointsWithRefreshableHeaderSessionSpec.scala @@ -1,17 +1,8 @@ package app.softnetwork.payment.service -import app.softnetwork.payment.scalatest.PaymentEndpointsTestKit -import app.softnetwork.session.scalatest.RefreshableHeaderSessionEndpointsTestKit -import app.softnetwork.session.CsrfCheckHeader -import app.softnetwork.session.model.SessionDataCompanion -import app.softnetwork.session.service.JwtClaimsSessionMaterials +import app.softnetwork.payment.scalatest.PaymentEndpointsWithRefreshableHeaderSessionSpecTestKit import org.softnetwork.session.model.JwtClaims class PaymentEndpointsWithRefreshableHeaderSessionSpec extends PaymentServiceSpec[JwtClaims] - with RefreshableHeaderSessionEndpointsTestKit[JwtClaims] - with PaymentEndpointsTestKit[JwtClaims] - with CsrfCheckHeader - with JwtClaimsSessionMaterials { - override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims -} + with PaymentEndpointsWithRefreshableHeaderSessionSpecTestKit diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithOneOffCookieSessionSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithOneOffCookieSessionSpec.scala index 3ed8baa..f2317ce 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithOneOffCookieSessionSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithOneOffCookieSessionSpec.scala @@ -1,15 +1,8 @@ package app.softnetwork.payment.service -import app.softnetwork.payment.scalatest.PaymentRoutesTestKit -import app.softnetwork.session.model.SessionDataCompanion -import app.softnetwork.session.scalatest.OneOffCookieSessionServiceTestKit -import app.softnetwork.session.service.JwtClaimsSessionMaterials +import app.softnetwork.payment.scalatest.PaymentRoutesWithOneOffCookieSessionSpecTestKit import org.softnetwork.session.model.JwtClaims class PaymentRoutesWithOneOffCookieSessionSpec extends PaymentServiceSpec[JwtClaims] - with OneOffCookieSessionServiceTestKit[JwtClaims] - with PaymentRoutesTestKit[JwtClaims] - with JwtClaimsSessionMaterials { - override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims -} + with PaymentRoutesWithOneOffCookieSessionSpecTestKit diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithOneOffHeaderSessionSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithOneOffHeaderSessionSpec.scala index b5d8671..9af0dd1 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithOneOffHeaderSessionSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithOneOffHeaderSessionSpec.scala @@ -1,15 +1,8 @@ package app.softnetwork.payment.service -import app.softnetwork.payment.scalatest.PaymentRoutesTestKit -import app.softnetwork.session.model.SessionDataCompanion -import app.softnetwork.session.scalatest.OneOffHeaderSessionServiceTestKit -import app.softnetwork.session.service.JwtClaimsSessionMaterials +import app.softnetwork.payment.scalatest.PaymentRoutesWithOneOffHeaderSessionSpecTestKit import org.softnetwork.session.model.JwtClaims class PaymentRoutesWithOneOffHeaderSessionSpec extends PaymentServiceSpec[JwtClaims] - with OneOffHeaderSessionServiceTestKit[JwtClaims] - with PaymentRoutesTestKit[JwtClaims] - with JwtClaimsSessionMaterials { - override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims -} + with PaymentRoutesWithOneOffHeaderSessionSpecTestKit diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithRefreshableCookieSessionSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithRefreshableCookieSessionSpec.scala index 144a98b..08a72e1 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithRefreshableCookieSessionSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithRefreshableCookieSessionSpec.scala @@ -1,15 +1,8 @@ package app.softnetwork.payment.service -import app.softnetwork.payment.scalatest.PaymentRoutesTestKit -import app.softnetwork.session.model.SessionDataCompanion -import app.softnetwork.session.scalatest.RefreshableCookieSessionServiceTestKit -import app.softnetwork.session.service.JwtClaimsSessionMaterials +import app.softnetwork.payment.scalatest.PaymentRoutesWithRefreshableCookieSessionSpecTestKit import org.softnetwork.session.model.JwtClaims class PaymentRoutesWithRefreshableCookieSessionSpec extends PaymentServiceSpec[JwtClaims] - with RefreshableCookieSessionServiceTestKit[JwtClaims] - with PaymentRoutesTestKit[JwtClaims] - with JwtClaimsSessionMaterials { - override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims -} + with PaymentRoutesWithRefreshableCookieSessionSpecTestKit diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithRefreshableHeaderSessionSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithRefreshableHeaderSessionSpec.scala index 3986808..c942ce5 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithRefreshableHeaderSessionSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentRoutesWithRefreshableHeaderSessionSpec.scala @@ -1,15 +1,8 @@ package app.softnetwork.payment.service -import app.softnetwork.payment.scalatest.PaymentRoutesTestKit -import app.softnetwork.session.model.SessionDataCompanion -import app.softnetwork.session.scalatest.RefreshableHeaderSessionServiceTestKit -import app.softnetwork.session.service.JwtClaimsSessionMaterials +import app.softnetwork.payment.scalatest.PaymentRoutesWithRefreshableHeaderSessionSpecTestKit import org.softnetwork.session.model.JwtClaims class PaymentRoutesWithRefreshableHeaderSessionSpec extends PaymentServiceSpec[JwtClaims] - with RefreshableHeaderSessionServiceTestKit[JwtClaims] - with PaymentRoutesTestKit[JwtClaims] - with JwtClaimsSessionMaterials { - override implicit def companion: SessionDataCompanion[JwtClaims] = JwtClaims -} + with PaymentRoutesWithRefreshableHeaderSessionSpecTestKit diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentServiceSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentServiceSpec.scala index 8f8d58d..9b063b5 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/PaymentServiceSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/PaymentServiceSpec.scala @@ -1,12 +1,12 @@ package app.softnetwork.payment.service import akka.http.scaladsl.model.{RemoteAddress, StatusCodes} -import akka.http.scaladsl.model.headers.`X-Forwarded-For` +import akka.http.scaladsl.model.headers.{`User-Agent`, `X-Forwarded-For`} import app.softnetwork.api.server.ApiRoutes import app.softnetwork.api.server.config.ServerSettings.RootPath -import app.softnetwork.payment.api.PaymentClient import app.softnetwork.payment.data._ -import app.softnetwork.payment.config.PaymentSettings._ +import app.softnetwork.payment.config.PaymentSettings +import app.softnetwork.payment.config.PaymentSettings.PaymentConfig._ import app.softnetwork.payment.message.PaymentMessages._ import app.softnetwork.payment.model.SoftPayAccount.Client.Provider import app.softnetwork.payment.model._ @@ -32,93 +32,12 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] import app.softnetwork.serialization._ - lazy val paymentClient: PaymentClient = PaymentClient(ts) - - lazy val customerSession: SD with SessionDataDecorator[SD] = - companion.newSession.withId(customerUuid).withProfile(Some("customer")).withClientId(clientId) - - lazy val sellerSession: SD with SessionDataDecorator[SD] = - companion.newSession.withId(sellerUuid).withProfile(Some("seller")).withClientId(clientId) - "Payment service" must { - "pre register card" in { - createNewSession(customerSession) - withHeaders( - Get(s"/$RootPath/$PaymentPath/$CardRoute") - ) ~> routes ~> check { - status shouldEqual StatusCodes.BadRequest - } - withHeaders( - Post( - s"/$RootPath/$PaymentPath/$CardRoute", - PreRegisterCard( - orderUuid, - naturalUser - ) - ) - ) ~> routes ~> check { - status shouldEqual StatusCodes.OK - cardPreRegistration = responseAs[CardPreRegistration] - } - val paymentAccount = loadPaymentAccount() - assert(paymentAccount.naturalUser.isDefined) - } - - "pre authorize card" in { - withHeaders( - Post( - s"/$RootPath/$PaymentPath/$PreAuthorizeCardRoute", - Payment( - orderUuid, - 5100, - "EUR", - Some(cardPreRegistration.id), - Some(cardPreRegistration.preregistrationData), - registerCard = true, - printReceipt = true - ) - ).withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost))) - ) ~> routes ~> check { - status shouldEqual StatusCodes.Accepted - val redirection = responseAs[PaymentRedirection] - val params = redirection.redirectUrl - .split("\\?") - .last - .split("[&=]") - .grouped(2) - .map(a => (a(0), a(1))) - .toMap - preAuthorizationId = params.getOrElse("preAuthorizationId", "") - assert(params.getOrElse("printReceipt", "") == "true") - } - } - - "pre authorize card for 3ds" in { - Get( - s"/$RootPath/$PaymentPath/$SecureModeRoute/$PreAuthorizeCardRoute/$orderUuid?preAuthorizationId=$preAuthorizationId®isterCard=true&printReceipt=true" - ) ~> routes ~> check { - status shouldEqual StatusCodes.OK - val paymentAccount = loadPaymentAccount() - log.info(serialization.write(paymentAccount)) - assert(paymentAccount.cards.nonEmpty) - } - } - - "load cards" in { - val card = loadCards().head - assert(card.firstName == firstName) - assert(card.lastName == lastName) - assert(card.birthday == birthday) - assert(card.getActive) - assert(!card.expired) - cardId = card.id - } - "not create bank account with wrong iban" in { - createNewSession(sellerSession) + createNewSession(sellerSession()) withHeaders( Post( - s"/$RootPath/$PaymentPath/$BankRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", BankAccountCommand( BankAccount(None, ownerName, ownerAddress, "", bic), naturalUser, @@ -134,7 +53,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "not create bank account with wrong bic" in { withHeaders( Post( - s"/$RootPath/$PaymentPath/$BankRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", BankAccountCommand( BankAccount(None, ownerName, ownerAddress, iban, "WRONG"), naturalUser, @@ -150,12 +69,15 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "create bank account with natural user" in { withHeaders( Post( - s"/$RootPath/$PaymentPath/$BankRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", BankAccountCommand( BankAccount(None, ownerName, ownerAddress, iban, bic), - naturalUser, - None + naturalUser.withExternalUuid(externalUserId), + Some(true) ) + ).withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost)), + `User-Agent`("test") ) ) ~> routes ~> check { status shouldEqual StatusCodes.OK @@ -167,10 +89,10 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "update bank account with natural user" in { withHeaders( Post( - s"/$RootPath/$PaymentPath/$BankRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", BankAccountCommand( - BankAccount(Some(sellerBankAccountId), ownerName, ownerAddress, iban, bic), - naturalUser.withLastName("anotherLastName"), + BankAccount(Option(sellerBankAccountId), ownerName, ownerAddress, iban, bic), + naturalUser.withLastName("anotherLastName").withExternalUuid(externalUserId), None ) ) @@ -186,10 +108,10 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "not update bank account with wrong siret" in { withHeaders( Post( - s"/$RootPath/$PaymentPath/$BankRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", BankAccountCommand( BankAccount( - Some(sellerBankAccountId), + Option(sellerBankAccountId), ownerName, ownerAddress, iban, @@ -208,10 +130,10 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "not update bank account with empty legal name" in { withHeaders( Post( - s"/$RootPath/$PaymentPath/$BankRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", BankAccountCommand( BankAccount( - Some(sellerBankAccountId), + Option(sellerBankAccountId), ownerName, ownerAddress, iban, @@ -230,10 +152,10 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "not update bank account without accepted terms of PSP" in { withHeaders( Post( - s"/$RootPath/$PaymentPath/$BankRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", BankAccountCommand( BankAccount( - Some(sellerBankAccountId), + Option(sellerBankAccountId), ownerName, ownerAddress, iban, @@ -250,20 +172,24 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } "update bank account with sole trader legal user" in { + externalUserId = "soleTrader" withHeaders( Post( - s"/$RootPath/$PaymentPath/$BankRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", BankAccountCommand( BankAccount( - Some(sellerBankAccountId), + Option(sellerBankAccountId), ownerName, ownerAddress, iban, bic ), - legalUser, + legalUser.withLegalRepresentative(naturalUser.withExternalUuid(externalUserId)), Some(true) ) + ).withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost)), + `User-Agent`("test") ) ) ~> routes ~> check { status shouldEqual StatusCodes.OK @@ -275,21 +201,24 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } "update bank account with business legal user" in { + externalUserId = "business" val bank = BankAccountCommand( BankAccount( - Some(sellerBankAccountId), + Option(sellerBankAccountId), ownerName, ownerAddress, iban, bic ), - legalUser.withLegalUserType(LegalUser.LegalUserType.BUSINESS), + legalUser + .withLegalUserType(LegalUser.LegalUserType.BUSINESS) + .withLegalRepresentative(naturalUser.withExternalUuid(externalUserId)), Some(true) ) log.info(serialization.write(bank)) withHeaders( - Post(s"/$RootPath/$PaymentPath/$BankRoute", bank) + Post(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", bank) ) ~> routes ~> check { status shouldEqual StatusCodes.OK val bankAccount = loadBankAccount() @@ -309,7 +238,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "create or update ultimate beneficial owner" in { withHeaders( - Post(s"/$RootPath/$PaymentPath/$DeclarationRoute", ubo) + Post(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$declarationRoute", ubo) ) ~> routes ~> check { status shouldEqual StatusCodes.OK val declaration = loadDeclaration() @@ -320,7 +249,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "ask for declaration validation" in { withHeaders( - Put(s"/$RootPath/$PaymentPath/$DeclarationRoute") + Put(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$declarationRoute") ) ~> routes ~> check { status shouldEqual StatusCodes.OK val declaration = loadDeclaration() @@ -332,7 +261,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "update declaration status" in { Get( - s"/$RootPath/$PaymentPath/$HooksRoute/${Provider.ProviderType.MOCK.name.toLowerCase}?EventType=UBO_DECLARATION_VALIDATED&RessourceId=$uboDeclarationId" + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$hooksRoute/${Provider.ProviderType.MOCK.name.toLowerCase}?EventType=UBO_DECLARATION_VALIDATED&RessourceId=$uboDeclarationId" ) ~> routes ~> check { status shouldEqual StatusCodes.OK val declaration = loadDeclaration() @@ -340,11 +269,85 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } } + "pre register card" in { + createNewSession(customerSession) + withHeaders( + Get(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute") + ) ~> routes ~> check { + status shouldEqual StatusCodes.BadRequest + } + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute", + PreRegisterCard( + orderUuid, + naturalUser + ) + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + cardPreRegistration = responseAs[CardPreRegistration] + } + val paymentAccount = loadPaymentAccount() + assert(paymentAccount.naturalUser.isDefined) + } + + "pre authorize card" in { + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$preAuthorizeCardRoute", + Payment( + orderUuid, + 5100, + "EUR", + Some(cardPreRegistration.id), + Some(cardPreRegistration.preregistrationData), + registerCard = true, + printReceipt = true + ) + ).withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost))) + ) ~> routes ~> check { + status shouldEqual StatusCodes.Accepted + val redirection = responseAs[PaymentRedirection] + val params = redirection.redirectUrl + .split("\\?") + .last + .split("[&=]") + .grouped(2) + .map(a => (a(0), a(1))) + .toMap + preAuthorizationId = params.getOrElse("preAuthorizationId", "") + assert(params.getOrElse("printReceipt", "") == "true") + } + } + + "pre authorize card callback" in { + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$callbacksRoute/$preAuthorizeCardRoute/$orderUuid?preAuthorizationId=$preAuthorizationId®isterCard=true&printReceipt=true" + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val paymentAccount = loadPaymentAccount() + log.info(serialization.write(paymentAccount)) + assert(paymentAccount.cards.nonEmpty) + } + } + + "load cards" in { + val card = loadCards().head + assert(card.firstName == firstName) + assert(card.lastName == lastName) + assert(card.birthday == birthday) + assert(card.getActive) + assert(!card.expired) + cardId = card.id + } + "pay in / out with pre authorized card" in { createNewSession(customerSession) withHeaders( Post( - s"/$RootPath/$PaymentPath/$PreAuthorizeCardRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$preAuthorizeCardRoute/${URLEncoder + .encode(computeExternalUuidWithProfile(sellerUuid, Some("seller")), "UTF-8")}", Payment( orderUuid, 100, @@ -371,7 +374,8 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] 100, 0, "EUR", - Some("reference") + Some("reference"), + result.transactionId ) complete () match { case Success(s) => assert(s.transactionId.isDefined) @@ -387,7 +391,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] createNewSession(customerSession) withHeaders( Post( - s"/$RootPath/$PaymentPath/$PayInRoute/${URLEncoder + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$payInRoute/${URLEncoder .encode(computeExternalUuidWithProfile(sellerUuid, Some("seller")), "UTF-8")}", Payment( orderUuid, @@ -415,7 +419,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] val printReceipt = params.getOrElse("printReceipt", "") assert(printReceipt == "true") Get( - s"/$RootPath/$PaymentPath/$SecureModeRoute/$PayInRoute/$orderUuid?transactionId=$transactionId®isterCard=$registerCard&printReceipt=$printReceipt" + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$callbacksRoute/$payInRoute/$orderUuid?transactionId=$transactionId®isterCard=$registerCard&printReceipt=$printReceipt" ) ~> routes ~> check { status shouldEqual StatusCodes.OK assert(responseAs[PaidIn].transactionId == transactionId) @@ -425,7 +429,8 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] 5100, 0, "EUR", - None + None, + transactionId ) complete () match { case Success(s) => assert(s.transactionId.isDefined) @@ -440,12 +445,11 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] createNewSession(customerSession) withHeaders( Post( - s"/$RootPath/$PaymentPath/$PayInRoute/${URLEncoder + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$payInRoute/${URLEncoder .encode(computeExternalUuidWithProfile(sellerUuid, Some("seller")), "UTF-8")}", Payment( orderUuid, 5100, - "EUR", paymentType = Transaction.PaymentType.PAYPAL, printReceipt = true ) @@ -464,7 +468,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] val printReceipt = params.getOrElse("printReceipt", "") assert(printReceipt == "true") Get( - s"/$RootPath/$PaymentPath/$PayPalRoute/$orderUuid?transactionId=$transactionId&printReceipt=$printReceipt" + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$callbacksRoute/$payInRoute/$orderUuid?transactionId=$transactionId®isterCard=false&printReceipt=$printReceipt" ) ~> routes ~> check { status shouldEqual StatusCodes.OK assert(responseAs[PaidIn].transactionId == transactionId) @@ -474,7 +478,8 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] 5100, 0, "EUR", - None + None, + transactionId ) complete () match { case Success(s) => assert(s.transactionId.isDefined) @@ -486,9 +491,9 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } "create mandate" in { - createNewSession(sellerSession) + createNewSession(sellerSession()) withHeaders( - Post(s"/$RootPath/$PaymentPath/$MandateRoute") + Post(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$mandateRoute") ) ~> routes ~> check { status shouldEqual StatusCodes.OK assert(loadPaymentAccount().bankAccount.flatMap(_.mandateId).isDefined) @@ -502,7 +507,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "register recurring direct debit payment" in { withHeaders( Post( - s"/$RootPath/$PaymentPath/$RecurringPaymentRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute", RegisterRecurringPayment( "", `type` = RecurringPayment.RecurringPaymentType.DIRECT_DEBIT, @@ -518,7 +523,9 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] recurringPaymentRegistrationId = responseAs[RecurringPaymentRegistered].recurringPaymentRegistrationId withHeaders( - Get(s"/$RootPath/$PaymentPath/$RecurringPaymentRoute/$recurringPaymentRegistrationId") + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/$recurringPaymentRegistrationId" + ) ) ~> routes ~> check { status shouldEqual StatusCodes.OK val recurringPayment = responseAs[RecurringPaymentView] @@ -541,13 +548,15 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "execute direct debit automatically for next recurring payment" in { withHeaders( - Delete(s"/$RootPath/$PaymentPath/$MandateRoute") + Delete(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$mandateRoute") ) ~> routes ~> check { status shouldEqual StatusCodes.BadRequest } probe.expectMessageType[Schedule4PaymentTriggered] withHeaders( - Get(s"/$RootPath/$PaymentPath/$RecurringPaymentRoute/$recurringPaymentRegistrationId") + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/$recurringPaymentRegistrationId" + ) ) ~> routes ~> check { status shouldEqual StatusCodes.OK val recurringPayment = responseAs[RecurringPaymentView] @@ -568,7 +577,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] createNewSession(customerSession) withHeaders( Post( - s"/$RootPath/$PaymentPath/$RecurringPaymentRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute", RegisterRecurringPayment( "", `type` = RecurringPayment.RecurringPaymentType.CARD, @@ -584,7 +593,9 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] recurringPaymentRegistrationId = responseAs[RecurringPaymentRegistered].recurringPaymentRegistrationId withHeaders( - Get(s"/$RootPath/$PaymentPath/$RecurringPaymentRoute/$recurringPaymentRegistrationId") + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/$recurringPaymentRegistrationId" + ) ) ~> routes ~> check { status shouldEqual StatusCodes.OK val recurringPayment = responseAs[RecurringPaymentView] @@ -607,7 +618,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "execute first recurring card payment" in { withHeaders( Post( - s"/$RootPath/$PaymentPath/$RecurringPaymentRoute/${URLEncoder + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/${URLEncoder .encode(recurringPaymentRegistrationId, "UTF-8")}", Payment( "", @@ -617,7 +628,9 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] ) ~> routes ~> check { status shouldEqual StatusCodes.OK withHeaders( - Get(s"/$RootPath/$PaymentPath/$RecurringPaymentRoute/$recurringPaymentRegistrationId") + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/$recurringPaymentRegistrationId" + ) ) ~> routes ~> check { status shouldEqual StatusCodes.OK val recurringPayment = responseAs[RecurringPaymentView] @@ -642,9 +655,9 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } "cancel mandate" in { - createNewSession(sellerSession) + createNewSession(sellerSession()) withHeaders( - Delete(s"/$RootPath/$PaymentPath/$MandateRoute") + Delete(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$mandateRoute") ) ~> routes ~> check { status shouldEqual StatusCodes.OK assert(loadPaymentAccount().bankAccount.flatMap(_.mandateId).isEmpty) @@ -657,7 +670,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] assert(paymentAccount.paymentAccountStatus.isCompteOk) val userId = paymentAccount.legalUser.flatMap(_.legalRepresentative.userId).getOrElse("") Get( - s"/$RootPath/$PaymentPath/$HooksRoute/${Provider.ProviderType.MOCK.name.toLowerCase}?EventType=USER_KYC_LIGHT&RessourceId=$userId" + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$hooksRoute/${Provider.ProviderType.MOCK.name.toLowerCase}?EventType=USER_KYC_LIGHT&RessourceId=$userId" ) ~> routes ~> check { status shouldEqual StatusCodes.OK assert(loadPaymentAccount().paymentAccountStatus.isDocumentsKo) @@ -666,7 +679,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "delete bank account" in { withHeaders( - Delete(s"/$RootPath/$PaymentPath/$BankRoute") + Delete(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute") ) ~> routes ~> check { status shouldEqual StatusCodes.OK assert(loadPaymentAccount().bankAccount.isEmpty) @@ -676,13 +689,13 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] "disable card" in { createNewSession(customerSession) withHeaders( - Delete(s"/$RootPath/$PaymentPath/$CardRoute?cardId=$cardId") + Delete(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute?cardId=$cardId") ) ~> routes ~> check { status shouldEqual StatusCodes.BadRequest } withHeaders( Put( - s"/$RootPath/$PaymentPath/$RecurringPaymentRoute", + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute", UpdateRecurringCardPaymentRegistration( "", recurringPaymentRegistrationId, @@ -693,7 +706,7 @@ trait PaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] status shouldEqual StatusCodes.OK } withHeaders( - Delete(s"/$RootPath/$PaymentPath/$CardRoute?cardId=$cardId") + Delete(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute?cardId=$cardId") ) ~> routes ~> check { status shouldEqual StatusCodes.OK val cards = loadCards() diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala new file mode 100644 index 0000000..e718a59 --- /dev/null +++ b/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala @@ -0,0 +1,844 @@ +package app.softnetwork.payment.service + +import akka.http.scaladsl.model.{RemoteAddress, StatusCodes} +import akka.http.scaladsl.model.headers.{`User-Agent`, `X-Forwarded-For`} +import app.softnetwork.api.server.ApiRoutes +import app.softnetwork.api.server.config.ServerSettings.RootPath +import app.softnetwork.payment.config.PaymentSettings.PaymentConfig._ +import app.softnetwork.payment.config.{PaymentSettings, StripeApi, StripeSettings} +import app.softnetwork.payment.data.{ + bic, + birthday, + cardId, + cardPreRegistration, + firstName, + iban, + lastName, + legalUser, + naturalUser, + orderUuid, + ownerAddress, + ownerName, + preAuthorizationId, + sellerBankAccountId, + ubo, + uboDeclarationId +} +import app.softnetwork.payment.handlers.MockPaymentDao +import app.softnetwork.payment.message.PaymentMessages.{ + BankAccountCommand, + CardPreAuthorized, + PaidIn, + Payment, + PaymentRedirection, + PaymentRequired, + PreRegisterCard +} +import app.softnetwork.payment.model.{ + computeExternalUuidWithProfile, + BankAccount, + CardPreRegistration, + LegalUser, + Transaction, + UboDeclaration +} +import app.softnetwork.payment.scalatest.StripePaymentRouteTestKit +import app.softnetwork.payment.serialization.paymentFormats +import app.softnetwork.session.model.{SessionData, SessionDataDecorator} +import app.softnetwork.session.service.SessionMaterials +import com.stripe.model.PaymentIntent +import com.stripe.param.PaymentIntentConfirmParams +import org.scalatest.wordspec.AnyWordSpecLike +import org.slf4j.{Logger, LoggerFactory} +import com.stripe.model.SetupIntent +import com.stripe.param.SetupIntentConfirmParams +import org.json4s.Formats +import org.openqa.selenium.{By, WebDriver, WebElement} +import org.openqa.selenium.htmlunit.HtmlUnitDriver + +import java.net.{InetAddress, URLEncoder} +import scala.util.{Failure, Success, Try} +import collection.JavaConverters._ + +trait StripePaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] + extends AnyWordSpecLike + with StripePaymentRouteTestKit[SD] { _: ApiRoutes with SessionMaterials[SD] => + + lazy val log: Logger = LoggerFactory getLogger getClass.getName + + override implicit lazy val providerConfig: StripeApi.Config = StripeSettings.StripeApiConfig + + import app.softnetwork.serialization._ + + var customer: String = _ + + var payInTransactionId: Option[String] = None + + var payOutTransactionId: Option[String] = None + + val debitedAmount: Int = 5100 + + val feesAmount: Int = debitedAmount * 10 / 100 + + val currency = "EUR" + + override implicit def formats: Formats = paymentFormats + + "individual account" should { + "be created or updated" in { + externalUserId = "individual" + createNewSession(sellerSession(externalUserId)) + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", + BankAccountCommand( + BankAccount(None, ownerName, ownerAddress, iban, bic), + naturalUser.withExternalUuid(externalUserId), + Some(true) + ) + ).withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost)), + `User-Agent`("test") + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val bankAccount = loadBankAccount() + sellerBankAccountId = bankAccount.bankAccountId + } + } + } + + "sole trader account" should { + "be created or updated" in { + externalUserId = "soleTrader" + createNewSession(sellerSession(externalUserId)) + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", + BankAccountCommand( + BankAccount( + Option(sellerBankAccountId), + ownerName, + ownerAddress, + iban, + bic + ), + legalUser.withLegalRepresentative(naturalUser.withExternalUuid(externalUserId)), + Some(true) + ) + ).withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost)), + `User-Agent`("test") + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val bankAccount = loadBankAccount() + sellerBankAccountId = bankAccount.bankAccountId + } + } + } + + "business account" should { + "be created or updated" in { + externalUserId = "business" + createNewSession(sellerSession(externalUserId)) + val bank = + BankAccountCommand( + BankAccount( + Option(sellerBankAccountId), + ownerName, + ownerAddress, + iban, + bic + ), + legalUser + .withLegalUserType(LegalUser.LegalUserType.BUSINESS) + .withLegalRepresentative(naturalUser.withExternalUuid(externalUserId)), + Some(true) + ) + log.info(serialization.write(bank)) + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$bankRoute", + bank + ).withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost)), + `User-Agent`("test") + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val bankAccount = loadBankAccount() + sellerBankAccountId = bankAccount.bankAccountId + } + } + "declare beneficial owner(s)" in { + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$declarationRoute", + ubo + ).withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost)), + `User-Agent`("test") + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val declaration = loadDeclaration() + assert(declaration.ubos.size == 1) + uboDeclarationId = declaration.uboDeclarationId + } + } + "ask for declaration validation" in { + withHeaders( + Put( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$declarationRoute" + ).withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost)), + `User-Agent`("test") + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val declaration = loadDeclaration() + assert( + declaration.status == UboDeclaration.UboDeclarationStatus.UBO_DECLARATION_VALIDATED + ) + } + } + } + + "checkout" should { + "pre register 3ds card" in { + createNewSession(customerSession) + withHeaders( + Get(s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute") + ) ~> routes ~> check { + status shouldEqual StatusCodes.BadRequest + } + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute", + PreRegisterCard( + orderUuid, + naturalUser + ) + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + cardPreRegistration = responseAs[CardPreRegistration] + log.info(serialization.write(cardPreRegistration)) + } + + val paymentAccount = loadPaymentAccount() + assert(paymentAccount.naturalUser.flatMap(_.userId).isDefined) + + // front end simulation + // confirm setup intent + Try { + val requestOptions = StripeApi().requestOptions + + SetupIntent + .retrieve( + cardPreRegistration.id, + requestOptions + ) + .confirm( + SetupIntentConfirmParams + .builder() + .setPaymentMethod("pm_card_authenticationRequired") // simulate 3DS + .build(), + requestOptions + ) + } match { + case Success(_) => + case Failure(f) => + log.error("Error while confirming setup intent", f) + fail(f) + } + } + + "pre authorize 3ds card" in { + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$preAuthorizeCardRoute", + Payment( + orderUuid, + debitedAmount, + currency, + Option(cardPreRegistration.id), + Option(cardPreRegistration.preregistrationData), + registerCard = true, + printReceipt = true + ) + ).withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost))) + ) ~> routes ~> check { + status shouldEqual StatusCodes.Accepted + val redirection = responseAs[PaymentRedirection] + log.info(redirection.redirectUrl) + val params = redirection.redirectUrl + .split("\\?") + .last + .split("[&=]") + .grouped(2) + .map(a => (a(0), a(1))) + .toMap + preAuthorizationId = params.get("payment_intent") + assert(preAuthorizationId.isDefined) +// assert(params.getOrElse("registerCard", "false").toBoolean) +// assert(params.getOrElse("printReceipt", "false").toBoolean) + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$callbacksRoute/$preAuthorizeCardRoute/$orderUuid?preAuthorizationIdParameter=payment_intent&payment_intent=$preAuthorizationId®isterCard=true&printReceipt=true" + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val paymentAccount = loadPaymentAccount() + log.info(serialization.write(paymentAccount)) + assert(paymentAccount.cards.nonEmpty) + } + loadCards().find(_.getActive) match { + case Some(card) => + assert(card.firstName == firstName) + assert(card.lastName == lastName) + assert(card.birthday == birthday) + assert(card.getActive) + assert(!card.expired) + cardId = card.id + case _ => fail("No active card found") + } + } + } + + "cancel 3ds card pre authorization" in { + MockPaymentDao.cancelPreAuthorization(orderUuid, preAuthorizationId) await { + case Right(result) => assert(result.preAuthorizationCanceled) + case Left(f) => fail(f) + } + } + + "pay in with 3ds card" in { + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$payInRoute/${URLEncoder + .encode(computeExternalUuidWithProfile(externalUserId, Some("seller")), "UTF-8")}", + Payment( + orderUuid, + debitedAmount, + currency, + Some(cardPreRegistration.id), + None, + registerCard = true, + printReceipt = true + ) + ).withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost))) + ) ~> routes ~> check { + status shouldEqual StatusCodes.Accepted + val redirection = responseAs[PaymentRedirection] + val params = redirection.redirectUrl + .split("\\?") + .last + .split("[&=]") + .grouped(2) + .map(a => (a(0), a(1))) + .toMap + payInTransactionId = params.get("payment_intent") + assert(payInTransactionId.isDefined) + /*val registerCard = params.getOrElse("registerCard", "false").toBoolean + assert(registerCard) + val printReceipt = params.getOrElse("printReceipt", "false").toBoolean + assert(printReceipt)*/ + } + } + + "pay out with 3ds card" in { + paymentClient.payOut( + orderUuid, + computeExternalUuidWithProfile(externalUserId, Some("seller")), + debitedAmount, + feesAmount, + currency, + Some("reference"), + payInTransactionId + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.isDefined) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + payOutTransactionId = result.transactionId + paymentClient.loadPayOutTransaction( + orderUuid, + result.transactionId.get + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.getOrElse("") == payOutTransactionId.getOrElse("unknown")) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + case Failure(f) => fail(f) + } + case Failure(f) => fail(f) + } + } + + "disable 3ds card" in { + withHeaders( + Delete( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute?cardId=$cardId" + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + loadCards().find(_.id == cardId) match { + case Some(card) => + assert(!card.getActive) + case _ => fail("No card found") + } + } + } + + "pre register card" in { + createNewSession(customerSession) + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute", + PreRegisterCard( + orderUuid, + naturalUser + ) + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + cardPreRegistration = responseAs[CardPreRegistration] + } + + val paymentAccount = loadPaymentAccount() + assert(paymentAccount.naturalUser.flatMap(_.userId).isDefined) + customer = paymentAccount.naturalUser.flatMap(_.userId).get + + // front end simulation + // confirm setup intent + Try { + val requestOptions = StripeApi().requestOptions + SetupIntent + .retrieve(cardPreRegistration.id, requestOptions) + .confirm( + SetupIntentConfirmParams + .builder() + .setPaymentMethod("pm_card_visa") + .build(), + requestOptions + ) + } match { + case Success(_) => + case Failure(f) => + log.error("Error while confirming setup intent", f) + fail(f) + } + } + + "pre authorize card" in { + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$preAuthorizeCardRoute", + //${URLEncoder.encode(computeExternalUuidWithProfile(externalUserId, Some("seller")), "UTF-8")} + Payment( + orderUuid, + debitedAmount, + currency, + Option(cardPreRegistration.id), + Option(cardPreRegistration.preregistrationData), + registerCard = true, + printReceipt = true, + feesAmount = Some(feesAmount) + ) + ).withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost))) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val result = responseAs[CardPreAuthorized] + preAuthorizationId = result.transactionId + loadCards().find(_.getActive) match { + case Some(card) => + assert(card.firstName == firstName) + assert(card.lastName == lastName) + assert(card.birthday == birthday) + assert(card.getActive) + assert(!card.expired) + cardId = card.id + case _ => fail("No active card found") + } + } + } + + "pay in with pre authorized card" in { + paymentClient.payInWithCardPreAuthorized( + preAuthorizationId, + computeExternalUuidWithProfile(externalUserId, Some("seller")), + None + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.isDefined) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + payInTransactionId = result.transactionId + paymentClient.loadPayInTransaction(orderUuid, payInTransactionId.get) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.getOrElse("") == payInTransactionId.getOrElse("unknown")) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + case Failure(f) => fail(f) + } + case Failure(f) => fail(f) + } + } + + "pay out with pre authorized card" in { + paymentClient.payOut( + orderUuid, + computeExternalUuidWithProfile(externalUserId, Some("seller")), + debitedAmount, + feesAmount, + currency, + Some("reference"), + payInTransactionId + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.isDefined) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + payOutTransactionId = result.transactionId + paymentClient.loadPayOutTransaction( + orderUuid, + result.transactionId.get + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.getOrElse("") == payOutTransactionId.getOrElse("unknown")) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + case Failure(f) => fail(f) + } + case Failure(f) => fail(f) + } + } + + "pay in with pre registered card" in { + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$payInRoute/${URLEncoder + .encode(computeExternalUuidWithProfile(externalUserId, Some("seller")), "UTF-8")}", + Payment( + orderUuid, + debitedAmount, + currency, + Some(cardPreRegistration.id), + None, + registerCard = true, + printReceipt = true + ) + ).withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost))) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val result = responseAs[PaidIn] + payInTransactionId = result.transactionId + paymentClient.loadPayInTransaction(orderUuid, payInTransactionId.get) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.getOrElse("") == payInTransactionId.getOrElse("unknown")) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + case Failure(f) => fail(f) + } + loadCards().find(_.getActive) match { + case Some(card) => + assert(card.firstName == firstName) + assert(card.lastName == lastName) + assert(card.birthday == birthday) + assert(card.getActive) + assert(!card.expired) + cardId = card.id + case _ => fail("No active card found") + } + } + } + + "pay out with pre registered card" in { + paymentClient.payOut( + orderUuid, + computeExternalUuidWithProfile(externalUserId, Some("seller")), + debitedAmount, + feesAmount, + currency, + Some("reference"), + payInTransactionId + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.isDefined) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + payOutTransactionId = result.transactionId + paymentClient.loadPayOutTransaction( + orderUuid, + result.transactionId.get + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.getOrElse("") == payOutTransactionId.getOrElse("unknown")) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + case Failure(f) => fail(f) + } + case Failure(f) => fail(f) + } + } + + "disable card" in { + withHeaders( + Delete( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute?cardId=$cardId" + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + loadCards().find(_.id == cardId) match { + case Some(card) => + assert(!card.getActive) + case _ => fail("No card found") + } + } + } + + "pay in with PayPal" in { + createNewSession(customerSession) + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$payInRoute/${URLEncoder + .encode(computeExternalUuidWithProfile(externalUserId, Some("seller")), "UTF-8")}", + Payment( + orderUuid, + debitedAmount, + paymentType = Transaction.PaymentType.PAYPAL, + printReceipt = true, + user = Some(naturalUser) + ) + ).withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost)), + `User-Agent`("test") + ) + ) ~> routes ~> check { + if (status == StatusCodes.PaymentRequired) { + val payment = responseAs[PaymentRequired] + + val paymentClientReturnUrl = payment.paymentClientReturnUrl + log.info(paymentClientReturnUrl) + assert(Option(paymentClientReturnUrl).isDefined) + + val clientSecret = payment.paymentClientSecret + log.info(clientSecret) + assert(Option(clientSecret).isDefined) + + /*val requestOptions = StripeApi().requestOptions + + PaymentIntent + .retrieve(payment.transactionId, requestOptions) + .confirm( + PaymentIntentConfirmParams + .builder() + .setPaymentMethod("pm_paypal") // FIXME + .setReturnUrl(paymentClientReturnUrl) + .build(), + requestOptions + ) + + val index = paymentClientReturnUrl.indexOf(RootPath) + assert(index > 0) + val payInUri = paymentClientReturnUrl.substring(index) + log.info(payInUri) + withHeaders( + Get(s"/$payInUri") + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val result = responseAs[PaidIn] + payInTransactionId = result.transactionId + assert(result.transactionStatus.isTransactionSucceeded) + }*/ + } + else if(status == StatusCodes.Accepted){ + val redirection = responseAs[PaymentRedirection].redirectUrl + log.info(redirection) + + // Create a new instance of the HtmlUnit driver + val driver: WebDriver = new HtmlUnitDriver() + Try { + // Navigate to the URL + driver.get(redirection) + // Find the button to click + driver + .findElements(By.xpath("//*[@id=\"main-content\"]/div[3]/section[1]/div[2]/div/a[1]")) + .asScala + .headOption match { + case Some(button: WebElement) => + // Click the button + button.click() + case _ => fail("No button found") + } + } match { + case _ => + val returnUrl = driver.getCurrentUrl + log.info(returnUrl) + driver.quit() + val index = returnUrl.indexOf(RootPath) + assert(index > 0) + val payInUri = returnUrl.substring(index) + log.info(payInUri) + withHeaders( + Get(s"/$payInUri") + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val result = responseAs[PaidIn] + payInTransactionId = result.transactionId + assert(result.transactionStatus.isTransactionSucceeded) + } + } + } + else{ + fail(s"Unexpected status -> $status") + } + } + } + + /*"pay out with PayPal" in { + paymentClient.payOut( + orderUuid, + computeExternalUuidWithProfile(externalUserId, Some("seller")), + debitedAmount, + feesAmount, + currency, + Some("reference"), + payInTransactionId + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.isDefined) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + payOutTransactionId = result.transactionId + paymentClient.loadPayOutTransaction( + orderUuid, + result.transactionId.get + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.getOrElse("") == payOutTransactionId.getOrElse("unknown")) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + case Failure(f) => fail(f) + } + case Failure(f) => fail(f) + } + }*/ + + "pay in without pre registered card" in { + createNewSession(customerSession) + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$payInRoute/${URLEncoder + .encode(computeExternalUuidWithProfile(externalUserId, Some("seller")), "UTF-8")}", + Payment( + orderUuid, + debitedAmount, + paymentType = Transaction.PaymentType.CARD, + printReceipt = true, + user = Some(naturalUser) + ) + ).withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost))) + ) ~> routes ~> check { + if (status == StatusCodes.PaymentRequired) { + val payment = responseAs[PaymentRequired] + + val paymentClientReturnUrl = payment.paymentClientReturnUrl + log.info(paymentClientReturnUrl) + + val clientSecret = payment.paymentClientSecret + log.info(clientSecret) + + val requestOptions = StripeApi().requestOptions + + PaymentIntent + .retrieve(payment.transactionId, requestOptions) + .confirm( + PaymentIntentConfirmParams + .builder() + .setPaymentMethod("pm_card_visa") + .build(), + requestOptions + ) + + val index = paymentClientReturnUrl.indexOf(RootPath) + assert(index > 0) + val payInUri = paymentClientReturnUrl.substring(index) + log.info(payInUri) + withHeaders( + Get(s"/$payInUri") + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val result = responseAs[PaidIn] + payInTransactionId = result.transactionId + assert(result.transactionStatus.isTransactionSucceeded) + } + } else { + status shouldEqual StatusCodes.OK + val result = responseAs[PaidIn] + payInTransactionId = result.transactionId + } + } + } + + "pay out without pre registered card" in { + paymentClient.payOut( + orderUuid, + computeExternalUuidWithProfile(externalUserId, Some("seller")), + debitedAmount, + feesAmount, + currency, + Some("reference"), + payInTransactionId + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.isDefined) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + payOutTransactionId = result.transactionId + paymentClient.loadPayOutTransaction( + orderUuid, + result.transactionId.get + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.getOrElse("") == payOutTransactionId.getOrElse("unknown")) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + case Failure(f) => fail(f) + } + case Failure(f) => fail(f) + } + } + + "refund pay in and reverse transfer" in { + paymentClient.refund( + orderUuid, + payOutTransactionId, + debitedAmount, + currency, + "reason message", + initializedByClient = false + ) complete () match { + case Success(result) => + log.info(serialization.write(result)) + assert(result.transactionId.isDefined) + assert(result.error.isEmpty) + assert(result.transactionStatus.isTransactionSucceeded) + case Failure(f) => fail(f) + } + } + } +}