Skip to content

Handle incoming Shipmondo webhooks (transition order state) - #13

Open
loevgaard wants to merge 10 commits into
2.xfrom
feature/webhook-order-transitions
Open

loevgaard wants to merge 10 commits into
2.xfrom
feature/webhook-order-transitions

Conversation

@loevgaard

Copy link
Copy Markdown
Member

Summary

Incoming Shipmondo webhooks were received but nothing reacted to them — they were only written to a write-only RemoteEvent table that was never read. This PR makes the plugin act on webhooks immediately:

  • orders/status_update with shipped_percent >= 100 → ships the order's Sylius shipment(s) (via the sylius_shipment ship transition), which fulfils the order once payment is complete.
  • Cancellation (orders/delete, or status_update with order_status=cancelled) → cancels the Sylius order.

Design

  • Extensible tagged-handler framework mirroring the existing data-mapper composite: RemoteEventHandlerInterface + CompositeRemoteEventHandler + CompositeCompilerPass + the setono_sylius_shipmondo.remote_event_handler autoconfiguration tag (so integrators can add handlers).
  • Transitions go through Sylius\Abstraction\StateMachine\StateMachineInterface and are can()-guarded, making re-delivered webhooks idempotent (Shipmondo retries on any non-200).
  • OrderResolver links a webhook to its Sylius order via shipmondoId (fallback: order number).
  • Removed the write-only RemoteEvent resource/entity/factory/ORM mapping; WebhookConsumer now just dispatches to the handlers. The setono_sylius_shipmondo__remote_event table can be dropped — documented in UPGRADE.md.

Test plan

All gates green on PHP 8.1: PHPStan (max), PHPUnit (36 tests), ECS, Infection (Covered MSI 100%), lint:container.

Live-verified end-to-end by posting real signed webhooks to the local endpoint against uploaded sandbox orders:

Scenario Result
ship (shipped_percent=100) order new→fulfilled, shipment ready→shipped
re-deliver (idempotency) 200, stays fulfilled (no double transition)
cancel via order_status=cancelled order → cancelled
cancel via delete action order → cancelled

Note

The fulfil trigger keys off shipped_percent (a confirmed field on the sales-order payload; >= 100 = fully shipped). The cancel order_status=cancelled value is an assumption (marked @todo in CancelOrderHandler) — the orders/delete action is the robust cancel path that doesn't depend on it. Worth confirming the exact value against a real cancellation event.

Until now incoming webhooks were only stored in a write-only RemoteEvent
table that nothing read. They now drive Sylius order state immediately:

- orders/status_update with shipped_percent >= 100 ships the order's
  Sylius shipment(s), which fulfils the order once payment is complete
- order cancellation in Shipmondo (orders/delete, or status_update with
  order_status=cancelled) cancels the Sylius order

Reactions run through a tagged RemoteEventHandlerInterface framework
(CompositeRemoteEventHandler + CompositeCompilerPass + the
setono_sylius_shipmondo.remote_event_handler autoconfiguration tag), so
integrators can add their own handlers the same way they add data mappers.
Transitions go through the Sylius state-machine abstraction and are
can()-guarded, so re-delivered webhooks are idempotent. An OrderResolver
links a webhook back to its Sylius order via shipmondoId (falling back to
the order number).

The write-only RemoteEvent Sylius resource/entity/factory is removed and
the WebhookConsumer now just dispatches to the handlers; see UPGRADE.md.
@codecov

codecov Bot commented Jun 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.11656% with 21 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (2.x@b960493). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...ndencyInjection/SetonoSyliusShipmondoExtension.php 27.77% 13 Missing ⚠️
src/SetonoSyliusShipmondoPlugin.php 0.00% 4 Missing ⚠️
src/Registrar/WebhookRegistrar.php 77.77% 2 Missing ⚠️
src/Message/Command/DeleteOrder.php 83.33% 1 Missing ⚠️
src/Webhook/Handler/FulfillOrderHandler.php 96.29% 1 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##             2.x      #13   +/-   ##
======================================
  Coverage       ?   49.44%           
  Complexity     ?      238           
======================================
  Files          ?       53           
  Lines          ?      904           
  Branches       ?        0           
======================================
  Hits           ?      447           
  Misses         ?      457           
  Partials       ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The new webhook handlers use Sylius\Component\Shipping\ShipmentTransitions
and Sylius\Abstraction\StateMachine\StateMachineInterface, which were only
available transitively. Declare them so the dependency-analysis job (which
analyses src against the split Sylius packages) passes.
The webhook handlers obtain the entity manager via the maintained
setono/doctrine-orm-trait ORMTrait (getManager()) and a ManagerRegistry,
rather than injecting an ObjectManager. Also migrate the existing
OrderUploader off the abandoned setono/doctrine-object-manager-trait to the
same trait, and drop the abandoned package.
Live-testing the integration against the Shipmondo sandbox surfaced that the
handlers did not actually work against real webhooks — the earlier tests used a
fabricated payload shape and masked it:

- Real webhooks wrap the resource object in a `data` envelope ({webhook, data,
  url}); the parser passed the whole envelope through, so handlers read
  $payload['id'] as null and silently no-op'd. JWT::decode also returns nested
  stdClass. WebhookParser now decodes to associative arrays and unwraps `data`,
  so handlers receive the resource object directly.

- Fulfilment: the fully-shipped state (shipped_percent 100, order_status "sent")
  arrives via `orders/create_shipment`, not `status_update` (which fires at
  shipped_percent 0 when the order is merely packed/fulfilled). FulfillOrderHandler
  now triggers on create_shipment (and status_update defensively), still gated on
  shipped_percent >= 100.

- Cancellation: Shipmondo has no "cancel" action — cancelling a sales order
  deletes/archives it (order_status "archived") and fires `orders/delete`.
  CancelOrderHandler now keys purely on the delete action; the speculative
  order_status === 'cancelled' branch is removed.

Tests are rewritten onto real payloads captured from the sandbox (fixtures under
tests/Unit/Webhook/fixtures/), and a WebhookParser test guards the envelope
unwrapping. Verified end-to-end through an Expose tunnel: a real orders/delete
cancelled the Sylius order, and a real orders/create_shipment fulfilled it.
Orders are uploaded to Shipmondo while either paid or authorized, so an
authorized payment can be captured or voided later in Shipmondo. A new
PaymentStateHandler reacts to those events:

- orders/payment_captured -> complete the Sylius payment(s); the order's
  payment state becomes paid
- orders/payment_voided -> cancel the order's payment state

Capture is applied to the individual payments (so they end up `completed`
and consistent). A void is applied to the order payment state machine
(sylius_order_payment) directly: cancelling a payment makes Sylius spawn a
replacement payment (its "pay again" flow), which is not what a voided
authorization means here. Both are can()-guarded, so an already-paid order
is a safe no-op.

Declares sylius/payment (PaymentTransitions). Verified against real payloads
captured from the sandbox: an authorized order -> paid on payment_captured,
and -> payment state cancelled on payment_voided.
src/RemoteEvent/RemoteEvent.php -> src/Webhook/RemoteEvent.php
(Setono\SyliusShipmondoPlugin\RemoteEvent\RemoteEvent ->
 Setono\SyliusShipmondoPlugin\Webhook\RemoteEvent); it is only used by the
webhook parser/consumer/handlers, so it belongs next to them.
When an order is deleted in Shipmondo (orders/delete), it no longer exists
there, so CancelOrderHandler now resets the order's shipmondoState from
uploaded_to_shipmondo back to pending in addition to cancelling the Sylius
order. Adds a `reset` transition (uploaded_to_shipmondo -> pending) to the
OrderWorkflow. The cancel and the reset are applied independently, each
can()-guarded, so either one being inapplicable doesn't block the other.
Move incoming-webhook verification and parsing into the Shipmondo SDK
(setono/shipmondo-php-sdk ^2.0, firebase/php-jwt ^7.0):

- The plugin's WebhookParser is now a thin adapter over the SDK's
  Setono\Shipmondo\Webhook\WebhookParser, which verifies the HS256 signature,
  reads the SMD-* headers and unwraps the `data` envelope.
- Shipmondo identifies a delivery by its SMD-* headers, so the registered
  endpoint no longer carries resource/action query parameters;
  IsShipmondoWebhookRequestMatcher matches on the SMD-Resource-Type/SMD-Action
  headers and the now-unused HasQueryParameterRequestMatcher is removed.
- RemoteEvent::getResource()/getAction(), the handlers, and WebhookRegistrar
  use the SDK's WebhookResourceName/WebhookAction enums instead of strings.
- HS256 requires a >= 32-byte key, so SHIPMONDO_WEBHOOKS_KEY must now be at
  least 32 bytes (the test-app default is bumped). Documented in UPGRADE.md;
  consumers must re-run register-webhooks after upgrading.

The SDK constraint is temporarily 2.x-dev (the new webhook API is not tagged
yet) — composer validate --strict warns on this until it is switched to the tag.
Deleting a sales order in Shipmondo no longer cancels the Sylius order. The
old CancelOrderHandler did two contradictory things on `orders/delete` — it
cancelled the Sylius order *and* reset the upload state for re-upload — but a
cancelled order is never re-upload-eligible (the provider requires state=new),
so the reset was dead weight and the order just ended up cancelled.

Replace it with ResetUploadStateHandler: on `orders/delete` it resets the
order's Shipmondo upload state (shipmondoState -> pending) and clears its
shipmondoId, so the next `upload-orders` run re-uploads it. Cancellation is a
Sylius-side decision and is intentionally not driven by a Shipmondo deletion.

Drops the order state-machine dependency from the handler; updates the service
wiring, the test, and UPGRADE.md.
When an order is cancelled in Sylius, delete its Shipmondo sales order (if it
was uploaded) and flash a message in the admin so the user knows it happened.

The cancellation is caught on both state-machine backends so it survives Sylius's
migration from winzou to Symfony Workflow:
- a winzou `after` callback on the sylius_order `cancel` transition (the 1.14
  default), prepended from the extension and guarded by hasExtension();
- a Symfony Workflow listener on `workflow.sylius_order.completed.cancel`.

Both call OrderCancellationListener, which dispatches a new DeleteOrder command on
setono_sylius_shipmondo.command_bus (handled synchronously by default; route it to
an async transport to keep the Shipmondo call out of the cancel request). The
handler deletes via the SDK's new salesOrders()->delete() and clears the order's
shipmondoId.

Also add the unit tests requested for UploadOrderHandler and DeleteOrderHandler by
mocking the PSR-18 HTTP client and constructing a real SDK Client (the final
SalesOrdersEndpoint isn't the boundary to mock — the transport is). Drop the
(string) casts in their throw messages, which are equivalent mutants once covered.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant