Skip to main content

Magento 2 Gateway Module (Model B)

Überblick

Das PunchFlow Gateway Module für Magento 2 ermöglicht den Gateway/Proxy-Modus (Model B) für PunchOut-Integration. Der gesamte PunchOut-Flow läuft direkt im Magento Shop.

Model A vs Model B
  • Model A (Hosted Catalog): Produkte werden zu PunchFlow synchronisiert
  • Model B (Gateway/Proxy): Benutzer kaufen direkt im Shop, Modul handhabt Session und cXML

Voraussetzungen

  • Magento 2.4.4 oder höher
  • PHP 8.1 oder höher
  • Composer
  • PunchFlow Account mit Gateway-Lizenz

Installation

Via Composer (empfohlen)

# Im Magento-Root-Verzeichnis
composer require punchflow/module-punchout

# Modul aktivieren
bin/magento module:enable PunchFlow_PunchOut
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush

Manuelle Installation

# Modul herunterladen
mkdir -p app/code/PunchFlow/PunchOut
cd app/code/PunchFlow/PunchOut
git clone https://github.com/punchflow/magento2-punchout.git .

# Magento Setup
bin/magento module:enable PunchFlow_PunchOut
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush

Konfiguration

Admin-Oberfläche

  1. Stores → Configuration → PunchFlow → PunchOut
  2. Konfigurieren Sie:
EinstellungBeschreibungStandard
Enable ModuleModul aktivierenJa
API SecretShared Secret für HMAC-ValidierungErforderlich
Session TimeoutSitzungsdauer in Minuten60
Debug ModeErweiterte ProtokollierungNein
Auto Create CustomersAutomatische KundenerstellungJa
Default Customer GroupStandard B2B-Gruppe2
Product ID AttributeFeld für SupplierPartIDsku

Umgebungsvariablen

# app/etc/env.php oder .env
PUNCHFLOW_API_SECRET=your-secure-secret-key
PUNCHFLOW_DEBUG=false
PUNCHFLOW_SESSION_TIMEOUT=3600

CLI-Konfiguration

# Konfiguration setzen
bin/magento config:set punchflow/general/enabled 1
bin/magento config:set punchflow/general/api_secret "your-secret"
bin/magento config:set punchflow/general/session_timeout 60
bin/magento config:set punchflow/customer/auto_create 1
bin/magento config:set punchflow/customer/default_group_id 2

Architektur

Modul-Struktur

PunchFlow/PunchOut/
├── Api/
│ ├── Data/
│ │ ├── SessionInterface.php
│ │ └── SessionSearchResultsInterface.php
│ ├── SessionRepositoryInterface.php
│ └── PunchOutManagementInterface.php
├── Controller/
│ ├── Session/
│ │ ├── Start.php
│ │ ├── Transfer.php
│ │ └── Status.php
│ └── Adminhtml/
│ └── Session/
│ ├── Index.php
│ └── View.php
├── Model/
│ ├── Session.php
│ ├── SessionRepository.php
│ ├── ResourceModel/
│ │ ├── Session.php
│ │ └── Session/Collection.php
│ ├── ValidationResult.php
│ └── Config.php
├── Service/
│ ├── TokenValidationService.php
│ ├── SessionService.php
│ ├── CxmlBuilderService.php
│ └── CustomerService.php
├── Observer/
│ └── CheckoutSubmitObserver.php
├── Plugin/
│ └── CustomerSessionPlugin.php
├── Helper/
│ └── Config.php
├── Ui/
│ └── Component/
│ └── Listing/
│ └── Column/
│ └── SessionActions.php
├── view/
│ ├── frontend/
│ │ ├── layout/
│ │ ├── templates/
│ │ └── web/
│ └── adminhtml/
│ ├── layout/
│ ├── templates/
│ └── ui_component/
├── etc/
│ ├── module.xml
│ ├── di.xml
│ ├── config.xml
│ ├── adminhtml/
│ │ ├── routes.xml
│ │ └── system.xml
│ ├── frontend/
│ │ └── routes.xml
│ └── db_schema.xml
└── registration.php

Datenbank-Schema

<!-- etc/db_schema.xml -->
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<table name="punchflow_session" resource="default" engine="innodb">
<column xsi:type="int" name="session_id" unsigned="true" nullable="false"
identity="true" comment="Session ID"/>
<column xsi:type="varchar" name="session_token" nullable="false" length="255"/>
<column xsi:type="varchar" name="buyer_cookie" nullable="false" length="255"/>
<column xsi:type="varchar" name="buyer_id" nullable="true" length="255"/>
<column xsi:type="varchar" name="buyer_name" nullable="true" length="255"/>
<column xsi:type="varchar" name="network_id" nullable="true" length="100"/>
<column xsi:type="text" name="return_url" nullable="false"/>
<column xsi:type="varchar" name="operation" nullable="true" length="50"/>
<column xsi:type="int" name="quote_id" unsigned="true" nullable="true"/>
<column xsi:type="int" name="customer_id" unsigned="true" nullable="true"/>
<column xsi:type="varchar" name="status" nullable="false" length="50"/>
<column xsi:type="varchar" name="ip_address" nullable="true" length="45"/>
<column xsi:type="varchar" name="user_agent" nullable="true" length="500"/>
<column xsi:type="json" name="buyer_metadata" nullable="true"/>
<column xsi:type="json" name="cart_snapshot" nullable="true"/>
<column xsi:type="text" name="incoming_cxml" nullable="true"/>
<column xsi:type="text" name="outgoing_cxml" nullable="true"/>
<column xsi:type="timestamp" name="created_at" nullable="false"
default="CURRENT_TIMESTAMP"/>
<column xsi:type="timestamp" name="expires_at" nullable="false"/>
<column xsi:type="timestamp" name="completed_at" nullable="true"/>
<column xsi:type="timestamp" name="updated_at" nullable="true"
on_update="true" default="CURRENT_TIMESTAMP"/>

<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="session_id"/>
</constraint>
<index referenceId="PUNCHFLOW_SESSION_TOKEN" indexType="btree">
<column name="session_token"/>
</index>
<index referenceId="PUNCHFLOW_SESSION_BUYER_COOKIE" indexType="btree">
<column name="buyer_cookie"/>
</index>
<index referenceId="PUNCHFLOW_SESSION_STATUS" indexType="btree">
<column name="status"/>
</index>
</table>

<table name="punchflow_order_message" resource="default" engine="innodb">
<column xsi:type="int" name="message_id" unsigned="true" nullable="false"
identity="true"/>
<column xsi:type="int" name="session_id" unsigned="true" nullable="false"/>
<column xsi:type="text" name="cxml_content" nullable="false"/>
<column xsi:type="varchar" name="payload_id" nullable="false" length="255"/>
<column xsi:type="varchar" name="direction" nullable="true" length="20"/>
<column xsi:type="varchar" name="message_type" nullable="true" length="50"/>
<column xsi:type="timestamp" name="sent_at" nullable="true"/>
<column xsi:type="int" name="response_code" nullable="true"/>
<column xsi:type="text" name="response_body" nullable="true"/>
<column xsi:type="varchar" name="error_message" nullable="true" length="1000"/>
<column xsi:type="json" name="cart_data" nullable="true"/>
<column xsi:type="int" name="item_count" nullable="true"/>
<column xsi:type="varchar" name="total_amount" nullable="true" length="50"/>
<column xsi:type="varchar" name="currency" nullable="true" length="3"/>
<column xsi:type="timestamp" name="created_at" nullable="false"
default="CURRENT_TIMESTAMP"/>

<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="message_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="PUNCHFLOW_MESSAGE_SESSION"
table="punchflow_order_message" column="session_id"
referenceTable="punchflow_session" referenceColumn="session_id"
onDelete="CASCADE"/>
</table>
</schema>

API-Endpunkte

Session starten

POST /punchout/session/start
Content-Type: application/json
X-PunchFlow-Signature: hmac-sha256-signature
X-PunchFlow-Timestamp: 1701234567
X-PunchFlow-Nonce: unique-request-id

{
"buyer_cookie": "abc123",
"return_url": "https://erp.company.com/punchout/return",
"buyer_id": "DUNS:123456789",
"buyer_name": "Acme Corp",
"operation": "create",
"extrinsics": {
"UserEmail": "buyer@company.com",
"UserName": "Max Mustermann"
}
}

Response:

{
"success": true,
"session_id": "abc123def456",
"redirect_url": "https://shop.de/punchout/session/start/token/abc123def456",
"expires_at": "2024-12-01T12:00:00Z"
}

Warenkorb übertragen

POST /punchout/session/transfer
Cookie: punchout_session=abc123def456

# Response: Auto-Submit HTML Form mit cXML

REST API (Magento Standard)

# Session Status via REST
GET /rest/V1/punchflow/session/{sessionId}
Authorization: Bearer admin-token

{
"session_id": "abc123",
"status": "active",
"quote_id": 12345,
"cart_items_count": 3,
"cart_grand_total": 299.99,
"currency": "EUR"
}

Sicherheit

HMAC-Validierung

// Service/TokenValidationService.php
class TokenValidationService
{
public function validate(
string $signature,
string $timestamp,
string $nonce,
string $body
): ValidationResult {
// Timestamp prüfen (max 5 Minuten)
if (abs(time() - (int)$timestamp) > 300) {
return ValidationResult::failure(
'Request expired',
ValidationResult::ERROR_EXPIRED_REQUEST
);
}

// Nonce prüfen (Replay-Attack Prevention)
if ($this->isNonceUsed($nonce)) {
return ValidationResult::failure(
'Nonce already used',
ValidationResult::ERROR_NONCE_REUSED
);
}

// HMAC berechnen
$payload = $timestamp . $nonce . $body;
$expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $this->getSecret());

if (!hash_equals($expectedSignature, $signature)) {
return ValidationResult::failure(
'Invalid signature',
ValidationResult::ERROR_INVALID_SIGNATURE
);
}

$this->storeNonce($nonce);
return ValidationResult::success();
}
}

Nonce-Tracking via Cache

private function isNonceUsed(string $nonce): bool
{
$cacheKey = self::NONCE_CACHE_PREFIX . $nonce;
return $this->cache->load($cacheKey) !== false;
}

private function storeNonce(string $nonce): void
{
$cacheKey = self::NONCE_CACHE_PREFIX . $nonce;
$this->cache->save('1', $cacheKey, [], self::NONCE_TTL);
}

cXML PunchOutOrderMessage

Generierung

// Service/CxmlBuilderService.php
public function build(SessionInterface $session, CartInterface $quote): string
{
$xml = new \DOMDocument('1.0', 'UTF-8');
$xml->formatOutput = true;

// DOCTYPE
$doctype = $xml->createDocumentType(
'cXML',
'',
'http://xml.cxml.org/schemas/cXML/1.2.050/cXML.dtd'
);

// cXML Root
$cxml = $xml->createElement('cXML');
$cxml->setAttribute('version', self::CXML_VERSION);
$cxml->setAttribute('payloadID', $this->generatePayloadId());
$cxml->setAttribute('timestamp', (new \DateTime())->format('c'));
$cxml->setAttribute('xml:lang', 'de-DE');

// Header, Message mit Items...
$this->buildHeader($xml, $cxml, $session);
$this->buildMessage($xml, $cxml, $session, $quote);

return $xml->saveXML();
}

Dynamische Währung

// Währung aus Quote übernehmen
$currencyCode = $quote->getQuoteCurrencyCode() ?? 'EUR';

// Für alle Money-Elemente verwenden
$money = $xml->createElement('Money', number_format($amount, 2, '.', ''));
$money->setAttribute('currency', $currencyCode);

Admin-Oberfläche

Session-Grid

<!-- view/adminhtml/ui_component/punchflow_session_listing.xml -->
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<columns name="punchflow_session_columns">
<column name="session_id">
<settings>
<label translate="true">ID</label>
</settings>
</column>
<column name="session_token">
<settings>
<label translate="true">Token</label>
</settings>
</column>
<column name="buyer_name">
<settings>
<label translate="true">Buyer</label>
</settings>
</column>
<column name="status" component="Magento_Ui/js/grid/columns/select">
<settings>
<label translate="true">Status</label>
<options class="PunchFlow\PunchOut\Model\Session\Status"/>
</settings>
</column>
<column name="created_at" class="Magento\Ui\Component\Listing\Columns\Date">
<settings>
<label translate="true">Created</label>
</settings>
</column>
<actionsColumn name="actions" class="PunchFlow\PunchOut\Ui\Component\Listing\Column\SessionActions">
<settings>
<label translate="true">Actions</label>
</settings>
</actionsColumn>
</columns>
</listing>

System-Konfiguration

<!-- etc/adminhtml/system.xml -->
<config>
<system>
<section id="punchflow" translate="label">
<label>PunchFlow PunchOut</label>
<tab>general</tab>
<resource>PunchFlow_PunchOut::config</resource>
<group id="general" translate="label">
<label>General Settings</label>
<field id="enabled" translate="label" type="select">
<label>Enable Module</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
<field id="api_secret" translate="label" type="obscure">
<label>API Secret</label>
<backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
</field>
<field id="session_timeout" translate="label" type="text">
<label>Session Timeout (Minutes)</label>
<validate>required-entry validate-digits</validate>
</field>
<field id="debug" translate="label" type="select">
<label>Debug Mode</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
</group>
<group id="customer" translate="label">
<label>Customer Settings</label>
<field id="auto_create" translate="label" type="select">
<label>Auto-Create Customers</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
<field id="default_group_id" translate="label" type="select">
<label>Default Customer Group</label>
<source_model>Magento\Customer\Model\Config\Source\Group</source_model>
</field>
</group>
<group id="mapping" translate="label">
<label>Product Mapping</label>
<field id="product_id_attribute" translate="label" type="select">
<label>Product ID Attribute</label>
<source_model>PunchFlow\PunchOut\Model\Config\Source\ProductAttribute</source_model>
</field>
<field id="unspsc_attribute" translate="label" type="text">
<label>UNSPSC Attribute Code</label>
</field>
</group>
</section>
</system>
</config>

Testing

Unit Tests

# Tests ausführen
./vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist \
app/code/PunchFlow/PunchOut/Test/Unit

# Mit Coverage
./vendor/bin/phpunit --coverage-html var/coverage/ \
app/code/PunchFlow/PunchOut/Test/Unit

Integration Tests

# Integration Tests
./vendor/bin/phpunit -c dev/tests/integration/phpunit.xml.dist \
app/code/PunchFlow/PunchOut/Test/Integration

Test-Session erstellen

# Via CLI
bin/magento punchflow:session:create \
--buyer-cookie="test123" \
--return-url="https://test.erp.com/return" \
--buyer-email="test@example.com"

# Session Status prüfen
bin/magento punchflow:session:status test123

Frontend-Integration

Checkout-Modifikation

// Observer/CheckoutSubmitObserver.php
class CheckoutSubmitObserver implements ObserverInterface
{
public function execute(Observer $observer)
{
if (!$this->sessionService->hasActiveSession()) {
return;
}

$quote = $observer->getEvent()->getQuote();
$session = $this->sessionService->getActiveSession();

// cXML generieren
$cxml = $this->cxmlBuilder->build($session, $quote);

// Session als completed markieren
$this->sessionService->complete($session, $cxml);

// BrowserFormPost Response
$this->redirect->setRedirectUrl(
$this->urlBuilder->getUrl('punchout/session/transfer')
);
}
}

PunchOut-spezifisches Layout

<!-- view/frontend/layout/checkout_index_index.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<body>
<referenceBlock name="checkout.root">
<arguments>
<argument name="jsLayout" xsi:type="array">
<item name="components" xsi:type="array">
<item name="checkout" xsi:type="array">
<item name="children" xsi:type="array">
<item name="punchout-banner" xsi:type="array">
<item name="component" xsi:type="string">
PunchFlow_PunchOut/js/view/punchout-banner
</item>
</item>
</item>
</item>
</item>
</argument>
</arguments>
</referenceBlock>
</body>
</page>

Troubleshooting

Logs prüfen

# Modul-spezifische Logs
tail -f var/log/punchflow.log

# Magento Exception Logs
tail -f var/log/exception.log | grep -i punchflow

Debug-Modus

# Debug aktivieren
bin/magento config:set punchflow/general/debug 1
bin/magento cache:flush

Häufige Probleme

"Module not found"

# Modul-Status prüfen
bin/magento module:status PunchFlow_PunchOut

# DI neu kompilieren
rm -rf generated/
bin/magento setup:di:compile

"Invalid Signature"

# Secret prüfen
bin/magento config:show punchflow/general/api_secret

# Zeitdifferenz prüfen
date +%s

Quote nicht gefunden

# Quote in DB prüfen
bin/magento dev:query "SELECT * FROM quote WHERE entity_id = 123"

# Session prüfen
bin/magento dev:query "SELECT * FROM punchflow_session WHERE quote_id = 123"

Performance

Cache-Konfiguration

// etc/cache.xml
<config>
<type name="punchflow_session" translate="label">
<label>PunchFlow Session Cache</label>
<description>Cache for PunchOut session data</description>
</type>
</config>

Indexer

# Wenn custom Indexer benötigt
bin/magento indexer:reindex punchflow_session

Updates

Modul aktualisieren

composer update punchflow/module-punchout

bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush

Datenbank-Migrationen

# Schema-Änderungen anwenden
bin/magento setup:upgrade

# Whitelist generieren
bin/magento setup:db-declaration:generate-whitelist --module-name=PunchFlow_PunchOut

Support

Bei Fragen oder Problemen: