Repository navigation
feat(integrations): Shopify integration epic (#4091) - #4100
Draft
jakubretajczykBD wants to merge 7 commits into
Draft
jakubretajczykBD wants to merge 7 commits into
jakubretajczykBD wants to merge 7 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR merges the Shopify integration epic (#4091) from
4091-shopify-epicintomain. It adds Shopify as an OpenLinker connection that can be: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
requiresShippingper line, whole-order discountStill to merge into the epic
Known follow-ups (not in this epic)
other(not mapped yet).OrderFulfillmentReadback) and noShopProductStatusReaderyet.Test plan
Closes #3974
Closes #3975
Closes #3976
Closes #3977
Closes #3978
Closes #3979
Closes #3981
Refs #4091
🤖 Generated with Claude Code