diff --git a/CHANGELOG.md b/CHANGELOG.md index 24449b1..1a80ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Support for `sub_merchants` on payment creation - Support for `hosted_page` on payment creation (`return_uri`, `country_code`, `language_code`, `max_wait_for_result`), exposing `getHostedPageUri()` on the created payment - Support for `settlement_stalled_at`, `reversed_at` and `reversal_reason` on a failed payment, exposing `getSettlementStalledAt()`, `getReversedAt()` and `getReversalReason()` on `PaymentFailedInterface` - Support for `failed_attempts` on a retrieved payment, exposing `getFailedAttempts()` on all payment statuses @@ -17,7 +18,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed -- Bumped `truelayer/signing` dependency to widen support for the underlying `web-token/jwt-library` ([#89](https://github.com/TrueLayer/truelayer-php/pull/89)) +- Bumped `truelayer/signing` dependency to widen support for the underlying `web-token/jwt-library` ([#89](https://github.com/TrueLayer/truelayer-php/pull/89)) ## [3.2.0] - 2025-05-13 diff --git a/README.md b/README.md index 34bd645..343482d 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ $paymentData = $client->getPayment($paymentId)->toArray(); ### 1. Creating a beneficiary -*Merchant account beneficiary* +_Merchant account beneficiary_ ```php // If the merchant account id is known: @@ -192,14 +192,14 @@ $beneficiary = $client->beneficiary() ->verification($remitterVerification); ``` -For your *merchant account beneficiary* you can pass a statement reference that should be set on the end user's +For your _merchant account beneficiary_ you can pass a statement reference that should be set on the end user's statement. Not all banks support setting such a reference, this value will be used wherever possible. ```php $beneficiary->statementReference('Statement reference.'); ``` -*External account beneficiary - Sort code & account number* +_External account beneficiary - Sort code & account number_ ```php $beneficiary = $client->beneficiary()->externalAccount() @@ -212,7 +212,7 @@ $beneficiary = $client->beneficiary()->externalAccount() ); ``` -*External account beneficiary - IBAN* +_External account beneficiary - IBAN_ ```php $beneficiary = $client->beneficiary()->externalAccount() @@ -352,6 +352,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(); +``` + You can optionally request a hosted page URI directly as part of payment creation, instead of building the Hosted Payments Page URL yourself afterwards: diff --git a/config/bindings.php b/config/bindings.php index a365e8d..dfd6309 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\PaymentHostedPageInterface::class => Entities\Payment\PaymentHostedPage::class, Interfaces\Payment\PaymentCreatedInterface::class => Entities\Payment\PaymentCreated::class, Interfaces\Payment\PaymentAuthorizationRequiredInterface::class => Entities\Payment\PaymentRetrieved\PaymentAuthorizationRequired::class, diff --git a/config/discriminations.php b/config/discriminations.php index f5cc23c..5fa4a8c 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 0000000..29fc342 --- /dev/null +++ b/src/Constants/UltimateCounterpartyTypes.php @@ -0,0 +1,11 @@ + PaymentMethodInterface::class, 'risk_assessment' => PaymentRiskAssessmentInterface::class, 'user' => UserInterface::class, + 'sub_merchants' => PaymentSubMerchantsInterface::class, 'hosted_page' => PaymentHostedPageInterface::class, ]; @@ -83,6 +90,7 @@ final class PaymentRequest extends Entity implements PaymentRequestInterface, Ha 'payment_method', 'risk_assessment', 'user', + 'sub_merchants', 'hosted_page', ]; @@ -162,6 +170,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 PaymentHostedPageInterface|null $hostedPage * diff --git a/src/Entities/Payment/SubMerchants/BusinessClientUltimateCounterparty.php b/src/Entities/Payment/SubMerchants/BusinessClientUltimateCounterparty.php new file mode 100644 index 0000000..8d852ac --- /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 0000000..e6cc1d6 --- /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 0000000..f2cacbe --- /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 0000000..05c6577 --- /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 09df074..ac9e85d 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 db28bf7..0bf44ad 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 PaymentHostedPageInterface|null $hostedPage * diff --git a/src/Interfaces/Payment/SubMerchants/BusinessClientUltimateCounterpartyInterface.php b/src/Interfaces/Payment/SubMerchants/BusinessClientUltimateCounterpartyInterface.php new file mode 100644 index 0000000..4bddb0f --- /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 cd00597..3d6b973 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('sends the hosted page on payment creation', function () { $factory = CreatePayment::responses([PaymentResponse::created()]);