diff --git a/Gateway/Request/InstallmentOptionsDataBuilder.php b/Gateway/Request/InstallmentOptionsDataBuilder.php new file mode 100644 index 000000000..82891589b --- /dev/null +++ b/Gateway/Request/InstallmentOptionsDataBuilder.php @@ -0,0 +1,160 @@ +getOrder()->getGrandTotalAmount(); + + $storeId = $this->storeManager->getStore()->getId(); + + if (!$this->configHelper->getAdyenCcConfigData('enable_installments', $storeId)) { + return []; + } + + $raw = $this->configHelper->getAdyenCcConfigData('installments', $storeId); + if (empty($raw)) { + return []; + } + + $installmentsConfig = $this->serializer->unserialize($raw); + + $installmentOptions = $this->formatInstallmentOptions($installmentsConfig, $orderAmount); + + return empty($installmentOptions) + ? [] + : ['body' => ['installmentOptions' => $installmentOptions]]; + } + + private function formatInstallmentOptions(array $config, float $orderAmount): array + { + $brandMap = $this->getBrandMapFromXml(); + $result = []; + + foreach ($config as $brandCode => $rules) { + $pm = $brandMap[$brandCode] ?? null; + if (!$pm || !is_array($rules)) { + continue; + } + + $thresholds = $this->parseThresholds($rules); + if (!$thresholds) { + continue; + } + + $values = $this->collectEligibleValues($thresholds, $orderAmount); + + $values[] = 1; + + $values = array_values(array_unique(array_filter($values, static fn($v) => $v > 0))); + sort($values, SORT_NUMERIC); + + if ($values) { + $result[$pm] = ['values' => $values]; + } + } + + return $result; + } + + /** + * Converts config rules into a sorted map: [minAmount(float) => values(int[])] + * Accepts common shapes: + * - [minAmount => [2,3]] + * - [minAmount => ['values' => [2,3]]] + * - [minAmount => 3] + */ + private function parseThresholds(array $rules): array + { + $thresholds = []; + + foreach ($rules as $minAmount => $installments) { + $values = $this->normalizeInstallmentValues($installments); + if (!$values) { + continue; + } + + $thresholds[(float)$minAmount] = $values; + } + + if (!$thresholds) { + return []; + } + + ksort($thresholds, SORT_NUMERIC); + return $thresholds; + } + + private function collectEligibleValues(array $thresholds, float $orderAmount): array + { + $values = []; + + foreach ($thresholds as $minAmount => $tierValues) { + if ($orderAmount < (float)$minAmount) { + break; + } + // Append values from each eligible tier + $values = array_merge($values, $tierValues); + } + + return $values; + } + + private function normalizeInstallmentValues(mixed $installments): array + { + // If admin config stores something like ['values' => [2,3]] + if (is_array($installments) && isset($installments['values']) && is_array($installments['values'])) { + $installments = $installments['values']; + } + + if (is_numeric($installments)) { + return [(int)$installments]; + } + + if (is_array($installments)) { + $out = []; + foreach ($installments as $v) { + if (is_numeric($v)) { + $out[] = (int)$v; + } + } + return $out; + } + + return []; + } + + private function getBrandMapFromXml(): array + { + $altData = $this->adyenHelper->getCcTypesAltData(); + + $map = []; + foreach ($altData as $altCode => $data) { + if (!empty($data['code'])) { + $map[$data['code']] = $altCode; + } + } + + return $map; + } +} diff --git a/Test/Unit/Gateway/Request/InstallmentOptionsDataBuilderTest.php b/Test/Unit/Gateway/Request/InstallmentOptionsDataBuilderTest.php new file mode 100644 index 000000000..d6924b887 --- /dev/null +++ b/Test/Unit/Gateway/Request/InstallmentOptionsDataBuilderTest.php @@ -0,0 +1,266 @@ +configHelper = $this->createMock(Config::class); + $this->serializer = $this->createMock(SerializerInterface::class); + $this->storeManager = $this->createMock(StoreManagerInterface::class); + $this->adyenHelper = $this->createMock(AdyenDataHelper::class); + + $this->store = $this->createMock(StoreInterface::class); + $this->paymentDataObject = $this->createMock(PaymentDataObject::class); + $this->order = $this->createMock(OrderAdapterInterface::class); + + $this->storeManager->method('getStore')->willReturn($this->store); + $this->store->method('getId')->willReturn(1); + + $this->paymentDataObject->method('getOrder')->willReturn($this->order); + + $this->subject = new InstallmentOptionsDataBuilder( + $this->configHelper, + $this->serializer, + $this->storeManager, + $this->adyenHelper + ); + } + + public function testBuildReturnsEmptyArrayWhenInstallmentsDisabled(): void + { + $this->order->method('getGrandTotalAmount')->willReturn(100.0); + + $this->configHelper + ->expects($this->once()) + ->method('getAdyenCcConfigData') + ->with('enable_installments', 1) + ->willReturn(false); + + $result = $this->subject->build([ + 'payment' => $this->paymentDataObject + ]); + + $this->assertSame([], $result); + } + + public function testBuildReturnsEmptyArrayWhenInstallmentsConfigIsEmpty(): void + { + $this->order->method('getGrandTotalAmount')->willReturn(100.0); + + $this->configHelper + ->method('getAdyenCcConfigData') + ->willReturnMap([ + ['enable_installments', 1, true], + ['installments', 1, ''], // empty raw + ]); + + $result = $this->subject->build([ + 'payment' => $this->paymentDataObject + ]); + + $this->assertSame([], $result); + } + + public function testBuildReturnsInstallmentOptionsForEligibleTiersAndAddsOne(): void + { + $this->order->method('getGrandTotalAmount')->willReturn(65.0); + + $this->configHelper + ->method('getAdyenCcConfigData') + ->willReturnMap([ + ['enable_installments', 1, true], + ['installments', 1, 'serialized-config'], + ]); + + $this->adyenHelper + ->method('getCcTypesAltData') + ->willReturn([ + 'visa' => ['code' => 'visa'], + 'mc' => ['code' => 'mc'], + ]); + + $this->serializer + ->expects($this->once()) + ->method('unserialize') + ->with('serialized-config') + ->willReturn([ + // Eligible tiers for 65: >=20 and >=60, not >=100 + 'visa' => [ + 20 => [2], + 60 => [1], + 100 => [3], + ], + ]); + + $result = $this->subject->build([ + 'payment' => $this->paymentDataObject + ]); + + $this->assertSame( + [ + 'body' => [ + 'installmentOptions' => [ + 'visa' => [ + 'values' => [1, 2], + ], + ], + ], + ], + $result + ); + } + + public function testBuildSkipsUnknownBrandAndNonArrayRules(): void + { + $this->order->method('getGrandTotalAmount')->willReturn(200.0); + + $this->configHelper + ->method('getAdyenCcConfigData') + ->willReturnMap([ + ['enable_installments', 1, true], + ['installments', 1, 'raw'], + ]); + + $this->adyenHelper + ->method('getCcTypesAltData') + ->willReturn([ + 'visa' => ['code' => 'visa'], + ]); + + $this->serializer + ->method('unserialize') + ->willReturn([ + 'unknown_brand' => [20 => [2, 3]], + 'visa' => 'not-an-array', + ]); + + $result = $this->subject->build([ + 'payment' => $this->paymentDataObject + ]); + + $this->assertSame([], $result); + } + + public function testBuildSupportsVariousInstallmentValueShapes(): void + { + $this->order->method('getGrandTotalAmount')->willReturn(250.0); + + $this->configHelper + ->method('getAdyenCcConfigData') + ->willReturnMap([ + ['enable_installments', 1, true], + ['installments', 1, 'raw'], + ]); + + $this->adyenHelper + ->method('getCcTypesAltData') + ->willReturn([ + 'mc' => ['code' => 'mc'], + ]); + + $this->serializer + ->method('unserialize') + ->willReturn([ + 'mc' => [ + // scalar + 20 => 3, + // array with numeric + non-numeric + 60 => [2, 'x', 4], + // nested "values" shape + 100 => ['values' => [6, 8]], + // empty should be ignored + 120 => [], + ], + ]); + + $result = $this->subject->build([ + 'payment' => $this->paymentDataObject + ]); + + $this->assertSame( + [ + 'body' => [ + 'installmentOptions' => [ + 'mc' => [ + // eligible tiers are all (orderAmount=250) + // values merged: [3] + [2,4] + [6,8] + 1, sorted unique + 'values' => [1, 2, 3, 4, 6, 8], + ], + ], + ], + ], + $result + ); + } + + public function testBuildBreaksEarlyWhenOrderAmountBelowFirstThresholdAndReturnsOnlyOne(): void + { + // orderAmount is below the first minAmount (20) + $this->order->method('getGrandTotalAmount')->willReturn(10.0); + + $this->configHelper + ->method('getAdyenCcConfigData') + ->willReturnMap([ + ['enable_installments', 1, true], + ['installments', 1, 'raw'], + ]); + + $this->adyenHelper + ->method('getCcTypesAltData') + ->willReturn([ + 'visa' => ['code' => 'visa'], + ]); + + $this->serializer + ->method('unserialize') + ->willReturn([ + 'visa' => [ + 20 => [2, 3], + 60 => [1], + ], + ]); + + $result = $this->subject->build([ + 'payment' => $this->paymentDataObject + ]); + + $this->assertSame( + [ + 'body' => [ + 'installmentOptions' => [ + 'visa' => [ + 'values' => [1], + ], + ], + ], + ], + $result + ); + } +} diff --git a/etc/di.xml b/etc/di.xml index 2abaab82c..7104cc4ed 100755 --- a/etc/di.xml +++ b/etc/di.xml @@ -1147,6 +1147,7 @@ Adyen\Payment\Gateway\Request\PaymentDataBuilder Adyen\Payment\Gateway\Request\DescriptionDataBuilder Adyen\Payment\Gateway\Request\CheckoutDataBuilder + Adyen\Payment\Gateway\Request\InstallmentOptionsDataBuilder Adyen\Payment\Gateway\Request\Header\HeaderDataBuilder Adyen\Payment\Gateway\Request\CompanyDataBuilder Adyen\Payment\Gateway\Request\LineItemsDataBuilder @@ -1539,6 +1540,11 @@ Magento\Framework\Serialize\Serializer\Serialize + + + Magento\Framework\Serialize\Serializer\Serialize + + Magento\Framework\Serialize\Serializer\Serialize