conneqt / module-punchout
Punchout System
Package info
git.dev.epartment.nl/conneqt/m2/module-punchout
Type:magento2-module
pkg:composer/conneqt/module-punchout
Requires
- php: >=8.1
Requires (Dev)
- magento/framework: ^103.0
- magento/magento-coding-standard: *
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^9.5 || ^10.0
- squizlabs/php_codesniffer: ^3.9
This package is auto-updated.
Last update: 2026-07-15 11:52:32 UTC
README
conneqt/module-punchout
What this module does
This module adds PunchOut support to Magento 2 so a customer can start in a procurement system, punch into Magento, add products to a cart, and send the cart back to the procurement or ERP system.
It supports two wire protocols:
- OCI — the buyer's browser is form-POSTed straight into Magento's login endpoint; the cart is returned as a browser form POST of
NEW_ITEM-*fields. - cXML — a two-step handshake (
PunchOutSetupRequest/PunchOutSetupResponse) followed by a browser-postedPunchOutOrderMessagecart return.
Which fields are sent, in what order, and from what source is not hardcoded — it is defined by admin-configurable Punchout Type records (base protocol + field mappings + an optional custom handler class). Three types (oci, oci_afas, cxml) are seeded on install and reproduce the module's original behaviour; you add your own type to support a new procurement system's field layout without writing code, or write a small handler class for logic a mapping can't express (custom price math, derived fields).
In short, the module does four things:
- Accepts a punchout login request from an external system (OCI form POST or cXML
PunchOutSetupRequest). - Maps that request to a Magento customer using a
PunchoutLink(website + customer +PunchoutSystem+ credential identifiers), with credentials matched and stored only via the link — never customer attributes. - Lets the customer shop inside Magento in a normal storefront session, with punchout-specific labels/messages so the buyer isn't confused by a normal checkout.
- Returns the selected cart back to the external system, formatted per the resolved
PunchoutType's field mappings.
It also stores every inbound/outbound request in a Request Log, the first place to look when testing or troubleshooting an integration, and ships a one-click "Start Test Round-Trip" launcher so an integration can be verified without a manual tester walkthrough.
Contents
- Installation
- Architecture at a glance
- How it works
- Punchout Types & field mapping
- Writing a custom handler
- Authentication & credential security
- Frontend punchout-session UX
- Magento admin configuration
- Testing an integration
- Development
- Running the tests
- Upgrade notes for existing installs
- Important files
- Database entities
- Troubleshooting notes
Installation
In production, use the
--keep-generatedflag where appropriate.
Install from a zip file
- Unzip the module to
app/code/Conneqt/Punchout - Enable the module:
php bin/magento module:enable Conneqt_Punchout
- Apply database and data patches:
php bin/magento setup:upgrade
- Flush cache:
php bin/magento cache:flush
Install with Composer
composer require conneqt/module-punchout
php bin/magento module:enable Conneqt_Punchout
php bin/magento setup:upgrade
php bin/magento cache:flush
See Upgrade notes for existing installs if you are updating from a pre-0.5.0 version of this module (hardcoded types, customer-attribute credentials, pub/media cXML session files).
Architecture at a glance
Four DB tables (etc/db_schema.xml, all prefixed conneqt_punchout_):
- punchouttype — a reusable protocol + field-mapping definition:
base_protocol(oci|cxml),handler_code(key into the handler pool),field_mappings(JSON rows). Replaces the old hardcoded oci/oci_afas/cxml switch. - punchoutsystem — an external procurement system: name,
punchouttype_id(FK),shared_secret(cXML only, encrypted at rest). The legacytypevarchar column is kept but deprecated — read only as a fallback for systems the seed patch hasn't backfilled yet. - punchoutlink — binds a website + Magento customer + punchoutsystem, with an
identifiersJSON blob of identifier/value rows used to authenticate inbound requests. This is the single credential source — no customer attributes are involved. - requestlog — inbound/outbound traffic log, including the raw cXML session payloads (no more
pub/mediafiles). First place to look when debugging an integration.
There is no longer a persisted per-quote-item snapshot table. Model/Response/SnapshotBuilder computes an in-memory Model\PunchoutItems value object (sku, price, vat, unit…) fresh from the live quote item at return time — nothing is written to the database while the customer shops. The old conneqt_punchout_punchoutitems table, its Api/ResourceModel/Repository stack, and Observer/RemoveFromCart.php were removed; Observer/AddToCart.php survives only to set a one-time SkipPunchoutCheckoutRedirect session flag. The table's etc/db_schema_whitelist.json entry is kept deliberately — declarative schema only drops a table that's no longer declared, while the whitelist entry still records prior ownership of that table name; it can be removed in a later release once every environment has upgraded past this change.
Outbound payload assembly is split into small, single-purpose classes:
Model/Handler/HandlerPool+Api/PunchoutTypeHandlerInterface+Model/Handler/GenericHandler— resolves a mapping row's value; extension point for custom logic (see Writing a custom handler).Model/Handler/PunchoutContext— the value object handed to a handler (quote, quote item, product, punchout-item snapshot, resolved link/system/type).Model/Response/OciPayloadBuilder/Model/Response/CxmlPayloadBuilder— turn resolved mapping rows into the wire format.Model/Response/SnapshotBuilder— computes the in-memory per-quote-itemModel\PunchoutItemssnapshot from the live quote item; nothing is persisted.Model/Response/FinishService— orchestrates the return ("finish") flow: resolves the session's type/link/system, rebuilds item snapshots viaSnapshotBuilder, builds the payload, records it on the request log, deactivates the quote, and (on success only) tells the block to clean up the session.Block/Response/Datais now a thin delegator toFinishService;Helper/Dataonly keeps enablement/config/session access andupdateLogStatus().
How it works
OCI flow
The procurement system sends an HTTP POST to:
{base-url}/conneqt-punchout/index/loginController/Index/Login.phpvalidates the required OCI parameters (USERNAME,PASSWORD,HOOK_URL).Helper/Login.phpmatches the credentials againstPunchoutLinkidentifiers only: an SQL prefilter on the non-secretUSERNAMEvalue, then a PHP loop that decrypts each candidate's storedPASSWORDand compares it withhash_equals()— the password never appears in a SQL predicate. On a match, it also checks that the matched customer belongs to the website configured on the link (see Authentication & credential security).On success, the customer is logged in, the current store is switched to the link's configured website (if any), and the punchout session data (including
HOOK_URL, the resolvedPunchoutTypeid/code, and the matched link/system ids) is stored in the core session.While the customer shops, no snapshot is written anywhere:
Observer/AddToCart.phponly sets a one-timeSkipPunchoutCheckoutRedirectsession flag (so a checkout route hit right after an add doesn't immediately trigger the punchout return). The per-item snapshot itself is computed on demand, in memory, byModel/Response/SnapshotBuilderwhen the cart is actually returned.When the customer hits a configured checkout route (see Session lifecycle),
Observer/PunchoutAction.phpredirects to/conneqt-punchout/index/customcheckout.Block/Response/Data.phpdelegates toModel/Response/FinishService, which resolves the session'sPunchoutTypeand builds the outboundNEW_ITEM-*fields viaModel/Response/OciPayloadBuilderand the type's handler.view/frontend/templates/postData.phtmlauto-posts those fields back toHOOK_URL, then cleans up the punchout session (only if the payload was actually built — see below).
cXML flow
The procurement system sends a cXML
PunchOutSetupRequestto:{base-url}/conneqt-punchout/index/authController/Index/Auth.phpreads the raw POST body and parses it entirely inside a try/catch viaHelper/CxmlLogin::parseXml(), which loads the XML withLIBXML_NONETand no entity/DTD loading (XXE-safe) and throws on malformed input instead of returning a false/warningSimpleXMLElement. It extracts:Header/From/Credential/@domainHeader/Sender/Credential/IdentityHeader/Sender/Credential/SharedSecretRequest/PunchOutSetupRequest/BuyerCookieRequest/PunchOutSetupRequest/BrowserFormPost/URL
Helper/CxmlLogin::login($params, onlyVerify: true)matches aPunchoutLinkwhoseidentifierscontain an entry named exactly the inbounddomainvalue (e.g.NetworkId,DUNS— whatever the buyer's system sends) withidentifier_valueequal to the inbound identity, then decrypts the matchedPunchoutSystem.shared_secretand compares it against the inboundSharedSecretwithhash_equals(). Website scope is validated the same way as OCI.On success, Magento writes a
RequestLogrow (session_status = 3, "cXML Auth complete") with the raw cXML request body stored directly inrequestlog.request_data— nothing is written topub/media— and returns aPunchOutSetupResponsewhoseStartPage/URLpoints atController/Index/CreateSession.php?sid=<requestlog_id>.The buyer's browser follows that URL.
CreateSessionre-loads the requestlog row, checks it is still fresh (created within the last 5 minutes, compared in UTC via the framework date library) and still in the "auth complete" state, re-parses the stored XML, and this time callslogin($data, onlyVerify: false)to actually log the customer in and switch the store to the link's website. On success the row moves tosession_status = 4("cXML Session Active") and the punchout session data is stored, same as OCI.The customer shops in Magento.
At checkout,
FinishServicebuilds thePunchOutOrderMessageviaModel/Response/CxmlPayloadBuilderand the type's handler.postData.phtmlposts it back toBrowserFormPost/URLas thecxml-urlencodedform field. The raw XML goes into the input'svalueattribute HTML-escaped only — the browser's ownapplication/x-www-form-urlencodedsubmission applies URL-encoding exactly once. Do not add an expliciturlencode()there; that was a real bug (double-encoding, fixed July 2026) that made receivers see literal%3C%3Fxml...instead of parsed XML.
Session lifecycle, empty-cart exit, checkout triggering
- Single-use, cleaned up on success only.
Block\Response\Data::getResponseParams()callsFinishService::process()and remembers whether a payload was actually produced;shouldCleanup()is onlytruein that case.postData.phtmlonly callslogoutCustomer()(which delegates toFinishService::cleanup()— customer logout + clearing themage-cache-sessidcookie) whenshouldCleanup()is true. A failed or empty-without-confirmation attempt leaves the punchout session intact so the buyer can retry. - Empty-cart exit requires explicit confirmation. An empty cart is never silently posted.
Observer/PunchoutAction.phpdoes not redirect at all when the quote has no items; if the buyer explicitly triggers "finish punchout" with an empty cart (minicart/cart button or the dedicated empty-cart exit link), the frontend shows a confirm/cancel dialog and, on confirmation, navigates tocustomcheckout?confirm_empty=1.Controller/Index/CustomCheckout.phpredirects back to the cart with a notice unlessconfirm_empty=1is present;FinishService::process()throwsEmptyCartExceptionfor an empty cart unless$confirmEmptyis true, in which case it builds a deliberately empty transfer (the conventional punchout cancel) and treats it as a successful exit. - Checkout trigger routes are admin-configurable. A single
controller_action_predispatchobserver (Observer/PunchoutAction.php) matches the current full action name againstStores > Configuration > Conneqt Punchout > General > Checkout Trigger Routes(config pathconneqt_punchout_section/general/checkout_routes, one full action name per line). Defaults:checkout_index_index,checkout_cart_index,checkout_onepage_index,firecheckout_index_index,hyva_checkout_index_index. Add a route by finding its full action name (route id + controller folder + action method, e.g.hyva_checkout+Controller/Index/Index.php→hyva_checkout_index_index) — no code change needed. - Redirect mechanics. The observer sets
ActionFlag::FLAG_NO_DISPATCHbefore issuing the redirect through the controller action's own response object, so the target controller never runs after the redirect is set (this used to be a real bug — see Troubleshooting notes). - A
SkipPunchoutCheckoutRedirectsession flag (set byObserver/AddToCart.phpafter certain adds) is consumed on the very next hit of any configured checkout route, so it can never linger and suppress a later, unrelated redirect.
Punchout Types & field mapping
A Punchout Type (Conneqt > Punchout Types) defines how a punchout system talks to the store. It replaces the old hardcoded oci/oci_afas/cxml switch in helper code.
Fields:
- Name — human-readable, shown when picking a type on a Punchout System.
- Code — unique machine code (e.g.
oci,cxml). Keep stable once an integration relies on it. - Base Protocol —
oci(browser form POST ofNEW_ITEM-*fields) orcxml(PunchOutOrderMessage). Selects both the login flow and the outbound format; the field choices below depend on it. - Handler —
Generic (mappings only)by default; a custom handler registered viadi.xmlalso appears here (see Writing a custom handler). - Field Mapping — an ordered (
sort_order) list of rows, each{punchout_field, source_type, source_value}.
The admin UI for punchout_field and source_value is a dropdown filtered by context, with a "Custom…" option that reveals a free-text input — not a plain free-text field. punchout_field's dropdown is filtered by the row's Base Protocol; source_value's dropdown is filtered by the row's own Source (source_type). Whatever is chosen or typed is stored as one plain string either way (Conneqt_Punchout/js/form/element/mappingValueSelect component), so existing rows and the seed data remain simple strings.
Why the component overrides
normalizeDataand sets acaption(regression guard — do not remove either): the baseMagento_Ui/js/form/element/selectmaps the loaded value onto the option list duringsetInitialValue()and returnsundefinedfor anything not in it, which would wipe every persisted custom field name, static value, and custom identifier the moment the form opens (and the next save would persist the wiped value — the "custom/static values don't save" bug). And without an explicit caption, the basesetOptions()sets the caption observable to booleanfalse, which Knockout'soptionsCaptionrenders as a literal "false" dropdown entry whileclear()/normalizeData('')silently auto-select the first option for new rows.
punchout_field — for OCI, the field name without the NEW_ITEM- prefix. The dropdown offers the conventional names both payload builders themselves recognize (Model\Response\OciPayloadBuilder::KNOWN_FIELDS / CxmlPayloadBuilder::KNOWN_FIELDS, so the dropdown and what the builders actually do can't drift apart):
OCI (NEW_ITEM-<field>) | cXML (ItemIn/ItemDetail element, or Extrinsic:<name>) |
|---|---|
VENDORMAT, DESCRIPTION, LONGTEXT, MATGROUP, PRICE, PRICEUNIT, CURRENCY, QUANTITY, UNIT, TAXRATE, EXT_PRODUCT_ID, EXT_QUOTE_ID, CUST_FIELD1–CUST_FIELD5 | Quantity, SupplierPartID, SupplierPartAuxiliaryID, UnitPrice, Currency, Description, UnitOfMeasure, Classification |
Picking "Custom field name…" and typing a name posts it verbatim: NEW_ITEM-<CUSTOM> for OCI (the OCI builder is fully generic — any field name is forwarded as-is), or Extrinsic:<CUSTOM> for cXML (rendered as <Extrinsic name="<CUSTOM>"> inside ItemDetail).
source_type — where the value comes from:
| source_type | source_value | Example values |
|---|---|---|
punchout_item | a per-item snapshot field, computed in memory from the live quote item at return time by Model\Response\SnapshotBuilder (nothing is persisted) | sku, quantity, price (row total), unit_price (per unit), unit_fixed (always 1), vat, unit, currency, product_description, product_id, quote_item_id |
quote_item | a Quote\Item attribute/getter | sku, name, qty, price, base_price, row_total, base_row_total, discount_amount, tax_amount, weight, product_id, product_type, description (not exhaustive — the handler passes any code straight to getData()) |
product | a product EAV attribute code, loaded from the quote item's product | fetched live from the attribute repository, so a newly added attribute appears automatically |
quote | a quote-level key | quote_id, reserved_order_id, store_currency, quote_currency, grand_total, base_grand_total |
static | a literal value typed into source_value | anything |
Three types are seeded on install (oci, oci_afas, cxml), reproducing this module's original hardcoded mappings exactly, with three deliberate behaviour fixes baked in:
EXT_QUOTE_IDnow maps to the quote's own id (source_type: quote,quote_id) instead of the never-set snapshot fieldidit used to point at (which always posted empty).CUST_FIELD2(base OCI type) now maps tounit_priceinstead of the oldgetFactorUnitPrice()hook, which read an unrelated config path and then returned the price unchanged — that hook is gone; if you need price math, write a custom handler.- cXML
UnitPricenow carries the per-unit price (unit_price), never the row total — the legacy code put the row total there, which was wrong for any quantity other than 1.
One documented, intentional inconsistency: the cXML header's PunchOutOrderMessageHeader/Total/Money is the quote's grand total (tax included), while each line's ItemDetail/UnitPrice is excl. tax — the header total does not equal the sum of the lines. This mirrors the legacy behaviour and is pinned by a unit test rather than "fixed", since changing it would change what buyer systems expect to reconcile.
Existing PunchoutSystem rows are automatically pointed at the matching seeded type by a data patch (see Upgrade notes).
Writing a custom handler
The built-in generic handler (Model\Handler\GenericHandler) resolves every field purely from the configured mappings. When a mapping alone can't express what you need — custom price math, a derived field, a whole-payload tweak for one customer — write a handler:
Extend
Model\Handler\GenericHandler(or implementApi\PunchoutTypeHandlerInterfacedirectly) and override one or more of:resolveValue(array $mapping, PunchoutContext $context): mixed— per-field value resolution.afterBuildItem(array $itemData, PunchoutContext $context): array— post-process one item's resolved field map (punchout_field => value) before it is serialized.afterBuildPayload($payload, PunchoutContext $context)— post-process the whole assembled payload (the OCI parameter array, or the cXML XML string).getLabel(): string— the label shown in the admin "Handler" dropdown.
PunchoutContextexposesgetType(),getQuote(),getQuoteItem(),getProduct(),getPunchoutItem()(the snapshot),getLink()andgetSystem()— the item/product/snapshot getters arenullfor the quote-levelafterBuildPayload()call, andgetLink()/getSystem()can benullfor a session established before these ids were stored (old in-flight sessions), so custom handlers must tolerate that.Register it in your own module's
di.xml— no code in this module changes:<type name="Conneqt\Punchout\Model\Handler\HandlerPool"> <arguments> <argument name="handlers" xsi:type="array"> <item name="my_erp" xsi:type="object">Vendor\Module\Model\Handler\MyErpHandler</item> </argument> </arguments> </type>Select it under Handler on the relevant Punchout Type — it appears automatically (
Model\Handler\Source\HandlerOptionslists every handler in the pool).
Example — a handler that adds a fixed surcharge to every unit price and uppercases the SKU:
namespace Vendor\Module\Model\Handler;
use Conneqt\Punchout\Model\Handler\GenericHandler;
use Conneqt\Punchout\Model\Handler\PunchoutContext;
class MyErpHandler extends GenericHandler
{
public function getLabel(): string
{
return 'My ERP (surcharge + uppercase SKU)';
}
public function resolveValue(array $mapping, PunchoutContext $context)
{
$value = parent::resolveValue($mapping, $context);
if (($mapping['punchout_field'] ?? null) === 'PRICE' && is_numeric($value)) {
return $value + 1.50; // fixed surcharge per unit
}
return $value;
}
public function afterBuildItem(array $itemData, PunchoutContext $context): array
{
if (isset($itemData['VENDORMAT']) && is_string($itemData['VENDORMAT'])) {
$itemData['VENDORMAT'] = strtoupper($itemData['VENDORMAT']);
}
return $itemData;
}
}
The module's own integration test suite (Test/Integration/Fixture/UppercaseSkuTestHandler.php, exercised by Test/Integration/TypeSystemTest.php) uses exactly this afterBuildItem() pattern to prove the extension point end to end.
Authentication & credential security
- PunchoutLink identifiers are the single credential source. The old double-check against customer EAV attributes (
punchout_username,punchout_password) is gone; a data patch migrated any still-needed values into link identifiers and removed the attributes (includingpunchout_secretkey, which was created by an old patch but never actually used for cXML auth — cXML has always authenticated viaPunchoutSystem.shared_secret). - Secrets are encrypted at rest via
Model\Crypt\SecretCodec(wrappingMagento\Framework\Encryption\EncryptorInterface):punchoutsystem.shared_secret, and anypunchoutlink.identifiersrow whoseidentifierisPASSWORDorSharedSecret(PunchoutLink::SECRET_IDENTIFIERS). Encryption happens in the models'beforeSave(), so it applies whether the value comes from the admin form or a repository consumer. Values are shown decrypted on the admin forms by design — they must be handed to the procurement party — but are never shown decrypted in the Request Log or any grid. - Matching never puts a secret in a SQL predicate. Both
Helper\Login(OCI) andHelper\CxmlLogin(cXML) prefilter candidate links in SQL on a non-secret value only (USERNAME, or the cXML domain identifier), then decrypt and compare the actual secret in PHP withhash_equals(). - Login enforces website scope.
punchoutlink.store_id— despite the column name — holds a website id (the admin field is labeled "Website ID" and its option source is the same one the core customer "Associate to Website" field uses). A non-empty value means: the matched customer must belong to that website, or the login is refused; the storefront session is then switched to that website's default store, so the buyer shops the intended catalog/prices/currency. A link with no configured scope is treated as unscoped (any website accepted) for backward compatibility with links created before this check existed. - Secrets are masked in the Request Log, not just encrypted in the source tables: the OCI
PASSWORDparameter and the cXML<SharedSecret>element content are replaced with***MASKED***before the inbound request is persisted (Helper\Login::registerRequestLog()). - cXML parsing is XXE-safe.
Helper\CxmlLogin::parseXml()loads XML withLIBXML_NONETand noLIBXML_NOENT/LIBXML_DTDLOADflags (PHP's safe defaults), inside a try/catch, so malformed or hostile XML yields a clean cXML error response instead of a Magento exception page or an XXE attempt. - The Request Log grid never renders external payloads as HTML.
request_data/response_dataare plain, escaped text columns; a "View" row action (Controller/Adminhtml/RequestLog/View.php) opens the full payload in its own page, HTML-escaped, with XML pretty-printed (also XXE-safe) — this replaced the oldbodyTmpl=ui/grid/cells/htmlrendering, which was a stored-XSS surface for unauthenticated, externally-supplied content.
Credentials are still handled as plaintext at the point of use — they exist unencrypted at login time and on the admin form — this module encrypts values at rest and avoids leaking them into logs, not zero-knowledge storage. Treat the database and admin access accordingly.
Frontend punchout-session UX
During an active punchout session (detected the same way everywhere: the core session key ConneqtPunchoutData present and carrying a HOOK_URL), the storefront changes in a few small, reversible ways — everything is gated on the session and inactive sessions see completely normal Magento text:
- Checkout/cart buttons say "Finish PunchOut shopping process" instead of "Proceed to Checkout": server-side on the cart page (
Plugin\Block\RewriteCheckoutButtonLabelPlugin, anafterToHtmlplugin onMagento\Checkout\Block\Onepage\Link), client-side on the minicart (view/frontend/web/js/finish-guard.js, DOM-patching after every knockout re-render, since the minicart's button text is hard-coded in a Magento_Checkout.htmltemplate no module can override). - The add-to-cart success message is link-free:
Plugin\Message\RewriteAddToCartMessagePlugininterceptsInterpretationStrategy::interpret()(the one call both the static page message and the "popup" customer-data message go through) and replaces it with"%1 has been added to your cart."— no "View cart"/"Proceed to checkout" link, since punchout buyers post their cart to the external system, not through Magento checkout. - An empty-cart exit needs explicit confirmation. Clicking a "finish punchout" trigger (minicart button, cart page button, or the dedicated exit link injected into the cart's empty-cart view — stock Magento renders no checkout button there at all) with an empty cart opens a confirm/cancel dialog ("You are about to exit PunchOut shopping with an empty cart" / "Keep shopping" / "Continue & exit"). Confirming navigates to
customcheckout?confirm_empty=1. Works on Luma/Blank and Hyvä. The Luma implementation (
finish-guard.js) usesMagento_Customer/js/customer-dataandMagento_Ui/js/modal/confirm. The Hyvä implementation (view/frontend/web/js/hyva/finish-guard.js, loaded only whenHyva_Themeis enabled, viatemplates/hyva/finish-guard-init.phtml) is dependency-free: it talks directly tocustomer/section/load, finds triggers by their stable, server-translated text rather than a guessed CSS selector, and ships its own minimal confirm dialog since Hyvä has no built-in equivalent.Not exercised against a real Hyvä install — this project's host Magento has no Hyvä theme/modules present. Re-verify
hyva/finish-guard.jsagainst your shop's actual Hyvä theme before relying on it in production; the Luma half is exercised normally.Customer-data invalidation uses a cookie, not
sections.xmlalone. The punchout login/createsession/checkout-return endpoints are all top-level browser navigations (often the very first request in the tab, or a cross-domain form post) — there is no already-loaded page for Magento's normal AJAX-observing invalidation to work with.Plugin\CustomerData\ForceCustomerDataRefreshPlugin(anafterExecuteplugin on the Login/CreateSession/CustomCheckout controllers) instead sets the samesection_data_cleancookie Magento core uses for store-view switching, forcing every customer-data section — including the newpunchout-sessionsection — to reload unconditionally on the next page.
Magento admin configuration
Global configuration
Path: Stores > Configuration > Conneqt Punchout > General
Defined in etc/adminhtml/system.xml / etc/config.xml. Config path prefix: conneqt_punchout_section/.
| Group | Field | Config path | Default | What it does |
|---|---|---|---|---|
| General Configuration | Enable | general/enable | Yes | Master switch. Disabled = login endpoints refuse every request, no checkout redirect, storefront behaves like a normal shop. |
| General Configuration | Max Description Length | general/description_length | 250 | Characters kept from a product's name/description before sending it (DESCRIPTION/LONGTEXT for OCI, Description for cXML). Previously defaulted to 40, which visibly clipped names; installs with an explicit saved value are unaffected by the new default. |
| General Configuration | Checkout Trigger Routes | general/checkout_routes | see Session lifecycle | One full action name per line; see that section for how to add a route. |
| cXML Configuration | cXML Sender Identity | cxml/identity | AN0000000000-T | The Identity placed in the From/Sender credentials of outbound cXML documents. Only relevant for cXML types. |
| Testing | OCI Test Return URL | testing/oci_return_url | https://punchoutcommerce.com/tools/oci-roundtrip-return | HOOK_URL used by the "Start Test Round-Trip" button for OCI-based types. |
| Testing | cXML Test Return URL | testing/cxml_return_url | https://punchoutcommerce.com/tools/cxml-punchout-return | BrowserFormPost/URL used by the "Start Test Round-Trip" button for cXML-based types. |
Punchout Type
Path: Conneqt > Punchout Types — see Punchout Types & field mapping.
Punchout System
Path: Conneqt > PunchoutSystem
Fields:
- System Name
- Punchout Type — selects the protocol + field mapping (replaces the old
Typeselect ofoci/oci_afas/cxml). Manage the available types underConneqt > Punchout Types. - Shared Secret — only used by cXML-based types (the credential the procurement system signs its
PunchOutSetupRequestwith); encrypted at rest, shown decrypted on this form. Leave blank for OCI types.
Punchout Link
Path: Conneqt > PunchoutLink
Fields:
- Website ID — despite the internal column name (
store_id), this is a website. Now also scopes login (see Authentication & credential security). - Customer — searchable by name/email (
Block/Adminhtml/Form/Component/CustomerSearchBar.php). - Punchout System — the procurement system this link belongs to.
- Identifier Mapping (dynamic rows) — Identifier / Value pairs. The Identifier column is a dropdown of the names the module's login flows recognize (
Model/Config/Source/IdentifierOptions, rendered by the samemappingValueSelectselect-with-free-text-fallback component as the PunchoutType mapping rows), with a "Custom identifier…" option revealing a free-text input for anything not listed. Existing rows with an unlisted name automatically load in custom mode; the persisted shape is unchanged (a plain string).- For OCI, add
USERNAMEandPASSWORDrows (both in the dropdown — these are the literal namesHelper/Loginmatches on). - For cXML, add one row whose Identifier is the exact domain value the buyer's system sends in
From/Credential/@domainand whose Value is the buyer'sSender/Credential/Identity. Common domains (NetworkId,DUNS,AribaNetworkUserId) are in the dropdown; any other domain goes in via "Custom identifier…". The shared secret itself lives on the Punchout System, not here. PASSWORD/SharedSecret-named values are encrypted at rest but shown decrypted on this form (they must be handed to the procurement party).
- For OCI, add
A link binds together one website, one customer, one Punchout System, and its credential identifiers into a single loginable identity. Use "Start Test Round-Trip" (edit page button or grid row action) to verify a link end to end — see Testing an integration.
Request Log
Path: Conneqt > RequestLog, stored in conneqt_punchout_requestlog.
| Status | Meaning |
|---|---|
0 | Invalid Request |
1 | Session Active (an OCI buyer is logged in and shopping) |
2 | Closed |
3 | cXML Auth complete (setup request verified, buyer's browser hasn't arrived yet) |
4 | cXML Session Active (the cXML buyer is logged in and shopping) |
request_data/response_data are plain, escaped text in the grid; use the View row action to see the full payload (HTML-escaped, XML pretty-printed) in a new tab. Secret values (PASSWORD, <SharedSecret>) are masked before being written, so they never appear here even in the raw payload. cXML session payloads live directly in this table — there is no pub/media file to look for anymore.
Testing an integration
Built-in round-trip tester
Every Punchout Link has a "Start Test Round-Trip" button (edit page + grid row action, gated by its own ACL resource Conneqt_Punchout::PunchoutLink_test — narrower than plain view/save, since it uses the link's real, decrypted credentials):
- OCI-based types: opens a new tab with a page that auto-submits the link's
USERNAME/PASSWORD(plus the standard~TARGET/~OkCode/~CALLEROCI control fields) straight into the store's ownconneqt-punchout/index/loginendpoint, withHOOK_URLset to the configured OCI Test Return URL. - cXML-based types: Magento itself builds a fresh
PunchOutSetupRequest(identity from the link's identifiers,SharedSecretdecrypted from the system) and POSTs it server-side to the store's ownconneqt-punchout/index/auth, then redirects your browser to thePunchOutSetupResponse'sStartPageURL — or shows the cXMLStatustext as an admin error if the system rejects it. - Either way you land on the storefront logged in as the punchout customer; shop, then use the (now-relabeled) "Finish PunchOut shopping process" button to post the cart to the configured Test Return URL — by default a PunchOutCommerce page that displays whatever it receives.
- Both legs mark the resulting
requestlogrow (request_type = test_roundtrip) so test traffic is easy to tell apart from real traffic in the grid. - Requirements: the link needs
USERNAME/PASSWORDidentifiers for OCI, or a domain/identity identifier plus the system's Shared Secret for cXML — the button tells you which is missing. - Because both legs are initiated by your own browser/server, this works from local dev environments even though the return leg posts to a public page.
Manual fallback: PunchOutCommerce testers
If you'd rather drive the buyer side yourself (or the built-in launcher can't reach your environment):
- OCI RoundTrip Tester:
https://punchoutcommerce.com/tools/oci-roundtrip-tester→ POSTURL = {base-url}/conneqt-punchout/index/loginwithUSERNAME/PASSWORDmatching a link's identifiers,HOOK_URLleft at the tester default (or your own receiver). - cXML PunchOut Tester:
https://punchoutcommerce.com/tools/cxml-punchout-tester→ POSTURL = {base-url}/conneqt-punchout/index/auth, withFromDomain/SenderIdentitymatching a link's identifier, andSharedSecretmatching the Punchout System's Shared Secret.
In both cases, check Conneqt > RequestLog to confirm what Magento received and answered.
Development
The module ships a standalone PHPUnit/PHPCS/PHPStan setup that runs outside a full Magento install (.gitlab-ci.yml):
composer install --ignore-platform-reqs # pulls magento/framework from the public mage-os mirror — no repo.magento.com credentials needed
composer test # lint + phpcs + phpstan + phpunit
composer lint # php -l across all PHP files
composer phpcs # Magento2 coding standard
composer phpstan # static analysis (level 5, see phpstan.neon / phpstan-baseline.neon)
composer phpunit # standalone unit suite
CRITICAL when the module is checked out inside a real Magento project (
app/code/Conneqt/Punchout): delete the module-localvendor/directory (andcomposer.lock) after a standalonecomposer install— both are gitignored, but ifvendor/is left in place it breaksbin/magento setup:di:compilewith a "Phar wrapper" error. Only run standalonecomposer installwhen you actually intend to run the module's own CI-style checks in isolation; day-to-day development inside a host project never needs it.
Inside a host Magento project, run the unit suite through the host's own PHPUnit config instead (it already discovers app/code/*/*/Test/Unit):
roll clinotty php vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/Conneqt/Punchout/Test/Unit
Magento's usual commands still apply after XML/config/patch changes:
bin/magento setup:upgrade # db_schema.xml / data patch changes
bin/magento setup:di:compile # di.xml changes / DI-layer fatals
bin/magento cache:flush # after XML/config changes
Running the tests
Unit tests (Test/Unit/) run both standalone (see above) and inside a host project. They cover matching/encryption logic, the handler pool and payload builders, controllers, plugins and setup patches — mirroring the class namespace under Test/Unit/.
Integration tests (Test/Integration/) exercise full request lifecycles (OCI/cXML round trips, session lifecycle regressions, the type system, migration patches, the test launcher) against a real Magento application and database, and are not run in CI — only via roll from a host project:
roll db connect -e 'CREATE DATABASE IF NOT EXISTS magento_integration_tests'
Configure the host project's dev/tests/integration/etc/install-config-mysql.php from the .dist template to point at the RollDev DB container with that dedicated database name (the integration test framework drops and reinstalls it, so never point this at a real project database). Some host projects also need to add unrelated third-party modules to disable-modules here if their own data patches fail against a fresh install — that's a host-project quirk, not something this module's tests depend on.
Run the suite:
roll clinotty bash -c 'cd dev/tests/integration && TESTS_CLEANUP=disabled php ../../../vendor/bin/phpunit -c phpunit.xml.dist ../../../app/code/Conneqt/Punchout/Test/Integration'
A few tests markTestSkipped() with an explanation in their docblock/message (typically third-party plugin quirks that surface when the integration test harness re-initializes state mid-test in some host projects) — their underlying logic is covered at the unit-test level instead of being silently disabled.
Upgrade notes for existing installs
Running setup:upgrade on an existing installation applies, in order:
SeedPunchoutTypes— inserts the three built-in types (idempotent: a type whosecodealready exists is left alone) and backfills everypunchoutsystem.punchouttype_idfrom its legacytypecolumn.EncryptExistingPunchoutSecrets— encrypts any plaintextshared_secret/PASSWORD/SharedSecretvalue found in existing rows (idempotent — already-encrypted values are left untouched).MigrateCustomerCredentialsToPunchoutIdentifiers— for any link still missing aUSERNAME/PASSWORDidentifier, copies the values from the customer'spunchout_username/punchout_passwordattributes (encrypting the password immediately).RemoveCustomerCredentialAttributes— removes thepunchout_username,punchout_passwordandpunchout_secretkeycustomer attributes. By this point nothing reads them.
Practical consequences:
- In-flight cXML sessions at the moment you deploy will need to re-authenticate. The cXML session payload storage moved from
pub/media/punchout/cxml/*.xmlfiles torequestlog.request_data; aPunchOutSetupResponseissued just before the upgrade still points at acreatesessionURL, but the freshness window is only 5 minutes, so in practice this is a non-issue. - The legacy
PunchoutSystem.typecolumn and value are kept, purely as a fallback for any system row the seed patch didn't backfill (it shouldn't happen, but the code tolerates it) — new development should usepunchouttype_id/ the type'sbase_protocol, nottype. Setup/Patch/Data/CustomerAttribute.phpandCustomerSecretKeyAttribute.phpare intentionally left in place even though they're superseded — patch history must not be rewritten;RemoveCustomerCredentialAttributesonly undoes their effect going forward.bin/magento setup:upgradedrops theconneqt_punchout_punchoutitemstable now that it's no longer declared inetc/db_schema.xml. This is intentional and safe: the table only ever held a transient, observer-maintained preview of the current cart, never the source of truth for a transfer (the return flow always rebuilds the snapshot fresh from the live quote viaModel\Response\SnapshotBuilder) — there is no data to lose. The correspondingetc/db_schema_whitelist.jsonentry is deliberately kept; see Database entities.
Important files
| File | What it does |
|---|---|
Controller/Index/Login.php | Entry point for OCI punchout login requests |
Helper/Login.php | Matches OCI requests to PunchoutLink identifiers (encrypted, hash_equals), enforces website scope, writes request logs |
Controller/Index/Auth.php | Entry point for cXML PunchOutSetupRequest |
Controller/Index/CreateSession.php | Completes the cXML login by converting the requestlog token into a Magento session |
Helper/CxmlLogin.php | cXML-specific authentication, XXE-safe XML parsing, setup response generation |
Model/Response/FinishService.php | Orchestrates the checkout-return flow: resolves type/link/system, rebuilds item snapshots via SnapshotBuilder, builds the payload, records it, deactivates the quote, decides whether to clean up |
Model/Response/SnapshotBuilder.php | Computes the in-memory per-quote-item Model\PunchoutItems snapshot from the live quote item; nothing is persisted |
Model/Response/OciPayloadBuilder.php / CxmlPayloadBuilder.php | Turn a PunchoutType's resolved field mappings into the OCI/cXML wire format |
Model/Handler/GenericHandler.php / HandlerPool.php | Default mapping-only value resolution; extension point for custom handlers |
Block/Response/Data.php | Thin frontend block delegating to FinishService; decides success vs failure for cleanup |
view/frontend/templates/postData.phtml | Auto-posts the generated payload back to the procurement system, then cleans up on success |
Observer/PunchoutAction.php | Redirects a configured checkout route to the punchout return page (with FLAG_NO_DISPATCH) |
Observer/AddToCart.php | Sets a one-time SkipPunchoutCheckoutRedirect session flag on certain adds (no snapshot persistence — that observer, and RemoveFromCart.php, were removed) |
Model/Crypt/SecretCodec.php | Encrypts/decrypts/idempotency-checks credential values |
Controller/Adminhtml/PunchoutLink/Test.php | "Start Test Round-Trip" launcher |
Controller/Adminhtml/RequestLog/View.php | Escaped, pretty-printed full-payload viewer for a Request Log row |
Setup/Patch/Data/* | Type seeding, secret encryption, credential migration/removal (see Upgrade notes) |
etc/db_schema.xml | Defines the punchouttype, punchoutsystem, punchoutlink, and requestlog tables |
Database entities
Defined in etc/db_schema.xml:
conneqt_punchout_punchouttype— protocol + field-mapping definitionsconneqt_punchout_punchoutsystem— external procurement system definitionsconneqt_punchout_punchoutlink— website + customer + system + credential bindingsconneqt_punchout_requestlog— inbound/outbound request logs (including cXML session payloads)
There is no conneqt_punchout_punchoutitems table anymore — the per-item snapshot is an in-memory value object computed by Model\Response\SnapshotBuilder (see Architecture at a glance). The table's etc/db_schema_whitelist.json entry is kept on purpose for now; see that section for why.
Troubleshooting notes
The frontend route is
conneqt-punchout(etc/frontend/routes.xml). Public entry URLs:/conneqt-punchout/index/login /conneqt-punchout/index/auth /conneqt-punchout/index/createsession /conneqt-punchout/index/customcheckoutThese endpoints are deliberately unauthenticated by Magento —
Controller/Index/Login.phpimplementsCsrfAwareActionInterfaceand skips CSRF validation because requests come from external systems. The only gate is the identifier/credential match; treat every request parameter (HOOK_URLincluded) as untrusted input. There is no domain allowlist onHOOK_URL/BrowserFormPost URLand no rate limiting/lockout on the login endpoints — both were considered and explicitly declined as out of scope.Observer/PunchoutAction.phponly redirects when the punchout session data contains aHOOK_URLand the current quote has items and the flag hasn't already been consumed for this cart action — see Session lifecycle if a redirect seems to fire (or not fire) unexpectedly.- If the cart returns empty data, first verify products were added after the punchout session was created. There is no
punchoutitemstable to inspect anymore — the return-time payload is always built fresh from the live quote byModel\Response\SnapshotBuilder, soconneqt_punchout_requestlog(the recorded outbound payload) is the only place left to inspect what was actually sent. - If the receiving system rejects the cXML cart return with a "Malformed XML" error whose payload is still URL-encoded (starts with
%3C%3Fxml...), something re-introduced double-encoding inpostData.phtml— see the note at the end of the cXML flow. Controller/Index/CustomCheckoutCart.php(unconditionalprint_r+diedebug code) has been removed — if you're looking at an old checkout, it is no longer part of this module.