diff --git a/build.sbt b/build.sbt index 7ef39ae..f96c5ad 100644 --- a/build.sbt +++ b/build.sbt @@ -18,7 +18,7 @@ ThisBuild / organization := "app.softnetwork" name := "payment" -ThisBuild / version := "0.9.0" +ThisBuild / version := "0.9.1" ThisBuild / scalaVersion := scala212 diff --git a/client/src/main/protobuf/api/payment.proto b/client/src/main/protobuf/api/payment.proto index aebd0aa..a123e6e 100644 --- a/client/src/main/protobuf/api/payment.proto +++ b/client/src/main/protobuf/api/payment.proto @@ -160,6 +160,7 @@ message RegisterRecurringPaymentRequest { google.protobuf.StringValue statementDescriptor = 12; google.protobuf.StringValue externalReference = 13; string clientId = 14; + map metadata = 15; } message RegisterRecurringPaymentResponse { diff --git a/client/src/main/protobuf/model/payment/paymentUser.proto b/client/src/main/protobuf/model/payment/paymentUser.proto index fa375a5..7a40484 100644 --- a/client/src/main/protobuf/model/payment/paymentUser.proto +++ b/client/src/main/protobuf/model/payment/paymentUser.proto @@ -235,4 +235,5 @@ message RecurringPayment { optional string cardId = 22; optional string statementDescriptor = 23; optional string externalReference = 24; + map metadata = 25; } \ No newline at end of file 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 77c2f65..69ada1d 100644 --- a/client/src/main/scala/app/softnetwork/payment/api/PaymentClient.scala +++ b/client/src/main/scala/app/softnetwork/payment/api/PaymentClient.scala @@ -279,6 +279,7 @@ trait PaymentClient extends GrpcClient { nextFeesAmount: Option[Int], statementDescriptor: Option[String], externalReference: Option[String], + metadata: Map[String, String] = Map.empty, token: Option[String] = None ): Future[Option[String]] = { val t = token.getOrElse(generatedToken) @@ -306,7 +307,8 @@ trait PaymentClient extends GrpcClient { nextFeesAmount, statementDescriptor, externalReference, - clientId.getOrElse(settings.clientId) + clientId.getOrElse(settings.clientId), + metadata ) ) map (_.recurringPaymentRegistrationId) } 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 47edaba..1a28a7c 100644 --- a/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala +++ b/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala @@ -520,6 +520,7 @@ object PaymentMessages { nextFeesAmount: Option[Int] = None, statementDescriptor: Option[String] = None, externalReference: Option[String] = None, + metadata: Map[String, String] = Map.empty, clientId: Option[String] = None ) extends PaymentCommandWithKey with RecurringPaymentCommand { @@ -580,12 +581,13 @@ object PaymentMessages { * - transaction payIn id */ @InternalApi - private[payment] case class FirstRecurringPaymentCallback( + private[payment] case class RecurringPaymentCallback( recurringPayInRegistrationId: String, - transactionId: String + transactionId: String, + debitedAccount: Option[String] = None ) extends PaymentCommandWithKey with RecurringPaymentCommand { - lazy val key: String = transactionId + lazy val key: String = debitedAccount.getOrElse(transactionId) } /** @param recurringPaymentRegistrationId 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 33e9940..51688bf 100644 --- a/core/src/main/scala/app/softnetwork/payment/api/PaymentServer.scala +++ b/core/src/main/scala/app/softnetwork/payment/api/PaymentServer.scala @@ -226,6 +226,7 @@ trait PaymentServer extends PaymentServiceApi with PaymentDao { nextFeesAmount, statementDescriptor, externalReference, + metadata.toMap, Some(clientId) ) map { case Right(r: RecurringPaymentRegistered) => 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 a09c285..bb59da1 100644 --- a/core/src/main/scala/app/softnetwork/payment/handlers/PaymentDao.scala +++ b/core/src/main/scala/app/softnetwork/payment/handlers/PaymentDao.scala @@ -485,6 +485,7 @@ trait PaymentDao extends PaymentHandler { nextFeesAmount: Option[Int] = None, statementDescriptor: Option[String] = None, externalReference: Option[String] = None, + metadata: Map[String, String] = Map.empty, clientId: Option[String] = None )(implicit system: ActorSystem[_] @@ -505,6 +506,7 @@ trait PaymentDao extends PaymentHandler { nextFeesAmount, statementDescriptor, externalReference, + metadata, Some(clientId) ) ) map { diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala index 080164d..ed12bda 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/RecurringPaymentCommandHandler.scala @@ -15,7 +15,6 @@ import app.softnetwork.payment.message.PaymentMessages.{ ExecuteNextRecurringPayment, FirstRecurringCardPaymentFailed, FirstRecurringPaidIn, - FirstRecurringPaymentCallback, IllegalMandateStatus, LoadRecurringPayment, MandateNotFound, @@ -27,6 +26,7 @@ import app.softnetwork.payment.message.PaymentMessages.{ PaymentResult, RecurringCardPaymentRegistrationNotUpdated, RecurringCardPaymentRegistrationUpdated, + RecurringPaymentCallback, RecurringPaymentCommand, RecurringPaymentLoaded, RecurringPaymentNotFound, @@ -122,7 +122,8 @@ trait RecurringPaymentCommandHandler frequency = cmd.frequency, fixedNextAmount = cmd.fixedNextAmount, nextDebitedAmount = cmd.nextDebitedAmount, - nextFeesAmount = cmd.nextFeesAmount + nextFeesAmount = cmd.nextFeesAmount, + metadata = cmd.metadata ) val clientId = paymentAccount.clientId .orElse(cmd.clientId) @@ -202,7 +203,8 @@ trait RecurringPaymentCommandHandler frequency = cmd.frequency, fixedNextAmount = cmd.fixedNextAmount, nextDebitedAmount = cmd.nextDebitedAmount, - nextFeesAmount = cmd.nextFeesAmount + nextFeesAmount = cmd.nextFeesAmount, + metadata = cmd.metadata ) import app.softnetwork.time._ val nextDirectDebit: List[ExternalEntityToSchedulerEvent] = @@ -391,7 +393,7 @@ trait RecurringPaymentCommandHandler case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo) } - case cmd: FirstRecurringPaymentCallback => + case cmd: RecurringPaymentCallback => state match { case Some(paymentAccount) => import cmd._ diff --git a/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala b/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala index c18e55e..488a8d4 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/CheckoutEndpoints.scala @@ -327,7 +327,7 @@ trait CheckoutEndpoints[SD <: SessionData with SessionDataDecorator[SD]] { .description("Execute first recurring payment for 3D secure") .serverLogic { case (recurringPayInRegistrationId, transactionId) => run( - FirstRecurringPaymentCallback(recurringPayInRegistrationId, transactionId) + RecurringPaymentCallback(recurringPayInRegistrationId, transactionId) ).map { case result: PaidIn => Right(result) case result: PaymentRedirection => Right(result) 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 7943c19..3a7aecb 100644 --- a/core/src/main/scala/app/softnetwork/payment/service/PaymentService.scala +++ b/core/src/main/scala/app/softnetwork/payment/service/PaymentService.scala @@ -470,7 +470,7 @@ trait PaymentService[SD <: SessionData with SessionDataDecorator[SD]] pathPrefix(Segment) { recurringPayInRegistrationId => parameter("transactionId") { transactionId => run( - FirstRecurringPaymentCallback(recurringPayInRegistrationId, transactionId) + RecurringPaymentCallback(recurringPayInRegistrationId, transactionId) ) completeWith { case r: PaidIn => complete( diff --git a/stripe/build.sbt b/stripe/build.sbt index 1685046..caf7bbf 100644 --- a/stripe/build.sbt +++ b/stripe/build.sbt @@ -4,5 +4,5 @@ name := "stripe-core" libraryDependencies ++= Seq( // stripe - "com.stripe" % "stripe-java" % "26.12.0" + "com.stripe" % "stripe-java" % "26.12.0" // TODO upgrade to v31.4.1 ) diff --git a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala index 1b7d3cc..7b8e109 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala @@ -14,6 +14,8 @@ import com.stripe.param.{ WebhookEndpointUpdateParams } +import org.slf4j.{Logger, LoggerFactory} + import java.nio.file.Paths import scala.util.{Failure, Success, Try} @@ -73,12 +75,15 @@ object StripeApi { this.copy(paymentConfig = paymentConfig) } + private[this] lazy val log: Logger = LoggerFactory.getLogger(getClass) + 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" + // TODO migrate webhook secrets to encrypted storage (Sealed Secrets / Vault) private[this] def addSecret(hash: String, secret: String): Unit = { val dir = s"$STRIPE_SECRETS_DIR/$hash" Paths.get(dir).toFile.mkdirs() @@ -93,7 +98,7 @@ object StripeApi { private[StripeApi] 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}") + log.debug(s"Loading secret from: ${file.getAbsolutePath}") if (file.exists()) { import scala.io.Source val source = Source.fromFile(file) @@ -142,7 +147,7 @@ object StripeApi { None }) match { case Some(webhookEndpoint) => - Console.println(s"Webhook endpoint found: ${webhookEndpoint.getId}") + log.info(s"Webhook endpoint found: ${webhookEndpoint.getId}") loadSecret(hash) match { case None => Try(webhookEndpoint.delete(requestOptions)) @@ -159,6 +164,18 @@ object StripeApi { .addEnabledEvent( WebhookEndpointUpdateParams.EnabledEvent.PERSON__UPDATED ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.INVOICE__PAYMENT_FAILED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED + ) + .addEnabledEvent( + WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED + ) .setUrl(url) .build(), requestOptions @@ -179,6 +196,18 @@ object StripeApi { .addEnabledEvent( WebhookEndpointCreateParams.EnabledEvent.PERSON__UPDATED ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_FAILED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED + ) .setUrl(url) .setApiVersion(WebhookEndpointCreateParams.ApiVersion.VERSION_2024_06_20) .setConnect(true) diff --git a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala index 6b7fdf0..1cd272f 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala @@ -6,22 +6,51 @@ import app.softnetwork.payment.message.PaymentMessages.{ CreateOrUpdateKycDocument, InvalidateRegularUser, KycDocumentCreatedOrUpdated, + RecurringPaymentCallback, RegularUserInvalidated, RegularUserValidated, + UpdateRecurringCardPaymentRegistration, ValidateRegularUser } -import app.softnetwork.payment.model.KycDocument -import com.stripe.model.{Account, Event, Person, StripeObject} +import app.softnetwork.payment.model.{KycDocument, RecurringPayment} +import com.stripe.model.{Account, Event, Invoice, Person, StripeObject, Subscription} import com.stripe.net.Webhook +import java.util.concurrent.ConcurrentHashMap + import scala.util.{Failure, Success, Try} -import scala.language.implicitConversions import scala.jdk.CollectionConverters._ /** Created by smanciot on 27/04/2021. */ trait StripeEventHandler extends Completion { _: BasicPaymentService with PaymentHandler => + /** Bounded set of recently processed webhook event IDs for idempotency. Stripe may deliver the + * same event multiple times. + */ + private[this] val processedEventIds: ConcurrentHashMap[String, java.lang.Boolean] = + new ConcurrentHashMap[String, java.lang.Boolean](256) + + private[this] val MaxProcessedEventIds = 10000 + + private[this] def isEventAlreadyProcessed(eventId: String): Boolean = { + if (processedEventIds.containsKey(eventId)) { + true + } else { + if (processedEventIds.size() >= MaxProcessedEventIds) { + // Evict oldest entries (ConcurrentHashMap has no LRU, so clear half) + val iterator = processedEventIds.keys() + var count = 0 + while (iterator.hasMoreElements && count < MaxProcessedEventIds / 2) { + processedEventIds.remove(iterator.nextElement()) + count += 1 + } + } + processedEventIds.put(eventId, java.lang.Boolean.TRUE) + false + } + } + def toStripeEvent(payload: String, sigHeader: String, secret: String): Option[Event] = { Try { Webhook.constructEvent(payload, sigHeader, secret) @@ -36,11 +65,17 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen } def handleStripeEvent(event: Event): Unit = { + if (isEventAlreadyProcessed(event.getId)) { + log.info( + s"[Payment Hooks] Skipping duplicate Stripe event: ${event.getId} (${event.getType})" + ) + return + } event.getType match { case "account.updated" => log.info(s"[Payment Hooks] Stripe Webhook received: Account Updated") - val maybeAccount: Option[Account] = event + val maybeAccount: Option[Account] = extractStripeObject[Account](event) maybeAccount match { case Some(account) => val accountId = account.getId @@ -170,7 +205,7 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen case "person.updated" => log.info(s"[Payment Hooks] Stripe Webhook received: Person Updated") - val maybePerson: Option[Person] = event + val maybePerson: Option[Person] = extractStripeObject[Person](event) maybePerson match { case Some(person) => val personId = person.getId @@ -248,13 +283,133 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen log.warn(s"[Payment Hooks] Stripe Webhook received: Person Updated -> No data") } + case "invoice.payment_succeeded" => + log.info(s"[Payment Hooks] Stripe Webhook: Invoice Payment Succeeded") + val maybeInvoice: Option[Invoice] = extractStripeObject[Invoice](event) + maybeInvoice.foreach { invoice => + Option(invoice.getSubscription).foreach { subscriptionId => + val transactionId = Option(invoice.getPaymentIntent).getOrElse(invoice.getId) + val customerId = invoice.getCustomer + log.info( + s"[Payment Hooks] Subscription $subscriptionId invoice paid: $transactionId" + ) + run( + RecurringPaymentCallback(subscriptionId, transactionId, Some(customerId)) + ).complete() match { + case Success(_) => + log.info( + s"[Payment Hooks] Recurring payment callback processed: $subscriptionId" + ) + case Failure(f) => + log.error( + s"[Payment Hooks] Failed to process recurring payment callback: ${f.getMessage}", + f + ) + } + } + } + + case "invoice.payment_failed" => + log.warn(s"[Payment Hooks] Stripe Webhook: Invoice Payment Failed") + val maybeInvoice: Option[Invoice] = extractStripeObject[Invoice](event) + maybeInvoice.foreach { invoice => + Option(invoice.getSubscription).foreach { subscriptionId => + val transactionId = Option(invoice.getPaymentIntent).getOrElse(invoice.getId) + val customerId = invoice.getCustomer + log.warn( + s"[Payment Hooks] Subscription $subscriptionId invoice payment failed: $transactionId" + ) + run( + RecurringPaymentCallback(subscriptionId, transactionId, Some(customerId)) + ).complete() match { + case Success(_) => + log.info( + s"[Payment Hooks] Payment failure callback processed for $subscriptionId" + ) + case Failure(f) => + log.error( + s"[Payment Hooks] Failed to process payment failure callback for $subscriptionId: ${f.getMessage}", + f + ) + } + } + } + + case "customer.subscription.deleted" => + log.info(s"[Payment Hooks] Stripe Webhook: Subscription Deleted") + val maybeSubscription: Option[Subscription] = extractStripeObject[Subscription](event) + maybeSubscription.foreach { subscription => + val subscriptionId = subscription.getId + val customerId = subscription.getCustomer + log.info( + s"[Payment Hooks] Subscription $subscriptionId deleted for customer $customerId" + ) + run( + UpdateRecurringCardPaymentRegistration( + customerId, + subscriptionId, + status = Some(RecurringPayment.RecurringCardPaymentStatus.ENDED) + ) + ).complete() match { + case Success(_) => + log.info(s"[Payment Hooks] Subscription $subscriptionId marked as ENDED") + case Failure(f) => + log.error( + s"[Payment Hooks] Failed to end subscription $subscriptionId: ${f.getMessage}", + f + ) + } + } + + case "customer.subscription.updated" => + log.info(s"[Payment Hooks] Stripe Webhook: Subscription Updated") + val maybeSubscription: Option[Subscription] = extractStripeObject[Subscription](event) + maybeSubscription.foreach { subscription => + val subscriptionId = subscription.getId + val customerId = subscription.getCustomer + val stripeStatus = subscription.getStatus + log.info( + s"[Payment Hooks] Subscription $subscriptionId updated: status=$stripeStatus" + ) + // If subscription moved to a terminal state, sync local state + stripeStatus match { + case "canceled" | "incomplete_expired" | "paused" => + run( + UpdateRecurringCardPaymentRegistration( + customerId, + subscriptionId, + status = Some(RecurringPayment.RecurringCardPaymentStatus.ENDED) + ) + ).complete() match { + case Success(_) => + log.info(s"[Payment Hooks] Subscription $subscriptionId synced to ENDED") + case Failure(f) => + log.error( + s"[Payment Hooks] Failed to sync subscription $subscriptionId: ${f.getMessage}", + f + ) + } + case _ => // Non-terminal status changes are informational + } + } + 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 extractStripeObject[T <: StripeObject: scala.reflect.ClassTag]( + event: Event + ): Option[T] = { + val ct = scala.reflect.classTag[T] + Option(event.getDataObjectDeserializer.getObject.orElse(null)).flatMap { + case obj if ct.runtimeClass.isInstance(obj) => Some(obj.asInstanceOf[T]) + case obj => + log.warn( + s"[Payment Hooks] Expected ${ct.runtimeClass.getSimpleName} but got ${obj.getClass.getSimpleName}" + ) + None + } } private[this] def refuseDocument( diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApi.scala index 33bd53c..e874318 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApi.scala @@ -1,10 +1,74 @@ package app.softnetwork.payment.spi +import app.softnetwork.payment.config.StripeApi import app.softnetwork.payment.model.{RecurringPayment, RecurringPaymentTransaction, Transaction} -import com.stripe.param.SubscriptionScheduleCreateParams +import com.stripe.model.{Invoice, PaymentIntent, Product, Subscription} +import com.stripe.net.RequestOptions +import com.stripe.param.{ + InvoiceListParams, + ProductCreateParams, + SubscriptionCancelParams, + SubscriptionCreateParams, + SubscriptionUpdateParams +} + +import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Success, Try} trait StripeRecurringPaymentApi extends RecurringPaymentApi { _: StripeContext => + /** Maps RecurringPaymentFrequency to Stripe (interval, interval_count). */ + private[spi] def toStripeInterval( + frequency: RecurringPayment.RecurringPaymentFrequency + ): (String, Long) = frequency match { + case RecurringPayment.RecurringPaymentFrequency.DAILY => ("day", 1L) + case RecurringPayment.RecurringPaymentFrequency.WEEKLY => ("week", 1L) + // Stripe has no native semi-monthly interval; bi-weekly (~26/yr) approximates twice-a-month (24/yr) + case RecurringPayment.RecurringPaymentFrequency.TWICE_A_MONTH => ("week", 2L) + case RecurringPayment.RecurringPaymentFrequency.MONTHLY => ("month", 1L) + case RecurringPayment.RecurringPaymentFrequency.BIMONTHLY => ("month", 2L) + case RecurringPayment.RecurringPaymentFrequency.QUARTERLY => ("month", 3L) + case RecurringPayment.RecurringPaymentFrequency.BIANNUAL => ("month", 6L) + case RecurringPayment.RecurringPaymentFrequency.ANNUAL => ("year", 1L) + case _ => ("month", 1L) + } + + /** Maps Stripe subscription status to RecurringCardPaymentStatus. + * @param stripeStatus + * Stripe subscription status string + * @param requiresAction + * true if the subscription's latest invoice payment intent has status "requires_action" + * (3DS/SCA) + */ + private[spi] def toRecurringCardPaymentStatus( + stripeStatus: String, + requiresAction: Boolean = false + ): RecurringPayment.RecurringCardPaymentStatus = stripeStatus match { + case "active" | "trialing" => RecurringPayment.RecurringCardPaymentStatus.IN_PROGRESS + case "incomplete" if requiresAction => + RecurringPayment.RecurringCardPaymentStatus.AUTHENTICATION_NEEDED + case "incomplete" | "past_due" => RecurringPayment.RecurringCardPaymentStatus.CREATED + case "unpaid" => RecurringPayment.RecurringCardPaymentStatus.CREATED + case "canceled" => RecurringPayment.RecurringCardPaymentStatus.ENDED + case "incomplete_expired" | "paused" => RecurringPayment.RecurringCardPaymentStatus.ENDED + case _ => RecurringPayment.RecurringCardPaymentStatus.CREATED + } + + /** Check if a subscription's latest invoice payment intent requires 3DS action. */ + private[spi] def requiresAction(subscription: Subscription)(implicit + requestOptions: RequestOptions + ): Boolean = { + Try { + Option(subscription.getLatestInvoice).exists { invoiceId => + val invoice = Invoice.retrieve(invoiceId, requestOptions) + Option(invoice.getPaymentIntent).exists { piId => + val pi = PaymentIntent.retrieve(piId, requestOptions) + pi.getStatus == "requires_action" + } + } + }.getOrElse(false) + } + /** @param userId * - Provider user id * @param walletId @@ -22,17 +86,160 @@ trait StripeRecurringPaymentApi extends RecurringPaymentApi { _: StripeContext = cardId: String, recurringPayment: RecurringPayment ): Option[RecurringPayment.RecurringCardPaymentResult] = { - SubscriptionScheduleCreateParams - .builder() - .addPhase( - SubscriptionScheduleCreateParams.Phase - .builder() - .setBillingCycleAnchor( - SubscriptionScheduleCreateParams.Phase.BillingCycleAnchor.AUTOMATIC + if (!recurringPayment.`type`.isCard) { + mlog.warn( + s"Only card recurring payments are supported by Stripe, got: ${recurringPayment.`type`}" + ) + return None + } + + Try { + implicit val requestOptions: RequestOptions = StripeApi().requestOptions() + val (interval, intervalCount) = recurringPayment.frequency + .map(toStripeInterval) + .getOrElse(("month", 1L)) + + val metadata = recurringPayment.metadata + + // Build price_data — product from per-subscription metadata + val priceDataBuilder = SubscriptionCreateParams.Item.PriceData + .builder() + .setCurrency(recurringPayment.currency.toLowerCase) + .setUnitAmount(recurringPayment.firstDebitedAmount.toLong) + .setRecurring( + SubscriptionCreateParams.Item.PriceData.Recurring + .builder() + .setInterval( + SubscriptionCreateParams.Item.PriceData.Recurring.Interval.valueOf( + interval.toUpperCase + ) + ) + .setIntervalCount(intervalCount) + .build() + ) + + val productId = metadata.get("stripe_product_id").getOrElse { + // Create an inline product when no pre-existing product ID is provided + val product = Product.create( + ProductCreateParams + .builder() + .setName(metadata.getOrElse("product_name", "Recurring Payment")) + .build(), + requestOptions + ) + product.getId + } + priceDataBuilder.setProduct(productId) + + val params = SubscriptionCreateParams + .builder() + .setCustomer(userId) + .setDefaultPaymentMethod(cardId) + .addItem( + SubscriptionCreateParams.Item + .builder() + .setPriceData(priceDataBuilder.build()) + .build() + ) + .putMetadata("transaction_type", "recurring_payment") + .putMetadata("recurring_payment_type", "card") + + recurringPayment.externalReference.foreach(ref => + params.putMetadata("external_reference", ref) + ) + recurringPayment.statementDescriptor.foreach(sd => params.setDescription(sd)) + + // Route payment to connected account if applicable + // walletId is a Stripe connected account only if it starts with "acct_" + if (walletId.startsWith("acct_") && walletId != userId) { + params.setTransferData( + SubscriptionCreateParams.TransferData + .builder() + .setDestination(walletId) + .build() + ) + if (recurringPayment.firstDebitedAmount > 0) { + params.setApplicationFeePercent( + java.math.BigDecimal.valueOf( + recurringPayment.firstFeesAmount.toDouble / + recurringPayment.firstDebitedAmount * 100 + ) ) - .build() + } + } + + // Future start date: use trial_end to defer first billing + recurringPayment.startDate.foreach { startDate => + val startEpoch = startDate.toInstant.getEpochSecond + if (startEpoch > java.time.Instant.now().getEpochSecond) { + params.setTrialEnd(startEpoch) + } + } + + // End date: schedule automatic cancellation + recurringPayment.endDate.foreach { endDate => + params.setCancelAt(endDate.toInstant.getEpochSecond) + } + + // Idempotency key to prevent duplicate subscriptions on retry + val idempotencyKey = s"$userId-${recurringPayment.createdDate.getTime}" + val options = requestOptions + .toBuilderFullCopy() + .setIdempotencyKey(idempotencyKey) + .build() + + mlog.info(s"Creating Stripe subscription for customer: $userId") + val subscription = Subscription.create(params.build(), options) + + val isFutureStart = recurringPayment.startDate.exists { sd => + sd.toInstant.getEpochSecond > java.time.Instant.now().getEpochSecond + } + val status = if (isFutureStart) { + RecurringPayment.RecurringCardPaymentStatus.CREATED + } else { + toRecurringCardPaymentStatus( + subscription.getStatus, + requiresAction(subscription) + ) + } + + RecurringPayment.RecurringCardPaymentResult.defaultInstance + .withId(subscription.getId) + .withStatus(status) + } match { + case Success(result) => + mlog.info(s"Stripe subscription created: ${result.id} -> ${result.status}") + Some(result) + case Failure(f) => + mlog.error(s"Failed to register recurring card payment: ${f.getMessage}", f) + None + } + } + + /** @param recurringPayInRegistrationId + * - recurring payIn registration id + * @return + * recurring card payment registration result + */ + override def loadRecurringCardPayment( + recurringPayInRegistrationId: String + ): Option[RecurringPayment.RecurringCardPaymentResult] = { + Try { + implicit val requestOptions: RequestOptions = StripeApi().requestOptions() + val subscription = Subscription.retrieve(recurringPayInRegistrationId, requestOptions) + val status = toRecurringCardPaymentStatus( + subscription.getStatus, + requiresAction(subscription) ) - None + RecurringPayment.RecurringCardPaymentResult.defaultInstance + .withId(subscription.getId) + .withStatus(status) + } match { + case Success(result) => Some(result) + case Failure(f) => + mlog.error(s"Failed to load recurring card payment: ${f.getMessage}", f) + None + } } /** @param recurringPayInRegistrationId @@ -48,16 +255,41 @@ trait StripeRecurringPaymentApi extends RecurringPaymentApi { _: StripeContext = recurringPayInRegistrationId: String, cardId: Option[String], status: Option[RecurringPayment.RecurringCardPaymentStatus] - ): Option[RecurringPayment.RecurringCardPaymentResult] = None + ): Option[RecurringPayment.RecurringCardPaymentResult] = { + Try { + implicit val requestOptions: RequestOptions = StripeApi().requestOptions() + val subscription = Subscription.retrieve(recurringPayInRegistrationId, requestOptions) - /** @param recurringPayInRegistrationId - * - recurring payIn registration id - * @return - * recurring card payment registration result - */ - override def loadRecurringCardPayment( - recurringPayInRegistrationId: String - ): Option[RecurringPayment.RecurringCardPaymentResult] = None + status match { + case Some(s) if s.isEnded => + mlog.info(s"Canceling subscription: $recurringPayInRegistrationId") + val canceled = + subscription.cancel(SubscriptionCancelParams.builder().build(), requestOptions) + RecurringPayment.RecurringCardPaymentResult.defaultInstance + .withId(canceled.getId) + .withStatus(RecurringPayment.RecurringCardPaymentStatus.ENDED) + case _ => + val params = SubscriptionUpdateParams.builder() + cardId.foreach(id => params.setDefaultPaymentMethod(id)) + val updated = subscription.update(params.build(), requestOptions) + RecurringPayment.RecurringCardPaymentResult.defaultInstance + .withId(updated.getId) + .withStatus( + toRecurringCardPaymentStatus( + updated.getStatus, + requiresAction(updated) + ) + ) + } + } match { + case Success(result) => + mlog.info(s"Updated recurring card payment: ${result.id} -> ${result.status}") + Some(result) + case Failure(f) => + mlog.error(s"Failed to update recurring card payment: ${f.getMessage}", f) + None + } + } /** @param recurringPaymentTransaction * - recurring payment transaction @@ -66,5 +298,80 @@ trait StripeRecurringPaymentApi extends RecurringPaymentApi { _: StripeContext = */ override def createRecurringCardPayment( recurringPaymentTransaction: RecurringPaymentTransaction - ): Option[Transaction] = None + ): Option[Transaction] = { + Try { + val requestOptions = StripeApi().requestOptions() + val subscriptionId = recurringPaymentTransaction.recurringPaymentRegistrationId + + // Retrieve the latest invoice for this subscription + // Stripe returns invoices in reverse chronological order by default. + // The code handles all statuses: draft → finalize+pay, open → pay, + // paid/void/uncollectible → return as-is (idempotent). + val invoiceListParams = InvoiceListParams + .builder() + .setSubscription(subscriptionId) + .setLimit(1L) + .build() + val invoices = Invoice.list(invoiceListParams, requestOptions) + + invoices.getData.asScala.headOption match { + case Some(invoice) => + // Handle invoice based on status — guard against double-pay + val finalInvoice = invoice.getStatus match { + case "draft" => + val finalized = invoice.finalizeInvoice(requestOptions) + finalized.pay(requestOptions) + case "open" => + invoice.pay(requestOptions) + case _ => + // "paid", "void", "uncollectible" — return as-is (idempotent) + invoice + } + + val paymentIntentId = Option(finalInvoice.getPaymentIntent) + val status = finalInvoice.getStatus match { + case "paid" => Transaction.TransactionStatus.TRANSACTION_SUCCEEDED + case "void" => Transaction.TransactionStatus.TRANSACTION_FAILED + case "uncollectible" => Transaction.TransactionStatus.TRANSACTION_FAILED + case _ => Transaction.TransactionStatus.TRANSACTION_CREATED + } + + Transaction() + .withId(paymentIntentId.getOrElse(finalInvoice.getId)) + .withOrderUuid(recurringPaymentTransaction.externalUuid) + .withNature(Transaction.TransactionNature.REGULAR) + .withType(Transaction.TransactionType.PAYIN) + .withPaymentType(Transaction.PaymentType.CARD) + .withAmount(finalInvoice.getAmountPaid.intValue()) + .withFees( + Option(finalInvoice.getApplicationFeeAmount) + .map(_.intValue()) + .getOrElse(0) + ) + .withCurrency(finalInvoice.getCurrency) + .withResultCode(finalInvoice.getStatus) + .withStatus(status) + .withAuthorId(finalInvoice.getCustomer) + .copy( + recurringPayInRegistrationId = Some(subscriptionId) + ) + + case None => + throw new RuntimeException( + s"No invoice found for subscription: $subscriptionId" + ) + } + } match { + case Success(transaction) => + mlog.info( + s"Recurring card payment for subscription " + + s"${recurringPaymentTransaction.recurringPaymentRegistrationId} -> " + + s"${transaction.id} (${transaction.status})" + ) + Some(transaction) + case Failure(f) => + mlog.error(s"Failed to create recurring card payment: ${f.getMessage}", f) + None + } + } } diff --git a/stripe/src/test/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApiSpec.scala b/stripe/src/test/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApiSpec.scala new file mode 100644 index 0000000..2c52946 --- /dev/null +++ b/stripe/src/test/scala/app/softnetwork/payment/spi/StripeRecurringPaymentApiSpec.scala @@ -0,0 +1,108 @@ +package app.softnetwork.payment.spi + +import app.softnetwork.payment.config.{StripeApi, StripeSettings} +import app.softnetwork.payment.model.{RecurringPayment, SoftPayAccount} +import app.softnetwork.payment.serialization.paymentFormats +import com.typesafe.scalalogging.Logger +import org.json4s.Formats +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import org.slf4j.LoggerFactory + +class StripeRecurringPaymentApiSpec extends AnyWordSpec with Matchers { + + // Minimal concrete instance to test private[spi] helpers + private object TestApi extends StripeRecurringPaymentApi with StripeContext { + override protected def mlog: Logger = Logger(LoggerFactory.getLogger(getClass)) + override implicit def provider: SoftPayAccount.Client.Provider = + StripeSettings.StripeApiConfig.softPayProvider + override implicit def config: StripeApi.Config = StripeSettings.StripeApiConfig + override implicit def formats: Formats = paymentFormats + } + + "toStripeInterval" should { + "map DAILY to day/1" in { + TestApi.toStripeInterval( + RecurringPayment.RecurringPaymentFrequency.DAILY + ) shouldBe ("day", 1L) + } + "map WEEKLY to week/1" in { + TestApi.toStripeInterval( + RecurringPayment.RecurringPaymentFrequency.WEEKLY + ) shouldBe ("week", 1L) + } + "map TWICE_A_MONTH to week/2 (biweekly approximation)" in { + TestApi.toStripeInterval( + RecurringPayment.RecurringPaymentFrequency.TWICE_A_MONTH + ) shouldBe ("week", 2L) + } + "map MONTHLY to month/1" in { + TestApi.toStripeInterval( + RecurringPayment.RecurringPaymentFrequency.MONTHLY + ) shouldBe ("month", 1L) + } + "map BIMONTHLY to month/2" in { + TestApi.toStripeInterval( + RecurringPayment.RecurringPaymentFrequency.BIMONTHLY + ) shouldBe ("month", 2L) + } + "map QUARTERLY to month/3" in { + TestApi.toStripeInterval( + RecurringPayment.RecurringPaymentFrequency.QUARTERLY + ) shouldBe ("month", 3L) + } + "map BIANNUAL to month/6" in { + TestApi.toStripeInterval( + RecurringPayment.RecurringPaymentFrequency.BIANNUAL + ) shouldBe ("month", 6L) + } + "map ANNUAL to year/1" in { + TestApi.toStripeInterval( + RecurringPayment.RecurringPaymentFrequency.ANNUAL + ) shouldBe ("year", 1L) + } + } + + "toRecurringCardPaymentStatus" should { + "map active to IN_PROGRESS" in { + TestApi.toRecurringCardPaymentStatus("active") shouldBe + RecurringPayment.RecurringCardPaymentStatus.IN_PROGRESS + } + "map trialing to IN_PROGRESS" in { + TestApi.toRecurringCardPaymentStatus("trialing") shouldBe + RecurringPayment.RecurringCardPaymentStatus.IN_PROGRESS + } + "map incomplete with requiresAction to AUTHENTICATION_NEEDED" in { + TestApi.toRecurringCardPaymentStatus("incomplete", requiresAction = true) shouldBe + RecurringPayment.RecurringCardPaymentStatus.AUTHENTICATION_NEEDED + } + "map incomplete without requiresAction to CREATED" in { + TestApi.toRecurringCardPaymentStatus("incomplete") shouldBe + RecurringPayment.RecurringCardPaymentStatus.CREATED + } + "map past_due to CREATED" in { + TestApi.toRecurringCardPaymentStatus("past_due") shouldBe + RecurringPayment.RecurringCardPaymentStatus.CREATED + } + "map unpaid to CREATED" in { + TestApi.toRecurringCardPaymentStatus("unpaid") shouldBe + RecurringPayment.RecurringCardPaymentStatus.CREATED + } + "map canceled to ENDED" in { + TestApi.toRecurringCardPaymentStatus("canceled") shouldBe + RecurringPayment.RecurringCardPaymentStatus.ENDED + } + "map incomplete_expired to ENDED" in { + TestApi.toRecurringCardPaymentStatus("incomplete_expired") shouldBe + RecurringPayment.RecurringCardPaymentStatus.ENDED + } + "map paused to ENDED" in { + TestApi.toRecurringCardPaymentStatus("paused") shouldBe + RecurringPayment.RecurringCardPaymentStatus.ENDED + } + "map unknown status to CREATED" in { + TestApi.toRecurringCardPaymentStatus("some_unknown_status") shouldBe + RecurringPayment.RecurringCardPaymentStatus.CREATED + } + } +} 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 5f5c640..b135e0f 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentTestKit.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentTestKit.scala @@ -132,13 +132,13 @@ trait PaymentTestKit } } - def payInFirstRecurringPaymentCallback( + def payInRecurringPaymentCallback( recurringPayInRegistrationId: String, transactionId: String )(implicit ec: ExecutionContext): Future[ Either[FirstRecurringCardPaymentFailed, Either[PaymentRedirection, FirstRecurringPaidIn]] ] = { - MockPaymentHandler !? FirstRecurringPaymentCallback( + MockPaymentHandler !? RecurringPaymentCallback( recurringPayInRegistrationId, transactionId ) map { diff --git a/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala b/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala index 85023b8..b095e42 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala @@ -922,6 +922,192 @@ trait StripePaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } } + "register card for recurring payment" in { + createNewSession(customerSession) + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute", + PreRegisterPaymentMethod( + orderUuid, + naturalUser.copy(business = None), + paymentType = Transaction.PaymentType.CARD + ) + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + preRegistration = responseAs[PreRegistration] + } + // confirm setup intent + Try { + val requestOptions = StripeApi().requestOptions() + SetupIntent + .retrieve(preRegistration.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 to register the card + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$preAuthorizeRoute", + Payment( + orderUuid, + debitedAmount, + currency, + Option(preRegistration.id), + Option(preRegistration.registrationData), + registerCard = true, + printReceipt = true, + feesAmount = Some(feesAmount) + ) + ).withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost)), + `User-Agent`("test") + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val result = responseAs[PaymentPreAuthorized] + preAuthorizationId = result.transactionId + loadCards().find(_.getActive) match { + case Some(card) => + cardId = card.id + case _ => fail("No active card found") + } + } + } + + "register recurring card payment" in { + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute", + RegisterRecurringPayment( + "", + firstDebitedAmount = 4990, + firstFeesAmount = 0, + currency = currency, + `type` = RecurringPayment.RecurringPaymentType.CARD, + frequency = Some(RecurringPayment.RecurringPaymentFrequency.MONTHLY), + endDate = Some(LocalDate.now().plusMonths(1)), + fixedNextAmount = Some(true), + nextDebitedAmount = Some(4990), + nextFeesAmount = Some(0) + ) + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + recurringPaymentRegistrationId = + responseAs[RecurringPaymentRegistered].recurringPaymentRegistrationId + log.info( + s"Recurring card payment registered: $recurringPaymentRegistrationId" + ) + } + } + + "load recurring card payment after registration" in { + withHeaders( + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/$recurringPaymentRegistrationId" + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val recurringPayment = responseAs[RecurringPaymentView] + assert(recurringPayment.`type`.isCard) + assert(recurringPayment.frequency.exists(_.isMonthly)) + assert(recurringPayment.firstDebitedAmount == 4990) + assert(recurringPayment.firstFeesAmount == 0) + assert(recurringPayment.fixedNextAmount.exists(_.self)) + assert(recurringPayment.nextDebitedAmount.contains(4990)) + assert(recurringPayment.nextFeesAmount.contains(0)) + assert(recurringPayment.numberOfRecurringPayments.getOrElse(0) == 0) + assert( + recurringPayment.cardStatus.exists(s => s.isInProgress || s.isCreated) + ) + } + } + + "execute first recurring card payment" in { + withHeaders( + Post( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/${URLEncoder + .encode(recurringPaymentRegistrationId, "UTF-8")}", + Payment("", 0) + ).withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost))) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + withHeaders( + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/$recurringPaymentRegistrationId" + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val recurringPayment = responseAs[RecurringPaymentView] + assert(recurringPayment.`type`.isCard) + assert( + recurringPayment.cardStatus.exists(s => s.isInProgress || s.isCreated) + ) + assert(recurringPayment.numberOfRecurringPayments.getOrElse(0) >= 1) + } + } + } + + "load recurring card payment subscription from Stripe" in { + // Verify we can load the subscription directly from Stripe + Try { + val requestOptions = StripeApi().requestOptions() + val subscription = + com.stripe.model.Subscription.retrieve(recurringPaymentRegistrationId, requestOptions) + assert(subscription.getId == recurringPaymentRegistrationId) + assert( + subscription.getStatus == "active" || + subscription.getStatus == "trialing" || + subscription.getStatus == "incomplete" + ) + log.info( + s"Stripe subscription status: ${subscription.getStatus}" + ) + } match { + case Success(_) => + case Failure(f) => + log.error("Error while loading subscription from Stripe", f) + fail(f) + } + } + + "cancel recurring card payment" in { + withHeaders( + Delete( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/$recurringPaymentRegistrationId" + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + withHeaders( + Get( + s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/$recurringPaymentRegistrationId" + ) + ) ~> routes ~> check { + status shouldEqual StatusCodes.OK + val recurringPayment = responseAs[RecurringPaymentView] + assert(recurringPayment.cardStatus.exists(_.isEnded)) + } + } + } + + // TODO add webhook integration tests for: + // - invoice.payment_succeeded → RecurringPaymentCallback (verify entity state updated) + // - invoice.payment_failed → RecurringPaymentCallback (verify failure recorded) + // - customer.subscription.deleted → UpdateRecurringCardPaymentRegistration(ENDED) + // - customer.subscription.updated (canceled) → UpdateRecurringCardPaymentRegistration(ENDED) + // These tests require the stripe listen CLI to forward webhook events. + "pay in with PayPal" in { createNewSession(customerSession) withHeaders(