conneqt/module-punchout

Punchout System

Maintainers

Package info

git.dev.epartment.nl/conneqt/m2/module-punchout

Type:magento2-module

pkg:composer/conneqt/module-punchout

Transparency log

Statistics

Installs: 1 228

Dependents: 0

Suggesters: 0

0.5.0 2026-07-15 11:23 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-posted PunchOutOrderMessage cart 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:

  1. Accepts a punchout login request from an external system (OCI form POST or cXML PunchOutSetupRequest).
  2. 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.
  3. 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.
  4. 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

In production, use the --keep-generated flag where appropriate.

Install from a zip file

  1. Unzip the module to app/code/Conneqt/Punchout
  2. Enable the module:
php bin/magento module:enable Conneqt_Punchout
  1. Apply database and data patches:
php bin/magento setup:upgrade
  1. 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 legacy type varchar 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 identifiers JSON 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/media files). 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-item Model\PunchoutItems snapshot 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 via SnapshotBuilder, 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/Data is now a thin delegator to FinishService; Helper/Data only keeps enablement/config/session access and updateLogStatus().

How it works

OCI flow

  1. The procurement system sends an HTTP POST to:

    {base-url}/conneqt-punchout/index/login
    
  2. Controller/Index/Login.php validates the required OCI parameters (USERNAME, PASSWORD, HOOK_URL).

  3. Helper/Login.php matches the credentials against PunchoutLink identifiers only: an SQL prefilter on the non-secret USERNAME value, then a PHP loop that decrypts each candidate's stored PASSWORD and compares it with hash_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).

  4. 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 resolved PunchoutType id/code, and the matched link/system ids) is stored in the core session.

  5. While the customer shops, no snapshot is written anywhere: Observer/AddToCart.php only sets a one-time SkipPunchoutCheckoutRedirect session 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, by Model/Response/SnapshotBuilder when the cart is actually returned.

  6. When the customer hits a configured checkout route (see Session lifecycle), Observer/PunchoutAction.php redirects to /conneqt-punchout/index/customcheckout.

  7. Block/Response/Data.php delegates to Model/Response/FinishService, which resolves the session's PunchoutType and builds the outbound NEW_ITEM-* fields via Model/Response/OciPayloadBuilder and the type's handler.

  8. view/frontend/templates/postData.phtml auto-posts those fields back to HOOK_URL, then cleans up the punchout session (only if the payload was actually built — see below).

cXML flow

  1. The procurement system sends a cXML PunchOutSetupRequest to:

    {base-url}/conneqt-punchout/index/auth
    
  2. Controller/Index/Auth.php reads the raw POST body and parses it entirely inside a try/catch via Helper/CxmlLogin::parseXml(), which loads the XML with LIBXML_NONET and no entity/DTD loading (XXE-safe) and throws on malformed input instead of returning a false/warning SimpleXMLElement. It extracts:

    • Header/From/Credential/@domain
    • Header/Sender/Credential/Identity
    • Header/Sender/Credential/SharedSecret
    • Request/PunchOutSetupRequest/BuyerCookie
    • Request/PunchOutSetupRequest/BrowserFormPost/URL
  3. Helper/CxmlLogin::login($params, onlyVerify: true) matches a PunchoutLink whose identifiers contain an entry named exactly the inbound domain value (e.g. NetworkId, DUNS — whatever the buyer's system sends) with identifier_value equal to the inbound identity, then decrypts the matched PunchoutSystem.shared_secret and compares it against the inbound SharedSecret with hash_equals(). Website scope is validated the same way as OCI.

  4. On success, Magento writes a RequestLog row (session_status = 3, "cXML Auth complete") with the raw cXML request body stored directly in requestlog.request_data — nothing is written to pub/media — and returns a PunchOutSetupResponse whose StartPage/URL points at Controller/Index/CreateSession.php?sid=<requestlog_id>.

  5. The buyer's browser follows that URL. CreateSession re-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 calls login($data, onlyVerify: false) to actually log the customer in and switch the store to the link's website. On success the row moves to session_status = 4 ("cXML Session Active") and the punchout session data is stored, same as OCI.

  6. The customer shops in Magento.

  7. At checkout, FinishService builds the PunchOutOrderMessage via Model/Response/CxmlPayloadBuilder and the type's handler.

  8. postData.phtml posts it back to BrowserFormPost/URL as the cxml-urlencoded form field. The raw XML goes into the input's value attribute HTML-escaped only — the browser's own application/x-www-form-urlencoded submission applies URL-encoding exactly once. Do not add an explicit urlencode() 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() calls FinishService::process() and remembers whether a payload was actually produced; shouldCleanup() is only true in that case. postData.phtml only calls logoutCustomer() (which delegates to FinishService::cleanup() — customer logout + clearing the mage-cache-sessid cookie) when shouldCleanup() 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.php does 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 to customcheckout?confirm_empty=1. Controller/Index/CustomCheckout.php redirects back to the cart with a notice unless confirm_empty=1 is present; FinishService::process() throws EmptyCartException for an empty cart unless $confirmEmpty is 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_predispatch observer (Observer/PunchoutAction.php) matches the current full action name against Stores > Configuration > Conneqt Punchout > General > Checkout Trigger Routes (config path conneqt_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.phphyva_checkout_index_index) — no code change needed.
  • Redirect mechanics. The observer sets ActionFlag::FLAG_NO_DISPATCH before 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 SkipPunchoutCheckoutRedirect session flag (set by Observer/AddToCart.php after 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 Protocoloci (browser form POST of NEW_ITEM-* fields) or cxml (PunchOutOrderMessage). Selects both the login flow and the outbound format; the field choices below depend on it.
  • HandlerGeneric (mappings only) by default; a custom handler registered via di.xml also 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 normalizeData and sets a caption (regression guard — do not remove either): the base Magento_Ui/js/form/element/select maps the loaded value onto the option list during setInitialValue() and returns undefined for 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 base setOptions() sets the caption observable to boolean false, which Knockout's optionsCaption renders as a literal "false" dropdown entry while clear()/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_FIELD1CUST_FIELD5Quantity, 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_typesource_valueExample values
punchout_itema 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_itema Quote\Item attribute/gettersku, 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())
producta product EAV attribute code, loaded from the quote item's productfetched live from the attribute repository, so a newly added attribute appears automatically
quotea quote-level keyquote_id, reserved_order_id, store_currency, quote_currency, grand_total, base_grand_total
statica literal value typed into source_valueanything

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_ID now maps to the quote's own id (source_type: quote, quote_id) instead of the never-set snapshot field id it used to point at (which always posted empty).
  • CUST_FIELD2 (base OCI type) now maps to unit_price instead of the old getFactorUnitPrice() 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 UnitPrice now 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:

  1. Extend Model\Handler\GenericHandler (or implement Api\PunchoutTypeHandlerInterface directly) 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.

    PunchoutContext exposes getType(), getQuote(), getQuoteItem(), getProduct(), getPunchoutItem() (the snapshot), getLink() and getSystem() — the item/product/snapshot getters are null for the quote-level afterBuildPayload() call, and getLink()/getSystem() can be null for a session established before these ids were stored (old in-flight sessions), so custom handlers must tolerate that.

  2. 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>
    
  3. Select it under Handler on the relevant Punchout Type — it appears automatically (Model\Handler\Source\HandlerOptions lists 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 (including punchout_secretkey, which was created by an old patch but never actually used for cXML auth — cXML has always authenticated via PunchoutSystem.shared_secret).
  • Secrets are encrypted at rest via Model\Crypt\SecretCodec (wrapping Magento\Framework\Encryption\EncryptorInterface): punchoutsystem.shared_secret, and any punchoutlink.identifiers row whose identifier is PASSWORD or SharedSecret (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) and Helper\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 with hash_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 PASSWORD parameter 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 with LIBXML_NONET and no LIBXML_NOENT/LIBXML_DTDLOAD flags (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_data are 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 old bodyTmpl=ui/grid/cells/html rendering, 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, an afterToHtml plugin on Magento\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 .html template no module can override).
  • The add-to-cart success message is link-free: Plugin\Message\RewriteAddToCartMessagePlugin intercepts InterpretationStrategy::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) uses Magento_Customer/js/customer-data and Magento_Ui/js/modal/confirm. The Hyvä implementation (view/frontend/web/js/hyva/finish-guard.js, loaded only when Hyva_Theme is enabled, via templates/hyva/finish-guard-init.phtml) is dependency-free: it talks directly to customer/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.js against 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.xml alone. 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 (an afterExecute plugin on the Login/CreateSession/CustomCheckout controllers) instead sets the same section_data_clean cookie Magento core uses for store-view switching, forcing every customer-data section — including the new punchout-session section — 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/.

GroupFieldConfig pathDefaultWhat it does
General ConfigurationEnablegeneral/enableYesMaster switch. Disabled = login endpoints refuse every request, no checkout redirect, storefront behaves like a normal shop.
General ConfigurationMax Description Lengthgeneral/description_length250Characters 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 ConfigurationCheckout Trigger Routesgeneral/checkout_routessee Session lifecycleOne full action name per line; see that section for how to add a route.
cXML ConfigurationcXML Sender Identitycxml/identityAN0000000000-TThe Identity placed in the From/Sender credentials of outbound cXML documents. Only relevant for cXML types.
TestingOCI Test Return URLtesting/oci_return_urlhttps://punchoutcommerce.com/tools/oci-roundtrip-returnHOOK_URL used by the "Start Test Round-Trip" button for OCI-based types.
TestingcXML Test Return URLtesting/cxml_return_urlhttps://punchoutcommerce.com/tools/cxml-punchout-returnBrowserFormPost/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 Type select of oci/oci_afas/cxml). Manage the available types under Conneqt > Punchout Types.
  • Shared Secret — only used by cXML-based types (the credential the procurement system signs its PunchOutSetupRequest with); 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 same mappingValueSelect select-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 USERNAME and PASSWORD rows (both in the dropdown — these are the literal names Helper/Login matches on).
    • For cXML, add one row whose Identifier is the exact domain value the buyer's system sends in From/Credential/@domain and whose Value is the buyer's Sender/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).

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.

StatusMeaning
0Invalid Request
1Session Active (an OCI buyer is logged in and shopping)
2Closed
3cXML Auth complete (setup request verified, buyer's browser hasn't arrived yet)
4cXML 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/~CALLER OCI control fields) straight into the store's own conneqt-punchout/index/login endpoint, with HOOK_URL set to the configured OCI Test Return URL.
  • cXML-based types: Magento itself builds a fresh PunchOutSetupRequest (identity from the link's identifiers, SharedSecret decrypted from the system) and POSTs it server-side to the store's own conneqt-punchout/index/auth, then redirects your browser to the PunchOutSetupResponse's StartPage URL — or shows the cXML Status text 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 requestlog row (request_type = test_roundtrip) so test traffic is easy to tell apart from real traffic in the grid.
  • Requirements: the link needs USERNAME/PASSWORD identifiers 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 → POST URL = {base-url}/conneqt-punchout/index/login with USERNAME/PASSWORD matching a link's identifiers, HOOK_URL left at the tester default (or your own receiver).
  • cXML PunchOut Tester: https://punchoutcommerce.com/tools/cxml-punchout-tester → POST URL = {base-url}/conneqt-punchout/index/auth, with FromDomain/SenderIdentity matching a link's identifier, and SharedSecret matching 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-local vendor/ directory (and composer.lock) after a standalone composer install — both are gitignored, but if vendor/ is left in place it breaks bin/magento setup:di:compile with a "Phar wrapper" error. Only run standalone composer install when 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:

  1. SeedPunchoutTypes — inserts the three built-in types (idempotent: a type whose code already exists is left alone) and backfills every punchoutsystem.punchouttype_id from its legacy type column.
  2. EncryptExistingPunchoutSecrets — encrypts any plaintext shared_secret / PASSWORD / SharedSecret value found in existing rows (idempotent — already-encrypted values are left untouched).
  3. MigrateCustomerCredentialsToPunchoutIdentifiers — for any link still missing a USERNAME/PASSWORD identifier, copies the values from the customer's punchout_username/punchout_password attributes (encrypting the password immediately).
  4. RemoveCustomerCredentialAttributes — removes the punchout_username, punchout_password and punchout_secretkey customer 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/*.xml files to requestlog.request_data; a PunchOutSetupResponse issued just before the upgrade still points at a createsession URL, but the freshness window is only 5 minutes, so in practice this is a non-issue.
  • The legacy PunchoutSystem.type column 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 use punchouttype_id / the type's base_protocol, not type.
  • Setup/Patch/Data/CustomerAttribute.php and CustomerSecretKeyAttribute.php are intentionally left in place even though they're superseded — patch history must not be rewritten; RemoveCustomerCredentialAttributes only undoes their effect going forward.
  • bin/magento setup:upgrade drops the conneqt_punchout_punchoutitems table now that it's no longer declared in etc/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 via Model\Response\SnapshotBuilder) — there is no data to lose. The corresponding etc/db_schema_whitelist.json entry is deliberately kept; see Database entities.

Important files

FileWhat it does
Controller/Index/Login.phpEntry point for OCI punchout login requests
Helper/Login.phpMatches OCI requests to PunchoutLink identifiers (encrypted, hash_equals), enforces website scope, writes request logs
Controller/Index/Auth.phpEntry point for cXML PunchOutSetupRequest
Controller/Index/CreateSession.phpCompletes the cXML login by converting the requestlog token into a Magento session
Helper/CxmlLogin.phpcXML-specific authentication, XXE-safe XML parsing, setup response generation
Model/Response/FinishService.phpOrchestrates 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.phpComputes the in-memory per-quote-item Model\PunchoutItems snapshot from the live quote item; nothing is persisted
Model/Response/OciPayloadBuilder.php / CxmlPayloadBuilder.phpTurn a PunchoutType's resolved field mappings into the OCI/cXML wire format
Model/Handler/GenericHandler.php / HandlerPool.phpDefault mapping-only value resolution; extension point for custom handlers
Block/Response/Data.phpThin frontend block delegating to FinishService; decides success vs failure for cleanup
view/frontend/templates/postData.phtmlAuto-posts the generated payload back to the procurement system, then cleans up on success
Observer/PunchoutAction.phpRedirects a configured checkout route to the punchout return page (with FLAG_NO_DISPATCH)
Observer/AddToCart.phpSets a one-time SkipPunchoutCheckoutRedirect session flag on certain adds (no snapshot persistence — that observer, and RemoveFromCart.php, were removed)
Model/Crypt/SecretCodec.phpEncrypts/decrypts/idempotency-checks credential values
Controller/Adminhtml/PunchoutLink/Test.php"Start Test Round-Trip" launcher
Controller/Adminhtml/RequestLog/View.phpEscaped, 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.xmlDefines the punchouttype, punchoutsystem, punchoutlink, and requestlog tables

Database entities

Defined in etc/db_schema.xml:

  • conneqt_punchout_punchouttype — protocol + field-mapping definitions
  • conneqt_punchout_punchoutsystem — external procurement system definitions
  • conneqt_punchout_punchoutlink — website + customer + system + credential bindings
  • conneqt_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/customcheckout
    
  • These endpoints are deliberately unauthenticated by MagentoController/Index/Login.php implements CsrfAwareActionInterface and skips CSRF validation because requests come from external systems. The only gate is the identifier/credential match; treat every request parameter (HOOK_URL included) as untrusted input. There is no domain allowlist on HOOK_URL/BrowserFormPost URL and no rate limiting/lockout on the login endpoints — both were considered and explicitly declined as out of scope.

  • Observer/PunchoutAction.php only redirects when the punchout session data contains a HOOK_URL and 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 punchoutitems table to inspect anymore — the return-time payload is always built fresh from the live quote by Model\Response\SnapshotBuilder, so conneqt_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 in postData.phtml — see the note at the end of the cXML flow.
  • Controller/Index/CustomCheckoutCart.php (unconditional print_r + die debug code) has been removed — if you're looking at an old checkout, it is no longer part of this module.