conneqt/m2-module-sap-base

N/A

Maintainers

Package info

git.dev.epartment.nl/conneqt/m2/module-sap-base

Type:magento2-module

pkg:composer/conneqt/m2-module-sap-base

Transparency log

Statistics

Installs: 9 580

Dependents: 3

Suggesters: 0

1.2.12 2026-08-14 06:19 UTC

README

Magento 2 foundation module for SAP Business One Service Layer integrations.

conneqt/m2-module-sap-base provides the shared connection layer — an authenticated, session-caching Guzzle client — plus the customer/address EAV fields and order extension attributes that the SAP-facing feature modules build on. It contains no end-user feature of its own.

  • Package: conneqt/m2-module-sap-base
  • Module: Conneqt_SapBase
  • Requires: PHP >= 8.1, magento/framework, conneqt/m2-base (1.*)
  • License: proprietary

Table of contents

What it provides

CapabilityEntry point
Store-scoped SAP configurationHelper/ScopeConfigHelper.php
Authenticated Guzzle client + cached SAP sessionHelper/SapClient.php
GET / POST / PATCH wrapper with retry + re-loginHelper/Api.php
SAP fields on customers and customer addressesSetup/Patch/Data/*.php
SAP fields on orders and order addresses (API output)Plugin/Order/*.php, etc/extension_attributes.xml
Admin connection test buttonBlock/Adminhtml/Config/TestConnectionButton.php, Controller/Adminhtml/Config/TestConnection.php

Consumed by

Feature modules that depend on this one:

  • conneqt/module-sap-my-accountHelper/Api/ApiBase.php, Helper/Api/ApiEquipment.php
  • conneqt/module-sap-service-layer-special-pricesHelper/PriceApi.php
  • project-level modules that inject Conneqt\SapBase\Helper\Api directly (e.g. cart/order preview calls)

Installation

composer require conneqt/m2-module-sap-base
bin/magento module:enable Conneqt_SapBase && bin/magento setup:upgrade

setup:upgrade runs the three data patches that create the customer and address EAV attributes. Run all bin/magento / composer commands inside the project's PHP container if the project uses one.

Configuration

Stores → Configuration → Conneqt → SAP (section id="sap"). Every field is available at default, website and store-view scope, so different websites can talk to different SAP instances.

ScopeConfigHelper resolves against the current store view by default. Outside a storefront request there is no meaningful current store view and Magento falls back to the default store view, so admin controllers, console commands and cron jobs must state the scope they mean:

$this->scopeConfigHelper->setScope(ScopeInterface::SCOPE_WEBSITES, $websiteId);
// ... SAP calls resolve against that website ...
$this->scopeConfigHelper->resetScope();

setScope() accepts any scope type ScopeConfigInterface::getValue() understands (default, websites, stores). The helper is a shared DI instance, so pinning it also affects the SapClient and Api instances built from it — reset it once the scoped work is done.

sap/api — SAP API

PathLabelTypeDefaultNotes
sap/api/base_urlBase URLtextService Layer root, e.g. https://sap.example.com:50000/b1s/v1/. Used as Guzzle base_uri, so a trailing slash matters.
sap/api/usernameUsernametext
sap/api/passwordPasswordpasswordSee Known gaps — not encrypted at rest.
sap/api/databaseDatabasetextSAP CompanyDB.
sap/api/subscription_header_keyAiden API Manager Header KeytextOptional. Header is only sent when both key and value are filled.
sap/api/subscription_header_valueAiden API Manager Header ValuetextOptional.
sap/api/enable_proxyEnable Proxyyes/no
sap/api/proxyProxytextOnly shown when the proxy is enabled; passed straight to Guzzle's proxy option.
sap/api/test_connectionTest ConnectionbuttonSee Admin: Test Connection.

sap/default — Default API

PathLabelDefault (etc/config.xml)Notes
sap/default/timeoutResponse Timeout15Guzzle timeout, seconds.
sap/default/connect_timeoutConnection Timeout15Guzzle connect_timeout, seconds.

sap/login — Login API

PathLabelDefault (etc/config.xml)Notes
sap/login/timeoutResponse Timeout2Applied to the Login call only.
sap/login/connect_timeoutConnection Timeout2Applied to the Login call only.
sap/login/retry_limitMax Connection Attempts5Retries on HTTP 429 and on connection failures.
sap/login/cooldownCooldown60Seconds that all SAP traffic is blocked after the retry limit is exhausted.

sap/logging — Logging

PathLabelDefault (etc/config.xml)Notes
sap/logging/enableLog request/response0Writes full request and response bodies to var/log/system.log.

The timeout/retry defaults come from etc/config.xml, not from the PHP getters — see Known gaps.

Using the API helper

Inject Conneqt\SapBase\Helper\Api and call the verb you need. The helper resolves the shared client, logs in when required, persists cookies and retries on 429 / 401.

use Conneqt\SapBase\Helper\Api;

class MyService
{
    public function __construct(
        private Api $api
    ) {
    }

    public function getItem(string $sku): array
    {
        return $this->api->get(sprintf("Items('%s')", $sku));
    }
}

Method contract

MethodSecond argumentReturns
get(string $uri, array $data = [], bool $raw = false)Guzzle request options, e.g. ['query' => '$filter=...']decoded array, or the raw body string when $raw === true
post(string $uri, array $data = [])Request payload, wrapped internally as ['json' => $data]raw body string
patch(string $uri, array $data = [])Request payload, wrapped internally as ['json' => $data]raw body string

The asymmetry is deliberate but easy to trip over: get() forwards $data to Guzzle untouched, so query parameters must be nested under a query key. post() and patch() take the body directly.

// GET with an OData query
$this->api->get('SpecialPrices', ['query' => "\$filter=ItemCode eq 'ABC' & \$top=20"]);

// POST with a JSON body
$this->api->post('OrdersService_Preview', ['CardCode' => 'C10001', 'DocumentLines' => []]);

Api::getClient() exposes the underlying GuzzleHttp\Client for calls the wrapper does not cover (other verbs, streaming, custom options). Doing so bypasses the logging, cookie-persistence and retry logic, so prefer the wrapper methods.

Exceptions

ConditionThrown
Cooldown flag active (a previous login sequence failed)\Exception('Login failed in a previous request', 401)
Login retry limit exhausted\Exception('Login retry limit exceeded', 401)
Any other 4xx from SAPGuzzleHttp\Exception\ClientException
Transport failureGuzzleHttp\Exception\GuzzleException

Callers are expected to catch these; nothing in this module swallows them.

Session and cookie caching

SAP Service Layer authenticates with a session cookie, and login is expensive and rate-limited. This module therefore reuses one SAP session across Magento requests:

  1. SapClient::getClient() builds the Guzzle client once per Magento request (the class is a DI singleton) and hydrates its CookieJar from Magento cache.
  2. If the jar comes back empty, login() is called immediately with the configured credentials.
  3. After a successful login, and after every successful get() / post() / patch(), the jar is serialized back into cache by saveCookiesInCache().
  4. If SAP later answers 401 (session expired server-side), Api re-logs in once and replays the request.

Cache keys, all tagged with Magento\Framework\App\Cache\Type\Config::TYPE_IDENTIFIER:

ConstantKeyHolds
SapClient::COOKIE_IDENTIFIERconneqt-sap-cookiesserialized Guzzle cookie jar
SapClient::COOKIE_LIFE_TIME_IDENTIFIERconneqt-sap-cookies-lifetimeTTL used for the cookie entry
SapClient::LOGIN_FAILED_IDENTIFIERconneqt-sap-login-failedcooldown flag after a failed login sequence

The TTL is derived from SessionTimeout (minutes) in the SAP login response the first time it is stored, and reused for subsequent saves until the lifetime entry itself expires.

The cache keys are global, not per scope. Cookies are matched by domain inside the jar, so scopes pointing at different SAP hosts do not interfere. Scopes that share a host but use a different CompanyDB would share one session cookie — avoid that layout.

Because everything is tagged as config cache, flushing the config cache drops the SAP sessionbin/magento cache:clean config, a config save in the admin, or a deploy all force the next SAP call to log in again. That is safe, just slower, and it is the intended way to clear a stuck cooldown.

Retry, cooldown and failure behaviour

Login (SapClient::login() / retryLogin()):

  • HTTP 429 or a ConnectExceptionsleep(1), retry, up to sap/login/retry_limit attempts.
  • Any other ClientException → logged and rethrown immediately.
  • Retry limit exhausted → the cooldown flag is cached for sap/login/cooldown seconds and \Exception('Login retry limit exceeded', 401) is thrown.

Requests (Api::get() / post() / patch()):

  • Cooldown flag set → throws before any HTTP traffic. This is what prevents a dead SAP host from adding seconds of latency to every storefront page.
  • HTTP 429sleep(1) and retry the same call (recursive, no attempt cap).
  • HTTP 401login() and replay once.
  • Anything else → logged and rethrown.

Note that both retry paths use a blocking sleep(1), which occupies the PHP-FPM worker. Keep sap/login/timeout low (the 2 second default exists for exactly this reason) so a slow SAP does not stall storefront rendering.

EAV attributes

Created by data patches on setup:upgrade.

Customer (Magento\Customer\Model\Customer::ENTITY)

CodeLabelTypeAdmin formGrid
card_codeSAP CardCodevarcharadminhtml_customerused / visible / filterable
sap_interncodeSap Internal Codevarcharadminhtml_customerused / visible / filterable

Customer address (Magento\Customer\Model\Indexer\Address\AttributeProvider::ENTITY)

CodeLabelTypeAdmin form
sap_address_nameSAP Address Namevarcharadminhtml_customer_address
sap_address_typeSAP Address Typevarcharadminhtml_customer_address

All four are user_defined = 0 and are meant to be written by the SAP sync modules or maintained by hand in the admin — this module never populates them.

Because the customer attributes are grid-enabled, run bin/magento indexer:reindex customer_grid after a bulk import if the values do not show up in the customer grid.

Order extension attributes

etc/extension_attributes.xml declares:

EntityAttributeSource
Magento\Sales\Api\Data\OrderInterfacesap_interncodecustomer attribute sap_interncode
Magento\Sales\Api\Data\OrderInterfacecard_codecustomer attribute card_code
Magento\Sales\Api\Data\OrderAddressInterfacesap_address_namecustomer address attribute sap_address_name
Magento\Sales\Api\Data\OrderAddressInterfacesap_address_typecustomer address attribute sap_address_type

Three plugins on Magento\Sales\Api\OrderRepositoryInterface (etc/di.xml) fill them in afterGet and afterGetList:

  • Plugin/Order/SapInternCodePlugin.php
  • Plugin/Order/CardCodePlugin.php
  • Plugin/Order/SapAddressPlugin.php

Behaviour worth knowing:

  • Values are resolved live from the customer, not stored on the order. Changing a customer's card_code retroactively changes what GET /V1/orders/:id reports for their historical orders.
  • Guest orders are skipped — all three plugins return early when getCustomerId() is empty.
  • SapAddressPlugin matches an order address to a customer address by OrderAddressInterface::getCustomerAddressId(). Addresses typed in at checkout, or customer addresses deleted after the order was placed, get no SAP values.
  • It covers the billing address, every shipping assignment in the order extension attributes, and getShippingAddress() — virtual orders and orders with a null shipping assignment are handled.
  • The plugins only run on the repository. Orders loaded through Magento\Sales\Model\OrderFactory, order collections, or the admin sales grid do not get the extension attributes.
  • Each plugin loads the customer independently, so a repository getList() over N orders performs customer lookups per order. CustomerRepository caches by ID within the request, which keeps this to one DB round trip per distinct customer rather than three — but a large getList() is still N customer loads.

Admin: Test Connection

Stores → Configuration → Conneqt → SAP → SAP API → Test Connection.

Flow:

  1. Block/Adminhtml/Config/TestConnectionButton.php renders view/adminhtml/templates/system/config/test-connection-button.phtml in place of the field, and forwards the website / store parameters of the configuration page into the AJAX URL.
  2. The template boots view/adminhtml/web/js/test-connection.js via x-magento-init, passing the admin URL.
  3. The JS AJAXes to sap-base/config/testConnection (Controller/Adminhtml/Config/TestConnection.php, route declared in etc/adminhtml/routes.xml).
  4. The controller pins ScopeConfigHelper to the scope those parameters describe — stores/<id>, websites/<id> or default — so the credentials under test are the ones shown on the page.
  5. It then cleans the whole config cache and calls SapClient::getClient(), which forces a fresh login because the cookie cache was just dropped.
  6. The response is {"success": true} or {"success": false, "message": "..."}; the message is the SAP error body when there is one, otherwise the exception message. The JS shows it in a modal alert.

Two things to keep in mind:

  • The button reflects the saved configuration, not what is currently typed into the form — save first, then test.
  • It tests the scope you are currently editing. A website-scope test resolves website-scope values and ignores store-view overrides beneath it, which is what the page itself shows; switch to the store view to test an override.

Logging

With sap/logging/enable on, SapClient::log() writes every request and response through Psr\Log\LoggerInterface (so var/log/system.log by default), prefixed Conneqt\SapBase::

Conneqt\SapBase: GET request Items('ABC') - array (...)
Conneqt\SapBase: GET response Items('ABC') - {"odata.metadata":...}

Two consequences: it is verbose enough to hurt on pages that make many SAP calls, and request bodies are logged verbatim, including the Login payload with the SAP username and password. Treat it as a temporary debugging switch, not a production setting.

Troubleshooting

SymptomLikely causeWhat to do
Every SAP call throws Login failed in a previous request (401)Cooldown flag is cached after an exhausted retry sequenceFix the underlying cause, then bin/magento cache:clean config to clear the flag, or wait out sap/login/cooldown
Login retry limit exceededSAP kept returning 429 or refusing connectionsCheck SAP availability / licence seats; raise sap/login/retry_limit only as a last resort
TypeError: ...getBaseUrl(): Return value must be of type string, null returnedsap/api/base_url is empty in the resolved scopeFill in the SAP API group for that store view
Timeouts everywhere after a config changeTimeout config resolved to 0, which Guzzle reads as "no timeout"Make sure sap/default/* and sap/login/* have values; etc/config.xml supplies them unless they were overridden with blanks
Extension attributes missing on an orderOrder not loaded through OrderRepositoryInterface, guest order, or no matching customer_address_idSee Order extension attributes
SAP section not visible for a restricted admin roleThe section's ACL resource does not existSee Known gaps
SAP config resolves to the wrong values in cron, a console command or an admin controllerThere is no meaningful current store view outside a storefront request, so the store manager falls back to the default store viewCall ScopeConfigHelper::setScope() with the scope you mean before reading any getter, and resetScope() afterwards

Known gaps and gotchas

Documented rather than silently fixed — several are load-bearing for existing installs.

  • The password is stored in plain text. sap/api/password is type="password" but has no <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>, so the value lands unencrypted in core_config_data. Adding the backend model later requires re-entering the password on every scope where it is set.
  • The ACL resource is undefined. etc/adminhtml/system.xml guards the section with Conneqt_SapBase::config, but no etc/acl.xml in this module (or in Conneqt_Base) declares it. Magento's ACL policy falls back to the role's global permission for unknown resources, so full-access admins see the section, but the permission cannot be granted to a restricted role.
  • TestConnection has no ADMIN_RESOURCE, so it inherits Magento_Backend::admin — any logged-in admin can trigger a config-cache flush and a SAP login. It also does not implement HttpGetActionInterface.
  • TestConnection flushes the entire config cache, not just the three SAP keys, so a connection test evicts the whole shop's cached configuration.
  • ?? defaults in ScopeConfigHelper are dead code. (int)$value ?? 15 can never be null, so a missing config value yields 0, not the literal in the getter. The real defaults are the ones in etc/config.xml.
  • TestConnectionButton::getButtonHtml() is unused. The template renders its own <button>; the method's setLocation() behaviour would navigate away from the config page instead of AJAXing.
  • TLS verification is disabled ('verify' => false in SapClient::getClient()), presumably for self-signed Service Layer certificates.
  • Client::getConfig() (used by saveCookiesInCache()) is deprecated in Guzzle 7 and removed in Guzzle 8 — this will need replacing with a cookie jar the module holds itself before a Guzzle major upgrade.
  • JS strings are not translatable. Connection successful / Connection failed in test-connection.js are hardcoded, and the button label is literally Click Me. The module ships no i18n/ directory.

File map

CHANGELOG.md                                             release history per version
composer.json                                            package metadata, conneqt/m2-base dependency
registration.php                                         module registration
etc/module.xml                                           declaration + sequence after Conneqt_Base
etc/config.xml                                           default timeouts, retry limit, cooldown, logging
etc/di.xml                                               order repository plugins
etc/extension_attributes.xml                             order + order address attribute declarations
etc/adminhtml/system.xml                                 SAP configuration section
etc/adminhtml/routes.xml                                 admin route sapbase / sap-base

Helper/ScopeConfigHelper.php                             store-scoped config lookups
Helper/SapClient.php                                     Guzzle client, login, cookie + cooldown cache
Helper/Api.php                                           GET/POST/PATCH wrapper, logging, retry, re-login

Plugin/Order/SapInternCodePlugin.php                     order.sap_interncode
Plugin/Order/CardCodePlugin.php                          order.card_code
Plugin/Order/SapAddressPlugin.php                        order address sap_address_name / sap_address_type

Setup/Patch/Data/AddCustomerAttributesPatch.php          customer.card_code
Setup/Patch/Data/AddCustomerInternCodePatch.php          customer.sap_interncode
Setup/Patch/Data/AddAddressAttributesPatch.php           customer address SAP fields

Block/Adminhtml/Config/TestConnectionButton.php          renders the config button
Controller/Adminhtml/Config/TestConnection.php           connection test endpoint (JSON)
view/adminhtml/templates/system/config/test-connection-button.phtml
view/adminhtml/web/js/test-connection.js