Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,53 @@ object PaymentMessages {
val key: String = debitedAccount
}

/** Register a payment method from a webhook event (payment method attached to customer).
* @param debitedAccount
* - customer id
* @param paymentMethodId
* - id of the payment method to register
*/
case class RegisterPaymentMethodFromWebhook(
debitedAccount: String,
paymentMethodId: String,
clientId: Option[String] = None
) extends PaymentCommandWithKey
with PaymentMethodCommand {
val key: String = debitedAccount
}

/** Disable a payment method from a webhook event (payment method detached from customer).
*
* Note: On `payment_method.detached`, Stripe sets the `customer` field to null on the
* PaymentMethod object. The customer ID must be extracted from `event.data.previous_attributes`
* before constructing this command.
*
* @param debitedAccount
* - customer id (extracted from previous_attributes since it's null after detach)
* @param paymentMethodId
* - id of the payment method to disable
*/
case class DisablePaymentMethodFromWebhook(debitedAccount: String, paymentMethodId: String)
extends PaymentCommandWithKey
with PaymentMethodCommand {
val key: String = debitedAccount
}

/** Update customer information from a webhook event (customer updated).
* @param debitedAccount
* - customer id
*/
case class UpdateCustomerFromWebhook(
debitedAccount: String,
name: Option[String] = None,
email: Option[String] = None,
phone: Option[String] = None,
address: Option[Address] = None
) extends PaymentCommandWithKey
with PaymentAccountCommand {
val key: String = debitedAccount
}

/** Commands related to the kyc documents */

/** @param creditedAccount
Expand Down Expand Up @@ -1123,6 +1170,10 @@ object PaymentMessages {

case object PaymentMethodDisabled extends PaymentResult

case object PaymentMethodRegistered extends PaymentResult

case object CustomerUpdated extends PaymentResult

case object PaymentAccountCreated extends PaymentResult

case object PaymentAccountUpdated extends PaymentResult
Expand Down Expand Up @@ -1257,6 +1308,8 @@ object PaymentMessages {

case object PaymentMethodsNotLoaded extends PaymentError("PaymentMethodsNotLoaded")

case object PaymentMethodNotFound extends PaymentError("PaymentMethodNotFound")

case object PaymentMethodNotDisabled extends PaymentError("PaymentMethodNotDisabled")

case object UserNotFound extends PaymentError("UserNotFound")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2169,6 +2169,35 @@ trait PaymentBehavior
Effect.none.thenRun(_ => BalanceNotLoaded ~> replyTo)
}

case cmd: UpdateCustomerFromWebhook =>
state match {
case Some(paymentAccount) =>
paymentAccount.user match {
case PaymentAccount.User.NaturalUser(naturalUser) =>
val updatedUser = naturalUser.copy(
name = cmd.name.orElse(naturalUser.name),
email = cmd.email.getOrElse(naturalUser.email),
phone = cmd.phone.orElse(naturalUser.phone),
address = cmd.address.orElse(naturalUser.address)
)
val lastUpdated = now()
val updatedPaymentAccount = paymentAccount
.withUser(PaymentAccount.User.NaturalUser(updatedUser))
.withLastUpdated(lastUpdated)
Effect
.persist(
PaymentAccountUpsertedEvent.defaultInstance
.withDocument(updatedPaymentAccount)
.withLastUpdated(lastUpdated)
)
.thenRun(_ => CustomerUpdated ~> replyTo)
case _ =>
log.warn(s"No natural user found for customer update")
Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo)
}
case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo)
}

case _ => super.handleCommand(entityId, state, command, replyTo, timers)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,98 @@ trait PaymentMethodCommandHandler
case _ => Effect.none.thenRun(_ => PaymentMethodNotDisabled ~> replyTo)
}

case cmd: RegisterPaymentMethodFromWebhook =>
state match {
case Some(paymentAccount) =>
// Idempotency: if payment method already exists, skip persistence
if (paymentAccount.paymentMethods.exists(_.id == cmd.paymentMethodId)) {
log.info(
s"Payment method ${cmd.paymentMethodId} already registered, skipping"
)
Effect.none.thenRun(_ => PaymentMethodRegistered ~> replyTo)
} else {
val clientId =
paymentAccount.clientId.orElse(cmd.clientId).orElse(internalClientId)
val paymentProvider = loadPaymentProvider(clientId)
import paymentProvider._
loadPaymentMethod(cmd.paymentMethodId) match {
case Some(card: Card) =>
val lastUpdated = now()
val updatedPaymentAccount = paymentAccount
.withCards(
paymentAccount.cards.filterNot(_.id == cmd.paymentMethodId) :+ card
)
keyValueDao.addKeyValue(cmd.paymentMethodId, entityId)
Effect
.persist(
PaymentAccountUpsertedEvent.defaultInstance
.withDocument(updatedPaymentAccount.withLastUpdated(lastUpdated))
.withLastUpdated(lastUpdated)
)
.thenRun(_ => PaymentMethodRegistered ~> replyTo)
case Some(paypal: Paypal) =>
val lastUpdated = now()
val updatedPaymentAccount = paymentAccount
.withPaypals(
paymentAccount.paypals.filterNot(_.id == cmd.paymentMethodId) :+ paypal
)
keyValueDao.addKeyValue(cmd.paymentMethodId, entityId)
Effect
.persist(
PaymentAccountUpsertedEvent.defaultInstance
.withDocument(updatedPaymentAccount.withLastUpdated(lastUpdated))
.withLastUpdated(lastUpdated)
)
.thenRun(_ => PaymentMethodRegistered ~> replyTo)
case _ =>
log.warn(
s"Payment method ${cmd.paymentMethodId} not found for registration"
)
Effect.none.thenRun(_ => PaymentMethodNotFound ~> replyTo)
}
}
case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo)
}

case cmd: DisablePaymentMethodFromWebhook =>
state match {
case Some(paymentAccount) =>
paymentAccount.paymentMethods.find(_.id == cmd.paymentMethodId) match {
case Some(card: Card) =>
val lastUpdated = now()
val updatedPaymentAccount = paymentAccount
.withCards(
paymentAccount.cards.filterNot(_.id == cmd.paymentMethodId) :+
card.withActive(false)
)
Effect
.persist(
PaymentAccountUpsertedEvent.defaultInstance
.withDocument(updatedPaymentAccount.withLastUpdated(lastUpdated))
.withLastUpdated(lastUpdated)
)
.thenRun(_ => PaymentMethodDisabled ~> replyTo)
case Some(paypal: Paypal) =>
val lastUpdated = now()
val updatedPaymentAccount = paymentAccount
.withPaypals(
paymentAccount.paypals.filterNot(_.id == cmd.paymentMethodId) :+
paypal.withActive(false)
)
Effect
.persist(
PaymentAccountUpsertedEvent.defaultInstance
.withDocument(updatedPaymentAccount.withLastUpdated(lastUpdated))
.withLastUpdated(lastUpdated)
)
.thenRun(_ => PaymentMethodDisabled ~> replyTo)
case _ =>
log.warn(s"Payment method ${cmd.paymentMethodId} not found for disabling")
Effect.none.thenRun(_ => PaymentMethodNotFound ~> replyTo)
}
case _ => Effect.none.thenRun(_ => PaymentAccountNotFound ~> replyTo)
}

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ object StripeApi {
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__UPDATED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.PAYMENT_METHOD__DETACHED
)
.setUrl(url)
.build(),
requestOptions
Expand Down Expand Up @@ -208,6 +217,15 @@ object StripeApi {
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__DETACHED
)
.setUrl(url)
.setApiVersion(WebhookEndpointCreateParams.ApiVersion.VERSION_2024_06_20)
.setConnect(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,31 @@ import app.softnetwork.concurrent.Completion
import app.softnetwork.payment.handlers.PaymentHandler
import app.softnetwork.payment.message.PaymentMessages.{
CreateOrUpdateKycDocument,
CustomerUpdated,
DisablePaymentMethodFromWebhook,
InvalidateRegularUser,
KycDocumentCreatedOrUpdated,
PaymentMethodDisabled,
PaymentMethodRegistered,
RecurringPaymentCallback,
RegisterPaymentMethodFromWebhook,
RegularUserInvalidated,
RegularUserValidated,
UpdateCustomerFromWebhook,
UpdateRecurringCardPaymentRegistration,
ValidateRegularUser
}
import app.softnetwork.payment.model.{KycDocument, RecurringPayment}
import com.stripe.model.{Account, Event, Invoice, Person, StripeObject, Subscription}
import app.softnetwork.payment.model.{Address, KycDocument, RecurringPayment}
import com.stripe.model.{
Account,
Customer,
Event,
Invoice,
PaymentMethod => StripePaymentMethod,
Person,
StripeObject,
Subscription
}
import com.stripe.net.Webhook

import scala.util.{Failure, Success, Try}
Expand Down Expand Up @@ -390,6 +405,112 @@ trait StripeEventHandler extends Completion { _: BasicPaymentService with Paymen
}
}

case "payment_method.attached" =>
log.info(s"[Payment Hooks] Webhook: Payment Method Attached")
val maybePm: Option[StripePaymentMethod] =
extractStripeObject[StripePaymentMethod](event)
maybePm.foreach { pm =>
val paymentMethodId = pm.getId
val customerId = pm.getCustomer
log.info(
s"[Payment Hooks] Payment method $paymentMethodId attached to customer $customerId"
)
run(
RegisterPaymentMethodFromWebhook(customerId, paymentMethodId)
).complete() match {
case Success(_: PaymentMethodRegistered.type) =>
log.info(
s"[Payment Hooks] Payment method $paymentMethodId registered for $customerId"
)
case Success(other) =>
log.warn(
s"[Payment Hooks] Unexpected result for payment method attach: $other"
)
case Failure(f) =>
log.error(
s"[Payment Hooks] Failed to register payment method $paymentMethodId: ${f.getMessage}",
f
)
}
}

case "payment_method.detached" =>
log.info(s"[Payment Hooks] Webhook: Payment Method Detached")
val maybePm: Option[StripePaymentMethod] =
extractStripeObject[StripePaymentMethod](event)
maybePm.foreach { pm =>
val paymentMethodId = pm.getId
// After detach, pm.getCustomer() is null. Retrieve the customer ID from
// previous_attributes where Stripe stores the pre-detach state.
val customerId = Option(pm.getCustomer).orElse {
Option(event.getData.getPreviousAttributes)
.flatMap(prev => Option(prev.get("customer")).map(_.toString))
}
customerId match {
case Some(cid) =>
log.info(
s"[Payment Hooks] Payment method $paymentMethodId detached from customer $cid"
)
run(
DisablePaymentMethodFromWebhook(cid, paymentMethodId)
).complete() match {
case Success(_: PaymentMethodDisabled.type) =>
log.info(s"[Payment Hooks] Payment method $paymentMethodId disabled")
case Success(other) =>
log.warn(
s"[Payment Hooks] Unexpected result for payment method detach: $other"
)
case Failure(f) =>
log.error(
s"[Payment Hooks] Failed to disable payment method $paymentMethodId: ${f.getMessage}",
f
)
}
case None =>
log.warn(
s"[Payment Hooks] Cannot disable payment method $paymentMethodId: " +
"customer ID not found in event data or previous_attributes"
)
}
}

case "customer.updated" =>
log.info(s"[Payment Hooks] Webhook: Customer Updated")
val maybeCustomer: Option[Customer] = extractStripeObject[Customer](event)
maybeCustomer.foreach { customer =>
val customerId = customer.getId
log.info(s"[Payment Hooks] Customer $customerId updated")
val address = Option(customer.getAddress).map { a =>
Address.defaultInstance
.withAddressLine(
Seq(Option(a.getLine1), Option(a.getLine2)).flatten.mkString(", ")
)
.withCity(Option(a.getCity).getOrElse(""))
.withPostalCode(Option(a.getPostalCode).getOrElse(""))
.withCountry(Option(a.getCountry).getOrElse(""))
.copy(state = Option(a.getState))
}
run(
UpdateCustomerFromWebhook(
customerId,
name = Option(customer.getName),
email = Option(customer.getEmail),
phone = Option(customer.getPhone),
address = address
)
).complete() match {
case Success(_: CustomerUpdated.type) =>
log.info(s"[Payment Hooks] Customer $customerId info updated")
case Success(other) =>
log.warn(s"[Payment Hooks] Unexpected result for customer update: $other")
case Failure(f) =>
log.error(
s"[Payment Hooks] Failed to update customer $customerId: ${f.getMessage}",
f
)
}
}

case _ =>
log.info(s"[Payment Hooks] Stripe Webhook received: ${event.getType}")
}
Expand Down
Loading
Loading