From 8acc97b4484edc0486a903ef620171d5de58bbcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 16 Apr 2026 09:02:54 +0200 Subject: [PATCH 1/2] feat: add NaturalUser name/additionalProperties fields and Stripe Tax ID registration Enhances NaturalUser proto with optional name (field 17), additionalProperties map (field 18), and default values for firstName/lastName/birthday. StripeAccountApi now uses name for customer display name (with firstName/lastName fallback), registers Tax IDs via TaxId.create() with country-resolved type, and handles idempotent updates. Adds user oneof field to PaymentAccountCreatedOrUpdatedEvent for downstream licensing consumers. Version bumped to 0.9.2. Closed Issue #14 Closed Issue #15 Co-Authored-By: Claude Opus 4.6 (1M context) --- build.sbt | 2 +- .../protobuf/model/payment/paymentUser.proto | 8 +- .../payment/model/NaturalUserDecorator.scala | 8 +- .../protobuf/message/payment/payment.proto | 4 + .../persistence/typed/PaymentBehavior.scala | 21 +- .../payment/spi/StripeAccountApi.scala | 831 +++++++++++------- .../softnetwork/payment/data/package.scala | 31 +- .../scalatest/PaymentRouteTestKit.scala | 5 +- .../service/StripePaymentServiceSpec.scala | 53 +- 9 files changed, 608 insertions(+), 355 deletions(-) diff --git a/build.sbt b/build.sbt index f96c5ad..3f89694 100644 --- a/build.sbt +++ b/build.sbt @@ -18,7 +18,7 @@ ThisBuild / organization := "app.softnetwork" name := "payment" -ThisBuild / version := "0.9.1" +ThisBuild / version := "0.9.2" ThisBuild / scalaVersion := scala212 diff --git a/client/src/main/protobuf/model/payment/paymentUser.proto b/client/src/main/protobuf/model/payment/paymentUser.proto index 7a40484..c00f1fb 100644 --- a/client/src/main/protobuf/model/payment/paymentUser.proto +++ b/client/src/main/protobuf/model/payment/paymentUser.proto @@ -82,11 +82,11 @@ message NaturalUser { option (scalapb.message).extends = "ProtobufDomainObject"; option (scalapb.message).extends = "NaturalUserDecorator"; option (scalapb.message).companion_extends = "NaturalUserCompanion"; - required string firstName = 1; - required string lastName = 2; + required string firstName = 1 [default = ""]; + required string lastName = 2 [default = ""]; required string email = 3; required string nationality = 4 [default = "FR"]; - required string birthday = 5; + required string birthday = 5 [default = ""]; required string countryOfResidence = 6 [default = "FR"]; optional string userId = 7; optional string walletId = 8; @@ -98,6 +98,8 @@ message NaturalUser { optional string phone = 14; optional Business business = 15; optional string title = 16; + optional string name = 17; + map additionalProperties = 18; } message LegalUser { diff --git a/client/src/main/scala/app/softnetwork/payment/model/NaturalUserDecorator.scala b/client/src/main/scala/app/softnetwork/payment/model/NaturalUserDecorator.scala index 7e20fcf..5f4b277 100644 --- a/client/src/main/scala/app/softnetwork/payment/model/NaturalUserDecorator.scala +++ b/client/src/main/scala/app/softnetwork/payment/model/NaturalUserDecorator.scala @@ -22,7 +22,9 @@ case class NaturalUserView( address: Option[Address] = None, phone: Option[String] = None, business: Option[Business] = None, - title: Option[String] = None + title: Option[String] = None, + name: Option[String] = None, + additionalProperties: Map[String, String] = Map.empty ) extends User object NaturalUserView { @@ -42,7 +44,9 @@ object NaturalUserView { address, phone, business, - title + title, + paymentUser.name, + paymentUser.additionalProperties ) } } diff --git a/common/src/main/protobuf/message/payment/payment.proto b/common/src/main/protobuf/message/payment/payment.proto index d6d5948..33ec823 100644 --- a/common/src/main/protobuf/message/payment/payment.proto +++ b/common/src/main/protobuf/message/payment/payment.proto @@ -322,6 +322,10 @@ message PaymentAccountCreatedOrUpdatedEvent { required string externalUuid = 1; required google.protobuf.Timestamp lastUpdated = 2 [(scalapb.field).type = "java.time.Instant"]; optional string profile = 3; + oneof user { + app.softnetwork.payment.model.NaturalUser naturalUser = 4; + app.softnetwork.payment.model.LegalUser legalUser = 5; + } } message CancelMandateCommandEvent { 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 bd16917..70f57f0 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 @@ -46,6 +46,17 @@ trait PaymentBehavior override protected val manifestWrapper: ManifestW = ManifestW() + private def toEventUser( + user: PaymentAccount.User + ): PaymentAccountCreatedOrUpdatedEvent.User = user match { + case PaymentAccount.User.NaturalUser(value) => + PaymentAccountCreatedOrUpdatedEvent.User.NaturalUser(value) + case PaymentAccount.User.LegalUser(value) => + PaymentAccountCreatedOrUpdatedEvent.User.LegalUser(value) + case _ => + PaymentAccountCreatedOrUpdatedEvent.User.Empty + } + def paymentDao: PaymentDao = PaymentDao def softPayAccountDao: SoftPayAccountDao = SoftPayAccountDao @@ -283,7 +294,10 @@ trait PaymentBehavior PaymentAccountCreatedOrUpdatedEvent.defaultInstance .withLastUpdated(lastUpdated) .withExternalUuid(updatedPaymentAccount.externalUuid) - .copy(profile = updatedPaymentAccount.profile) + .copy( + profile = updatedPaymentAccount.profile, + user = toEventUser(updatedPaymentAccount.user) + ) ) :+ PaymentAccountUpsertedEvent.defaultInstance .withDocument(updatedPaymentAccount) @@ -1376,7 +1390,10 @@ trait PaymentBehavior PaymentAccountCreatedOrUpdatedEvent.defaultInstance .withLastUpdated(lastUpdated) .withExternalUuid(updatedPaymentAccount.externalUuid) - .copy(profile = updatedPaymentAccount.profile) + .copy( + profile = updatedPaymentAccount.profile, + user = toEventUser(updatedPaymentAccount.user) + ) ) if ( diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeAccountApi.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeAccountApi.scala index 727046d..5163a90 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeAccountApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeAccountApi.scala @@ -18,7 +18,7 @@ import app.softnetwork.time import app.softnetwork.time.dateToInstant import com.google.gson.Gson import com.stripe.Stripe -import com.stripe.model.{Customer, Token} +import com.stripe.model.{Customer, TaxId, Token} import com.stripe.param.{ AccountListParams, CustomerCreateParams, @@ -26,6 +26,8 @@ import com.stripe.param.{ CustomerUpdateParams, ExternalAccountCollectionCreateParams, ExternalAccountCollectionListParams, + TaxIdCreateParams, + TaxIdListParams, TokenCreateParams } @@ -124,198 +126,54 @@ trait StripeAccountApi extends PaymentAccountApi { _: StripeContext => case Some(naturalUser) => mlog.info(s"natural user -> ${asJson(naturalUser)}") // create or update natural user - val birthday = naturalUser.birthday - val sdf = new SimpleDateFormat("dd/MM/yyyy") - sdf.setTimeZone(TimeZone.getTimeZone("UTC")) - Try(sdf.parse(birthday)) match { - case Success(date) => - val customer = - naturalUser.naturalUserType.contains(model.NaturalUser.NaturalUserType.PAYER) - if (customer) { - createOrUpdateCustomer(naturalUser) - } else { - val tos_shown_and_accepted = - acceptedTermsOfPSP && ipAddress.isDefined && userAgent.isDefined - val c = Calendar.getInstance() - c.setTime(date) - Try( - (naturalUser.userId match { - case Some(userId) if userId.startsWith("acct_") => - Option(Account.retrieve(userId, StripeApi().requestOptions())) - case _ => - Account - .list( - AccountListParams - .builder() - .setLimit(100) - .build(), - StripeApi().requestOptions() - ) - .getData - .asScala - .find(acc => acc.getMetadata.get("external_uuid") == naturalUser.externalUuid) - }) match { - case Some(account) => - mlog.info(s"individual account to update -> ${new Gson().toJson(account)}") - - // update individual account - val token = tokenId match { - case Some(t) => - Token.retrieve(t, StripeApi().requestOptions()) - case _ => - // create account with token - val individual = - TokenCreateParams.Account.Individual - .builder() - .setFirstName(naturalUser.firstName) - .setLastName(naturalUser.lastName) - .setDob( - TokenCreateParams.Account.Individual.Dob - .builder() - .setDay(c.get(Calendar.DAY_OF_MONTH)) - .setMonth(c.get(Calendar.MONTH) + 1) - .setYear(c.get(Calendar.YEAR)) - .build() - ) - .setEmail(naturalUser.email) - naturalUser.phone match { - case Some(phone) => - individual.setPhone(phone) - case _ => - } - naturalUser.address match { - case Some(address) => - individual.setAddress( - TokenCreateParams.Account.Individual.Address - .builder() - .setCity(address.city) - .setCountry(address.country) - .setLine1(address.addressLine) - .setPostalCode(address.postalCode) - .build() - ) - case _ => - } - val params = TokenCreateParams.Account - .builder() - .setBusinessType(TokenCreateParams.Account.BusinessType.INDIVIDUAL) - .setIndividual(individual.build()) - .setTosShownAndAccepted(tos_shown_and_accepted) - mlog.info( - s"update individual account token params -> ${new Gson().toJson(params.build())}" - ) - Token.create( - TokenCreateParams + val customer = + naturalUser.naturalUserType.contains(model.NaturalUser.NaturalUserType.PAYER) + if (customer) { + createOrUpdateCustomer(naturalUser) + } else { + val birthday = naturalUser.birthday + if (birthday.trim.isEmpty) { + mlog.error( + s"Cannot create connected account for COLLECTOR user ${naturalUser.externalUuid}: birthday is required but was empty" + ) + None + } else { + val sdf = new SimpleDateFormat("dd/MM/yyyy") + sdf.setTimeZone(TimeZone.getTimeZone("UTC")) + Try(sdf.parse(birthday)) match { + case Success(date) => + val tos_shown_and_accepted = + acceptedTermsOfPSP && ipAddress.isDefined && userAgent.isDefined + val c = Calendar.getInstance() + c.setTime(date) + Try( + (naturalUser.userId match { + case Some(userId) if userId.startsWith("acct_") => + Option(Account.retrieve(userId, StripeApi().requestOptions())) + case _ => + Account + .list( + AccountListParams .builder() - .setAccount(params.build()) + .setLimit(100) .build(), StripeApi().requestOptions() ) - } - val params = - AccountUpdateParams - .builder() - .setAccountToken(token.getId) - .setCapabilities( - AccountUpdateParams.Capabilities - .builder() - .setBankTransferPayments( - AccountUpdateParams.Capabilities.BankTransferPayments - .builder() - .setRequested(true) - .build() - ) - .setCardPayments( - AccountUpdateParams.Capabilities.CardPayments - .builder() - .setRequested(true) - .build() - ) - .setCartesBancairesPayments( - AccountUpdateParams.Capabilities.CartesBancairesPayments - .builder() - .setRequested(true) - .build() - ) - .setTransfers( - AccountUpdateParams.Capabilities.Transfers - .builder() - .setRequested(true) - .build() - ) - .setSepaBankTransferPayments( - AccountUpdateParams.Capabilities.SepaBankTransferPayments - .builder() - .setRequested(true) - .build() - ) - .setSepaDebitPayments( - AccountUpdateParams.Capabilities.SepaDebitPayments - .builder() - .setRequested(true) - .build() - ) - .build() - ) - .setSettings( - AccountUpdateParams.Settings - .builder() - .setPayouts( - AccountUpdateParams.Settings.Payouts - .builder() - .setSchedule( - AccountUpdateParams.Settings.Payouts.Schedule - .builder() - .setInterval( - AccountUpdateParams.Settings.Payouts.Schedule.Interval.MANUAL - ) - .build() - ) - .build() - ) - .build() + .getData + .asScala + .find(acc => + acc.getMetadata.get("external_uuid") == naturalUser.externalUuid ) - .putMetadata("external_uuid", naturalUser.externalUuid) - naturalUser.business match { - case Some(business) => - val businessProfile = - AccountUpdateParams.BusinessProfile - .builder() - .setMcc(business.merchantCategoryCode) - .setUrl(business.website) - business.support match { - case Some(support) => - businessProfile.setSupportEmail(support.email) - support.phone match { - case Some(phone) => - businessProfile.setSupportPhone(phone) - case _ => - } - support.url match { - case Some(url) => - businessProfile.setSupportUrl(url) - case _ => - } - case _ => - } - params.setBusinessProfile(businessProfile.build()) - case _ => - } - mlog.info( - s"update individual account params -> ${new Gson().toJson(params.build())}" - ) - account.update( - params.build(), - StripeApi().requestOptions() - ) + }) match { + case Some(account) => + mlog.info(s"individual account to update -> ${new Gson().toJson(account)}") - case _ => - // create account - val token = { - tokenId match { + // update individual account + val token = tokenId match { case Some(t) => Token.retrieve(t, StripeApi().requestOptions()) case _ => + // create account with token val individual = TokenCreateParams.Account.Individual .builder() @@ -354,7 +212,7 @@ trait StripeAccountApi extends PaymentAccountApi { _: StripeContext => .setIndividual(individual.build()) .setTosShownAndAccepted(tos_shown_and_accepted) mlog.info( - s"create individual account token params -> ${new Gson().toJson(params.build())}" + s"update individual account token params -> ${new Gson().toJson(params.build())}" ) Token.create( TokenCreateParams @@ -364,168 +222,321 @@ trait StripeAccountApi extends PaymentAccountApi { _: StripeContext => StripeApi().requestOptions() ) } - } - val params = - AccountCreateParams - .builder() - .setAccountToken(token.getId) - .setCapabilities( - AccountCreateParams.Capabilities - .builder() - .setBankTransferPayments( - AccountCreateParams.Capabilities.BankTransferPayments - .builder() - .setRequested(true) - .build() - ) - .setCardPayments( - AccountCreateParams.Capabilities.CardPayments - .builder() - .setRequested(true) - .build() - ) - .setCartesBancairesPayments( - AccountCreateParams.Capabilities.CartesBancairesPayments - .builder() - .setRequested(true) - .build() - ) - .setTransfers( - AccountCreateParams.Capabilities.Transfers - .builder() - .setRequested(true) - .build() - ) - .setSepaBankTransferPayments( - AccountCreateParams.Capabilities.SepaBankTransferPayments - .builder() - .setRequested(true) - .build() - ) - .setSepaDebitPayments( - AccountCreateParams.Capabilities.SepaDebitPayments - .builder() - .setRequested(true) - .build() - ) - .build() - ) - .setController( - AccountCreateParams.Controller - .builder() - .setFees( - AccountCreateParams.Controller.Fees - .builder() - .setPayer(AccountCreateParams.Controller.Fees.Payer.APPLICATION) - .build() - ) - .setLosses( - AccountCreateParams.Controller.Losses - .builder() - .setPayments( - AccountCreateParams.Controller.Losses.Payments.APPLICATION - ) - .build() - ) - .setRequirementCollection( - AccountCreateParams.Controller.RequirementCollection.APPLICATION - ) - .setStripeDashboard( - AccountCreateParams.Controller.StripeDashboard - .builder() - .setType(AccountCreateParams.Controller.StripeDashboard.Type.NONE) - .build() - ) - .build() - ) - .setCountry(naturalUser.countryOfResidence) - .setSettings( - AccountCreateParams.Settings - .builder() - .setPayouts( - AccountCreateParams.Settings.Payouts + val params = + AccountUpdateParams + .builder() + .setAccountToken(token.getId) + .setCapabilities( + AccountUpdateParams.Capabilities + .builder() + .setBankTransferPayments( + AccountUpdateParams.Capabilities.BankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setCardPayments( + AccountUpdateParams.Capabilities.CardPayments + .builder() + .setRequested(true) + .build() + ) + .setCartesBancairesPayments( + AccountUpdateParams.Capabilities.CartesBancairesPayments + .builder() + .setRequested(true) + .build() + ) + .setTransfers( + AccountUpdateParams.Capabilities.Transfers + .builder() + .setRequested(true) + .build() + ) + .setSepaBankTransferPayments( + AccountUpdateParams.Capabilities.SepaBankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setSepaDebitPayments( + AccountUpdateParams.Capabilities.SepaDebitPayments + .builder() + .setRequested(true) + .build() + ) + .build() + ) + .setSettings( + AccountUpdateParams.Settings + .builder() + .setPayouts( + AccountUpdateParams.Settings.Payouts + .builder() + .setSchedule( + AccountUpdateParams.Settings.Payouts.Schedule + .builder() + .setInterval( + AccountUpdateParams.Settings.Payouts.Schedule.Interval.MANUAL + ) + .build() + ) + .build() + ) + .build() + ) + .putMetadata("external_uuid", naturalUser.externalUuid) + naturalUser.business match { + case Some(business) => + val businessProfile = + AccountUpdateParams.BusinessProfile + .builder() + .setMcc(business.merchantCategoryCode) + .setUrl(business.website) + business.support match { + case Some(support) => + businessProfile.setSupportEmail(support.email) + support.phone match { + case Some(phone) => + businessProfile.setSupportPhone(phone) + case _ => + } + support.url match { + case Some(url) => + businessProfile.setSupportUrl(url) + case _ => + } + case _ => + } + params.setBusinessProfile(businessProfile.build()) + case _ => + } + mlog.info( + s"update individual account params -> ${new Gson().toJson(params.build())}" + ) + account.update( + params.build(), + StripeApi().requestOptions() + ) + + case _ => + // create account + val token = { + tokenId match { + case Some(t) => + Token.retrieve(t, StripeApi().requestOptions()) + case _ => + val individual = + TokenCreateParams.Account.Individual .builder() - .setSchedule( - AccountCreateParams.Settings.Payouts.Schedule + .setFirstName(naturalUser.firstName) + .setLastName(naturalUser.lastName) + .setDob( + TokenCreateParams.Account.Individual.Dob .builder() - .setInterval( - AccountCreateParams.Settings.Payouts.Schedule.Interval.MANUAL - ) + .setDay(c.get(Calendar.DAY_OF_MONTH)) + .setMonth(c.get(Calendar.MONTH) + 1) + .setYear(c.get(Calendar.YEAR)) .build() ) - .build() - ) - .build() - ) - .setMetadata(Map("external_uuid" -> naturalUser.externalUuid).asJava) - - naturalUser.business match { - case Some(business) => - val businessProfile = - AccountCreateParams.BusinessProfile - .builder() - .setMcc(business.merchantCategoryCode) - .setUrl(business.website) - business.support match { - case Some(support) => - businessProfile.setSupportEmail(support.email) - support.phone match { + .setEmail(naturalUser.email) + naturalUser.phone match { case Some(phone) => - businessProfile.setSupportPhone(phone) + individual.setPhone(phone) case _ => } - support.url match { - case Some(url) => - businessProfile.setSupportUrl(url) + naturalUser.address match { + case Some(address) => + individual.setAddress( + TokenCreateParams.Account.Individual.Address + .builder() + .setCity(address.city) + .setCountry(address.country) + .setLine1(address.addressLine) + .setPostalCode(address.postalCode) + .build() + ) case _ => } - case _ => + val params = TokenCreateParams.Account + .builder() + .setBusinessType(TokenCreateParams.Account.BusinessType.INDIVIDUAL) + .setIndividual(individual.build()) + .setTosShownAndAccepted(tos_shown_and_accepted) + mlog.info( + s"create individual account token params -> ${new Gson().toJson(params.build())}" + ) + Token.create( + TokenCreateParams + .builder() + .setAccount(params.build()) + .build(), + StripeApi().requestOptions() + ) } - params.setBusinessProfile(businessProfile.build()) - case _ => - } + } + val params = + AccountCreateParams + .builder() + .setAccountToken(token.getId) + .setCapabilities( + AccountCreateParams.Capabilities + .builder() + .setBankTransferPayments( + AccountCreateParams.Capabilities.BankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setCardPayments( + AccountCreateParams.Capabilities.CardPayments + .builder() + .setRequested(true) + .build() + ) + .setCartesBancairesPayments( + AccountCreateParams.Capabilities.CartesBancairesPayments + .builder() + .setRequested(true) + .build() + ) + .setTransfers( + AccountCreateParams.Capabilities.Transfers + .builder() + .setRequested(true) + .build() + ) + .setSepaBankTransferPayments( + AccountCreateParams.Capabilities.SepaBankTransferPayments + .builder() + .setRequested(true) + .build() + ) + .setSepaDebitPayments( + AccountCreateParams.Capabilities.SepaDebitPayments + .builder() + .setRequested(true) + .build() + ) + .build() + ) + .setController( + AccountCreateParams.Controller + .builder() + .setFees( + AccountCreateParams.Controller.Fees + .builder() + .setPayer(AccountCreateParams.Controller.Fees.Payer.APPLICATION) + .build() + ) + .setLosses( + AccountCreateParams.Controller.Losses + .builder() + .setPayments( + AccountCreateParams.Controller.Losses.Payments.APPLICATION + ) + .build() + ) + .setRequirementCollection( + AccountCreateParams.Controller.RequirementCollection.APPLICATION + ) + .setStripeDashboard( + AccountCreateParams.Controller.StripeDashboard + .builder() + .setType(AccountCreateParams.Controller.StripeDashboard.Type.NONE) + .build() + ) + .build() + ) + .setCountry(naturalUser.countryOfResidence) + .setSettings( + AccountCreateParams.Settings + .builder() + .setPayouts( + AccountCreateParams.Settings.Payouts + .builder() + .setSchedule( + AccountCreateParams.Settings.Payouts.Schedule + .builder() + .setInterval( + AccountCreateParams.Settings.Payouts.Schedule.Interval.MANUAL + ) + .build() + ) + .build() + ) + .build() + ) + .setMetadata(Map("external_uuid" -> naturalUser.externalUuid).asJava) - mlog.info( - s"create individual account params -> ${new Gson().toJson(params.build())}" - ) + naturalUser.business match { + case Some(business) => + val businessProfile = + AccountCreateParams.BusinessProfile + .builder() + .setMcc(business.merchantCategoryCode) + .setUrl(business.website) + business.support match { + case Some(support) => + businessProfile.setSupportEmail(support.email) + support.phone match { + case Some(phone) => + businessProfile.setSupportPhone(phone) + case _ => + } + support.url match { + case Some(url) => + businessProfile.setSupportUrl(url) + case _ => + } + case _ => + } + params.setBusinessProfile(businessProfile.build()) + case _ => + } - Account.create( - params.build(), - StripeApi().requestOptions() - ) - } - ) match { - case Success(account) => - if (tos_shown_and_accepted) { - mlog.info(s"****** tos_shown_and_accepted -> $tos_shown_and_accepted") - val params = - AccountUpdateParams - .builder() - .setTosAcceptance( - AccountUpdateParams.TosAcceptance - .builder() - .setIp(ipAddress.get) - .setUserAgent(userAgent.get) - .setDate(persistence.now().getEpochSecond) - .build() - ) - .build() - Try( - account.update( - params, + mlog.info( + s"create individual account params -> ${new Gson().toJson(params.build())}" + ) + + Account.create( + params.build(), StripeApi().requestOptions() ) - ) } - Some(account.getId) - case Failure(f) => - mlog.error(f.getMessage, f) - None - } + ) match { + case Success(account) => + if (tos_shown_and_accepted) { + mlog.info(s"****** tos_shown_and_accepted -> $tos_shown_and_accepted") + val params = + AccountUpdateParams + .builder() + .setTosAcceptance( + AccountUpdateParams.TosAcceptance + .builder() + .setIp(ipAddress.get) + .setUserAgent(userAgent.get) + .setDate(persistence.now().getEpochSecond) + .build() + ) + .build() + Try( + account.update( + params, + StripeApi().requestOptions() + ) + ) + } + Some(account.getId) + case Failure(f) => + mlog.error(f.getMessage, f) + None + } + case Failure(f) => + mlog.error(f.getMessage, f) + None } - case Failure(f) => - mlog.error(f.getMessage, f) - None + } } case _ => None } @@ -2085,7 +2096,9 @@ trait StripeAccountApi extends PaymentAccountApi { _: StripeContext => val params = CustomerUpdateParams .builder() //.setEmail(naturalUser.email) - .setName(s"${naturalUser.firstName} ${naturalUser.lastName}") + .setName( + naturalUser.name.getOrElse(s"${naturalUser.firstName} ${naturalUser.lastName}") + ) .setMetadata( Map( "external_uuid" -> naturalUser.externalUuid @@ -2120,7 +2133,9 @@ trait StripeAccountApi extends PaymentAccountApi { _: StripeContext => val params = CustomerCreateParams .builder() .setEmail(naturalUser.email) - .setName(s"${naturalUser.firstName} ${naturalUser.lastName}") + .setName( + naturalUser.name.getOrElse(s"${naturalUser.firstName} ${naturalUser.lastName}") + ) .setMetadata( Map( "external_uuid" -> naturalUser.externalUuid @@ -2152,13 +2167,161 @@ trait StripeAccountApi extends PaymentAccountApi { _: StripeContext => Customer.create(params.build(), StripeApi().requestOptions()) } } match { - case Success(customer) => Some(customer.getId) + case Success(customer) => + naturalUser.additionalProperties.get("vatNumber").foreach { vatNumber => + val country = naturalUser.additionalProperties + .getOrElse("countryOfResidence", naturalUser.countryOfResidence) + registerCustomerTaxId(customer.getId, vatNumber, country) + } + Some(customer.getId) case Failure(f) => mlog.error(f.getMessage, f) None } } + private[this] def createTaxId( + customerId: String, + vatNumber: String, + taxIdType: TaxIdCreateParams.Type + ): TaxId = { + TaxId.create( + TaxIdCreateParams + .builder() + .setType(taxIdType) + .setValue(vatNumber) + .setOwner( + TaxIdCreateParams.Owner + .builder() + .setType(TaxIdCreateParams.Owner.Type.CUSTOMER) + .setCustomer(customerId) + .build() + ) + .build(), + StripeApi().requestOptions() + ) + } + + private[this] def registerCustomerTaxId( + customerId: String, + vatNumber: String, + country: String + ): Unit = { + resolveTaxIdType(country).foreach { taxIdType => + val taxIdTypeName = taxIdType.getValue + Try { + // List existing tax IDs for this customer to check for duplicates + val existing = TaxId + .list( + TaxIdListParams + .builder() + .setOwner( + TaxIdListParams.Owner + .builder() + .setType(TaxIdListParams.Owner.Type.CUSTOMER) + .setCustomer(customerId) + .build() + ) + .build(), + StripeApi().requestOptions() + ) + .getData + .asScala + .find(_.getType == taxIdTypeName) + + existing match { + case Some(existingTaxId) if existingTaxId.getValue == vatNumber => + // Same type and value — nothing to do + mlog.info( + s"Tax ID ${existingTaxId.getId} (type=$taxIdTypeName, value=$vatNumber) already registered for customer $customerId" + ) + case Some(existingTaxId) => + // Same type but different value — Stripe tax IDs are immutable, must delete and recreate + existingTaxId.delete(StripeApi().requestOptions()) + mlog.info( + s"Deleted outdated tax ID ${existingTaxId.getId} (type=$taxIdTypeName) for customer $customerId" + ) + val taxId = createTaxId(customerId, vatNumber, taxIdType) + mlog.info( + s"Registered tax ID ${taxId.getId} (type=$taxIdTypeName) for customer $customerId" + ) + case None => + // No existing tax ID for this type — create + val taxId = createTaxId(customerId, vatNumber, taxIdType) + mlog.info( + s"Registered tax ID ${taxId.getId} (type=$taxIdTypeName) for customer $customerId" + ) + } + } match { + case Success(_) => // already logged above + case Failure(f) => + mlog.error( + s"Failed to register tax ID for customer $customerId: ${f.getMessage}", + f + ) + } + } + } + + private[this] val euCountries: Set[String] = Set( + "AT", + "BE", + "BG", + "HR", + "CY", + "CZ", + "DK", + "EE", + "FI", + "FR", + "DE", + "GR", + "HU", + "IE", + "IT", + "LV", + "LT", + "LU", + "MT", + "NL", + "PL", + "PT", + "RO", + "SK", + "SI", + "ES", + "SE" + ) + + private[this] def resolveTaxIdType( + country: String + ): Option[TaxIdCreateParams.Type] = { + country.trim.toUpperCase match { + case c if euCountries.contains(c) => Some(TaxIdCreateParams.Type.EU_VAT) + case "GB" => Some(TaxIdCreateParams.Type.GB_VAT) + case "CH" => Some(TaxIdCreateParams.Type.CH_VAT) + case "NO" => Some(TaxIdCreateParams.Type.NO_VAT) + case "US" => Some(TaxIdCreateParams.Type.US_EIN) + case "AU" => Some(TaxIdCreateParams.Type.AU_ABN) + case "BR" => Some(TaxIdCreateParams.Type.BR_CNPJ) + case "CA" => Some(TaxIdCreateParams.Type.CA_BN) + case "IN" => Some(TaxIdCreateParams.Type.IN_GST) + case "JP" => Some(TaxIdCreateParams.Type.JP_TRN) + case "KR" => Some(TaxIdCreateParams.Type.KR_BRN) + case "MX" => Some(TaxIdCreateParams.Type.MX_RFC) + case "NZ" => Some(TaxIdCreateParams.Type.NZ_GST) + case "SG" => Some(TaxIdCreateParams.Type.SG_GST) + case "ZA" => Some(TaxIdCreateParams.Type.ZA_VAT) + // Note: RU is a sanctioned country — Stripe may reject or flag this depending on account configuration + case "RU" => Some(TaxIdCreateParams.Type.RU_INN) + case unknown => + mlog.warn( + s"Unknown country for tax ID type resolution: $unknown — skipping tax ID registration" + ) + None + } + } + private[this] def createLegalRepresentative( legalUser: LegalUser, birthday: Calendar diff --git a/testkit/src/main/scala/app/softnetwork/payment/data/package.scala b/testkit/src/main/scala/app/softnetwork/payment/data/package.scala index 2c8b92a..865f3a0 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/data/package.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/data/package.scala @@ -17,6 +17,8 @@ package object data { val customerUuid = "customer" + val enterpriseUuid = "enterprise" + val sellerUuid = "seller" val vendorUuid = "vendor" @@ -30,9 +32,16 @@ package object data { /** natural user */ val firstName = "firstName" val lastName = "lastName" + val name = "SoftNetwork" val birthday = "26/12/1972" val email = "demo@softnetwork.fr" val phone = "+33102030405" + val country = "FR" + val address: Address = Address.defaultInstance + .withAddressLine("17 rue Bouilloux Lafont") + .withCity("Paris") + .withPostalCode("75015") + .withCountry(country) val business: Business = Business.defaultInstance .withMerchantCategoryCode("5817") @@ -40,11 +49,7 @@ package object data { .withSupport(BusinessSupport.defaultInstance.withEmail(email).withPhone(phone)) val ownerName = s"$firstName $lastName" - val ownerAddress: Address = Address.defaultInstance - .withAddressLine("17 rue Bouilloux Lafont") - .withCity("Paris") - .withPostalCode("75015") - .withCountry("FR") + val ownerAddress: Address = address val naturalUser: NaturalUser = NaturalUser.defaultInstance @@ -56,8 +61,8 @@ package object data { .withEmail(email) .withPhone(phone) .withBusiness(business) - .withNationality("FR") - .withCountryOfResidence("FR") + .withNationality(country) + .withCountryOfResidence(country) /** bank account */ var sellerBankAccountId: String = _ @@ -92,6 +97,18 @@ package object data { .withPhone(phone) .withEmail(email) + val enterpriseUser: NaturalUser = + NaturalUser.defaultInstance + .withName(name) + .withExternalUuid(enterpriseUuid) + .withAddress(ownerAddress) + .withEmail(email) + .withPhone(phone) + .withNationality(country) + .withCountryOfResidence(country) + .withAdditionalProperties(Map("vatNumber" -> vatNumber)) + .withNaturalUserType(NaturalUser.NaturalUserType.PAYER) + var uboDeclarationId: String = _ var cardId: String = _ diff --git a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRouteTestKit.scala b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRouteTestKit.scala index 66b87da..553a162 100644 --- a/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRouteTestKit.scala +++ b/testkit/src/main/scala/app/softnetwork/payment/scalatest/PaymentRouteTestKit.scala @@ -8,7 +8,7 @@ import app.softnetwork.api.server.config.ServerSettings.RootPath import app.softnetwork.payment.api.{PaymentClient, PaymentGrpcServicesTestKit} import app.softnetwork.payment.config.PaymentSettings import app.softnetwork.payment.config.PaymentSettings.PaymentConfig._ -import app.softnetwork.payment.data.{customerUuid, sellerUuid} +import app.softnetwork.payment.data.{customerUuid, enterpriseUuid, sellerUuid} import app.softnetwork.payment.model.SoftPayAccount.Client.Provider import app.softnetwork.payment.model._ import app.softnetwork.session.model.{SessionData, SessionDataDecorator} @@ -29,6 +29,9 @@ trait PaymentRouteTestKit[SD <: SessionData with SessionDataDecorator[SD]] lazy val customerSession: SD with SessionDataDecorator[SD] = companion.newSession.withId(customerUuid).withProfile("customer").withClientId(clientId) + lazy val enterpriseSession: SD with SessionDataDecorator[SD] = + companion.newSession.withId(enterpriseUuid).withProfile("customer").withClientId(clientId) + var externalUserId: String = "individual" def sellerSession(id: String = sellerUuid): SD with SessionDataDecorator[SD] = 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 b095e42..77d1590 100644 --- a/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala +++ b/testkit/src/test/scala/app/softnetwork/payment/service/StripePaymentServiceSpec.scala @@ -12,11 +12,13 @@ import app.softnetwork.payment.data.{ cardId, debitedAmount, directDebitTransactionId, + enterpriseUser, feesAmount, firstName, iban, lastName, legalUser, + name, naturalUser, orderUuid, ownerAddress, @@ -26,7 +28,8 @@ import app.softnetwork.payment.data.{ recurringPaymentRegistrationId, sellerBankAccountId, ubo, - uboDeclarationId + uboDeclarationId, + vatNumber } import app.softnetwork.payment.handlers.MockPaymentDao import app.softnetwork.payment.message.PaymentMessages.{ @@ -65,8 +68,8 @@ import com.stripe.model.PaymentIntent import com.stripe.param.PaymentIntentConfirmParams import org.scalatest.wordspec.AnyWordSpecLike import org.slf4j.{Logger, LoggerFactory} -import com.stripe.model.SetupIntent -import com.stripe.param.SetupIntentConfirmParams +import com.stripe.model.{Customer, SetupIntent, TaxId} +import com.stripe.param.{SetupIntentConfirmParams, TaxIdListParams} import org.json4s.Formats import org.openqa.selenium.{By, WebDriver, WebElement} import org.openqa.selenium.htmlunit.HtmlUnitDriver @@ -923,13 +926,13 @@ trait StripePaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } "register card for recurring payment" in { - createNewSession(customerSession) + createNewSession(enterpriseSession) withHeaders( Post( s"/$RootPath/${PaymentSettings.PaymentConfig.path}/$cardRoute", PreRegisterPaymentMethod( orderUuid, - naturalUser.copy(business = None), + enterpriseUser, paymentType = Transaction.PaymentType.CARD ) ) @@ -985,6 +988,46 @@ trait StripePaymentServiceSpec[SD <: SessionData with SessionDataDecorator[SD]] } } + "verify enterprise customer payment account" in { + val paymentAccount = loadPaymentAccount() + assert( + paymentAccount.naturalUser.isDefined, + "enterprise payment account should have a natural user" + ) + val user = paymentAccount.naturalUser.get + assert(user.name.contains(name), s"enterprise customer name should be '$name'") + assert( + user.additionalProperties.get("vatNumber").contains(vatNumber), + s"enterprise customer additionalProperties should contain vatNumber '$vatNumber'" + ) + assert(user.userId.isDefined, "enterprise customer should have a Stripe userId") + // verify Stripe customer directly + val customerId = user.userId.get + val customer = Customer.retrieve(customerId, StripeApi().requestOptions()) + assert(customer.getName == name, s"Stripe customer name should be '$name'") + // verify tax ID was registered + val taxIds = TaxId + .list( + TaxIdListParams + .builder() + .setOwner( + TaxIdListParams.Owner + .builder() + .setType(TaxIdListParams.Owner.Type.CUSTOMER) + .setCustomer(customerId) + .build() + ) + .build(), + StripeApi().requestOptions() + ) + .getData + .asScala + assert(taxIds.nonEmpty, "Stripe customer should have at least one tax ID") + val taxId = taxIds.head + assert(taxId.getValue == vatNumber, s"Stripe tax ID value should be '$vatNumber'") + assert(taxId.getType == "eu_vat", "Stripe tax ID type should be 'eu_vat' for FR") + } + "register recurring card payment" in { withHeaders( Post( From 06029a3e65326d9984240d0aeebf160c4f1abbd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 16 Apr 2026 09:24:32 +0200 Subject: [PATCH 2/2] chore: update stripe-java version upgrade notes in build.sbt --- stripe/build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stripe/build.sbt b/stripe/build.sbt index caf7bbf..d8fb80f 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" // TODO upgrade to v31.4.1 + "com.stripe" % "stripe-java" % "26.12.0" // TODO upgrade to v27.1.2, v28.4.0, v29.5.0, v30.2.0 and then v31.4.1 )