Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 42 additions & 26 deletions Model/Total/Creditmemo/Surcharge.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,27 +53,33 @@ public function collect(Creditmemo $creditmemo): self
// potentially more) and we keep 6dp internally so the refund
// line gross matches what ComposeOrder declared at placement.
// See Model/Total/Surcharge for the 6dp invariant rationale.
if ($creditmemo->hasData('two_surcharge_amount')
// The proportional default is the surcharge net Magento's native tax
// collector has ALREADY refunded VAT for on this credit memo (it
// prorates order tax by subtotal). Compute it regardless of any
// override so we can reconcile the tax line to what's actually
// refunded. Keep 6dp internally (a 2dp round here previously lost up
// to half a cent and defeated the Total\Surcharge precision fix).
$orderSubtotal = (float)$order->getSubtotal();
$cmSubtotal = (float)$creditmemo->getSubtotal();
$proportion = $orderSubtotal > 0 ? $cmSubtotal / $orderSubtotal : 0.0;
$defaultNet = round($orderSurcharge * $proportion, 6);

// Phase 5 plugin sets `two_surcharge_amount` directly on the creditmemo
// from request data. hasData() distinguishes "explicit merchant
// override" (including 0) from "never set, use proportional default".
$hasOverride = $creditmemo->hasData('two_surcharge_amount')
&& $creditmemo->getData('two_surcharge_amount') !== null
&& $creditmemo->getData('two_surcharge_amount') !== ''
) {
$amount = round((float)$creditmemo->getData('two_surcharge_amount'), 6);
} else {
// Proportional refund — keep at 6dp internally. Previously,
// this rounded at 2dp, losing up to half a cent that defeated
// the Total\Surcharge fix for the dominant (partial-refund)
// case.
$orderSubtotal = (float)$order->getSubtotal();
$cmSubtotal = (float)$creditmemo->getSubtotal();
$proportion = $orderSubtotal > 0 ? $cmSubtotal / $orderSubtotal : 0.0;
$amount = round($orderSurcharge * $proportion, 6);
}
&& $creditmemo->getData('two_surcharge_amount') !== '';
$amount = $hasOverride
? round((float)$creditmemo->getData('two_surcharge_amount'), 6)
: $defaultNet;

if ($amount <= 0) {
// Nothing refunded and nothing native assumed → no surcharge in play.
if ($amount <= 0 && $defaultNet <= 0) {
return $this;
}

$amount = min($amount, $maxRefundable);
$amount = max(0.0, min($amount, $maxRefundable));

$taxRatePercent = (float)$order->getTwoSurchargeTaxRate();
$taxAmount = round($amount * ($taxRatePercent / 100), 6);
Expand All @@ -87,9 +93,18 @@ public function collect(Creditmemo $creditmemo): self
// order/base (matches base_to_order_rate semantics above).
$rate = $baseOrderSurcharge > 0 ? $orderSurcharge / $baseOrderSurcharge : 1.0;
}
$baseAmount = round($amount / $rate, 6);
$baseAmount = min($baseAmount, $baseMaxRefundable);
$baseAmount = max(0.0, min(round($amount / $rate, 6), $baseMaxRefundable));
$baseTaxAmount = round($taxAmount / $rate, 6);
$baseDefaultNet = round($defaultNet / $rate, 6);

// Tax delta: native already refunded VAT on the proportional default
// surcharge net, so adjust the tax line ONLY for the difference an
// override introduces. This is exactly zero on the non-override path,
// preserving the #201 de-dup guarantee (surcharge VAT counted once);
// when the merchant edits the surcharge it moves the Tax line to the
// VAT on the surcharge actually refunded (refunded net × rate).
$taxDelta = round(($amount - $defaultNet) * ($taxRatePercent / 100), 6);
$baseTaxDelta = round(($baseAmount - $baseDefaultNet) * ($taxRatePercent / 100), 6);

$creditmemo->setTwoSurchargeAmount($amount);
$creditmemo->setBaseTwoSurchargeAmount($baseAmount);
Expand All @@ -98,14 +113,15 @@ public function collect(Creditmemo $creditmemo): self
$creditmemo->setTwoSurchargeDescription((string)$order->getTwoSurchargeDescription());
$creditmemo->setTwoSurchargeTaxRate($taxRatePercent);

// Add ONLY the surcharge net to the grand total. The surcharge VAT is
// already carried in the credit-memo's tax_amount/grand_total via
// Magento's native propagation of the order/invoice tax, so re-adding
// it here double-counts the VAT — which pushes the refund total past
// the order's paid total and makes Magento reject the refund with "The
// most money available to refund is ..." (ABN-443).
$creditmemo->setGrandTotal((float)$creditmemo->getGrandTotal() + $amount);
$creditmemo->setBaseGrandTotal((float)$creditmemo->getBaseGrandTotal() + $baseAmount);
// Grand total gets the surcharge net plus the tax delta. The base
// surcharge VAT is already in tax_amount via Magento's native tax
// propagation (re-adding the full VAT was the ABN-443 double-count);
// we only move the Tax line and grand total by the override delta so
// both stay consistent with the surcharge actually refunded.
$creditmemo->setGrandTotal((float)$creditmemo->getGrandTotal() + $amount + $taxDelta);
$creditmemo->setBaseGrandTotal((float)$creditmemo->getBaseGrandTotal() + $baseAmount + $baseTaxDelta);
$creditmemo->setTaxAmount((float)$creditmemo->getTaxAmount() + $taxDelta);
$creditmemo->setBaseTaxAmount((float)$creditmemo->getBaseTaxAmount() + $baseTaxDelta);

// NOTE: do NOT mutate $order->setTwoSurchargeRefunded here. collect()
// runs on prepareCreditmemo and again on save/register — mutating the
Expand Down
76 changes: 76 additions & 0 deletions Test/Js/creditmemo-surcharge-refresh.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Copyright © Two.inc All rights reserved.
* See COPYING.txt for license details.
*/

'use strict';

const { loadAmdModule } = require('./amd-harness');

describe('creditmemo-surcharge-refresh', () => {
let init;
let input;
let button;
let clickSpy;

beforeEach(() => {
jest.useFakeTimers();
document.body.innerHTML =
'<input id="two_surcharge_refund" value="5.00" />' +
'<button data-ui-id="order-items-update-button" class="action-default disabled" disabled>Update Qty\'s</button>';
input = document.getElementById('two_surcharge_refund');
button = document.querySelector('[data-ui-id="order-items-update-button"]');
clickSpy = jest.fn();
button.addEventListener('click', clickSpy);

init = loadAmdModule('view/adminhtml/web/js/creditmemo-surcharge-refresh.js');
init(document, window);
});

afterEach(() => {
jest.useRealTimers();
document.body.innerHTML = '';
});

test('reloads totals on blur after the surcharge is edited', () => {
input.value = '2.50';
input.dispatchEvent(new window.Event('blur'));

expect(clickSpy).toHaveBeenCalledTimes(1);
// The disabled "Update Qty's" button must be re-enabled so the click lands.
expect(button.disabled).toBe(false);
expect(button.classList.contains('disabled')).toBe(false);
});

test('does NOT reload on blur when the value is unchanged', () => {
input.dispatchEvent(new window.Event('blur'));
expect(clickSpy).not.toHaveBeenCalled();
});

test('reloads after the debounce window on change', () => {
input.value = '3.00';
input.dispatchEvent(new window.Event('input'));

expect(clickSpy).not.toHaveBeenCalled(); // still within debounce
jest.advanceTimersByTime(700);
expect(clickSpy).toHaveBeenCalledTimes(1);
});

test('debounce coalesces rapid edits into a single reload', () => {
input.value = '3.00';
input.dispatchEvent(new window.Event('input'));
jest.advanceTimersByTime(300);
input.value = '4.00';
input.dispatchEvent(new window.Event('input'));
jest.advanceTimersByTime(700);

expect(clickSpy).toHaveBeenCalledTimes(1);
});

test('a second unchanged blur does not re-fire the reload', () => {
input.value = '2.50';
input.dispatchEvent(new window.Event('blur'));
input.dispatchEvent(new window.Event('blur'));
expect(clickSpy).toHaveBeenCalledTimes(1);
});
});
79 changes: 79 additions & 0 deletions Test/Unit/Model/Total/Creditmemo/SurchargeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,83 @@ public function testPreservesLineItemVatAndDoesNotReAddSurchargeVat(): void
'tax_amount must be untouched: line-item VAT preserved AND surcharge VAT not re-added.'
);
}

/**
* When the merchant edits the surcharge refund down, the refunded surcharge
* VAT must follow (refunded net x rate) — both the Tax line and the grand
* total. Mirrors the canonical example: order €1000 goods + €100 surcharge,
* all @21% (full tax €231). Native refunds the full surcharge VAT (€21) by
* default; halving the surcharge to €50 must drop the refunded surcharge
* VAT to €10.50, so Tax €231 -> €220.50 and Grand €1331 -> €1270.50.
*/
public function testTaxTracksSurchargeOverrideDownward(): void
{
$order = new Order();
$order->setData('two_surcharge_amount', 100.0);
$order->setData('base_two_surcharge_amount', 100.0);
$order->setData('two_surcharge_refunded', 0.0);
$order->setData('base_two_surcharge_refunded', 0.0);
$order->setData('two_surcharge_tax_rate', 21.0);
$order->setData('two_surcharge_description', 'Surcharge');
$order->setData('subtotal', 1000.0);
$order->setData('base_to_order_rate', 1.0);

// Native credit-memo state for a FULL refund: goods 1000 + tax 231
// (210 goods VAT + 21 surcharge VAT). Surcharge net not yet added.
$creditmemo = new Creditmemo();
$creditmemo->setOrder($order);
$creditmemo->setData('subtotal', 1000.0);
$creditmemo->setData('grand_total', 1231.0);
$creditmemo->setData('base_grand_total', 1231.0);
$creditmemo->setData('tax_amount', 231.0);
$creditmemo->setData('base_tax_amount', 231.0);

// Merchant overrides the refunded surcharge to half (€50).
$creditmemo->setData('two_surcharge_amount', 50.0);

(new Surcharge())->collect($creditmemo);

$this->assertEqualsWithDelta(
220.5,
(float)$creditmemo->getTaxAmount(),
0.0001,
'Tax must drop to 220.50 (210 goods VAT + 10.50 surcharge VAT on the €50 refunded).'
);
$this->assertEqualsWithDelta(
1270.5,
(float)$creditmemo->getGrandTotal(),
0.0001,
'Grand total must be 1270.50 (1000 goods + 220.50 tax + 50 surcharge net).'
);
}

/**
* The proportional (non-override) path must remain a no-op on tax — the
* #201 / double-count guarantee. Refunded net equals the proportional
* default, so the delta is zero and native tax stands.
*/
public function testProportionalRefundDoesNotAdjustTax(): void
{
$order = $this->makeOrder();

$nativeTax = self::SURCHARGE_TAX; // native already carries the full surcharge VAT
$nativeGrand = 1000.0 + $nativeTax;

$creditmemo = new Creditmemo();
$creditmemo->setOrder($order);
$creditmemo->setData('subtotal', self::SUBTOTAL); // full refund => proportion 1.0
$creditmemo->setData('grand_total', $nativeGrand);
$creditmemo->setData('base_grand_total', $nativeGrand);
$creditmemo->setData('tax_amount', $nativeTax);
$creditmemo->setData('base_tax_amount', $nativeTax);

(new Surcharge())->collect($creditmemo);

$this->assertEqualsWithDelta(
$nativeTax,
(float)$creditmemo->getTaxAmount(),
0.0001,
'No override => proportional default => zero tax delta.'
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@
id="two_surcharge_refund" />
</td>
</tr>
<script>
require(['Two_Gateway/js/creditmemo-surcharge-refresh'], function (initSurchargeRefresh) {
initSurchargeRefresh();
});
</script>
71 changes: 71 additions & 0 deletions view/adminhtml/web/js/creditmemo-surcharge-refresh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright © Two.inc All rights reserved.
* See COPYING.txt for license details.
*
* Refreshes the admin credit-memo totals when the merchant edits the Two
* surcharge refund field, so the Tax line and Grand Total track the surcharge
* without a manual click. The server-side recalculation lives in
* Model/Total/Creditmemo/Surcharge; here we only re-trigger Magento's own
* "Update Qty's" round-trip — the surcharge input is part of #edit_form, so
* that round-trip serialises and posts it to the updateQty controller, which
* re-renders the totals.
*
* Fires on blur and on a debounced change so a typed edit settles before the
* (relatively expensive) server round-trip.
*
* Exposed as an init function taking (doc, win) so it can be unit-tested under
* jsdom; the template invokes it with the live document.
*/
define([], function () {
'use strict';

var DEBOUNCE_MS = 700;
var INPUT_ID = 'two_surcharge_refund';
var UPDATE_BUTTON_SELECTOR = '[data-ui-id="order-items-update-button"]';

return function init(doc, win) {
doc = doc || document;
win = win || window;

var input = doc.getElementById(INPUT_ID);
if (!input) {
return;
}

var lastValue = input.value;
var timer = null;

function triggerReload() {
if (timer) {
win.clearTimeout(timer);
timer = null;
}
// Skip if nothing changed since the last reload — avoids a
// redundant server round-trip on a blur that follows a debounced
// change, or a blur with no edit.
if (input.value === lastValue) {
return;
}
lastValue = input.value;

var button = doc.querySelector(UPDATE_BUTTON_SELECTOR);
if (!button) {
return;
}
// Magento disables "Update Qty's" until an item qty changes;
// re-enable it so the programmatic click fires the same updateQty
// round-trip that serialises and posts the surcharge field.
button.disabled = false;
button.classList.remove('disabled');
button.click();
}

input.addEventListener('blur', triggerReload);
input.addEventListener('input', function () {
if (timer) {
win.clearTimeout(timer);
}
timer = win.setTimeout(triggerReload, DEBOUNCE_MS);
});
};
});