Skip to content

Commit f8a09e6

Browse files
authored
Merge pull request #473 from buckaroo-it/fix/BTI-971-riverty-partial-refund-rest-api
BTI-971: match refund by amount for partial REST API line items
2 parents 3fe7979 + a540c6b commit f8a09e6

3 files changed

Lines changed: 297 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Loading

src/Gateways/AbstractPaymentGateway.php

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Buckaroo\Woocommerce\Services\SessionHandler;
1919
use Exception;
2020
use WC_Order;
21+
use WC_Order_Refund;
2122
use WC_Payment_Gateway;
2223
use WC_Tax;
2324
use WP_Error;
@@ -788,7 +789,7 @@ public function newRefundProcessorInstance($order, $amount, $reason)
788789

789790
$processorClass = static::REFUND_CLASS ?: AbstractRefundProcessor::class;
790791

791-
$line_items = $this->getRefundLineItemsFromRequest();
792+
$line_items = $this->getRefundedLineItemsFromOrder($order, $amount);
792793

793794
return new $processorClass(
794795
$this,
@@ -799,34 +800,55 @@ public function newRefundProcessorInstance($order, $amount, $reason)
799800
);
800801
}
801802

802-
private function getRefundLineItemsFromRequest(): array
803+
// Match the refund just created by wc_create_refund (WC admin UI + REST API both
804+
// create the refund before invoking process_refund). Selecting by amount avoids
805+
// picking up unrelated prior refunds in flows where process_refund runs first
806+
// (e.g. bl_refund_capture). HPOS does not guarantee get_refunds() ordering, so the
807+
// newest matching refund is identified by max ID rather than reset().
808+
protected function getRefundedLineItemsFromOrder(WC_Order $order, $amount): array
803809
{
804-
if (!isset($_POST['line_item_qtys']) || !is_string($_POST['line_item_qtys'])) {
810+
$target = (float) $amount;
811+
if ($target <= 0) {
805812
return [];
806813
}
807814

808-
$line_item_qtys = json_decode(stripslashes($_POST['line_item_qtys']), true);
809-
$line_item_totals = [];
815+
$match = null;
816+
foreach ($order->get_refunds() as $refund) {
817+
if (!$refund instanceof WC_Order_Refund) {
818+
continue;
819+
}
820+
821+
$refund_amount = abs((float) $refund->get_amount());
822+
if (abs($refund_amount - $target) > 0.01) {
823+
continue;
824+
}
810825

811-
if (isset($_POST['line_item_totals']) && is_string($_POST['line_item_totals'])) {
812-
$line_item_totals = json_decode(stripslashes($_POST['line_item_totals']), true) ?? [];
826+
if ($match === null || $refund->get_id() > $match->get_id()) {
827+
$match = $refund;
828+
}
813829
}
814830

815-
if (!is_array($line_item_qtys)) {
831+
if ($match === null) {
816832
return [];
817833
}
818834

819-
return array_values(array_filter(
820-
array_map(
821-
fn($item_id, $qty) => $qty > 0 ? [
822-
'item_id' => $item_id,
823-
'qty' => $qty,
824-
'total' => $line_item_totals[$item_id] ?? 0
825-
] : null,
826-
array_keys($line_item_qtys),
827-
$line_item_qtys
828-
)
829-
));
835+
$line_items = [];
836+
foreach ($match->get_items('line_item') as $refund_item) {
837+
$refunded_item_id = (int) $refund_item->get_meta('_refunded_item_id');
838+
$qty = abs((int) $refund_item->get_quantity());
839+
840+
if ($refunded_item_id <= 0 || $qty <= 0) {
841+
continue;
842+
}
843+
844+
$line_items[] = [
845+
'item_id' => $refunded_item_id,
846+
'qty' => $qty,
847+
'total' => abs((float) $refund_item->get_total()),
848+
];
849+
}
850+
851+
return $line_items;
830852
}
831853

832854
public function checkCurrencySupported(): bool
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Buckaroo\Woocommerce\Gateways\AbstractPaymentGateway;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class Test_AbstractPaymentGateway_RefundLineItems extends TestCase
9+
{
10+
public function test_returns_empty_when_amount_is_null()
11+
{
12+
$order = $this->createMock(\WC_Order::class);
13+
$order->expects($this->never())->method('get_refunds');
14+
15+
$this->assertSame([], $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, null]));
16+
}
17+
18+
public function test_returns_empty_when_amount_is_zero()
19+
{
20+
$order = $this->createMock(\WC_Order::class);
21+
$order->expects($this->never())->method('get_refunds');
22+
23+
$this->assertSame([], $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 0]));
24+
}
25+
26+
public function test_returns_empty_when_amount_is_negative()
27+
{
28+
$order = $this->createMock(\WC_Order::class);
29+
$order->expects($this->never())->method('get_refunds');
30+
31+
$this->assertSame([], $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, -5.0]));
32+
}
33+
34+
public function test_returns_empty_when_order_has_no_refunds()
35+
{
36+
$order = $this->createMock(\WC_Order::class);
37+
$order->method('get_refunds')->willReturn([]);
38+
39+
$this->assertSame([], $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 29.99]));
40+
}
41+
42+
public function test_returns_empty_when_no_refund_matches_amount()
43+
{
44+
$stale = $this->makeRefund(1, 50.00, []);
45+
46+
$order = $this->createMock(\WC_Order::class);
47+
$order->method('get_refunds')->willReturn([$stale]);
48+
49+
$this->assertSame([], $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 29.99]));
50+
}
51+
52+
public function test_skips_non_refund_objects()
53+
{
54+
$order = $this->createMock(\WC_Order::class);
55+
$order->method('get_refunds')->willReturn([new stdClass()]);
56+
57+
$this->assertSame([], $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 29.99]));
58+
}
59+
60+
public function test_extracts_partial_line_item_when_refund_amount_matches()
61+
{
62+
$refund = $this->makeRefund(10, 29.99, [
63+
$this->makeRefundItem(['_refunded_item_id' => 42], -1, -29.99),
64+
]);
65+
66+
$order = $this->createMock(\WC_Order::class);
67+
$order->method('get_refunds')->willReturn([$refund]);
68+
69+
$result = $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 29.99]);
70+
71+
$this->assertCount(1, $result);
72+
$this->assertSame(42, $result[0]['item_id']);
73+
$this->assertSame(1, $result[0]['qty']);
74+
$this->assertEquals(29.99, $result[0]['total']);
75+
}
76+
77+
public function test_normalizes_multiple_items_to_positive_values()
78+
{
79+
$refund = $this->makeRefund(11, 25.50, [
80+
$this->makeRefundItem(['_refunded_item_id' => 10], -2, -20.00),
81+
$this->makeRefundItem(['_refunded_item_id' => 11], -1, -5.50),
82+
]);
83+
84+
$order = $this->createMock(\WC_Order::class);
85+
$order->method('get_refunds')->willReturn([$refund]);
86+
87+
$result = $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 25.50]);
88+
89+
$this->assertCount(2, $result);
90+
$this->assertSame(10, $result[0]['item_id']);
91+
$this->assertSame(2, $result[0]['qty']);
92+
$this->assertEquals(20.00, $result[0]['total']);
93+
$this->assertSame(11, $result[1]['item_id']);
94+
$this->assertSame(1, $result[1]['qty']);
95+
$this->assertEquals(5.50, $result[1]['total']);
96+
}
97+
98+
public function test_skips_items_without_refunded_item_id()
99+
{
100+
$refund = $this->makeRefund(12, 10.00, [
101+
$this->makeRefundItem(['_refunded_item_id' => 0], -1, -10.00),
102+
$this->makeRefundItem(['_refunded_item_id' => 7], -1, -10.00),
103+
]);
104+
105+
$order = $this->createMock(\WC_Order::class);
106+
$order->method('get_refunds')->willReturn([$refund]);
107+
108+
$result = $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 10.00]);
109+
110+
$this->assertCount(1, $result);
111+
$this->assertSame(7, $result[0]['item_id']);
112+
}
113+
114+
public function test_skips_items_with_zero_quantity()
115+
{
116+
$refund = $this->makeRefund(13, 3.50, [
117+
$this->makeRefundItem(['_refunded_item_id' => 8], 0, 0.0),
118+
$this->makeRefundItem(['_refunded_item_id' => 9], -1, -3.50),
119+
]);
120+
121+
$order = $this->createMock(\WC_Order::class);
122+
$order->method('get_refunds')->willReturn([$refund]);
123+
124+
$result = $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 3.50]);
125+
126+
$this->assertCount(1, $result);
127+
$this->assertSame(9, $result[0]['item_id']);
128+
}
129+
130+
public function test_uses_matching_refund_when_other_refunds_have_different_amount()
131+
{
132+
$stale = $this->makeRefund(100, 50.00, [
133+
$this->makeRefundItem(['_refunded_item_id' => 1], -1, -50.00),
134+
]);
135+
$current = $this->makeRefund(101, 29.99, [
136+
$this->makeRefundItem(['_refunded_item_id' => 42], -1, -29.99),
137+
]);
138+
139+
$order = $this->createMock(\WC_Order::class);
140+
$order->method('get_refunds')->willReturn([$current, $stale]);
141+
142+
$result = $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 29.99]);
143+
144+
$this->assertCount(1, $result);
145+
$this->assertSame(42, $result[0]['item_id']);
146+
}
147+
148+
public function test_picks_highest_id_when_multiple_refunds_match_amount()
149+
{
150+
$older = $this->makeRefund(50, 10.00, [
151+
$this->makeRefundItem(['_refunded_item_id' => 1], -1, -10.00),
152+
]);
153+
$newer = $this->makeRefund(51, 10.00, [
154+
$this->makeRefundItem(['_refunded_item_id' => 2], -1, -10.00),
155+
]);
156+
157+
$order = $this->createMock(\WC_Order::class);
158+
// Intentionally reversed order to verify ID-based selection (HPOS-safe).
159+
$order->method('get_refunds')->willReturn([$older, $newer]);
160+
161+
$result = $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 10.00]);
162+
163+
$this->assertCount(1, $result);
164+
$this->assertSame(2, $result[0]['item_id']);
165+
}
166+
167+
public function test_float_tolerance_matches_within_one_cent()
168+
{
169+
$refund = $this->makeRefund(60, 29.9999, [
170+
$this->makeRefundItem(['_refunded_item_id' => 42], -1, -29.99),
171+
]);
172+
173+
$order = $this->createMock(\WC_Order::class);
174+
$order->method('get_refunds')->willReturn([$refund]);
175+
176+
$result = $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 29.99]);
177+
178+
$this->assertCount(1, $result);
179+
$this->assertSame(42, $result[0]['item_id']);
180+
}
181+
182+
public function test_returns_empty_when_matching_refund_has_no_line_items()
183+
{
184+
// Push-initiated refunds have empty line_items; helper should
185+
// return [] so the synthesize fallback can run.
186+
$empty_refund = $this->makeRefund(70, 29.99, []);
187+
188+
$order = $this->createMock(\WC_Order::class);
189+
$order->method('get_refunds')->willReturn([$empty_refund]);
190+
191+
$this->assertSame([], $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, 29.99]));
192+
}
193+
194+
public function test_accepts_string_amount_from_wc_refund_payment()
195+
{
196+
// WC core passes amount as a string ($refund->get_amount() returns string).
197+
$refund = $this->makeRefund(80, 29.99, [
198+
$this->makeRefundItem(['_refunded_item_id' => 42], -1, -29.99),
199+
]);
200+
201+
$order = $this->createMock(\WC_Order::class);
202+
$order->method('get_refunds')->willReturn([$refund]);
203+
204+
$result = $this->invokeProtected($this->newGateway(), 'getRefundedLineItemsFromOrder', [$order, '29.99']);
205+
206+
$this->assertCount(1, $result);
207+
$this->assertSame(42, $result[0]['item_id']);
208+
}
209+
210+
private function newGateway()
211+
{
212+
return $this->getMockBuilder(AbstractPaymentGateway::class)
213+
->disableOriginalConstructor()
214+
->onlyMethods([])
215+
->getMock();
216+
}
217+
218+
private function makeRefund(int $id, $amount, array $line_items)
219+
{
220+
$refund = $this->createMock(\WC_Order_Refund::class);
221+
$refund->method('get_id')->willReturn($id);
222+
$refund->method('get_amount')->willReturn((string) $amount);
223+
$refund->method('get_items')->with('line_item')->willReturn($line_items);
224+
225+
return $refund;
226+
}
227+
228+
private function makeRefundItem(array $meta, int $quantity, float $total)
229+
{
230+
$item = $this->getMockBuilder(\WC_Order_Item_Product::class)
231+
->disableOriginalConstructor()
232+
->onlyMethods(['get_meta', 'get_quantity', 'get_total'])
233+
->getMock();
234+
235+
$item->method('get_meta')->willReturnCallback(
236+
fn($key) => $meta[$key] ?? ''
237+
);
238+
$item->method('get_quantity')->willReturn($quantity);
239+
$item->method('get_total')->willReturn((string) $total);
240+
241+
return $item;
242+
}
243+
244+
private function invokeProtected(object $target, string $method, array $args = [])
245+
{
246+
$reflection = new ReflectionMethod($target, $method);
247+
$reflection->setAccessible(true);
248+
249+
return $reflection->invokeArgs($target, $args);
250+
}
251+
}

0 commit comments

Comments
 (0)