From 8d639c6474a724b078bf6faaac99ef80c9800555 Mon Sep 17 00:00:00 2001 From: Alexandru Lighezan Date: Fri, 7 Aug 2026 16:52:41 +0100 Subject: [PATCH] Add support for sub_merchant on payment creation --- CHANGELOG.md | 6 + README.md | 37 +++ config/bindings.php | 4 + config/discriminations.php | 6 + src/Constants/UltimateCounterpartyTypes.php | 11 + src/Entities/Payment/PaymentRequest.php | 22 ++ .../BusinessClientUltimateCounterparty.php | 217 ++++++++++++++++++ .../BusinessDivisionUltimateCounterparty.php | 79 +++++++ .../SubMerchants/PaymentSubMerchants.php | 51 ++++ .../UltimateCounterpartyBuilder.php | 34 +++ src/Interfaces/Client/ClientInterface.php | 6 + .../Payment/PaymentRequestInterface.php | 8 + ...essClientUltimateCounterpartyInterface.php | 94 ++++++++ ...sDivisionUltimateCounterpartyInterface.php | 32 +++ .../PaymentSubMerchantsInterface.php | 20 ++ .../UltimateCounterpartyBuilderInterface.php | 18 ++ .../UltimateCounterpartyInterface.php | 16 ++ src/Services/Client/Client.php | 11 + tests/integration/PaymentCreateTest.php | 67 ++++++ 19 files changed, 739 insertions(+) create mode 100644 src/Constants/UltimateCounterpartyTypes.php create mode 100644 src/Entities/Payment/SubMerchants/BusinessClientUltimateCounterparty.php create mode 100644 src/Entities/Payment/SubMerchants/BusinessDivisionUltimateCounterparty.php create mode 100644 src/Entities/Payment/SubMerchants/PaymentSubMerchants.php create mode 100644 src/Entities/Payment/SubMerchants/UltimateCounterpartyBuilder.php create mode 100644 src/Interfaces/Payment/SubMerchants/BusinessClientUltimateCounterpartyInterface.php create mode 100644 src/Interfaces/Payment/SubMerchants/BusinessDivisionUltimateCounterpartyInterface.php create mode 100644 src/Interfaces/Payment/SubMerchants/PaymentSubMerchantsInterface.php create mode 100644 src/Interfaces/Payment/SubMerchants/UltimateCounterpartyBuilderInterface.php create mode 100644 src/Interfaces/Payment/SubMerchants/UltimateCounterpartyInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fdabc2c8..a451e53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Support for `sub_merchants` on payment creation + ## [3.3.0] - 2025-09-17 ### Changed diff --git a/README.md b/README.md index e18a2586..22459545 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,43 @@ $payment->hostedPaymentsPage(); // Get the Hosted Payments Page helper, see belo $payment->toArray(); // Convert to array ``` +If needed, you can provide details about the sub-merchant the payment relates to, +for example an underlying division or client of your overall business: + +```php +$payment = $client->payment() + ->user($user) + ->amountInMinor(1) + ->currency(\TrueLayer\Constants\PaymentCurrencies::GBP) + ->paymentMethod($paymentMethod); + +// A business division of your own business: +$ultimateCounterparty = $client->ultimateCounterparty() + ->businessDivision() + ->id('my-division-id') + ->name('My Division'); + +// Or, an underlying business client: +$ultimateCounterparty = $client->ultimateCounterparty() + ->businessClient() + ->id('my-client-id') + ->tradingName('My Client Trading Name') + ->commercialName('My Client Commercial Name') // optional + ->url('https://my-client.example.com') // optional + ->mcc('1234') // optional, merchant category code + ->registrationNumber('12345678'); // required if no address is provided + +$ultimateCounterparty->address() // required if no registration number is provided + ->addressLine1('1 Example Street') + ->city('London') + ->zip('EC2A 1PX') + ->countryCode('GB'); + +$payment->subMerchants()->ultimateCounterparty($ultimateCounterparty); + +$payment = $payment->create(); +``` + ### 5. Creating a payment from an array diff --git a/config/bindings.php b/config/bindings.php index 9af12f58..95031798 100644 --- a/config/bindings.php +++ b/config/bindings.php @@ -18,6 +18,10 @@ Interfaces\Payment\PaymentRequestInterface::class => Entities\Payment\PaymentRequest::class, Interfaces\Payment\PaymentRiskAssessmentInterface::class => Entities\Payment\PaymentRiskAssessment::class, + Interfaces\Payment\SubMerchants\PaymentSubMerchantsInterface::class => Entities\Payment\SubMerchants\PaymentSubMerchants::class, + Interfaces\Payment\SubMerchants\UltimateCounterpartyBuilderInterface::class => Entities\Payment\SubMerchants\UltimateCounterpartyBuilder::class, + Interfaces\Payment\SubMerchants\BusinessDivisionUltimateCounterpartyInterface::class => Entities\Payment\SubMerchants\BusinessDivisionUltimateCounterparty::class, + Interfaces\Payment\SubMerchants\BusinessClientUltimateCounterpartyInterface::class => Entities\Payment\SubMerchants\BusinessClientUltimateCounterparty::class, Interfaces\Payment\PaymentCreatedInterface::class => Entities\Payment\PaymentCreated::class, Interfaces\Payment\PaymentAuthorizationRequiredInterface::class => Entities\Payment\PaymentRetrieved\PaymentAuthorizationRequired::class, Interfaces\Payment\PaymentAuthorizingInterface::class => Entities\Payment\PaymentRetrieved\PaymentAuthorizing::class, diff --git a/config/discriminations.php b/config/discriminations.php index f5cc23ce..5fa4a8c2 100644 --- a/config/discriminations.php +++ b/config/discriminations.php @@ -12,6 +12,7 @@ use TrueLayer\Constants\RefundStatus; use TrueLayer\Constants\RemitterVerificationTypes; use TrueLayer\Constants\SchemeSelectionTypes; +use TrueLayer\Constants\UltimateCounterpartyTypes; use TrueLayer\Constants\WebhookEventTypes; use TrueLayer\Interfaces; @@ -44,6 +45,11 @@ AccountIdentifierTypes::BBAN => Interfaces\AccountIdentifier\BbanInterface::class, AccountIdentifierTypes::NRB => Interfaces\AccountIdentifier\NrbInterface::class, ], + Interfaces\Payment\SubMerchants\UltimateCounterpartyInterface::class => [ + 'discriminate_on' => 'type', + UltimateCounterpartyTypes::BUSINESS_DIVISION => Interfaces\Payment\SubMerchants\BusinessDivisionUltimateCounterpartyInterface::class, + UltimateCounterpartyTypes::BUSINESS_CLIENT => Interfaces\Payment\SubMerchants\BusinessClientUltimateCounterpartyInterface::class, + ], Interfaces\Payment\Beneficiary\BeneficiaryInterface::class => [ 'discriminate_on' => 'type', BeneficiaryTypes::EXTERNAL_ACCOUNT => Interfaces\Payment\Beneficiary\ExternalAccountBeneficiaryInterface::class, diff --git a/src/Constants/UltimateCounterpartyTypes.php b/src/Constants/UltimateCounterpartyTypes.php new file mode 100644 index 00000000..29fc342f --- /dev/null +++ b/src/Constants/UltimateCounterpartyTypes.php @@ -0,0 +1,11 @@ + PaymentMethodInterface::class, 'risk_assessment' => PaymentRiskAssessmentInterface::class, 'user' => UserInterface::class, + 'sub_merchants' => PaymentSubMerchantsInterface::class, ]; /** @@ -76,6 +83,7 @@ final class PaymentRequest extends Entity implements PaymentRequestInterface, Ha 'payment_method', 'risk_assessment', 'user', + 'sub_merchants', ]; /** @@ -154,6 +162,20 @@ public function user(UserInterface $user): PaymentRequestInterface return $this; } + /** + * @param PaymentSubMerchantsInterface|null $subMerchants + * + * @throws InvalidArgumentException + * + * @return PaymentSubMerchantsInterface + */ + public function subMerchants(?PaymentSubMerchantsInterface $subMerchants = null): PaymentSubMerchantsInterface + { + $this->subMerchants = $subMerchants ?: $this->entityFactory->make(PaymentSubMerchantsInterface::class); + + return $this->subMerchants; + } + /** * @param RequestOptionsInterface $requestOptions * diff --git a/src/Entities/Payment/SubMerchants/BusinessClientUltimateCounterparty.php b/src/Entities/Payment/SubMerchants/BusinessClientUltimateCounterparty.php new file mode 100644 index 00000000..8d852ac6 --- /dev/null +++ b/src/Entities/Payment/SubMerchants/BusinessClientUltimateCounterparty.php @@ -0,0 +1,217 @@ + AddressInterface::class, + ]; + + /** + * @var string[] + */ + protected array $arrayFields = [ + 'id', + 'trading_name', + 'commercial_name', + 'url', + 'mcc', + 'registration_number', + 'address', + 'type', + ]; + + /** + * @return string + */ + public function getId(): string + { + return $this->id; + } + + /** + * @param string $id + * + * @return $this + */ + public function id(string $id): self + { + $this->id = $id; + + return $this; + } + + /** + * @return string + */ + public function getTradingName(): string + { + return $this->tradingName; + } + + /** + * @param string $tradingName + * + * @return $this + */ + public function tradingName(string $tradingName): self + { + $this->tradingName = $tradingName; + + return $this; + } + + /** + * @return string|null + */ + public function getCommercialName(): ?string + { + return $this->commercialName ?? null; + } + + /** + * @param string $commercialName + * + * @return $this + */ + public function commercialName(string $commercialName): self + { + $this->commercialName = $commercialName; + + return $this; + } + + /** + * @return string|null + */ + public function getUrl(): ?string + { + return $this->url ?? null; + } + + /** + * @param string $url + * + * @return $this + */ + public function url(string $url): self + { + $this->url = $url; + + return $this; + } + + /** + * @return string|null + */ + public function getMcc(): ?string + { + return $this->mcc ?? null; + } + + /** + * @param string $mcc + * + * @return $this + */ + public function mcc(string $mcc): self + { + $this->mcc = $mcc; + + return $this; + } + + /** + * @return string|null + */ + public function getRegistrationNumber(): ?string + { + return $this->registrationNumber ?? null; + } + + /** + * @param string $registrationNumber + * + * @return $this + */ + public function registrationNumber(string $registrationNumber): self + { + $this->registrationNumber = $registrationNumber; + + return $this; + } + + /** + * @return AddressInterface|null + */ + public function getAddress(): ?AddressInterface + { + return $this->address ?? null; + } + + /** + * @param AddressInterface|null $address + * + * @return AddressInterface + */ + public function address(?AddressInterface $address = null): AddressInterface + { + $this->address = $address ?: $this->entityFactory->make(AddressInterface::class); + + return $this->address; + } + + /** + * @return string + */ + public function getType(): string + { + return UltimateCounterpartyTypes::BUSINESS_CLIENT; + } +} diff --git a/src/Entities/Payment/SubMerchants/BusinessDivisionUltimateCounterparty.php b/src/Entities/Payment/SubMerchants/BusinessDivisionUltimateCounterparty.php new file mode 100644 index 00000000..e6cc1d6b --- /dev/null +++ b/src/Entities/Payment/SubMerchants/BusinessDivisionUltimateCounterparty.php @@ -0,0 +1,79 @@ +id; + } + + /** + * @param string $id + * + * @return $this + */ + public function id(string $id): self + { + $this->id = $id; + + return $this; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + * + * @return $this + */ + public function name(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * @return string + */ + public function getType(): string + { + return UltimateCounterpartyTypes::BUSINESS_DIVISION; + } +} diff --git a/src/Entities/Payment/SubMerchants/PaymentSubMerchants.php b/src/Entities/Payment/SubMerchants/PaymentSubMerchants.php new file mode 100644 index 00000000..f2cacbe8 --- /dev/null +++ b/src/Entities/Payment/SubMerchants/PaymentSubMerchants.php @@ -0,0 +1,51 @@ + UltimateCounterpartyInterface::class, + ]; + + /** + * @var string[] + */ + protected array $arrayFields = [ + 'ultimate_counterparty', + ]; + + /** + * @return UltimateCounterpartyInterface|null + */ + public function getUltimateCounterparty(): ?UltimateCounterpartyInterface + { + return $this->ultimateCounterparty ?? null; + } + + /** + * @param UltimateCounterpartyInterface $ultimateCounterparty + * + * @return PaymentSubMerchantsInterface + */ + public function ultimateCounterparty(UltimateCounterpartyInterface $ultimateCounterparty): PaymentSubMerchantsInterface + { + $this->ultimateCounterparty = $ultimateCounterparty; + + return $this; + } +} diff --git a/src/Entities/Payment/SubMerchants/UltimateCounterpartyBuilder.php b/src/Entities/Payment/SubMerchants/UltimateCounterpartyBuilder.php new file mode 100644 index 00000000..05c65778 --- /dev/null +++ b/src/Entities/Payment/SubMerchants/UltimateCounterpartyBuilder.php @@ -0,0 +1,34 @@ +entityFactory->make(BusinessDivisionUltimateCounterpartyInterface::class); + } + + /** + * @throws InvalidArgumentException + * + * @return BusinessClientUltimateCounterpartyInterface + */ + public function businessClient(): BusinessClientUltimateCounterpartyInterface + { + return $this->entityFactory->make(BusinessClientUltimateCounterpartyInterface::class); + } +} diff --git a/src/Interfaces/Client/ClientInterface.php b/src/Interfaces/Client/ClientInterface.php index 09df074f..ac9e85dd 100644 --- a/src/Interfaces/Client/ClientInterface.php +++ b/src/Interfaces/Client/ClientInterface.php @@ -23,6 +23,7 @@ use TrueLayer\Interfaces\Payment\RefundRetrievedInterface; use TrueLayer\Interfaces\Payment\Scheme\SchemeSelectionBuilderInterface; use TrueLayer\Interfaces\Payment\StartAuthorizationFlowRequestInterface; +use TrueLayer\Interfaces\Payment\SubMerchants\UltimateCounterpartyBuilderInterface; use TrueLayer\Interfaces\PaymentMethod\PaymentMethodBuilderInterface; use TrueLayer\Interfaces\Payout; use TrueLayer\Interfaces\Provider\ProviderFilterInterface; @@ -82,6 +83,11 @@ public function remitter(): RemitterInterface; */ public function remitterVerification(): RemitterVerificationBuilderInterface; + /** + * @return UltimateCounterpartyBuilderInterface + */ + public function ultimateCounterparty(): UltimateCounterpartyBuilderInterface; + /** * @return ProviderFilterInterface */ diff --git a/src/Interfaces/Payment/PaymentRequestInterface.php b/src/Interfaces/Payment/PaymentRequestInterface.php index 5fe6f24d..0564e973 100644 --- a/src/Interfaces/Payment/PaymentRequestInterface.php +++ b/src/Interfaces/Payment/PaymentRequestInterface.php @@ -5,6 +5,7 @@ namespace TrueLayer\Interfaces\Payment; use TrueLayer\Interfaces\HasAttributesInterface; +use TrueLayer\Interfaces\Payment\SubMerchants\PaymentSubMerchantsInterface; use TrueLayer\Interfaces\PaymentMethod\PaymentMethodInterface; use TrueLayer\Interfaces\RequestOptionsInterface; use TrueLayer\Interfaces\UserInterface; @@ -53,6 +54,13 @@ public function riskAssessment(?PaymentRiskAssessmentInterface $riskAssessment): */ public function user(UserInterface $user): PaymentRequestInterface; + /** + * @param PaymentSubMerchantsInterface|null $subMerchants + * + * @return PaymentSubMerchantsInterface + */ + public function subMerchants(?PaymentSubMerchantsInterface $subMerchants = null): PaymentSubMerchantsInterface; + /** * @param RequestOptionsInterface $requestOptions * diff --git a/src/Interfaces/Payment/SubMerchants/BusinessClientUltimateCounterpartyInterface.php b/src/Interfaces/Payment/SubMerchants/BusinessClientUltimateCounterpartyInterface.php new file mode 100644 index 00000000..4bddb0f7 --- /dev/null +++ b/src/Interfaces/Payment/SubMerchants/BusinessClientUltimateCounterpartyInterface.php @@ -0,0 +1,94 @@ +entityFactory->make(RemitterVerificationBuilderInterface::class); } + /** + * @throws InvalidArgumentException + * + * @return UltimateCounterpartyBuilderInterface + */ + public function ultimateCounterparty(): UltimateCounterpartyBuilderInterface + { + return $this->entityFactory->make(UltimateCounterpartyBuilderInterface::class); + } + /** * @throws InvalidArgumentException * @throws InvalidArgumentException diff --git a/tests/integration/PaymentCreateTest.php b/tests/integration/PaymentCreateTest.php index 8f0098fd..e40c0882 100644 --- a/tests/integration/PaymentCreateTest.php +++ b/tests/integration/PaymentCreateTest.php @@ -183,6 +183,73 @@ ]); }); +\it('sends the sub merchants business division ultimate counterparty on payment creation', function () { + $factory = CreatePayment::responses([PaymentResponse::created()]); + + $payment = $factory->payment($factory->newUser(), $factory->bankTransferMethod($factory->sortCodeBeneficiary())); + $ultimateCounterparty = $factory->getClient()->ultimateCounterparty() + ->businessDivision() + ->id('mock-division-id') + ->name('Mock Division'); + $payment->subMerchants()->ultimateCounterparty($ultimateCounterparty); + + $payment->create(); + + \expect(\getRequestPayload(1))->toMatchArray([ + 'sub_merchants' => [ + 'ultimate_counterparty' => [ + 'id' => 'mock-division-id', + 'name' => 'Mock Division', + 'type' => 'business_division', + ], + ], + ]); +}); + +\it('sends the sub merchants business client ultimate counterparty on payment creation', function () { + $factory = CreatePayment::responses([PaymentResponse::created()]); + + $payment = $factory->payment($factory->newUser(), $factory->bankTransferMethod($factory->sortCodeBeneficiary())); + $businessClient = $factory->getClient()->ultimateCounterparty() + ->businessClient() + ->id('mock-client-id') + ->tradingName('Mock Trading Name') + ->commercialName('Mock Commercial Name') + ->url('https://mock-client.example.com') + ->mcc('1234') + ->registrationNumber('mock-registration-number'); + $businessClient->address() + ->addressLine1('1 Mock Street') + ->city('London') + ->zip('EC2A 1PX') + ->countryCode('GB'); + $payment->subMerchants()->ultimateCounterparty($businessClient); + + $payment->create(); + + \expect(\getRequestPayload(1))->toMatchArray([ + 'sub_merchants' => [ + 'ultimate_counterparty' => [ + 'id' => 'mock-client-id', + 'trading_name' => 'Mock Trading Name', + 'commercial_name' => 'Mock Commercial Name', + 'url' => 'https://mock-client.example.com', + 'mcc' => '1234', + 'registration_number' => 'mock-registration-number', + 'address' => [ + 'address_line1' => '1 Mock Street', + 'address_line2' => null, + 'city' => 'London', + 'state' => null, + 'zip' => 'EC2A 1PX', + 'country_code' => 'GB', + ], + 'type' => 'business_client', + ], + ], + ]); +}); + \it('ensures preselected provider data access token is optional', function () { $factory = CreatePayment::responses([PaymentResponse::created()]);