Skip to content

feat(integrations): Shopify integration epic (#4091) - #4100

Draft
jakubretajczykBD wants to merge 7 commits into
mainfrom
4091-shopify-epic
Draft

jakubretajczykBD wants to merge 7 commits into
mainfrom
4091-shopify-epic

Conversation

@jakubretajczykBD

Copy link
Copy Markdown
Collaborator

Summary

This PR merges the Shopify integration epic (#4091) from 4091-shopify-epic into main. It adds Shopify as an OpenLinker connection that can be:

  • a catalogue and stock master;
  • an order source, with returns and refunds;
  • an order destination;
  • a publish destination.

Inbound webhooks are included. Spike: #3928.

Each child PR was retargeted onto the epic branch, reviewed, and merged with a merge commit, as the epic describes. This PR stays a draft until the remaining children below are merged.

Merged into the epic

Issue PR Scope
#3974 #4042 Plugin foundation: connection, OAuth / client credentials / access token, GraphQL client, connection tester, retry and auth-failure classifiers
#3975 #4074 ProductMaster: catalogue, variants, categories, delta sync, tax rate
#3976 #4075 InventoryMaster: one location, compare-and-set writes, corrections
#3977 #4076 OrderSource: order feed, returns, status writeback, refunds
#3978 #4077 Order destination: marketplace orders created in the store at the buyer's price, requiresShipping per line, whole-order discount
#3981 #4086 Inbound webhooks: HMAC decoder, event translator, idempotent provisioner, adapter-declared caller-supplied webhook secret
#3979 #4087 Publish destination: ProductPublisher and CategoryProvisioner (simple and variant products, collections, metafields, gross/net prices)

Still to merge into the epic

Issue PR Scope
#3980 #4088 OfferManager: stock write-back to published products
#3982 #4089 Connection page section and the Configure webhooks action
#3983 #4090 Dev-store seed tooling and setup documentation

Known follow-ups (not in this epic)

Test plan

Closes #3974
Closes #3975
Closes #3976
Closes #3977
Closes #3978
Closes #3979
Closes #3981
Refs #4091

🤖 Generated with Claude Code

jakubretajczykBD and others added 7 commits November 12, 2026 10:53
…classifiers (#3974) (#4042)

* feat(integrations): Shopify plugin foundation - connection, auth and classifiers (#3974)

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): wrap identity-check transport failures in ShopifyNetworkError

A timeout or network failure in the OAuth identity check surfaced as a bare
AbortError with no context. Wrap it the way the token request does.

Refs #3974

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): show API errors through describeApiError on the setup and callback pages

The setup form and the OAuth callback page rendered the API's English
error message. Route them through describeApiError so the operator sees
the translated copy for the error code.

Refs #3974

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): verify the OAuth redirect hmac over encoded values and code the callback errors

- Sign the callback query form-encoded, as Shopify's libraries do: the
  `host` parameter is base64 and may end in `=`, so a decoded message made
  a valid redirect fail as bad-hmac.
- Send the OAuth refusals as registered codes (OAUTH_STATE_MISSING,
  OAUTH_STATE_INVALID, new OAUTH_CALLBACK_UNVERIFIED with platformType and
  reason) instead of ad hoc SHOPIFY_OAUTH_* codes; an invalid install seed
  answers VALIDATION_FAILED with fields, like a config save.
- Callback page reads ApiError.code and gets a test for every state.
- Refuse an OAuth refresh with no lock rather than spend the rotating
  refresh token unserialised.
- Retry classifier walks the cause chain for both questions, but only for
  Shopify errors; a RangeError is terminal only when it is the failure
  itself, since every classifier is asked about every platform's jobs.
- Archive hook header states what "removed" covers.

Refs #3974

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* test(shopify): cover the setup form's three connection modes

Client credentials and access token create the connection with the
normalised store domain and offer the connection test; OAuth starts the
install with this page's callback URL and sends the operator to Shopify
without creating anything; a non-myshopify domain is refused before any
API call; a failed create shows the coded copy, never the server message.

Refs #3974

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…elta sync, tax rate (#3975) (#4074)

* feat(shopify): ProductMaster - catalogue, variants, categories, delta sync, tax rate

Shopify as product master (role A), read-only, plus the guard-only rungs
ModifiedProductLister, BulkProductReader and ProductTaxRateReader.

- One ProductVariant per Shopify variant (options as attributes, the
  "Default Title" variant is a simple product), gross price for tax-inclusive
  shops (ADR-077), EAN from barcode, weight, vendor, product and variant images.
- Product.categories holds the taxonomy leaf then every ancestor, so a mapping
  on any level resolves (A15); getCategories lists the shop's directory.
- product: null and ARCHIVED raise MasterProductNotFoundError (A10, A11);
  DRAFT syncs normally; gift cards are skipped (A17); compareAtPrice is not
  carried (A05).
- Delta pass: union of product and variant updated_at, pulled back 15 s
  (L-M2, A19). Enumeration resumes from a cursor checkpoint in the host cache.
- Tax rate computed with draftOrderCalculate (L-T4), cached per variant,
  country and product version; adds the write_draft_orders scope and the
  optional salesCountryIso2 config.
- Query cost kept under 1000 points: 50 variants per product read with the
  rest paged, batch reads halve on MAX_COST_EXCEEDED.

Refs #3975

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): optional draft-order scope, tax cache per variant, narrower pager fallback

Tech review of the ProductMaster:
- write_draft_orders moves to SHOPIFY_OPTIONAL_SCOPES: asked for at install,
  but the connection test passes and names it when missing, and the tax rate
  read answers unknown/unreadable instead of throwing.
- The tax rate is cached per variant and country, no longer per updatedAt,
  which every stock change bumps (L-M2).
- The id pager starts over only on a rejected cursor; a throttle, auth error
  or outage goes out at once.
- Tests for the category directory, external-id and search reads, and the
  bulk-read cost backoff; README notes for the review suggestions.

Refs #3975

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): gross up net prices of a tax-exclusive store

A store whose prices exclude tax reported no catalogue price at all, so
every product synced without one. ADR-077 makes the conversion the
adapter's job (the WooCommerce shape): the net price is now multiplied by
the rate Shopify computes for the sales country (draftOrderCalculate, one
rate per product, cached for a day) and rounded to the cent; an exemption
adds nothing. An unknown rate still leaves the price absent rather than
stating a net figure as gross. The connection test names this effect of
the missing write_draft_orders scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): keep an unknown tax rate in cache for 15 minutes, not a day

An unknown answer (no tax line, several, or no rate) usually means the
shop's tax setup is unfinished. Cached for a day, an operator who finishes
it would not see rates and prices until tomorrow; it is now kept 15 minutes,
while a resolved rate stays cached for a day.

Refs #3975

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): remember a missing write_draft_orders scope per connection

Without the optional scope every tax-rate read answered unreadable and,
since unreadable is not cached, a tax-exclusive shop sent a refused
draftOrderCalculate on every product read, spending the store's shared
budget. The missing scope is not transient, so it is now remembered per
connection for 15 minutes; other unreadable answers still are not cached.

Refs #3975

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rrections (#4075)

Shopify as inventory master, against ONE location (config.locationId, else
the primary), reported pooled, plus the guard-only BulkInventoryReader.

- One Inventory per variant: available, committed as reserved, on_hand as
  quantity; not stocked at the location -> 0; untracked -> the connection's
  unmanagedStockQuantity, 1000 by default (L-I1).
- Writes are a delta on available through inventoryAdjustQuantities with
  changeFromQuantity and the mandatory @idempotent. A stale level
  (CHANGE_FROM_QUANTITY_STALE, nothing written, G05) is re-read and retried
  inside the adapter, up to three attempts, because every core caller records
  a thrown error as in doubt and never retries it.
- The Shopify key is derived per attempt from the caller's key, so a retry
  after a crash meets IDEMPOTENCY_KEY_PARAMETER_MISMATCH instead of applying
  twice; the caller's key is also remembered in the host cache (A18).
- Refusals before the write are ShopifyNotSupportedException, the shape core
  reads as refused.

Refs #3976

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…writeback, refunds (#3977) (#4076)

* feat(shopify): OrderSource and returns - feed, writeback, mapping options, refunds

Shopify as an order source (role C), opt-in.

- Order feed on updated_at with an opaque (updatedAt, id) keyset cursor under
  shopify.orders.cursor; orders updated in the last 30 s wait for the next
  poll (search index lag, L-M1/L-S2j).
- getOrder: price the buyer paid per unit, currentQuantity after edits (lines
  at 0 dropped), line tax rate, B2B tax id from the company location (C08),
  COD, buyer note.
- SourceOptionsReader: delivery-profile method names plus codes seen on recent
  orders, gateways, neutral statuses (C13).
- OrderStatusWriteback shared with the destination role (L-S3c): fulfilment
  with tracking number, carrier and URL (L-F3), moving work Shopify routed to
  another location first (C24); cancellation keeps the payment.
- Returns: ReturnSourceReader over a lap-walked feed, returnReasonDefinition
  (C16), ReturnDecliner; RefundExecutor only with refundsThroughShopify (C20),
  refundCreate with @idempotent and an [ol:<hash>] marker.
- API 2026-10 changes: orderCancel without the deprecated refund flag, return
  status read through node(id:), read_shipping as an optional scope.

Refs #3977

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): order totals after discounts, newest-first return feed, review fixes

- Order lines use discountedUnitPriceAfterAllDiscountsSet and shipping uses
  currentShippingPriceSet, so an order-level code or a free-shipping discount
  no longer leaves lines and shipping adding up to more than the buyer paid
  (documents refuse such an order). The return mapper reads the same price.
- The return feed walks orders newest first (cursor rv2), so a new return is
  found on the next lap instead of after the shop's whole return history. An
  rv1 cursor starts a fresh lap.
- An empty order feed cursor starts from the beginning instead of failing.
- A refund whose outcome is unknown raises ShopifyRefundInDoubtException,
  classified as terminal.
- Warn on a multi-currency order and on a decline reason Shopify does not know.
- Tests for the return mapper, both cursors, the tracking mapper, the mapping
  options reader and a refund in doubt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* docs(shopify): describe discounted line prices, the newest-first return feed and known gaps

- README and architecture overview: line prices are taken after every
  discount and shipping after discounts, so lines add up to the total; a
  Shopify order carries no discountTotal, unlike Allegro and PrestaShop.
- The return feed walks orders from the newest down, and why.
- Known gaps: return reasons stored as `other`, no read-back of a fulfilment
  made in the Shopify admin, no separate display of a discount already inside
  the line prices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… orders in the store (#3978) (#4077)

* feat(shopify): OrderProcessorManager - create marketplace orders in the store

Shopify as an order destination (#3978): orderCreate at the price the
buyer paid on the source, with tax lines per stated rate, addresses,
pickup point (custom attributes, note, shipping title) and the payment
state (paid sale, pending COD, or none). orderCreate has no idempotency
support, so the adapter looks the order up by source_identifier before
creating; core's per-order lock covers the race. An order in another
currency is refused before anything is sent. Lines without a mapped
variant become custom lines that still ship. Open work Shopify routed
elsewhere is moved to the configured location; a failed move never fails
the create. Dispatch and cancellation reuse the order-status writeback,
the source connection's delivery and payment mappings give the shipping
line and gateway, and the create lowers Shopify's stock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): ship variant lines, send whole-order discounts, ignore foreign mappings

- `orderCreate` defaults `requiresShipping` to false on every line, so an
  order created in the store read "shipping not required" (D1). Every line
  now states it: a variant line from its inventory item (one query), a
  custom line true.
- A discount the source left outside the lines (`discountTotal`, e.g. an
  Allegro coupon) is sent as a fixed-amount discount code, with tax lines on
  each line's discounted share (`distributeOrderDiscount`), so the order
  totals to what the buyer paid. A discount that cannot be split is logged.
- Delivery and payment mappings are keyed by source, so a value mapped for
  another destination reached Shopify (D5, "1 (WAW15HO)"). A mapped value is
  now used only when this store offers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…or, provisioner (#3981) (#4086)

* feat(shopify): inbound webhooks - HMAC decoder, translator, provisioner

Shopify order and product changes reach OpenLinker through webhooks:
a decoder verifying X-Shopify-Hmac-Sha256 with the app client secret
(dedup on X-Shopiindow), atranslator to
the order / product / inventory domains, and a provisioner that stores
the client secret, subscribes the topics of the enabled capabilities
and removes our sck URL. Shopifyjoins
inFakt on the caller-supplied webhook secret allowlist.

Refs #3981

Refs #3981

Co-Authored-By: Copic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* fix(shopify): adapter-declared caller-supplied webhook secret, warn on a stale one

- Replace the platform-name allowlist in core
  (`CALLER_SUPPLIED_WEBHOOK_SECRET_PLATFORMS`) with an adapter-declared
  `AdapterMetadata.acceptsCallerSuppliedWebhookSecret`, read through the
  adapter registry by `WebhookSecretService.set` (the file's own stated
  direction once a second platform needed it). inFakt and Shopify declare
  it; an adapter that cannot be resolved accepts nothing.
- The Shopify webhook secret is a copy of the app client secret taken at
  install. The connection test now warns when it no longer matches the
  client secret in the credentials, so a rotation that silently breaks every
  delivery is visible. README and architecture overview describe both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… and CategoryProvisioner (#3979) (#4087)

* feat(shopify): ProductPublisher and CategoryProvisioner - publish from another master

Publishes products to Shopify with productSet: simple products, variant
groups settled without a lock (shared handle + group metafield, merge into
the winner), public images, collections as categories, parameters as
product metafiele shop currency. A store
entering prices without tax gets net prices divided by the catalogue rate;
a connection that is also the product master is refused (A20).

Refs #3979

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* test(shopify): assert publish refusals on the rejection errors, not the message

ProductPublishRejectedException keeps its message neutral (adapter key,
status, error count); the reason is in errors[].message. Four specs
matched the reason against the message and could never pass.

Refs #3979

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

* refactor(shopify): review fixes for the product publisher

- Point the master-connection refusal at the core issue #3985 instead of
  the spike case id A20, in the adapter, the plugin and the README.
- One currency-aware rounding helper (toStoreAmount, minorUnitExponentFor)
  for the publish and the price-only update, so a currency without cents
  is not given two decimals.
- Move the three GraphQL documents that lived in the adapter into
  shopify-product-publish.graphql.ts.
- Log when a shop's only publication is used because no Online Store
  channel matched.
- Note that grouped siblings share status, title and description.
- README: drop the line still listing ProductPublisher as a future capability.

Refs #3979

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>

---------

Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant