<?php declare(strict_types=1);
namespace MasterFFL\Checkout\Subscriber;
use MasterFFL\Checkout\Service\FFLConfigService;
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
use Shopware\Core\Checkout\Order\Event\OrderStateMachineStateChangeEvent;
use Shopware\Core\Checkout\Order\OrderEvents;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent; // FIXED IMPORT
use Shopware\Core\Checkout\Order\OrderEntity;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
class OrderPlacedSubscriber implements EventSubscriberInterface
{
private EntityRepository $orderRepository;
private EntityRepository $stateMachineStateRepository;
private EntityRepository $productRepository;
private SystemConfigService $systemConfigService;
private FFLConfigService $configService;
private RequestStack $requestStack;
public function __construct(
EntityRepository $orderRepository,
EntityRepository $productRepository,
SystemConfigService $systemConfigService,
FFLConfigService $configService,
EntityRepository $stateMachineStateRepository,
RequestStack $requestStack
) {
$this->orderRepository = $orderRepository;
$this->productRepository = $productRepository;
$this->systemConfigService = $systemConfigService;
$this->configService = $configService;
$this->stateMachineStateRepository = $stateMachineStateRepository;
$this->requestStack = $requestStack;
}
public static function getSubscribedEvents(): array
{
return [
// Listen to multiple events to catch order placement
OrderEvents::ORDER_WRITTEN_EVENT => 'onOrderWritten',
OrderStateMachineStateChangeEvent::class => 'onOrderStateChanged',
'checkout.order.placed' => 'onOrderPlaced'
];
}
public function onOrderWritten(EntityWrittenEvent $event): void // FIXED TYPE HINT
{
error_log("=== SHOPWARE ORDER WRITTEN EVENT TRIGGERED ===");
error_log("Event class: " . get_class($event));
error_log("Entity name: " . $event->getEntityName());
error_log("Write results count: " . count($event->getWriteResults()));
foreach ($event->getWriteResults() as $writeResult) {
$payload = $writeResult->getPayload();
error_log("Order ID from write result: " . ($payload['id'] ?? 'N/A'));
if (isset($payload['id'])) {
$this->processOrder($payload['id'], $event->getContext());
}
}
}
public function onOrderPlaced($event): void
{
error_log("=== SHOPWARE ORDER PLACED EVENT TRIGGERED ===");
error_log("Event class: " . get_class($event));
if (method_exists($event, 'getOrder')) {
$order = $event->getOrder();
error_log("Order ID from placed event: " . $order->getId());
$this->processOrder($order->getId(), $event->getContext());
}
}
private function getOrderStatusTechnicalName(string $statusId, Context $context): string
{
if (empty($statusId)) {
return 'open'; // default fallback
}
try {
$criteria = new Criteria([$statusId]);
$status = $this->stateMachineStateRepository->search($criteria, $context)->first();
if ($status) {
return $status->getTechnicalName();
}
} catch (\Exception $e) {
error_log('Error getting status technical name: ' . $e->getMessage());
}
return 'open'; // fallback
}
public function onOrderStateChanged(OrderStateMachineStateChangeEvent $event): void
{
$order = $event->getOrder();
$salesChannelId = $order->getSalesChannelId();
// Only process when order is placed/confirmed
if ($event->getToPlace()->getTechnicalName() !== 'open') {
return;
}
try {
// Get FFL configuration
$fflConfig = $this->configService->getFFLConfig($salesChannelId);
$environment = $fflConfig['environment'] ?? false;
$webhookUrl = $environment
? ($fflConfig['webhookUrlTest'] ?? 'https://api-qa.masterffl.com/ffl/bigcommerce/app/api/order/shopware/submittransfer')
: ($fflConfig['webhookUrl'] ?? 'https://ffl-api.masterffl.com/ffl/bigcommerce/app/api/order/shopware/submittransfer');
$orderStatusMapping = $fflConfig['orderStatusMapping'] ?? 'open';
// Get full order with associations
$fullOrder = $this->getFullOrder($order->getId(), $event->getContext());
if (!$fullOrder) {
return;
}
// Check if order has FFL products and build item data
$itemData = $this->buildFFLItemData($fullOrder, $event->getContext(), $salesChannelId);
if (empty($itemData)) {
return; // No FFL products, skip webhook
}
// Build order data for webhook
$orderData = $this->buildOrderData($fullOrder, $itemData, $orderStatusMapping);
// Send webhook using cURL (like Magento)
$this->sendWebhookWithCurl($webhookUrl, $orderData);
} catch (\Exception $e) {
// Log error but don't throw to avoid breaking order process
error_log('FFL Webhook Error: ' . $e->getMessage());
}
}
private function processOrder(string $orderId, Context $context): void
{
try {
error_log("=== PROCESSING ORDER: " . $orderId . " ===");
// Get full order
$order = $this->getFullOrder($orderId, $context);
if (!$order) {
error_log("ERROR: Could not retrieve order");
return;
}
$salesChannelId = $order->getSalesChannelId();
error_log("Sales Channel ID: " . $salesChannelId);
error_log("Order Number: " . $order->getOrderNumber());
error_log("Order State: " . $order->getStateMachineState()->getTechnicalName());
error_log("Order Total: " . $order->getAmountTotal());
error_log("Customer Email: " . $order->getOrderCustomer()->getEmail());
// Get FFL configuration
$fflConfig = $this->configService->getFFLConfig($salesChannelId);
$environment = $fflConfig['environment'] ?? false;
error_log("Environment: " . ($environment ? 'DEVELOPMENT/TEST' : 'PRODUCTION'));
$webhookUrl = $environment
? ($fflConfig['webhookUrlTest'] ?? 'https://api-qa.masterffl.com/ffl/bigcommerce/app/api/order/shopware/submittransfer')
: ($fflConfig['webhookUrl'] ?? 'https://ffl-api.masterffl.com/ffl/bigcommerce/app/api/order/shopware/submittransfer');
$orderStatusMapping = $fflConfig['orderStatusMapping'] ?? 'open';
error_log("Webhook URL: " . $webhookUrl);
error_log("Order Status Mapping: " . $orderStatusMapping);
// Check if order has FFL products and build item data
// echo "<pre>"; print_r($order); die;
$itemData = $this->buildFFLItemData($order, $context, $salesChannelId);
error_log("FFL Items found: " . count($itemData));
if (empty($itemData)) {
error_log("No FFL products found in order - skipping webhook");
return;
}
$dealerId = $this->getDealerId($order);
$this->updateOrderCustomFields($orderId, $dealerId, $context);
// Log FFL items details
foreach ($itemData as $index => $item) {
error_log("FFL Item " . ($index + 1) . ":");
error_log(" - ID: " . $item['item_id']);
error_log(" - Name: " . $item['item_name']);
error_log(" - SKU: " . $item['item_sku']);
error_log(" - Qty: " . $item['item_qty']);
error_log(" - Price: " . $item['item_price']);
error_log(" - Custom Fields: " . json_encode($item['custom_fields']));
}
// Build order data for webhook
$orderData = $this->buildOrderData($order, $itemData, $orderStatusMapping);
error_log("=== ORDER DATA BUILT FOR WEBHOOK ===");
error_log("Platform: " . $orderData['platform']);
error_log("Callback URL: " . $orderData['callback_url']);
error_log("Order Total: " . $orderData['order_total']);
error_log("Customer Email: " . $orderData['customer_email']);
error_log("License ID: " . $orderData['license_id']);
error_log("Domain: " . $orderData['domain']);
// Send webhook using cURL (like Magento)
error_log("=== SENDING WEBHOOK ===");
$this->sendWebhookWithCurl($webhookUrl, $orderData);
} catch (\Exception $e) {
error_log('=== FFL WEBHOOK ERROR ===');
error_log('Error Message: ' . $e->getMessage());
error_log('Error File: ' . $e->getFile() . ':' . $e->getLine());
error_log('Stack Trace: ' . $e->getTraceAsString());
}
}
/**
* Send webhook using cURL (same as Magento implementation)
*/
private function sendWebhookWithCurl(string $webhookUrl, array $orderData): void
{
try {
$json_shipping_add = json_encode($orderData);
// Initialize cURL
$ch = curl_init();
// Set cURL options (same as Magento's Curl client)
curl_setopt($ch, CURLOPT_URL, $webhookUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $json_shipping_add);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
// Execute the request
$response = curl_exec($ch);
// Get response info
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
// Close cURL
curl_close($ch);
// Handle errors
if ($error) {
throw new \Exception('cURL Error: ' . $error);
}
// Log response (optional, like Magento)
error_log("FFL Webhook sent. Status: {$httpCode}, Response: {$response}");
} catch (\Exception $e) {
error_log('=== WEBHOOK FAILURE ===');
error_log('FFL Webhook failed: ' . $e->getMessage());
throw $e;
}
}
private function getFullOrder(string $orderId, Context $context): ?OrderEntity
{
$criteria = new Criteria([$orderId]);
$criteria->addAssociation('addresses');
$criteria->addAssociation('lineItems');
$criteria->addAssociation('deliveries.shippingOrderAddress');
$criteria->addAssociation('transactions.paymentMethod');
$criteria->addAssociation('salesChannel');
$criteria->addAssociation('salesChannel.domains'); // Add this line
$criteria->addAssociation('currency');
$criteria->addAssociation('orderCustomer.customer');
return $this->orderRepository->search($criteria, $context)->first();
}
private function buildFFLItemData(OrderEntity $order, Context $context, string $salesChannelId): array
{
$itemData = [];
$productIds = [];
// Collect product IDs
foreach ($order->getLineItems() as $lineItem) {
if ($lineItem->getType() === LineItem::PRODUCT_LINE_ITEM_TYPE) {
$productIds[] = $lineItem->getReferencedId();
}
}
if (empty($productIds)) {
return [];
}
// Get products with categories
$criteria = new Criteria();
$criteria->addFilter(new EqualsAnyFilter('id', $productIds));
$criteria->addAssociation('categories');
$products = $this->productRepository->search($criteria, $context);
// Get FFL configuration
$fflAttributeName = $this->systemConfigService->get('MasterFFLCheckout.config.fflAttributeName', $salesChannelId);
$fflAttributeValue = $this->systemConfigService->get('MasterFFLCheckout.config.fflAttributeValue', $salesChannelId);
$firearmAttributeName = $this->systemConfigService->get('MasterFFLCheckout.config.firearmAttributeName', $salesChannelId);
// Get firearm type mappings
$firearmTypes = [
'hand_gun' => $this->systemConfigService->get('MasterFFLCheckout.config.handGunType', $salesChannelId),
'long_gun' => $this->systemConfigService->get('MasterFFLCheckout.config.longGunType', $salesChannelId),
'other_firearm' => $this->systemConfigService->get('MasterFFLCheckout.config.otherFirearmType', $salesChannelId),
'nfa' => $this->systemConfigService->get('MasterFFLCheckout.config.nfaType', $salesChannelId)
];
// Get FFL category mappings
$fflCategoryMappings = $this->getFFLCategoryMappings($salesChannelId);
foreach ($order->getLineItems() as $lineItem) {
if ($lineItem->getType() !== LineItem::PRODUCT_LINE_ITEM_TYPE) {
continue;
}
$product = $products->get($lineItem->getReferencedId());
if (!$product) {
continue;
}
$customAttributes = [];
$isFFLProduct = false;
$firearmType = '';
// Check custom fields first
$customFields = $product->getCustomFields() ?? [];
// Check if FFL field exists (try multiple variations)
$fflFieldValue = null;
if (isset($customFields[$fflAttributeName])) {
$fflFieldValue = $customFields[$fflAttributeName];
} elseif (isset($customFields[strtolower($fflAttributeName)])) {
$fflFieldValue = $customFields[strtolower($fflAttributeName)];
} elseif (isset($customFields[strtoupper($fflAttributeName)])) {
$fflFieldValue = $customFields[strtoupper($fflAttributeName)];
}
if ($fflFieldValue !== null) {
$fflFieldValueLower = strtolower(trim((string)$fflFieldValue));
$configValueLower = strtolower(trim($fflAttributeValue));
// Check for NO values first
if ($fflFieldValueLower === 'no' || $fflFieldValueLower === 'false' || $fflFieldValueLower === '0') {
continue; // Skip this product
}
// Check for YES values
if ($fflFieldValue === $configValueLower || $fflFieldValueLower == $fflAttributeValue) {
$isFFLProduct = true;
// Get firearm type - try multiple field name variations
$firearmTypeValue = null;
if (isset($customFields[$firearmAttributeName])) {
$firearmTypeValue = $customFields[$firearmAttributeName];
}
if ($firearmTypeValue) {
$firearmTypeValueLower = strtolower(trim((string)$firearmTypeValue));
$firearmType = $firearmTypes[$firearmTypeValueLower] ?? $firearmTypeValue;
}
}
}
// Check category mapping if not explicitly set
if (!$isFFLProduct) {
if ($product->getCategories()) {
foreach ($product->getCategories() as $category) {
foreach ($fflCategoryMappings as $mapping) {
if ($mapping['storeCategory'] === $category->getId()) {
$isFFLProduct = true;
$fflMapping = strtolower($mapping['fflMapping'] ?? '');
$firearmType = $firearmTypes[$fflMapping] ?? $mapping['fflMapping'] ?? '';
break 2;
}
}
}
}
}
if ($isFFLProduct) {
$customAttributes[] = [
'name' => $fflAttributeName,
'value' => $fflAttributeValue
];
if ($firearmType) {
if (strtolower($firearmType) === 'nfa' || strpos(strtolower($firearmType), 'suppressor') !== false) {
$customAttributes[] = ['name' => $firearmAttributeName, 'value' => 'suppressor'];
} else {
$customAttributes[] = ['name' => $firearmAttributeName, 'value' => $firearmType];
}
}
$itemData[] = [
'item_id' => $lineItem->getReferencedId(),
'item_name' => $lineItem->getLabel(),
'item_sku' => $lineItem->getPayload()['productNumber'] ?? '',
'price_ex_tax' => $lineItem->getUnitPrice(),
'item_qty' => $lineItem->getQuantity(),
'item_price' => $lineItem->getUnitPrice(),
'custom_fields' => $customAttributes
];
}
}
return $itemData;
}
private function checkProductCategories(ProductEntity $product, array $fflCategoryMappings, array $firearmTypes): array
{
$result = ['isFFLProduct' => false, 'firearmType' => ''];
if (!$product->getCategories()) {
error_log("Product has no categories");
return $result;
}
error_log("Product categories: " . $product->getCategories()->count());
foreach ($product->getCategories() as $category) {
error_log("Checking category: " . $category->getName() . " (ID: " . $category->getId() . ")");
foreach ($fflCategoryMappings as $mapping) {
if ($mapping['storeCategory'] === $category->getId()) {
error_log("Found FFL category mapping: " . json_encode($mapping));
$result['isFFLProduct'] = true;
$fflMapping = strtolower($mapping['fflMapping'] ?? '');
$result['firearmType'] = $firearmTypes[$fflMapping] ?? $mapping['fflMapping'] ?? '';
error_log("Category mapped to firearm type: " . $result['firearmType']);
return $result;
}
}
}
error_log("No FFL category mapping found for product");
return $result;
}
private function getFFLCategoryMappings(string $salesChannelId): array
{
$configKey = 'MasterFFLCheckout.config.categoryMappingTable';
$categoryMappings = $this->systemConfigService->get($configKey, $salesChannelId);
error_log("Raw category mappings from config: " . json_encode($categoryMappings));
return is_array($categoryMappings) ? $categoryMappings : [];
}
private function buildOrderData(OrderEntity $order, array $itemData, string $orderStatusMapping): array
{
$billingAddress = null;
$shippingAddress = null;
// Get addresses
foreach ($order->getAddresses() as $address) {
if ($address->getId() === $order->getBillingAddressId()) {
$billingAddress = $address;
}
if ($order->getDeliveries()->first() &&
$address->getId() === $order->getDeliveries()->first()->getShippingOrderAddressId()) {
$shippingAddress = $address;
}
}
// Get payment method
$paymentMethod = '';
if ($order->getTransactions()->first() && $order->getTransactions()->first()->getPaymentMethod()) {
$paymentMethod = $order->getTransactions()->first()->getPaymentMethod()->getName();
}
// Get shipping method
$shippingMethod = '';
if ($order->getDeliveries()->first() && $order->getDeliveries()->first()->getShippingMethod()) {
$shippingMethod = $order->getDeliveries()->first()->getShippingMethod()->getName();
}
$baseUrl = '';
if ($order->getSalesChannel() &&
$order->getSalesChannel()->getDomains() &&
$order->getSalesChannel()->getDomains()->first()) {
$baseUrl = $order->getSalesChannel()->getDomains()->first()->getUrl();
} else {
if (isset($_SERVER['HTTP_HOST'])) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$baseUrl = $protocol . '://' . $_SERVER['HTTP_HOST'];
}
}
$siteUrl = parse_url($baseUrl, PHP_URL_HOST) ?: 'localhost';
$billStreet = $billingAddress ? $billingAddress->getStreet() : '';
$bill_street_one = $billStreet;
$bill_street_two = $billingAddress ? ($billingAddress->getAdditionalAddressLine1() ?? '') : '';
$shipStreet = $shippingAddress ? $shippingAddress->getStreet() : '';
$ship_street_one = $shipStreet;
$ship_street_two = $shippingAddress ? ($shippingAddress->getAdditionalAddressLine1() ?? '') : '';
$callbackUrl = rtrim($baseUrl, '/') . '/api/_action/ffl/update-shipping-address/' . $order->getOrderNumber();
$orderStatusTechnicalName = $this->getOrderStatusTechnicalName($orderStatusMapping, Context::createDefaultContext());
$dealerId = $this->getDealerId($order);
$token = $this->getToken($order);
$orderData = [
'platform' => 'shopware',
'callback_url' => $callbackUrl,
'order_id' => $order->getOrderNumber(),
'order_number' => $order->getId(),
'order_date' => $order->getOrderDateTime()->format('Y-m-d H:i:s'),
'date_modified' => $order->getUpdatedAt() ? $order->getUpdatedAt()->format('Y-m-d H:i:s') : $order->getOrderDateTime()->format('Y-m-d H:i:s'),
'status' => $order->getStateMachineState()->getTechnicalName(),
'shipping_total' => $order->getShippingTotal(),
'shipping_tax_total' => 0,
'fee_total' => '',
'fee_tax_total' => '',
'tax_total' => $order->getAmountTotal() - $order->getAmountNet(),
'order_discount' => 0,
'discount_total' => 0,
'order_total' => $order->getAmountTotal(),
'order_currency' => $order->getCurrency()->getIsoCode(),
'payment_method' => $paymentMethod,
'payment_amount' => $order->getAmountTotal(),
'shipping_method' => $shippingMethod,
'shipping_cost_ex_tax' => $order->getShippingTotal(),
'customer_id' => $order->getOrderCustomer()->getCustomerId() ?? '',
'customer_email' => $order->getOrderCustomer()->getEmail(),
'billing_first_name' => $billingAddress ? $billingAddress->getFirstName() : '',
'billing_last_name' => $billingAddress ? $billingAddress->getLastName() : '',
'billing_company' => $billingAddress ? $billingAddress->getCompany() : '',
'billing_email' => $order->getOrderCustomer()->getEmail(),
'billing_phone' => $billingAddress->getPhoneNumber() ?? '0000000000',
'billing_address_1' => $bill_street_one,
'billing_address_2' => $bill_street_two,
'billing_postcode' => $billingAddress ? $billingAddress->getZipcode() : '',
'billing_city' => $billingAddress ? $billingAddress->getCity() : '',
'billing_state' => $billingAddress && $billingAddress->getCountryState() ? $billingAddress->getCountryState()->getName() : '',
'billing_country' => $billingAddress && $billingAddress->getCountry() ? $billingAddress->getCountry()->getIso() : '',
'shipping_first_name' => $shippingAddress ? $shippingAddress->getFirstName() : ($billingAddress ? $billingAddress->getFirstName() : ''),
'shipping_last_name' => $shippingAddress ? $shippingAddress->getLastName() : ($billingAddress ? $billingAddress->getLastName() : ''),
'shipping_company' => $shippingAddress ? $shippingAddress->getCompany() : '',
'shipping_address_1' => $ship_street_one,
'shipping_address_2' => $ship_street_two,
'shipping_postcode' => $shippingAddress ? $shippingAddress->getZipcode() : '',
'shipping_city' => $shippingAddress ? $shippingAddress->getCity() : '',
'shipping_phone' => $shippingAddress ? $shippingAddress->getPhoneNumber() : '',
'shipping_state' => $shippingAddress && $shippingAddress->getCountryState() ? $shippingAddress->getCountryState()->getShortCode() : '',
'shipping_country' => $shippingAddress && $shippingAddress->getCountry() ? $shippingAddress->getCountry()->getIso() : '',
'customer_note' => $order->getCustomerComment() ?? '',
'domain' => $siteUrl,
'license_id' => $dealerId,
'token' => $token,
'order_status_mapping' => $orderStatusTechnicalName,
'item' => $itemData,
];
return $orderData;
}
private function getDealerId(OrderEntity $order): string
{
$request = $this->requestStack->getCurrentRequest();
if ($request && $request->hasSession()) {
$session = $request->getSession();
$fflDealerData = $session->get('ffl_dealer_data');
if ($fflDealerData && isset($fflDealerData['dealer_id'])) {
return $fflDealerData['dealer_id'];
}
}
return '';
}
private function getToken(OrderEntity $order): string
{
$request = $this->requestStack->getCurrentRequest();
if ($request && $request->hasSession()) {
$session = $request->getSession();
$seassionData = $session->get('ffl_dealer_data');
$token = $seassionData['token'];
if ($token) {
return $token;
}
}
return '';
}
private function updateOrderCustomFields(string $orderId, string $dealerId, Context $context): void
{
try {
if (empty($dealerId)) {
error_log("Dealer ID is empty, skipping update");
return;
}
error_log("Updating order {$orderId} with dealer ID: {$dealerId}");
// Get existing custom fields to preserve other data
$order = $this->getFullOrder($orderId, $context);
if (!$order) {
error_log("Order not found: {$orderId}");
return;
}
$existingCustomFields = $order->getCustomFields() ?? [];
// Check if already set to avoid unnecessary updates
if (isset($existingCustomFields['masterffl_license_number']) &&
$existingCustomFields['masterffl_license_number'] === $dealerId) {
error_log("Dealer ID already set for order {$orderId}, skipping update");
return;
}
// Add/update the masterffl_license_number field
$existingCustomFields['masterffl_license_number'] = $dealerId;
// Update with complete custom fields array
$this->orderRepository->update([
[
'id' => $orderId,
'customFields' => $existingCustomFields
]
], $context);
error_log("Successfully updated masterffl_license_number with: " . $dealerId);
} catch (\Exception $e) {
error_log('Error updating order custom fields: ' . $e->getMessage());
error_log('Stack trace: ' . $e->getTraceAsString());
// Don't throw exception to avoid breaking order process
}
}
}