Two B2B Payment Module for Magento 2
Integrates Two as a B2B payment method for Magento, enabling invoice-based checkout with flexible payment terms, instant business credit checks, automated invoicing, partial captures and refunds.
Build Tests
Code Quality
Release Discipline
Release history audited up to v4.0.0
Tested on Magento 2.4.9
Recent Test History
Each release is tested against the latest Magento version at that time.
+1 older tests
Top Contributors
View LeaderboardShare This Module's Status
README
Loaded from GitHubB2B Buy Now, Pay Later for Magento 2.4.6+ on PHP 8.2+. This plugin integrates Two as a payment method, letting merchants offer invoice-based checkout with flexible net terms.
What it does
For merchants:
- Instant credit checks on business customers
- B2B guest checkout (up to 36% conversion uplift)
- Flexible invoice terms from 14 to 90 days
- Automatic invoicing via the PEPPOL e-invoicing network
- Partial capture and refunds
- Instant payment on fulfilment — Two assumes the credit risk
- Optional product-page promotion: a message and a buy button, both off by default (how to turn them on)
For buyers:
- Frictionless checkout with no onboarding
- Flexible repayment terms
- PDF and electronic invoicing straight to their ERP
Installation
Install via Composer:
composer require two-inc/magento2
php bin/magento module:enable Two_Gateway
php bin/magento setup:upgrade
php bin/magento cache:flush
In production mode, also deploy static content:
php bin/magento setup:static-content:deploy
Then configure the plugin under Stores > Configuration > Sales > Payment Methods > Two.
Cache types
setup:upgrade enables the module's two_gateway cache type, which holds the
merchant profile values fetched from Two. Drop just those values with:
php bin/magento cache:clean two_gateway
The type must be enabled for that to do anything: a disabled cache type accepts the command and drops nothing.
Post-install steps
Run these immediately after setup:upgrade to refresh the DI graph
and clear any stale cache types:
php bin/magento setup:di:compile
php bin/magento cache:flush
If admin Configuration is missing expected Two/brand sections, the cause is almost certainly one of:
- A plugin registered only under
etc/adminhtml/di.xmloretc/crontab/di.xmlinstead ofetc/di.xml. See AGENTS.md for the DI-scope rule. - An FPM worker holding stale opcache. Restart PHP-FPM
(
systemctl reload php-fpmorkill -USR2 <fpm-master-pid>). - A cache type (config / layout / full_page) in stale state.
bin/magento cache:flushis the canonical fix.
Product-page promotion
Two optional surfaces on the product page, added in 2.4.0. Both are off by default and independent: a shop may run either, both or neither.
Find them in the admin under Stores → Configuration → Two → Checkout fields → Title & display.
| Setting | What it does |
|---|---|
| Show message on product pages | Adds a short line telling the buyer they can pay on invoice with Two. |
| Show buy button on product pages | Adds a button under Add to Cart that puts the item in the basket and opens checkout with Two preselected where it is available. |
The button never places the order. It carries the buyer to checkout, and they confirm there as usual, so a misclick costs nothing. It sits beside the theme's Add to Cart rather than replacing it, and respects the quantity and any variant the buyer has chosen.
Preselection is conditional, by design. A product page has no cart, so
the button cannot know whether minimum order value, buyer country or
currency will allow the method. When the finished basket does not qualify,
checkout opens with nothing selected and the buyer chooses as they would
have anyway. See Block/Product/ExpressButton.php for the gates the button
does apply.
Brand overlays only see what they list. From 3.0.0 an overlay renders
only the paths in its <allowed_fields>, so these controls appear under an
overlay's own tab only if it names
checkout_fields/display/product_message_enabled and
checkout_fields/display/product_button_enabled. See
docs/brand-overlay-guide.md.
Both settings are per store view, so you can trial them on one storefront before rolling them out.
Invoicing in Magento before the fulfilment trigger
With the fulfilment trigger set to On Invoice, a Magento invoice captures online and fulfils the order with Two. With any other trigger, Two is told when that trigger happens, and a Magento invoice is recorded offline:
- An order the buyer has not yet verified with Two (state
pending_payment) cannot be invoiced offline at all (TWO-26294). - A verified order can be. Magento records the invoice as paid without contacting Two, and adds an order comment, visible to the merchant only, saying Two was not notified and will be when the order ships (On Shipment) or reaches one of the configured statuses (On Completion). That fulfilment still happens: an invoice recorded in Magento does not stop Two being told, and Two is never told twice. Anything refunded or cancelled in Magento before then is left out of what Two is told to invoice (TWO-26302). An invoice created through the REST API without an online capture is treated the same way.
- On a configured status, Two is told once the save has committed. Magento's own refunds (the credit memo form and the REST refund routes) save the order and its credit memo in one transaction, from 2.4.6 on, so the credit memo is saved by then. A refund made by another extension's own code that saves them without a transaction is not covered: Two may be told before that credit memo exists. If Two cannot be told, the save still stands: the order gets a comment saying why (and the admin an error message), and saving the order again while it has that status retries.
Upgrading to 4.0
4.0 removes the plugin's own shipping tax settings: the Default shipping tax
class field and the deprecated flat-percentage Default shipping tax rate
field. setup:upgrade deletes their stored values and does not carry them
over anywhere.
Where Magento records no tax rate for a shipping line, the fallback now uses Magento's own Stores > Configuration > Sales > Tax > Tax Classes > Tax Class for Shipping, and only on stores where the fallback has been enabled (see below). Anywhere else such a line is sent at 0% with its tax as charged, and Two's API validates it. This includes orders placed on 3.x.
Shipping tax fallback
The shipping tax fallback resolves a shipping line's rate from Magento's own Tax > Tax Classes > Tax Class for Shipping when Magento recorded no rate for that line. It is off by default and has no admin field. Contact Two before enabling it for a store:
bin/magento config:set --scope=stores --scope-code=<store_code> payment/two_payment/enable_shipping_tax_fallback 1
bin/magento cache:flush config
On a brand overlay the path is payment/<brand code>/enable_shipping_tax_fallback.
The fallback is populated when it is enabled for the store and the Tax Class for Shipping is set. Enabled with no class set, it does nothing.
| Shipping line | Fallback blank (the default) | Fallback populated |
|---|---|---|
| Magento recorded a rate, including an explicit 0% | Sent at the recorded rate. The plugin does not check it; the postprocessing hook runs, then Two's API validates it. | Same as blank. |
| Magento recorded no rate, whatever the line's tax (0 included) | Sent as is: rate 0, tax as charged. The plugin does not check it; the hook runs, then Two's API validates it. | The rate comes from the Tax Class for Shipping, and the line's tax must reconcile with it within 0.02. The line is built at that rate and the hook runs; with no subscriber, a line that does not reconcile is then refused. |
With the fallback blank, the plugin never refuses a request over shipping tax.
Each time the fallback supplies a shipping rate, the plugin writes a
ShippingTaxRateFallback warning to the error log naming the order, the
shipping tax class, the rate and the shipping charge, so a shop relying on it
shows without Debug Mode.
"Recorded a rate" means Magento applied a tax rate to the shipping line when the order was placed. A Tax Class for Shipping of None, or no tax rule matching the address, applies none, so it reads as no rate. A rule applying 0% is recorded when shipping prices exclude tax.
Placement stores which case applied on the order, in two_shipping_tax_rate_source
(declared or none), with the rate in two_shipping_tax_rate (percent): the
recorded rate, 0 included, or for none the fallback's rate, empty when the
fallback was blank. Magento does not save a 0% shipping rate with the order,
so this record is what update, capture, shipment and refund read, never the
current configuration: a later change to the fallback or the tax rules does not
move an order already placed. An order placed before this record existed has
both empty and resolves as it would at placement. A refund takes the order's
shipping rate; when the fallback supplied it at placement, the shipping the
credit memo refunds must carry the tax that rate gives, within 0.02
(TWO-26309). An order placed before the record existed is not checked on refund.
This reconcile is a shop-match check: it runs after the postprocessing hook, in the plugin's default handler, on order intent, create, update, capture and refund. A subscriber on the hook takes it over, and it does not run unless the subscriber opts back in (see "Stable extension contract: order postprocessing").
Tax codes on 0% lines
Every line the plugin sends at a 0% tax rate can carry a Two tax_code saying
why it is zero. For a merchant whose Two account is in Spain the API requires
one on every 0% line. The plugin adds it to order create, order edit, partial
capture, shipment and refund lines, while it builds the request and before the
postprocessing hook, so a subscriber can still change it. Lines at any other
rate are sent exactly as before.
How your shop levies tax is your decision: the plugin reads which of your rows a line falls under and sends the code you chose for it. Order management > Tax holds Validate tax subtotals and Tax codes for 0% lines; Order management > Status mapping holds the fulfilment settings.
Validate tax subtotals (default Yes, set per store view) sends a per-rate
tax_subtotals breakdown with order creation, order edits, fulfilments and
refunds, for validation against the order lines. With No it is sent on none of
them. The setting is read at the order's store view.
The table. "Tax codes for 0% lines" spans the width of the form, with one row per product tax class (including None) and the cases as columns:
- VAT-registered buyer, other EU state.
- Your 0% tax rates: one dropdown per 0% tax rate the class's tax rules
use, labelled with the rate's code, country and postcode, for example
ES-CANARIAS-0 (ES, 35*). Rates above 0% are not listed, since they never give a 0% line. A class with none says so. - Buyer's address does not map to a tax rule (no tax charged, e.g. exports).
Each dropdown offers (none), which sends no code and is where every row
starts, and the codes Two lists at rate 0 for your country, as CODE - name:
Two refuses any other code on a 0% line. A saved code that is no longer
offered stays selected and is marked "not valid on 0% lines" or "no longer
listed", so nothing saved disappears unseen. A changed row must take an
offered code, or the table is not saved and a message says which code.
When the table shows. Only when Two requires a code on 0% lines for your country: the country's code list says mandatory codes are enabled and one of the mandatory rates is 0. The country is the one on your Two merchant profile for the API key and environment of the scope you are editing. The code list is held for a day per country and environment, and a country with no code registry is held as an empty list. After a failure the list is not asked for again for five minutes, and the last list fetched is used meanwhile. Diagnostics > Refresh merchant profile expires the lists and reloads the page onto the button.
Otherwise the section shows only what needs attention:
- No API key: nothing.
- Your merchant profile or the code list cannot be read: why, with the HTTP status, or that the stored API key was rejected.
- Saved codes while the table is hidden: a notice that they still apply at checkout, or that some are not valid on 0% lines for your country, with Clear saved tax codes.
- A saved setting that cannot be read (below): a notice that Two is hidden at checkout, with Clear saved tax codes.
- At the default or a website scope, when a website or store view below it uses another API key or environment: a notice to check the setting in each of their scopes, since they inherit this mapping.
Clear saved tax codes stores an empty mapping at the scope being saved and ignores any rows posted with it. Rows posted while the table is not shown change nothing; if your profile or the code list could not be read, a message says your edits were not saved.
A product line uses its product's tax class (a configurable product, its child's). The shipping line uses Magento's Tax Class for Shipping (marked "(shipping)"; with that set to None, use the None rows), and the payment terms fee its own surcharge tax class. Codes that need an exemption reason the plugin has no way to supply are not offered; set those, with their reason, in the postprocessing hook.
For each 0% line the first of these that matches decides:
- Exempt buyer. The billing country and the address Magento taxes the line on (Tax Calculation Based On, normally the delivery address) are both in the EU VAT area and not your Two account's country, and the buyer's VAT number (below) is filled in. The line takes its class's Buyer in another EU country row. The EU VAT area is the 27 member states, Monaco, and Northern Ireland (GB with a postcode starting BT). An export, taxed on an address outside it, skips this step.
- Your 0% rate. The tax rate your rules applied to the line's class at that address is 0%: the line takes that rate's row. If the rate is above 0% (taxes switched off another way), the line gets no code.
- No rule. No tax rule matched the address for the class: the line takes its class's No rule for the address row.
- No tax class. "Other charges" and fee-provider lines, a flat-rate payment terms fee, the refund adjustment line, and a product line the plugin could not match to its item take the one code the request's 0% lines coded by steps 1 to 3 share. If those lines carry different codes, or none, the line gets no code.
- Otherwise no code is sent.
A row you leave at (none) sends no code: it never falls through to a later step. Goods versus services is yours to express through classes: give a services class's rows the services codes. Northern Ireland is in the EU VAT area for goods only, so if you sell services into Northern Ireland, do not set an intra-community services code on a services class's Buyer in another EU country row.
The buyer's VAT number (TWO-26153) is the order billing address's VAT
number, and otherwise the customer's Tax/VAT number. When Magento's VAT
check got an answer that marked the billing address's number invalid, the
order has no buyer VAT number at all: the customer's Tax/VAT number is not
used in its place, since it often holds the same number. A check that
could not reach the VAT service keeps the number. The number is used
exactly as entered, trimmed of leading and trailing spaces, and a value
with nothing left is no number. Nothing is corrected or added, and for
step 1 any value counts.
Order create also sends it to Two as buyer_vat_number, for a
merchant in Spain and a buyer outside Spain only: Two requires a Spanish
buyer's VAT number to equal its organisation number, so it is never sent
for one. Later requests leave it out, so Two keeps the number placement
sent.
Upgrading from one code per tax class. The upgrade copies each class's old code to that class's Buyer in another EU country row, its No rule for the address row and the row of every 0% rate its rules use at that moment, so every line the old setting covered keeps its code. Rates you add later start at (none).
Upgrading from a version that worked codes out itself. Earlier versions gave a Spanish merchant's 0% line a code of their own choosing when none of its class's rows was set. The plugin no longer does: a line gets a code only from your rows (steps 1 to 4 above). If your Two account is in Spain, set a code on every row your 0% lines fall under, or Two refuses those lines. Orders placed before this version keep the codes they recorded. On an order placed before the per-row settings, a later line with no tax class, such as a refund adjustment, takes the one code the request's other 0% lines carry, and no code if they carry different codes or one carries none.
The plugin never refuses; the API does. A 0% line with no code is sent as is, and Two's API decides. For a Spanish merchant it refuses such a line, so set a code on every row your 0% lines fall under, and make sure EU buyers outside Spain give their VAT number at checkout. A non-Spanish merchant with no row set sends exactly what it sent before.
A stored setting the form could not have written (TWO-26309), such as a
value set with config:set that is not a JSON object of valid rows, is never
read as "nothing mapped": Two is withheld from checkout, order create and an
order edit that needs the rows are refused, and TaxCodeMapUnreadable is
logged once per request until the setting is saved again or cleared with
Clear saved tax codes; the health checklist's payment method row says so. Capture, shipment
and refund are never held over it: they send the codes recorded at placement,
and a line that would need the rows goes without a code.
Placement records each line's code on the order (two_tax_codes), only where
the line got one: per product line, for shipping, for the payment terms fee,
and once for all other fee lines ("Other charges" and fee-provider lines),
which share one code whatever their id. Order edit, capture, shipment and
refund send those codes, so a later change to the addresses, your rows or a
product's tax class does not move a coded line. A line placed with no code is
resolved again against your rows on each later request (TWO-26309). The refund adjustment line
takes the one code the request's lines coded by steps 1 to 3 share (step 4), counting a
line placed with a code only while the request still carries it and steps 1 to 3 coded it (TWO-26340). These resolve afresh
instead: a product line placement could not match to its item (its SKU was
changed by another extension, or its item has no quote item), a fee line on
an order that had none at placement, and every line of an order placed before
this record existed.
Stable extension contract: order postprocessing
Every request this plugin sends to Two about an order passes through one extension point first. It presents the entire request body and lets your code change any part of it: split a charge differently, change an amount, add a field. Use it when the shop is not your accounting source of record and the invoice Two issues has to follow your books rather than the shop's figures.
Name and mechanism. An after plugin on
Two\Gateway\Api\OrderPostprocessingInterface::process(array $payload, array $context): array.
The default implementation returns the payload unchanged. Plugins chain by
sortOrder, and the final result is sent as returned.
<!-- your module's etc/di.xml -->
<type name="Two\Gateway\Api\OrderPostprocessingInterface">
<plugin name="acme_order_postprocessing" type="Acme\Two\Plugin\OrderPostprocessing"/>
</type>
Parameters.
$payload: the complete request body exactly as it would be sent, amounts as 2dp decimal strings:line_items,tax_subtotals(only while Validate tax subtotals is on),net_amount,tax_amount,gross_amount, buyer, addresses and the rest. A partial capture carries its lines underpartial; a refund carriesamount. A request with no body (confirm, cancel, whole-order capture) is[]. A 0% line may carrytax_code(see "Tax codes on 0% lines"); a subscriber may change it, and may addtax_exemption_reason_codefor a code that needs a reason the plugin cannot supply.$context, an array:
| Key | Type | Meaning |
|---|---|---|
request_type |
string | order_intent, order_create, order_update, order_confirm, capture, refund or cancel |
trigger |
string | What caused the request: checkout, admin_edit, confirmation, invoice, shipment, status_change, credit_memo, cancel, buyer_cancel |
endpoint |
string | The API path as sent, with Two's order id, for example /v1/order/<id>/refund |
quote or order |
object | The quote for order_intent, the order for everything else |
invoice, shipment, creditmemo |
object | Present where the request has one |
intent_order |
object | order_intent only: the unsaved order the plugin converted the quote into and built the lines from. Never saved |
shipping_tax_rate |
float or null | The rate Magento's Tax Class for Shipping applies at the order's tax address, whether or not the shipping line was taxed. 0.21 means 21%. Null when no class is set |
fallback_shipping_tax_rate |
float or null | The shipping tax fallback's rate, null unless the fallback is enabled for the store |
fallback_shipping_tax_code |
string or null | With the fallback enabled, the code steps 1 to 3 of "Tax codes on 0% lines" give a 0% shipping line taxed by the Tax Class for Shipping at the order's addresses. Null otherwise, and when that class is None, the rate there is not 0%, the row is (none) or the setting cannot be read |
contract_version |
int | 1 |
order_lines |
array or null | On order_update and refund: the order's line_items as Two's GET /v1/order/<id> returns them, with Two's line ids. Read for an admin edit, and otherwise only when a handler other than the plugin's own is registered. Null on other requests and when the read fails. Read-only |
order_refunds |
array or null | From the same read: Two's refunds on the order. Two leaves each refund line's prototype_id empty, so the plugin fills it with the order line it best matches on name, description, type, tax code and rate; a line that matches none keeps it empty. Null as for order_lines |
order_tenders |
array | What has already paid part of the order outside its lines (see "Tenders" below): the credit memo's for a refund, the invoice's for an invoice capture, else the order's. [] when there are none |
Return. The full payload, edited or not.
Tenders (TWO-26309). A tender is anything that has already paid part of the order without being a line: a gift card, store credit, reward points. Magento takes it off the grand total, so the lines add up to the grand total plus the tenders. Discounts are not tenders: they are on the lines already.
An order with tenders is sent as an invoice for its full lines, with the tenders declared as already paid. Two funds only what is left:
| Field | Value |
|---|---|
net_amount, tax_amount |
The lines' net and tax: a tender is a payment and reduces neither |
invoice_amount |
net_amount plus tax_amount |
prepaid_amount |
The tenders |
payable_amount, gross_amount |
The grand total: invoice_amount less prepaid_amount |
That holds for order_create and order_update. A partial capture (an
invoice, a shipment, or the fulfilment after refunds) and a refund declare
their prepaid part from Two's own record of the order, which the plugin reads
before sending (GET /v1/order/<id> and its /fulfillments):
- A fulfilment declares the tenders Magento still holds as paid (the order's, less what every credit memo returned to them, online or offline) that no earlier fulfilment has declared, up to its own lines. Two's rules then bound it: never more than Two's prepaid has left unfulfilled, and never so little that the unfulfilled rest holds more prepaid than invoice. A credit memo refunded offline never reaches Two, so a later fulfilment can be held to the prepaid Two still expects.
- A refund declares what its credit memo returned to the tenders, up to the
prepaid Two has fulfilled and not refunded, and at least what keeps its
amountwithin the payable Two has fulfilled and not refunded.amountis the rest of the refunded lines, so the two always sum to the lines.
If Two's record cannot be read, the request is not sent and says so: the prepaid part is never guessed.
A credit memo refunded offline never reaches Two, and Magento returns the
tenders before the payment. When it returns tenders a fulfilled part had
already declared as prepaid (a 60 gift card, a first shipment of 50 declared
wholly prepaid, then an offline memo of the unshipped rest returning 50 to the
card), Two still holds that part as paid and cannot raise what the buyer owes
on it. The plugin then logs TendersReturnedAfterDeclared as an error and
adds an order comment naming the amount not collected through Two, here 40;
collect it outside Two.
An order placed with tenders before this version has no prepaid at Two and an
invoice of the grand total alone. Every later request on it, edits included,
is sent exactly as before: its lines reconciled against the grand total, its
totals from the grand total, and no invoice_amount, payable_amount or
prepaid_amount. The plugin tells such an order by the prepaid Two holds for
it. The order_intent body is unchanged. An order with no tenders carries
none of invoice_amount, payable_amount and prepaid_amount, and its bodies
are as they always were.
$context['order_tenders'] lists them, each {type, label, amount, source}:
type is gift_card, store_credit or other; label a name for logs,
never a card's code; amount a positive 2dp string; source where it was
read from. The plugin reads Adobe Commerce's gift card account
(gift_cards_amount), store credit (customer_balance_amount) and reward
points (reward_currency_amount) on the order, invoice and credit memo, and
the quote's own fields for the cart; Magento Open Source has none. To declare
another tender, add an after plugin on
Two\Gateway\Api\OrderTendersInterface::collect($entity): array that
appends to the list. An entry whose amount is zero is skipped; one that is
negative or not a number is dropped and logged as OrderTenderDropped with its
type, source and amount, never its label. Declare each tender once: one
declared twice is counted twice, and the order no longer adds up.
When it fires. Immediately before every order request is sent, once per request:
| Request type | When |
|---|---|
order_intent |
The checkout's approval check. The body is built here from the quote, with the same lines as the order |
order_create |
Order placement |
order_update |
An admin edit of the order's address. Once the order can no longer be edited, for example once Two has invoiced all or part of it, the body is still built and the hook runs, but nothing is sent: the admin sees a notice instead |
order_confirm |
The buyer returns from Two's checkout |
capture |
An online invoice, a shipment, or the order reaching a fulfil-on status, per the fulfilment trigger |
refund |
A credit memo |
cancel |
The order is cancelled or voided, or the buyer abandons Two's checkout |
A few specifics:
- A whole-order capture has no body and its
invoiceis null. That covers an invoice for everything still open and the fulfil-on status trigger (which carries noinvoicekey at all). The status trigger sends apartialbody instead when part of the order was refunded or cancelled in Magento first: the order lines net of those quantities, shipping unless any of it was refunded, and whatever of the surcharge, fee lines and other charges is not yet refunded. - If Two answers a whole-order capture with
PARTIAL_ORDER_MISSING_DATA, the plugin retries it as a partial capture of the latest invoice, so the hook fires twice for one capture: first with[], then with thepartialbody. - For
order_intentthe plugin builds an unsaved order from the live checkout quote. It re-collects the quote's totals and runs core's quote-to-order conversion in memory, which dispatchessales_convert_quote_to_order. That happens on every approval check, and no order is placed. An observer on that event, or on the totals collection, that assumes a real placement (reserving stock, numbering, writing records) will run then too.
Unchanged means unchanged. When no subscriber changes the payload, every request is sent byte for byte as the plugin composed it. After the hook, the plugin refuses it only on a failed shop-match check or a fault in the subscriber's code.
What you return is sent. The payload goes out as your subscriber returns
it, and Two's API validates it as it validates any request, including whether
its lines and totals add up. If the API rejects your payload, its message is
shown to the admin for an admin action, or to the buyer at checkout. The API's
reason is always written to var/log/two/error.log (as a warning for an order
intent), and with Debug Mode on the request and response are written there too.
Checks. After the hook the plugin refuses a request only for a fault in your subscriber's code (it throws, returns something other than an array, or returns a payload that cannot be JSON-encoded) or for a failed shop-match check. A shop-match check asks whether what the plugin built matches what the shop worked out, which Two's API cannot see. On single lines, each on the line the plugin built while the payload still carries it unchanged:
- a shipping line whose rate the shipping tax fallback supplied must carry the tax Magento charged at that rate (see "Shipping tax fallback"), on order intent, create, update and capture, and the shipping a refund returns against the rate the fallback gave at placement;
- every other line's tax must follow its declared rate and net (or, with tax
applied before discount, net plus discount), on create and update. The line
is Magento's own figures, so a mismatch means the shop's tax does not match
the rate it declared. Refused as
ShopMatchLineTaxRefused.
On the whole order (TWO-26309): the payload's lines must add up to the order's
grand total plus its tenders, gross, net and tax each, within 0.02 or half a
cent per line plus half for the order if that is more. A create or update that
fails it is refused as ShopMatchWholeOrderRefused; the order intent only
logs ShopMatchWholeOrderDrift, since the create checks it again. The log
names the unexplained gap and OrderTendersInterface, for a payment taken
outside the lines.
The default handler and when it stands down. The plugin registers its own
after plugin on this interface, two_gateway_shop_match_checks, which runs
the shop-match checks. It runs them only when no other handler is registered on
process(): any before, around or after plugin on the interface or on
Two\Gateway\Model\OrderPostprocessing, or a preference that replaces that
default implementation. Detection reads the same interception config the hook
runs on, in the area of the request (storefront, admin, REST, cron), so a
plugin that is disabled, belongs to a disabled module, or is declared for
another area does not count. When the default handler stands down, it writes
OrderPostprocessingShopMatchDelegated to the debug log for each request,
naming the order (or quote) and the handlers.
With a subscriber, shop-match correctness is yours. Whatever you change, nothing in the plugin compares your result with the shop's figures. For example, a subscriber that adds a line for a cost the shop adds to the cart total outside a carrier, split at its own rate, is sent as returned as long as its lines add up.
Opting back in. To keep the plugin's shop-match checks on the payload you
return, or on the parts you did not touch, inject
Two\Gateway\Api\OrderPostprocessingShopMatchInterface and call
check($result, $payload, $context) with the payload you are about to return
and the $payload and $context your plugin received. Each check applies to
the line the plugin built it for, and only while your result still carries that
line unchanged: a line you edited, replaced or removed is yours. A failing check
throws Two\Gateway\Exception\ShopMatchRefusedException, and the request is
refused exactly as the default handler refuses it. Only a refusal those checks
raised is passed through as one: a ShopMatchRefusedException your code
constructs itself is treated as a fault in your code (below).
check() also runs the whole-order check on your result. A subscriber that
declares its own split of the order, which the shop's totals cannot match,
injects Two\Gateway\Api\OrderPostprocessingShopMatchLinesInterface instead
and calls checkLines($result, $payload, $context): the single-line checks
only.
public function afterProcess(OrderPostprocessingInterface $subject, array $result, array $payload, array $context): array
{
$edited = $this->addHandlingLine($result);
$this->shopMatch->check($edited, $payload, $context);
return $edited;
}
A shop-match refusal is logged with the check's own code
(ShippingTaxFallbackMismatch, ShopMatchLineTaxRefused,
ShopMatchWholeOrderRefused). On order intent and create the buyer sees a
generic notice. On update, an admin address edit, the update is not sent to
Two: the address still saves, and the admin sees the reason as a warning and in
the order's history. On a capture
the admin sees the reason, and the invoice or shipment is blocked.
A subscriber that throws, returns something other than an array, or returns a
payload that cannot be JSON-encoded has a bug. That request is refused and
logged with TWO_ORDER_POSTPROCESSING_HOOK_FAILED, naming the request, its
trigger, the exception's class and where it was thrown, never the exception's
own message, which can carry buyer data:
| Request type | Effect of a refusal |
|---|---|
order_intent |
The approval check is refused and the buyer sees a generic notice |
order_create |
Checkout is refused with a generic notice |
order_update |
The update is not sent to Two. The address edit still saves in Magento, and the admin sees the error as a warning and in the order's history |
capture |
The invoice or shipment that triggered it is blocked with the error. A fulfil-on status change still saves, and the order gets a comment with the error |
refund |
The credit memo is refused with the error |
order_confirm, cancel |
Never refused. These take no body, so a subscriber that throws or adds one is logged with TWO_ORDER_POSTPROCESSING_HOOK_FAILED or TWO_ORDER_POSTPROCESSING_BODY_NOT_ACCEPTED, and the request is sent empty. Magento has already confirmed or cancelled the order by then, and a Two order left live could still be invoiced |
The plugin never recomputes anything after the hook, because that would
overwrite your edits. If you change a line, move the totals and subtotals it
affects, or the API will reject the request: inject
Two\Gateway\Api\OrderPostprocessingTotalsInterface and call
recompute($edited, $before), passing the payload you received as $before.
It sets net_amount, tax_amount and gross_amount (or a refund's amount)
to the sum over your lines plus whatever the total carried outside its lines
in $before (rounding, an unitemised fee, a refund's prepaid part), and does
the same for each tax_subtotals bucket by rate. On a body that declares
tenders it keeps prepaid_amount, sets invoice_amount to net plus tax, and
payable_amount and gross_amount to the invoice less the prepaid amount. A bucket residual under half a cent is
float noise and is dropped. A total you set by hand before the call is replaced, not
counted twice.
With Debug Mode on, every change is written to the debug log as
OrderPostprocessingChanged: the request type and trigger, the handlers
registered, the number of changes and at most 40 of them by JSON pointer.
Old and new values are kept only for the totals, currency,
discount_amount, a refund's amount, line_items and tax_subtotals; any
other value can carry buyer data and is logged as redacted. Strings are cut
to 120 bytes. Nothing is written to the order's comment history.
Your code owns what it declares. With a subscriber that changes amounts, the invoice Two issues can differ from what the shop charged. That is your decision: only the shop-match checks you opt back into look at your result, before Two's API validates it.
The buyer surcharge is priced before the hook. The plugin's fee calculations operate on the order as Magento built it, before any subscriber runs. The payment terms surcharge is worked out by the plugin's totals collector on the quote's grand total as collected before the surcharge: items, shipping, discounts, tax, and any other total collected earlier. It is stored on the order and sent as its own line from there; a capture or refund sends the invoice's or credit memo's share of that stored fee, and a credit memo's share can be one the admin entered. The plugin does not re-price the surcharge on the payload your subscriber returns, so a subscriber that adds a line or changes gross, net or tax does not change it. Itemising a cost that was already in that grand total, such as a charge the shop adds to the cart total outside a carrier, does not change the fee either: it was already charged on that cost. Any fee consequence of the amounts your subscriber declares is yours to handle in that subscriber. With a percentage surcharge, for example, no surcharge is charged on a line your subscriber adds for an amount that was not in that grand total; if you want it charged, change the surcharge line in your subscriber, together with the totals it affects.
Requirements on a subscriber.
- Deterministic: the same inputs give the same output.
- Cheap: it runs on every approval check during checkout as well as on every order request.
- Present: a disabled module simply means the shop's own figures are sent, and the plugin's default handler runs its shop-match checks again.
Example. Treat untaxed shipping as VAT-inclusive at the shop's shipping tax
rate. A 29.00 shipping line becomes net round(29.00 / 1.21, 2) = 23.97 and tax
5.03, gross unchanged. A partial capture carries its lines under partial, so
the edit covers both places:
namespace Acme\Two\Plugin;
use Two\Gateway\Api\OrderPostprocessingInterface;
use Two\Gateway\Api\OrderPostprocessingTotalsInterface;
class OrderPostprocessing
{
public function __construct(private OrderPostprocessingTotalsInterface $totals)
{
}
public function afterProcess(OrderPostprocessingInterface $subject, array $result, array $payload, array $context): array
{
$rate = $context['shipping_tax_rate'];
if (!$rate) {
return $result;
}
$resplit = static function (array $line) use ($rate): array {
if ($line['type'] !== 'SHIPPING_FEE' || (float)$line['tax_amount'] != 0.0) {
return $line;
}
$gross = (float)$line['gross_amount'];
$net = round($gross / (1 + $rate), 2);
$line['net_amount'] = number_format($net, 2, '.', '');
$line['tax_amount'] = number_format($gross - $net, 2, '.', '');
$line['unit_price'] = $line['net_amount'];
$line['tax_rate'] = number_format($rate, 6, '.', '');
$line['tax_class_name'] = 'VAT ' . number_format($rate * 100, 2) . '%';
return $line;
};
$edited = $result;
if (isset($edited['line_items'])) {
$edited['line_items'] = array_map($resplit, $edited['line_items']);
}
if (isset($edited['partial']['line_items'])) {
$edited['partial']['line_items'] = array_map($resplit, $edited['partial']['line_items']);
}
return $this->totals->recompute($edited, $result);
}
}
Test/Integration/OrderPostprocessingFixture is a working module that does
this, and CI runs it inside a real Magento on every change.
Versioning. This contract is permanent. The interface, its method and the v1 context keys and request types are never removed or renamed, and it fires consistently in response to the same events in every release. New context keys, new request types or triggers, and relaxed checks may be added without notice. Removing a key, changing a unit (rates stay decimal fractions), tightening a check a v1 subscriber could already pass, or firing on fewer requests is never done. A genuinely incompatible change would arrive as a new interface, with this one still firing alongside it.
Development
The development environment runs Magento in Docker with the plugin bind-mounted, so file changes are reflected immediately.
docker-compose.yml brings up three containers: Magento, MariaDB and OpenSearch.
The Magento image (ghcr.io/brtkwr/magento-dev, built from
brtkwr/magento-helm) is published for
both amd64 and arm64, so on Apple Silicon it runs natively instead of under
QEMU emulation. It also means no Magento marketplace keys are needed — the
plugin is mounted as an app/code module and the language packs ship inside the
image, so nothing runs composer against repo.magento.com on your machine.
Prerequisites
- Docker
- Make
- A Two API key (request sandbox access)
Quick start
# Create the Magento container and install the plugin
make install
# Configure with your API key
make configure TWO_API_KEY=<your-key>
# Start / stop
make run
make stop
The first make install takes a few minutes: the container installs the shop
on first boot, and make install waits for it to finish before configuring
anything.
After install, Magento is available at http://localhost:1234/ (admin: http://localhost:1234/admin, credentials: exampleuser@two.inc / examplepassword123).
To use a different port: make install PORT=5678.
By default, the plugin points at Two's staging environment for @two.inc gcloud accounts, or sandbox for everyone else. You can override the API and checkout URLs explicitly:
make install TWO_API_BASE_URL=http://localhost:8000 TWO_CHECKOUT_BASE_URL=http://localhost:3000
In production mode these are ignored — the URLs are derived from the mode setting in the admin panel (sandbox/staging/production).
make install, make run and make debug print the resolved API / checkout-page hosts (honouring the overrides above and the developer-mode gate) in their status block, so you can see at a glance which real hosts your local instance will actually talk to without having to run dev/probe-hosts.php yourself.
Run make help to see all available targets.
Local-dev perf — disabled modules and what breaks
make install runs module:disable on a fixed list of modules that aren't needed for plugin development but add significant load to setup:di:compile (every module's DI is re-generated) and to the storefront's RequireJS dependency graph (every enabled module's JS gets pulled into the boot, even on pages that don't use it). Disabling them cuts setup:di:compile time and drops storefront button-enable latency from ~10s to under a second on the sample-data catalog.
| Module(s) | Why it's disabled in dev |
|---|---|
Magento_AdminAdobeImsTwoFactorAuth, Magento_TwoFactorAuth |
TOTP setup required on every admin login — friction for local dev |
Magento_Analytics, Magento_AdminAnalytics, Magento_CatalogAnalytics, Magento_CustomerAnalytics, Magento_QuoteAnalytics, Magento_ReviewAnalytics, Magento_SalesAnalytics, Magento_WishlistAnalytics, Magento_GoogleAnalytics, Magento_GoogleOptimizer |
JS/tracking hooks that fire on every storefront load |
Magento_PageBuilder, Magento_PageBuilderAnalytics, Magento_CatalogPageBuilderAnalytics, Magento_CmsPageBuilderAnalytics, Magento_PageBuilderAdminAnalytics, Magento_AwsS3PageBuilder |
Loads the full PageBuilder ContentTypes JS tree on every storefront page — biggest single contributor to client-side boot time |
Magento_NewRelicReporting is not disabled — Magento_GraphQl declares a hard dependency on it, and disabling it cascades through every GraphQL module. It stays quiet at runtime when un-licensed.
Consequence: PageBuilder-driven CMS content (banners, slides, promo blocks edited via the visual editor) will not render in a make install environment. If you're testing brand content that relies on PageBuilder blocks, re-enable them manually inside the container:
docker exec magento php bin/magento module:enable Magento_PageBuilder Magento_PageBuilderAnalytics Magento_CatalogPageBuilderAnalytics Magento_CmsPageBuilderAnalytics Magento_PageBuilderAdminAnalytics Magento_AwsS3PageBuilder
docker exec magento php bin/magento setup:upgrade
docker exec magento php bin/magento setup:di:compile
docker exec magento php bin/magento cache:flush
Install also runs:
config:set dev/js/merge_files=1,dev/js/minify_files=1,dev/css/merge_css_files=1— flatten the inline RequireJS bootstrap into a single merged bundle in the HTML.setup:static-content:deploy --area frontend --theme Magento/luma --no-html-minify -f --jobs 4 en_US— pre-bake the Luma theme so RequireJS's ~hundreds of runtime XHRs hit plain file IO instead of falling through Magento'spub/static.phprouter (a full Magento bootstrap per asset). Without this, on the sample catalog the storefront's "Add to Cart" button-enable latency is ~10s; with it, ~1s warm.
Brand overlays
Brand-specific overlay packages live in their own Composer packages and are
installed separately from this plugin, not through make install. See
docs/brand-overlay-guide.md for how overlay modules are structured.
Debugging
Xdebug is installed automatically by make install but is disabled by default. To start in debug mode:
make debug
This activates Xdebug (port 9003) and disables all Magento caches for hot reload — PHP changes, templates, layout XML, and config changes are picked up on the next request without manual cache flushing. The only exception is DI wiring changes (new classes, plugins, or preferences in di.xml), which still require make compile.
Setting breakpoints in VSCode:
- Install the PHP Debug extension
- Press F5 to start listening (uses the included
.vscode/launch.json) - Click the gutter next to any line in the plugin code to set a breakpoint
- Browse to the Magento store — every request will trigger the debugger automatically
The debugger will pause at your breakpoint with full access to variables, call stack, and step-through execution.
HTTPS proxy
For testing integrations that require HTTPS callbacks (e.g. the Two checkout flow), you can expose your local instance via an FRP reverse proxy.
Setup (one-time): install the FRP client (frpc):
- macOS:
brew install frpc - Linux: download from GitHub releases and place
frpcon your PATH
Authentication:
The proxy needs an FRP_AUTH_TOKEN to connect to the FRP server. The start-proxy.sh script resolves the token in this order:
- Command-line argument:
./start-proxy.sh <token> - Environment variable:
export FRP_AUTH_TOKEN=<token>(or set it in.env) - GCP Secret Manager: falls back to
gcloud secrets versions access latest --secret=FRP_AUTH_TOKEN --project=two-beta
Edit frpc.toml to point at your FRP server, then provide the token via any of the methods above.
Usage:
# Proxy starts automatically with make run / make debug.
# To run the proxy standalone in the foreground:
make proxy
Tests
# Unit tests
make test
# End-to-end API tests (requires a valid API key)
make test-e2e TWO_API_KEY=<your-key>
# Order tests also need a staging test buyer, as JSON; without one they skip
export TWO_E2E_BUYER='{"currency": "GBP", "buyer": {...}, "billing_address": {...}, "shipping_address": {...}}'
CI runs the same suite against staging in .github/workflows/staging-api.yml
with TWO_E2E_REQUIRED=1, which turns a missing key or buyer into a failure
rather than a skip.
Other useful targets
| Target | Description |
|---|---|
make compile |
Recompile Magento DI (after adding/changing PHP classes, plugins, or preferences) |
make logs |
Tail the Two plugin debug and error logs |
make format |
Run Prettier on frontend JS/CSS/templates |
make clean |
Stop and remove the Magento container |
Releases
The version is computed on the pull request that lands on staging, from that PR's own commits. main computes nothing — it tags the version already in the tree and cuts the GitHub Release.
The version-bump convention
| Change | What happens |
|---|---|
PR into staging |
the version is computed from that PR's own commits and committed onto the PR's branch (.github/workflows/version-bump.yml) |
merge into staging |
nothing — the merge brings in the version its PR computed |
staging into main |
nothing is computed; main tags the version already in the tree and cuts the GitHub Release |
With M the version on origin/main and C the version on the PR head, the PR's own commits (origin/staging..HEAD, --no-merges) decide the candidate: a ! type or a BREAKING CHANGE: footer gives (M.major + 1).0.0, a feat: gives M.major.(M.minor + 1).0, and anything else — fix and chore/docs/ci/test/refactor alike — gives M.major.M.minor.(M.patch + 1). The result is clamped with max(C, candidate), which makes it idempotent (a re-run, the synchronize the bump commit itself fires, or a second fix commit on the same PR all write nothing) and means the version can never regress while main is behind staging.
A major is an explicit escape hatch, and overrides the rule above. Two independent signals, the higher wins:
-
Declared — a root
.next-majorfile whose first whitespace-delimited token is the target major, with a short human reason on the same line:3 # overlay migration, 3.0.0 releaseReviewable in the PR that decides it, so a planned major with no single breaking commit still lands as a major. The file is never cleared by CI: it disarms itself once the major it names has shipped. A declaration that has fallen below the major on
mainis a hard CI failure — delete or raise it. -
Discovered — a
!on a conventional-commit type (feat!:,TWO-1/fix(scope)!:) or aBREAKING CHANGE:footer in this PR's own commits only. Deliberately not the cumulativemain..stagingrange: a break that already landed onstagingmust not be re-discovered by every later PR.
The new version for a major is exactly <target>.0.0, so a declaration may skip more than one major.
.github/scripts/decide-bump-level.sh owns this decision, is unit-tested by .github/scripts/test-decide-bump-level.sh, and is shared byte-identically across the plugin repos. It logs the full decision — inputs included — to the workflow log on every run.
Bumping (on the PR) and tagging (on main)
.github/workflows/version-bump.yml runs on every pull_request into staging. It:
- Runs the unit tests for the computation, then computes an absolute version with
.github/scripts/decide-bump-level.sh origin/staging HEAD. - If that version differs from the one on the PR head, runs
bumpver update --set-version <X.Y.Z> --no-tag-commit --no-pushto rewritecomposer.json,etc/config.xmlandbumpver.toml, and pushes the commit onto the PR's own branch under the org GitHub App identity. Otherwise it writes nothing.
The push goes out under the App token rather than GITHUB_TOKEN for two reasons: GITHUB_TOKEN pushes do not trigger workflows, so CI would never re-run on the bump SHA; and the App holds the ruleset bypass. Because the commit lands on a feature branch, it is outside terraform-managed-branch-protection (which targets only refs/heads/{main,release,staging}) entirely.
.github/workflows/release.yml is triggered by the CI workflow completing on main. When CI's conclusion is success, it:
- Skips itself if the branch tip drifted from the SHA CI signed off on, or if the SHA already carries a numeric tag. (That last check is what makes the merge-back a no-op: after a
mainrelease fast-forwards intostaging, staging's tip already carries the tag.) - Reads the version out of
bumpver.toml— it does not compute or bump one. - Tags
X.Y.Z(bare numeric, matching the established tag convention), pushes the tag, and creates a GitHub Release with a bucketed changelog (Breaking / Features / Fixes / Internals / Other).
.github/workflows/merge-back.yml keeps staging fast-forwarded to match main after each release (falling back to a sync PR if the two have diverged).
.github/workflows/auto-pr.yml runs on every push to staging (a merge is a push) and keeps a single rolling staging → main promotion PR open, no-opping when one already exists or when staging is not ahead of main.
To trigger a release, merge that staging → main PR. CI runs on the merged commit; once green, release.yml fires.
.github/workflows/release-dispatch.yml fires on the published Release and notifies the infrastructure repository, which resolves the newly published version and raises the pull request that moves the release-tracking Magento staging shop onto it. Because that shop installs from Packagist, a dispatch can arrive before Packagist has indexed the new tag; the infrastructure repository's daily reconcile covers that case, so nothing here needs to wait or retry (TWO-25769).
Links
License
OSL-3.0 / AFL-3.0. See composer.json for details.
This content is fetched directly from the module's GitHub repository. We are not the authors of this content and take no responsibility for its accuracy, completeness, or any consequences arising from its use.