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
- Stores → Configuration → PunchFlow → PunchOut
- Konfigurieren Sie:
| Einstellung | Beschreibung | Standard |
|---|---|---|
| Enable Module | Modul aktivieren | Ja |
| API Secret | Shared Secret für HMAC-Validierung | Erforderlich |
| Session Timeout | Sitzungsdauer in Minuten | 60 |
| Debug Mode | Erweiterte Protokollierung | Nein |
| Auto Create Customers | Automatische Kundenerstellung | Ja |
| Default Customer Group | Standard B2B-Gruppe | 2 |
| Product ID Attribute | Feld für SupplierPartID | sku |
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