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 cee009d..922fdf4 100644 --- a/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala +++ b/common/src/main/scala/app/softnetwork/payment/message/PaymentMessages.scala @@ -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 @@ -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 @@ -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") 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 bdcdf76..b3207a0 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 @@ -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) } } diff --git a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentMethodCommandHandler.scala b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentMethodCommandHandler.scala index 3c8de42..776c23d 100644 --- a/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentMethodCommandHandler.scala +++ b/core/src/main/scala/app/softnetwork/payment/persistence/typed/PaymentMethodCommandHandler.scala @@ -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) + } + } } 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 b6299f2..150c719 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala @@ -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 @@ -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) 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 38a55a0..65079eb 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/service/StripeEventHandler.scala @@ -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} @@ -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}") } 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 eae1bb0..788a852 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala @@ -1,5 +1,6 @@ package app.softnetwork.payment.service +import akka.actor.testkit.typed.scaladsl.FishingOutcomes import akka.http.scaladsl.model.{RemoteAddress, StatusCodes} import akka.http.scaladsl.model.headers.{`User-Agent`, `X-Forwarded-For`} import app.softnetwork.api.server.ApiRoutes @@ -32,6 +33,7 @@ import app.softnetwork.payment.data.{ vatNumber } import app.softnetwork.payment.handlers.MockPaymentDao +import app.softnetwork.payment.message.PaymentEvents.PaymentAccountUpsertedEvent import app.softnetwork.payment.message.PaymentMessages.{ BankAccountCommand, IbanMandate, @@ -68,14 +70,20 @@ import com.stripe.model.PaymentIntent import com.stripe.param.PaymentIntentConfirmParams import org.scalatest.wordspec.AnyWordSpecLike import org.slf4j.{Logger, LoggerFactory} -import com.stripe.model.{Customer, SetupIntent, TaxId} -import com.stripe.param.{SetupIntentConfirmParams, TaxIdListParams} +import com.stripe.model.{Customer, PaymentMethod => StripePaymentMethod, SetupIntent, TaxId} +import com.stripe.param.{ + PaymentMethodAttachParams, + PaymentMethodDetachParams, + SetupIntentConfirmParams, + TaxIdListParams +} import org.json4s.Formats import org.openqa.selenium.{By, WebDriver, WebElement} import org.openqa.selenium.htmlunit.HtmlUnitDriver import java.net.{InetAddress, URLEncoder} import java.time.LocalDate +import scala.concurrent.duration._ import scala.util.{Failure, Success, Try} import scala.jdk.CollectionConverters._ @@ -89,6 +97,8 @@ trait StripePaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] var customer: String = _ + var attachedPaymentMethodId: String = _ + var payInTransactionId: Option[String] = None var payOutTransactionId: Option[String] = None @@ -1051,28 +1061,35 @@ trait StripePaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } } - "execute first recurring card payment" in { + "check first recurring card payment" in { + // Stripe automatically creates an invoice when the subscription is registered. + // The Stripe CLI forwards the invoice.payment_succeeded webhook, which triggers + // RecurringPaymentCallback → persists FirstRecurringPaidInEvent + PaymentAccountUpsertedEvent. + // We probe for PaymentAccountUpsertedEvent (published via JDBC processor stream) + // since FirstRecurringPaidInEvent (BroadcastEvent) has no processor stream in tests. + val probe = createTestProbe[PaymentAccountUpsertedEvent]() + subscribeProbe(probe) + probe.fishForMessage(30.seconds) { + case evt: PaymentAccountUpsertedEvent + if evt.document.recurryingPayments.exists(r => + r.getId == recurringPaymentRegistrationId && + r.getNumberOfRecurringPayments >= 1 + ) => + FishingOutcomes.complete + case _ => FishingOutcomes.continueAndIgnore + } withHeaders( - Post( - s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$recurringPaymentRoute/${URLEncoder - .encode(recurringPaymentRegistrationId, "UTF-8")}", - Payment("", 0) - ).withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getLocalHost))) + Get( + 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.`type`.isCard) - assert( - recurringPayment.cardStatus.exists(s => s.isInProgress || s.isCreated) - ) - assert(recurringPayment.numberOfRecurringPayments.getOrElse(0) >= 1) - } + val recurringPayment = responseAs[RecurringPaymentView] + assert(recurringPayment.`type`.isCard) + assert( + recurringPayment.cardStatus.exists(s => s.isInProgress || s.isCreated) + ) + assert(recurringPayment.numberOfRecurringPayments.getOrElse(0) >= 1) } } @@ -1118,12 +1135,109 @@ trait StripePaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } } - // 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. + "handle payment_method.attached webhook" in { + val probe = createTestProbe[PaymentAccountUpsertedEvent]() + subscribeProbe(probe) + // Create a new payment method and attach it to the customer via Stripe API. + // Must use retrieve-then-attach pattern (same as StripePaymentMethodApi.attachPaymentMethod). + // The Stripe CLI forwards the payment_method.attached webhook automatically. + val requestOptions = StripeApi().requestOptions() + val pm = com.stripe.model.PaymentMethod.create( + com.stripe.param.PaymentMethodCreateParams + .builder() + .setType(com.stripe.param.PaymentMethodCreateParams.Type.CARD) + .setCard( + com.stripe.param.PaymentMethodCreateParams.Token + .builder() + .setToken("tok_visa") + .build() + ) + .build(), + requestOptions + ) + StripePaymentMethod + .retrieve(pm.getId, requestOptions) + .attach( + PaymentMethodAttachParams.builder().setCustomer(customer).build(), + requestOptions + ) + // Use fishForMessage to skip stale events from concurrent webhooks + // and wait for the specific event containing our card + probe.fishForMessage(30.seconds) { + case evt: PaymentAccountUpsertedEvent if evt.document.cards.exists(_.id == pm.getId) => + FishingOutcomes.complete + case _ => FishingOutcomes.continueAndIgnore + } + // Verify the card was registered in the payment account + val cards = loadCards() + assert( + cards.exists(_.id == pm.getId), + s"Payment method ${pm.getId} should be registered after attach webhook" + ) + // Store the payment method ID for the detach test + attachedPaymentMethodId = pm.getId + } + + "handle payment_method.detached webhook" in { + // Detach the payment method attached in the previous test. + // Must use retrieve-then-detach pattern (same as StripePaymentMethodApi.disablePaymentMethod). + val requestOptions = StripeApi().requestOptions() + val probe = createTestProbe[PaymentAccountUpsertedEvent]() + subscribeProbe(probe) + StripePaymentMethod + .retrieve(attachedPaymentMethodId, requestOptions) + .detach(PaymentMethodDetachParams.builder().build(), requestOptions) + // Use fishForMessage to skip stale events and wait for the specific disable event + probe.fishForMessage(30.seconds) { + case evt: PaymentAccountUpsertedEvent + if evt.document.cards.exists(c => c.id == attachedPaymentMethodId && !c.getActive) => + FishingOutcomes.complete + case _ => FishingOutcomes.continueAndIgnore + } + // Verify the card was disabled in the payment account + val methods = loadPaymentMethods() + assert( + methods.cards.exists(c => c.id == attachedPaymentMethodId && !c.active), + s"Payment method $attachedPaymentMethodId should be disabled after detach webhook" + ) + } + + "handle customer.updated webhook" in { + // Update the Stripe customer billing info directly + val requestOptions = StripeApi().requestOptions() + val updatedName = "Updated Test Name" + val updatedPhone = "+33607080910" + val probe = createTestProbe[PaymentAccountUpsertedEvent]() + subscribeProbe(probe) + com.stripe.model.Customer + .retrieve(customer, requestOptions) + .update( + com.stripe.param.CustomerUpdateParams + .builder() + .setName(updatedName) + .setPhone(updatedPhone) + .build(), + requestOptions + ) + // Use fishForMessage to skip stale events and wait for the customer update event + probe.fishForMessage(30.seconds) { + case evt: PaymentAccountUpsertedEvent + if evt.document.user.naturalUser.exists(_.name.contains(updatedName)) => + FishingOutcomes.complete + case _ => FishingOutcomes.continueAndIgnore + } + // Verify the payment account was updated + val paymentAccount = loadPaymentAccount() + val user = paymentAccount.naturalUser.get + assert( + user.name.contains(updatedName), + s"User name should be updated to '$updatedName'" + ) + assert( + user.phone.contains(updatedPhone), + s"User phone should be updated to '$updatedPhone'" + ) + } "pay in with PayPal" in { createNewSession(customerSession)