diff --git a/build.sbt b/build.sbt index cf95fbb..d7069db 100644 --- a/build.sbt +++ b/build.sbt @@ -18,7 +18,7 @@ ThisBuild / organization := "app.softnetwork" name := "payment" -ThisBuild / version := "0.9.4.1" +ThisBuild / version := "0.9.5" ThisBuild / scalaVersion := scala212 diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index f8ba7ef..6b9e083 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -32,6 +32,8 @@ payment{ declaration-route = "declaration" kyc-route = "kyc" + billing-portal-route = "billing-portal" + disable-bank-account-deletion = false akka-node-role = payment diff --git a/common/src/main/scala/app/softnetwork/payment/config/Payment.scala b/common/src/main/scala/app/softnetwork/payment/config/Payment.scala index e46f6cb..2546bd9 100644 --- a/common/src/main/scala/app/softnetwork/payment/config/Payment.scala +++ b/common/src/main/scala/app/softnetwork/payment/config/Payment.scala @@ -18,6 +18,7 @@ object Payment { accountRoute: String, declarationRoute: String, kycRoute: String, + billingPortalRoute: String, disableBankAccountDeletion: Boolean, externalToPaymentAccountTag: String, akkaNodeRole: String 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 1a28a7c..77efb71 100644 --- a/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala +++ b/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala @@ -632,6 +632,26 @@ object PaymentMessages { lazy val key: String = account } + case class BillingPortalRequest(returnUrl: String) + + /** Create a billing portal session for the authenticated user. + * + * @param account + * - payment account reference (externalUuid) + * @param returnUrl + * - the URL to redirect to after the user leaves the portal + * @param clientId + * - optional client id + */ + case class CreateBillingPortalSession( + account: String, + returnUrl: String, + clientId: Option[String] = None + ) extends PaymentCommandWithKey + with PaymentAccountCommand { + lazy val key: String = account + } + /** @param transactionId * - transaction id */ @@ -1115,6 +1135,8 @@ object PaymentMessages { case class BalanceLoaded(balance: Int) extends PaymentResult + case class BillingPortalSessionCreated(url: String) extends PaymentResult + class PaymentError(override val message: String) extends ErrorMessage(message) with PaymentResult case object PaymentMethodNotPreRegistered extends PaymentError("PaymentMethodNotPreRegistered") @@ -1155,6 +1177,8 @@ object PaymentMessages { case object PaymentAccountNotFound extends PaymentError("PaymentAccountNotFound") + case object BillingPortalSessionNotCreated extends PaymentError("BillingPortalSessionNotCreated") + case object MandateAlreadyExists extends PaymentError("MandateAlreadyExists") case class MandateCreationFailed(errorCode: String, errorMessage: String) diff --git a/common/src/main/scala/app/softnetwork/payment/service/BasicPaymentService.scala b/common/src/main/scala/app/softnetwork/payment/service/BasicPaymentService.scala index 808aacd..0252dfe 100644 --- a/common/src/main/scala/app/softnetwork/payment/service/BasicPaymentService.scala +++ b/common/src/main/scala/app/softnetwork/payment/service/BasicPaymentService.scala @@ -33,8 +33,10 @@ trait BasicPaymentService extends Service[PaymentCommand, PaymentResult] { ApiErrors.InternalServerError(PaymentMethodNotPreRegistered) case r: PreAuthorizationFailed => ApiErrors.InternalServerError(r) case r: PayInFailed => ApiErrors.InternalServerError(r) - case r: PaymentError => ApiErrors.BadRequest(r.message) - case _ => ApiErrors.BadRequest("Unknown") + case BillingPortalSessionNotCreated => + ApiErrors.InternalServerError(BillingPortalSessionNotCreated) + case r: PaymentError => ApiErrors.BadRequest(r.message) + case _ => ApiErrors.BadRequest("Unknown") } protected[payment] def extractBrowserInfo( diff --git a/common/src/main/scala/app/softnetwork/payment/spi/BillingPortalApi.scala b/common/src/main/scala/app/softnetwork/payment/spi/BillingPortalApi.scala new file mode 100644 index 0000000..0470fa4 --- /dev/null +++ b/common/src/main/scala/app/softnetwork/payment/spi/BillingPortalApi.scala @@ -0,0 +1,15 @@ +package app.softnetwork.payment.spi + +trait BillingPortalApi { _: PaymentContext => + + /** Create a billing portal session for the given user. + * + * @param userId + * \- the provider user ID + * @param returnUrl + * \- the URL to redirect to after the user leaves the portal + * @return + * the billing portal session URL, or None on failure + */ + def createBillingPortalSession(userId: String, returnUrl: String): Option[String] +} 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 5eef832..4ad6a92 100644 --- a/common/src/main/scala/app/softnetwork/payment/spi/PaymentProvider.scala +++ b/common/src/main/scala/app/softnetwork/payment/spi/PaymentProvider.scala @@ -17,7 +17,8 @@ private[payment] trait PaymentProvider with TransferApi with RefundApi with RecurringPaymentApi - with BalanceApi { + with BalanceApi + with BillingPortalApi { protected lazy val mlog: Logger = Logger(LoggerFactory.getLogger(getClass.getName)) 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 70f57f0..bdcdf76 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 @@ -996,6 +996,29 @@ trait PaymentBehavior case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } + case cmd: CreateBillingPortalSession => + state match { + case Some(paymentAccount) => + val userId = paymentAccount.getNaturalUser.userId + .orElse(paymentAccount.getLegalUser.legalRepresentative.userId) + userId match { + case Some(uid) => + val clientId = paymentAccount.clientId.orElse( + internalClientId + ) + val paymentProvider = loadPaymentProvider(clientId) + paymentProvider.createBillingPortalSession(uid, cmd.returnUrl) match { + case Some(url) => + Effect.none.thenRun(_ => BillingPortalSessionCreated(url) ~> replyTo) + case None => + Effect.none.thenRun(_ => BillingPortalSessionNotCreated ~> replyTo) + } + case None => + Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) + } + case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) + } + case cmd: LoadTransaction => import cmd._ state match { diff --git a/core/src/main/scala/app/softnetwork/payment/service/BillingPortalEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/BillingPortalEndpoints.scala new file mode 100644 index 0000000..5f011d0 --- /dev/null +++ b/core/src/main/scala/app/softnetwork/payment/service/BillingPortalEndpoints.scala @@ -0,0 +1,47 @@ +package app.softnetwork.payment.service + +import app.softnetwork.payment.config.PaymentSettings +import app.softnetwork.payment.handlers.PaymentHandler +import app.softnetwork.payment.message.PaymentMessages._ +import app.softnetwork.session.model.{SessionData, SessionDataDecorator} +import sttp.capabilities +import sttp.capabilities.akka.AkkaStreams +import sttp.model.StatusCode +import sttp.tapir.json.json4s.jsonBody +import sttp.tapir.server.ServerEndpoint + +import scala.concurrent.Future + +trait BillingPortalEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { + _: RootPaymentEndpoints[SD] with PaymentHandler => + + import app.softnetwork.serialization._ + + val createBillingPortalSession: ServerEndpoint[Any with AkkaStreams, Future] = + requiredSessionEndpoint.post + .in(PaymentSettings.PaymentConfig.billingPortalRoute) + .in(jsonBody[BillingPortalRequest]) + .out( + statusCode(StatusCode.Ok) + .and(jsonBody[BillingPortalSessionCreated]) + ) + .serverLogic { case (client, session) => + req => { + run( + CreateBillingPortalSession( + externalUuidWithProfile(session), + req.returnUrl, + clientId = client.map(_.clientId).orElse(session.clientId) + ) + ).map { + case r: BillingPortalSessionCreated => Right(r) + case other => Left(error(other)) + } + } + } + .description("Create a billing portal session for the authenticated user") + + val billingPortalEndpoints + : List[ServerEndpoint[AkkaStreams with capabilities.WebSockets, Future]] = + List(createBillingPortalSession) +} 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 d5870cb..337cec1 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/PaymentServiceEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/PaymentServiceEndpoints.scala @@ -23,7 +23,8 @@ trait PaymentServiceEndpoints[SD <: SessionData with SessionDataDecorator[SD]] with KycDocumentEndpoints[SD] with UboDeclarationEndpoints[SD] with RecurringPaymentEndpoints[SD] - with MandateEndpoints[SD] { + with MandateEndpoints[SD] + with BillingPortalEndpoints[SD] { _: PaymentHandler with SessionMaterials[SD] => /** should be implemented by each payment provider @@ -43,6 +44,7 @@ trait PaymentServiceEndpoints[SD <: SessionData with SessionDataDecorator[SD]] uboDeclarationEndpoints ++ mandateEndpoints ++ recurringPaymentEndpoints ++ + billingPortalEndpoints ++ hooks } diff --git a/core/src/main/scala/app/softnetwork/payment/service/SoftPayAccountServiceEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/SoftPayAccountServiceEndpoints.scala index e3b5455..4eef512 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/SoftPayAccountServiceEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/SoftPayAccountServiceEndpoints.scala @@ -1,6 +1,10 @@ package app.softnetwork.payment.service +import app.softnetwork.account.config.AccountSettings +import app.softnetwork.account.message.{LoadProfile, ProfileLoaded} +import app.softnetwork.account.model.{DefaultProfileView, ProfileType} import app.softnetwork.account.service.BasicAccountServiceEndpoints +import app.softnetwork.api.server.ApiErrors import app.softnetwork.payment.handlers.SoftPayAccountTypeKey import app.softnetwork.payment.serialization.paymentFormats import app.softnetwork.session.config.Settings @@ -10,7 +14,9 @@ import com.softwaremill.session.SessionConfig import org.json4s.Formats import sttp.capabilities import sttp.capabilities.akka.AkkaStreams +import sttp.tapir.json.json4s.jsonBody import sttp.tapir.server.ServerEndpoint +import sttp.tapir.{query, Schema} import scala.concurrent.Future @@ -22,9 +28,33 @@ trait SoftPayAccountServiceEndpoints[SD <: SessionData with SessionDataDecorator override implicit lazy val formats: Formats = paymentFormats + import app.softnetwork.serialization.serialization + + implicit lazy val profileTypeSchema: Schema[ProfileType] = Schema.derived + implicit lazy val defaultProfileViewSchema: Schema[DefaultProfileView] = Schema.derived + + lazy val loadProfile: ServerEndpoint[Any with AkkaStreams, Future] = + ApiErrors + .withApiErrorVariants( + antiCsrfWithRequiredSession(sc, gt, checkMode) + ) + .in(AccountSettings.Path / "profile") + .in(query[Option[String]]("name")) + .get + .out(jsonBody[DefaultProfileView]) + .serverLogic(session => + name => + run(session.id, LoadProfile(session.id, name)).map { + case r: ProfileLoaded => + Right(r.profile.view.asInstanceOf[DefaultProfileView]) + case other => Left(resultToApiError(other)) + } + ) + override lazy val endpoints : List[ServerEndpoint[AkkaStreams with capabilities.WebSockets, Future]] = List( + loadProfile, signUp, login, signIn, 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 d2d1595..40ec36b 100644 --- a/mangopay/src/main/scala/app/softnetwork/payment/spi/MangoPayProvider.scala +++ b/mangopay/src/main/scala/app/softnetwork/payment/spi/MangoPayProvider.scala @@ -2699,6 +2699,20 @@ trait MangoPayProvider extends PaymentProvider { None } } + + /** Create a billing portal session for the given user. + * + * @param userId + * \- the provider user ID + * @param returnUrl + * \- the URL to redirect to after the user leaves the portal + * @return + * the billing portal session URL, or None on failure + */ + override def createBillingPortalSession(userId: String, returnUrl: String): Option[String] = { + mlog.warn("Billing portal is not supported by MangoPay provider") + None + } } class MangoPayProviderFactory extends PaymentProviderSpi { diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeBillingPortalApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeBillingPortalApi.scala new file mode 100644 index 0000000..93d10d6 --- /dev/null +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeBillingPortalApi.scala @@ -0,0 +1,24 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.config.StripeApi + +import scala.util.{Failure, Success, Try} + +trait StripeBillingPortalApi extends BillingPortalApi { _: StripeContext => + + override def createBillingPortalSession(userId: String, returnUrl: String): Option[String] = { + Try { + val params = com.stripe.param.billingportal.SessionCreateParams + .builder() + .setCustomer(userId) + .setReturnUrl(returnUrl) + .build() + com.stripe.model.billingportal.Session.create(params, StripeApi().requestOptions()) + } match { + case Success(session) => Some(session.getUrl) + case Failure(f) => + mlog.error(s"Failed to create billing portal session for $userId: ${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 index 8d5bcb0..f8dba43 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala @@ -33,7 +33,8 @@ trait StripeProvider with StripeRecurringPaymentApi with StripeRefundApi with StripeTransferApi - with StripeBalanceApi { + with StripeBalanceApi + with StripeBillingPortalApi { /** @return * client