Skip to content

Commit 2cc8697

Browse files
Merge pull request #1632 from buckaroo-it/BTI-980-Magento-2-Klarna-orders-that-were-partially-paid-with-an-M2-gift-card-are-not-being-captured
Bti 980 magento 2 klarna orders that were partially paid with an m2 gift card are not being captured
2 parents 768f242 + 9fba56c commit 2cc8697

3 files changed

Lines changed: 305 additions & 0 deletions

File tree

Gateway/Request/Articles/ArticlesHandler/AbstractArticlesHandler.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,11 @@ public function getInvoiceArticlesData(Order $order, InfoInterface $payment): ar
695695
$articles = array_merge_recursive($articles, $shippingCosts);
696696
}
697697

698+
$additionalLines = $this->getAdditionalLines();
699+
if (!empty($additionalLines)) {
700+
$articles = array_merge_recursive($articles, $additionalLines);
701+
}
702+
698703
$articles = $this->reconcileArticlesWithGrandTotal($articles, (float)$currentInvoice->getGrandTotal(), (float)$currentInvoice->getTaxAmount());
699704

700705
return $articles;
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
<?php
2+
/**
3+
* NOTICE OF LICENSE
4+
*
5+
* This source file is subject to the MIT License
6+
* It is available through the world-wide-web at this URL:
7+
* https://tldrlegal.com/license/mit-license
8+
* If you are unable to obtain it through the world-wide-web, please email
9+
* to support@buckaroo.nl, so we can send you a copy immediately.
10+
*
11+
* DISCLAIMER
12+
*
13+
* Do not edit or add to this file if you wish to upgrade this module to newer
14+
* versions in the future. If you wish to customize this module for your
15+
* needs please contact support@buckaroo.nl for more information.
16+
*
17+
* @copyright Copyright (c) Buckaroo B.V.
18+
* @license https://tldrlegal.com/license/mit-license
19+
*/
20+
declare(strict_types=1);
21+
22+
namespace Buckaroo\Magento2\Test\Unit\Gateway\Request\Articles\ArticlesHandler;
23+
24+
use Buckaroo\Magento2\Gateway\Request\Articles\ArticlesHandler\KlarnaKpHandler;
25+
use Buckaroo\Magento2\Logging\BuckarooLoggerInterface;
26+
use Buckaroo\Magento2\Model\ConfigProvider\BuckarooFee;
27+
use Buckaroo\Magento2\Model\ConfigProvider\Factory as ConfigProviderMethodFactory;
28+
use Buckaroo\Magento2\Service\PayReminderService;
29+
use Buckaroo\Magento2\Service\Software\Data as SoftwareData;
30+
use Magento\Framework\App\Config\ScopeConfigInterface;
31+
use Magento\Framework\App\ProductMetadataInterface;
32+
use Magento\Payment\Model\InfoInterface;
33+
use Magento\Quote\Model\Quote;
34+
use Magento\Quote\Model\QuoteFactory;
35+
use Magento\Sales\Model\Order;
36+
use Magento\Sales\Model\Order\Invoice;
37+
use Magento\Sales\Model\ResourceModel\Order\Invoice\Collection as InvoiceCollection;
38+
use Magento\Tax\Model\Calculation;
39+
use Magento\Tax\Model\Config as TaxConfig;
40+
use PHPUnit\Framework\MockObject\MockObject;
41+
use PHPUnit\Framework\TestCase;
42+
43+
/**
44+
* Regression tests for the Klarna capture / gift-card bug.
45+
*
46+
* Root cause: getInvoiceArticlesData (capture path) did not call getAdditionalLines(),
47+
* so the gift card discount line (identifier = 6) sent during reserve was absent from
48+
* the capture request. reconcileArticlesWithGrandTotal then fired and inserted an
49+
* 'extra-fees' line that Klarna had never seen, causing a capture rejection:
50+
* "The following article numbers are unknown or not pending: extra-fees."
51+
*
52+
* Fix: getInvoiceArticlesData now calls getAdditionalLines() before reconciliation,
53+
* mirroring the reserve path in getOrderArticlesData.
54+
*/
55+
class KlarnaKpHandlerTest extends TestCase
56+
{
57+
private ScopeConfigInterface|MockObject $scopeConfig;
58+
private BuckarooLoggerInterface|MockObject $logger;
59+
private QuoteFactory|MockObject $quoteFactory;
60+
private Calculation|MockObject $taxCalculation;
61+
private TaxConfig|MockObject $taxConfig;
62+
private BuckarooFee|MockObject $buckarooFee;
63+
private SoftwareData|MockObject $softwareData;
64+
private ConfigProviderMethodFactory|MockObject $configProviderFactory;
65+
private PayReminderService|MockObject $payReminderService;
66+
67+
/** Staged invoice items set by buildHandler, consumed by makeOrder(). */
68+
private array $stagedItems = [];
69+
private float $stagedGrandTotal = 0.0;
70+
71+
protected function setUp(): void
72+
{
73+
$this->scopeConfig = $this->createMock(ScopeConfigInterface::class);
74+
$this->logger = $this->createMock(BuckarooLoggerInterface::class);
75+
$this->quoteFactory = $this->createMock(QuoteFactory::class);
76+
$this->taxCalculation = $this->createMock(Calculation::class);
77+
$this->taxConfig = $this->createMock(TaxConfig::class);
78+
$this->buckarooFee = $this->createMock(BuckarooFee::class);
79+
$this->softwareData = $this->createMock(SoftwareData::class);
80+
$this->configProviderFactory = $this->createMock(ConfigProviderMethodFactory::class);
81+
$this->payReminderService = $this->createMock(PayReminderService::class);
82+
}
83+
84+
// -------------------------------------------------------------------------
85+
// Tests
86+
// -------------------------------------------------------------------------
87+
88+
/**
89+
* When a gift card partially pays the order the capture request must include
90+
* the gift card discount line (identifier = 6) and must NOT include 'extra-fees'.
91+
*
92+
* Before the fix 'extra-fees' appeared at the same position as the gift card
93+
* line during reserve, causing Klarna to reject the capture.
94+
*/
95+
public function testCaptureWithGiftCardIncludesGiftCardLineAndNoExtraFees(): void
96+
{
97+
// Products €20 + €14 = €34, gift card -€6, invoice total €28.
98+
$handler = $this->buildHandler(
99+
items: [
100+
$this->makeItem('PROD1', 'Product 1', 20.00),
101+
$this->makeItem('PROD2', 'Product 2', 14.00),
102+
],
103+
invoiceGrandTotal: 28.00,
104+
giftCardAmount: 6.00
105+
);
106+
107+
$result = $handler->getInvoiceArticlesData(
108+
$this->makeOrder(),
109+
$this->createMock(InfoInterface::class)
110+
);
111+
112+
$identifiers = array_column($result['articles'], 'identifier');
113+
114+
$this->assertContains(
115+
6,
116+
$identifiers,
117+
'Gift card line (identifier=6) must be present in the capture request'
118+
);
119+
$this->assertNotContains(
120+
'extra-fees',
121+
$identifiers,
122+
'"extra-fees" must not appear — Klarna never saw it during the reserve'
123+
);
124+
}
125+
126+
/**
127+
* Without a gift card the article sum equals the invoice grand total,
128+
* so reconciliation must not fire and 'extra-fees' must not appear.
129+
*/
130+
public function testCaptureWithoutGiftCardHasNoExtraFeesAndNoGiftCardLine(): void
131+
{
132+
$handler = $this->buildHandler(
133+
items: [
134+
$this->makeItem('PROD1', 'Product 1', 20.00),
135+
$this->makeItem('PROD2', 'Product 2', 14.00),
136+
],
137+
invoiceGrandTotal: 34.00,
138+
giftCardAmount: 0.0
139+
);
140+
141+
$result = $handler->getInvoiceArticlesData(
142+
$this->makeOrder(),
143+
$this->createMock(InfoInterface::class)
144+
);
145+
146+
$identifiers = array_column($result['articles'], 'identifier');
147+
148+
$this->assertNotContains('extra-fees', $identifiers);
149+
$this->assertNotContains(6, $identifiers, 'No gift card line expected when no gift card was applied');
150+
}
151+
152+
/**
153+
* The gift card article line must carry the correct negative price so that
154+
* the article sum precisely matches the invoice grand total.
155+
*/
156+
public function testGiftCardLineHasCorrectNegativePrice(): void
157+
{
158+
$handler = $this->buildHandler(
159+
items: [$this->makeItem('PROD1', 'Product 1', 50.00)],
160+
invoiceGrandTotal: 33.99,
161+
giftCardAmount: 16.01
162+
);
163+
164+
$result = $handler->getInvoiceArticlesData(
165+
$this->makeOrder(),
166+
$this->createMock(InfoInterface::class)
167+
);
168+
169+
$giftCardLine = null;
170+
foreach ($result['articles'] as $article) {
171+
if (($article['identifier'] ?? null) === 6) {
172+
$giftCardLine = $article;
173+
break;
174+
}
175+
}
176+
177+
$this->assertNotNull($giftCardLine, 'Gift card article line must be present');
178+
$this->assertEquals(-16.01, $giftCardLine['price'], 'Gift card price must be negative and match the gift card amount');
179+
}
180+
181+
// -------------------------------------------------------------------------
182+
// Helpers
183+
// -------------------------------------------------------------------------
184+
185+
private function buildHandler(array $items, float $invoiceGrandTotal, float $giftCardAmount): KlarnaKpHandler
186+
{
187+
// Stage invoice data so makeOrder() can use it.
188+
$this->stagedItems = $items;
189+
$this->stagedGrandTotal = $invoiceGrandTotal;
190+
191+
// Quote mock — getGiftCardsAmount / getRewardCurrencyAmount are Adobe Commerce methods
192+
// absent on CE, so they must be added via addMethods().
193+
$quote = $this->getMockBuilder(Quote::class)
194+
->disableOriginalConstructor()
195+
->addMethods(['getGiftCardsAmount', 'getRewardCurrencyAmount'])
196+
->getMock();
197+
$quote->method('getGiftCardsAmount')->willReturn($giftCardAmount);
198+
$quote->method('getRewardCurrencyAmount')->willReturn(0.0);
199+
200+
$quoteProxy = $this->getMockBuilder(Quote::class)
201+
->disableOriginalConstructor()
202+
->onlyMethods(['load'])
203+
->addMethods(['getGiftCardsAmount', 'getRewardCurrencyAmount'])
204+
->getMock();
205+
$quoteProxy->method('load')->willReturn($quote);
206+
$quoteProxy->method('getGiftCardsAmount')->willReturn($giftCardAmount);
207+
$quoteProxy->method('getRewardCurrencyAmount')->willReturn(0.0);
208+
209+
$this->quoteFactory->method('create')->willReturn($quoteProxy);
210+
211+
// Price does not include tax → calculateProductPrice reads getPriceInclTax().
212+
$this->scopeConfig->method('getValue')->willReturn(false);
213+
214+
// Tax / shipping (shipping = 0 in these tests so shipping line is skipped).
215+
$rateRequest = $this->getMockBuilder(\stdClass::class)
216+
->addMethods(['setProductClassId'])
217+
->getMock();
218+
$rateRequest->method('setProductClassId')->willReturnSelf();
219+
$this->taxCalculation->method('getRateRequest')->willReturn($rateRequest);
220+
$this->taxCalculation->method('getRate')->willReturn(0.0);
221+
$this->taxConfig->method('getShippingTaxClass')->willReturn(0);
222+
223+
// Edition = Community (no Enterprise store-credit path).
224+
$meta = $this->createMock(ProductMetadataInterface::class);
225+
$meta->method('getEdition')->willReturn('Community');
226+
$this->softwareData->method('getProductMetaData')->willReturn($meta);
227+
228+
return new KlarnaKpHandler(
229+
$this->scopeConfig,
230+
$this->logger,
231+
$this->quoteFactory,
232+
$this->taxCalculation,
233+
$this->taxConfig,
234+
$this->buckarooFee,
235+
$this->softwareData,
236+
$this->configProviderFactory,
237+
$this->payReminderService
238+
);
239+
}
240+
241+
private function makeOrder(): Order
242+
{
243+
// getBuckarooFeeInclTax / getBuckarooFee are Buckaroo extension attributes absent
244+
// from the base Invoice class, so they must be added via addMethods().
245+
$invoice = $this->getMockBuilder(Invoice::class)
246+
->disableOriginalConstructor()
247+
->onlyMethods(['getAllItems', 'getGrandTotal', 'getTaxAmount', 'getShippingInclTax'])
248+
->addMethods(['getBuckarooFeeInclTax', 'getBuckarooFee'])
249+
->getMock();
250+
$invoice->method('getAllItems')->willReturn($this->stagedItems);
251+
$invoice->method('getGrandTotal')->willReturn($this->stagedGrandTotal);
252+
$invoice->method('getTaxAmount')->willReturn(0.0);
253+
$invoice->method('getShippingInclTax')->willReturn(0.0); // no shipping keeps mocking minimal
254+
$invoice->method('getBuckarooFeeInclTax')->willReturn(0.0);
255+
$invoice->method('getBuckarooFee')->willReturn(0.0);
256+
257+
$collection = $this->createMock(InvoiceCollection::class);
258+
$collection->method('count')->willReturn(1);
259+
$collection->method('getLastItem')->willReturn($invoice);
260+
261+
$order = $this->getMockBuilder(Order::class)
262+
->disableOriginalConstructor()
263+
->onlyMethods(['getInvoiceCollection', 'getQuoteId'])
264+
->getMock();
265+
$order->method('getInvoiceCollection')->willReturn($collection);
266+
$order->method('getQuoteId')->willReturn(1);
267+
268+
return $order;
269+
}
270+
271+
private function makeItem(string $sku, string $name, float $price): Invoice\Item
272+
{
273+
$orderItem = $this->createMock(Order\Item::class);
274+
$orderItem->method('getTaxPercent')->willReturn(0.0);
275+
276+
// Use createMock so PHPUnit handles real-vs-magic method split automatically.
277+
$item = $this->getMockBuilder(Invoice\Item::class)
278+
->disableOriginalConstructor()
279+
->onlyMethods(['getRowTotalInclTax', 'getOrderItem', 'getName', 'getSku', 'getQty', 'getDiscountAmount', 'getPriceInclTax'])
280+
->addMethods(['hasParentItemId', 'getWeeeTaxAppliedAmount'])
281+
->getMock();
282+
283+
$item->method('getRowTotalInclTax')->willReturn($price);
284+
$item->method('hasParentItemId')->willReturn(false);
285+
$item->method('getOrderItem')->willReturn($orderItem);
286+
$item->method('getName')->willReturn($name);
287+
$item->method('getSku')->willReturn($sku);
288+
$item->method('getQty')->willReturn(1.0);
289+
$item->method('getDiscountAmount')->willReturn(0.0);
290+
$item->method('getPriceInclTax')->willReturn($price);
291+
$item->method('getWeeeTaxAppliedAmount')->willReturn(0.0);
292+
293+
return $item;
294+
}
295+
}
Lines changed: 5 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)