BridalOp · Technical Spec

Selling Online

Syncing BridalOp inventory to WooCommerce and FluentCart first, then Shopify.

Every section has a In plain terms box. Skip the code, read those, and you'll have the whole picture.

Section 1

The big picture

In plain terms

A boutique keeps her real inventory in BridalOp. She also wants a shop page on her website where brides can buy a veil or a sash without calling.

The job is to keep those two lists agreeing with each other. When a veil sells in the shop, the website should stop offering it. When one sells on the website, BridalOp should know before anyone tries to sell it again on the floor.

Everything below is about doing that without ever selling the same gown twice.

BridalOp is the system of record. The storefront is a mirror. Catalogue, price and stock all originate in BridalOp and get pushed outward. The only thing that originates on the platform is an order.

Three moving parts:

What already exists

More of this is built than you'd think.

products.show_on_ecomPer-product ecom flag, already in the schema
inventory_itemsstatus, reserved_until, reserved_by_order_id — the reservation model is done
ReleaseExpiredReservationsAlready releases stale holds on a schedule
tenant_integrationscredentials encrypted, plus settings, status, last_error, last_synced_at
IntegrationProviderContract + registry pattern, proven across 5 CRM providers
OAuth refreshGoogle / QuickBooks / Square commands — Shopify follows the same shape
QueueRedis, already running

Section 2

The one hard problem

In plain terms

BridalOp tracks individual gowns. Barcode 4231 is a size 10 Aurora, it's on the rack in the downtown shop, and it's brand new.

WooCommerce and Shopify don't think that way. They just count: "size 10 Aurora — we have 3."

So going out, you have to turn a pile of individual items into a number. Coming back, a website order says "one sold" and you have to decide which physical gown that was. That translation is where nearly every bug in this project will come from.

BridalOp stock is serialized — one row per physical unit in inventory_items, each with barcode, condition, location and status. Every platform stock model is a counter — an integer against a variant.

Outbound is an aggregation. Inbound is a resolution — and the resolution has to happen inside a database transaction that locks the row, or two simultaneous orders both grab the same gown.

Section 3

The six decisions

None of these are engineering questions. Answer them and the code is straightforward; leave them open and it never settles.

1 · What counts as sellable online?

Every unit has a status (available / reserved / sold / on-order) and a condition (new / floor-sample / used).

Decided: sellable = status = 'available' AND location is in the published set. Status only — no condition filter. Sample sales are a real bridal revenue channel, and show_on_ecom already gives per-product control, so a boutique excludes a gown by unflagging it rather than by its condition. reserved is excluded automatically, which is what keeps a gown on hold from being sold underneath someone.

2 · Which locations feed the website?

Multi-location shops may not want the second store's stock sold online, or may want one shop to fulfil everything.

Recommended: a per-integration list of location ids. Default to all active locations; let her narrow it.

3 · Do special orders sell online?

Made-to-order gowns aren't backed by a physical unit, so they can't map to a stock counter at all.

Recommended: exclude from phase 1 entirely. Revisit later as backorder-allowed items with a lead time, which is a different feature.

4 · Who wins a conflict?

Someone edits a price in Woo. Someone edits it in BridalOp. Reconciliation runs. What happens?

Recommended: BridalOp wins on catalogue, price and stock — always, no exceptions, overwrite on reconcile. The platform wins on order data. One-way authority is what keeps this debuggable.

5 · What happens when you oversell anyway?

A bride buys the last veil online at the same moment it sells on the floor. Her card is already charged by the time the webhook reaches you — you cannot refuse the order.

Recommended: accept it, create the BridalOp order with no unit attached, flag it needs_attention, and surface it in an exception queue with a notification. The boutique decides: source another, or refund. Trying to prevent this outright is how you lose weeks.

6 · Safety buffer on one-of-one items?

Optionally publish stock minus N, so the last unit of anything never sells online.

Recommended: build the setting, default it off. Some boutiques will want it for gowns and not for accessories.

Section 4

Data model

In plain terms

Three new tables. One remembers which BridalOp product is which Woo product. One remembers which website orders you've already imported, so a repeated message doesn't create a duplicate sale. One is a log for when someone asks why the numbers disagree.

ecom_product_links

The mapping table. Without it there's no way to know that BridalOp product 812 is Woo product 4471.

id
tenant_id
provider              woo | fluentcart | shopify
product_id            → products.id
product_variant_id    → product_variants.id (nullable)
external_product_id
external_variant_id
external_inventory_id  Shopify only — inventory item handle
last_pushed_hash       hash of what we last sent
last_pushed_qty        last stock number we sent
last_synced_at
sync_status            ok | error | pending
last_error

last_pushed_hash is what stops you re-pushing 800 unchanged products every cycle and burning your rate limit.

ecom_order_links

Idempotency. Platforms retry webhooks; without a unique key you create the same order twice.

id
tenant_id
provider
external_order_id     UNIQUE with (tenant_id, provider)
order_id              → orders.id (nullable until created)
status                imported | needs_attention | failed
payload               raw webhook, for replay and debugging
imported_at

ecom_sync_log

Rolling record of pushes, pulls and conflicts. The first thing you'll open when a boutique says the site is wrong.

Section 5

Provider contract

In plain terms

Write one list of things any store must be able to do — show me your products, set this stock number, tell me about new orders. Then write one translator per platform.

Do this and Shopify is a few days of work instead of starting over. It's the same trick already used for Klaviyo, Mailchimp and the rest.

Mirror App\Services\Crm\Contracts\IntegrationProvider, which is already proven across five providers.

interface EcomProvider
{
    public function key(): string;              // woo | fluentcart | shopify
    public function name(): string;
    public function authType(): string;         // api_key | oauth | app_password

    public function validate(TenantIntegration $i): bool;

    // Catalogue
    public function pushProduct(TenantIntegration $i, Product $p): ExternalRef;
    public function pushVariants(TenantIntegration $i, Product $p): array;
    public function archiveProduct(TenantIntegration $i, EcomProductLink $l): bool;

    // Stock — the hot path
    public function setStock(TenantIntegration $i, EcomProductLink $l, int $qty): bool;
    public function setStockBatch(TenantIntegration $i, array $pairs): array;

    // Orders
    public function verifyWebhook(TenantIntegration $i, Request $r): bool;
    public function normalizeOrder(array $payload): NormalizedOrder;
    public function fetchOrdersSince(TenantIntegration $i, Carbon $since): array;

    // Reconciliation
    public function fetchAllStock(TenantIntegration $i): array;
}

setStockBatch is not optional. Woo needs it (a 50-item order would otherwise be 50 requests) and Shopify's rate limits assume it.

Two inbound shapes, not three

verifyWebhook and normalizeOrder are Shopify-only concerns. WordPress platforms are plugin-mediated — our plugin listens to their PHP hooks and posts an already-normalized payload to a single BridalOp endpoint (see §7). So BridalOp has exactly two inbound paths: the plugin, and Shopify's HMAC-signed webhooks. Adding another WordPress cart later touches only the plugin.

fetchOrdersSince remains the reconciliation sweep for Shopify, and the fallback for any site running a cart without our plugin installed.

Section 6

The three flows

Flow A · Push stock out

In plain terms

Something changes in the shop — a gown is received, sold, or put on hold. Count how many are left that qualify for the website, and tell the website that number.

1
Trigger

An inventory_items observer fires on status change, plus a nightly full pass.

2
Compute sellable

count(units where status='available' and location in [set]), minus the safety buffer.

3
Skip unchanged

Compare against last_pushed_qty. No change, no call.

4
Queue it

Redis job per provider. Debounce a few seconds — receiving a PO fires dozens of changes at once.

Flow B · Ingest an order

In plain terms

Someone buys on the website. Their card is already charged. You take that message, find the actual gown on your rack, mark it sold, and write it into BridalOp as a real order.

If the gown isn't there any more, you still take the order — the money's gone through — and flag it for a human.

1
Receive + verify

Check the signature. Reject anything unsigned.

2
Acknowledge immediately

Write the raw payload to ecom_order_links, return 200, do the work on the queue. Shopify enforces a 5-second ceiling and will disable a webhook that keeps timing out.

3
Dedupe

Unique on (tenant_id, provider, external_order_id). A retry is a no-op.

4
Resolve the unit

Inside a transaction: SELECT … FOR UPDATE on a qualifying available unit, set status='sold'. This lock is the only thing preventing a double-sell.

5
Create the order

Order + lines, payment recorded as captured externally, source tagged so reports can separate web from floor.

6
Or flag it

No unit available → order created without one, marked needs_attention, boutique notified.

Flow C · Reconcile nightly

In plain terms

Once a night, ask the website what it thinks it has, compare to what's actually on the rack, and correct it. Messages get lost; this is the safety net.

Pull all stock via fetchAllStock, diff against computed sellable, push corrections, log every discrepancy. Shopify's own docs assume you do this. Also pull orders since last_synced_at to catch any webhook that never arrived.

Section 7 · Build first

WordPress — Woo & FluentCart

In plain terms

Home turf. Both are WordPress plugins with REST APIs you talk to over normal HTTP with a key. No app store, no review, no approval.

WooCommerce

ItemDetail
Base/wp-json/wc/v3
AuthConsumer key + secret, generated in WooCommerce → Settings → Advanced → REST API. Store in tenant_integrations.credentials (already encrypted).
Products/products, /products/{id}/variations
Batch/products/batch, /products/{id}/variations/batch — use these
WebhooksNative, with automatic retry. Subscribe to order.created and order.updated.

Documented race condition. Writing stock through the Woo REST API while Woo is simultaneously processing its own orders can set the wrong quantity and oversell. Never treat Woo's counter as truth — it's a display of BridalOp's number. Inbound orders must reserve in BridalOp first (Flow B step 4), and the nightly reconcile is what corrects the drift this causes.

FluentCart

ItemDetail
Base/wp-json/fluent-cart/v2
AuthWordPress Application Passwords (HTTP Basic)
Coverage367+ endpoints — products, orders, customers, subscriptions
WebhooksConfirmed. Full webhook module with HMAC-SHA256 signing, retry logic, logging, async delivery via Action Scheduler.
Hooks315+ action and filter hooks

Considerably more mature than expected, and cheap to add once Woo's shape exists — same HTTP client, same site, different paths. No polling fallback needed.

Capture orders through the plugin, not webhooks

In plain terms

Our plugin is already sitting on her website, right next to the store. So instead of asking Woo or FluentCart to send a message across the internet to BridalOp, the plugin just notices the sale happening beside it and tells BridalOp directly.

Fewer moving parts, nothing to lose in transit, and it works identically whichever store she runs.

Both platforms fire PHP actions in-process. Because the BridalOp plugin runs on the same site, it subscribes directly and posts a single canonical payload to one BridalOp endpoint, authenticated with the token from the connect flow.

// FluentCart — "main hook for payment completion", per their docs
add_action('fluent_cart/order_paid_done', ...);   // order, transaction, customer
add_action('fluent_cart/order_refunded', ...);    // order, refunded_items, refunded_amount

// WooCommerce
add_action('woocommerce_order_status_changed', ...);
add_action('woocommerce_order_refunded', ...);

What this buys:

Their HTTP webhook system stays the fallback for anyone who wants BridalOp connected without installing our plugin — but the plugin path is the one to build.

Refunds are answerable now

FluentCart fires order_refunded, order_fully_refunded and order_partially_refunded, each carrying refunded_items. Woo has the equivalent. So the open "does a platform refund restock the unit?" question has a clean signal on both — decide the policy, the data's there.

Section 7b · Build first

The WordPress plugin

In plain terms

The boutique installs one plugin. It asks two things: which store are you running — Woo or FluentCart? — and click here to connect your BridalOp account.

After that it does everything on its own. No API keys to copy, nothing to paste, no settings file to edit.

Authentication — the one-click connect

There are two directions to authenticate, which is the part that's easy to miss:

The plugin brokers both in a single click, because it's running inside WordPress as an admin and can mint its own credentials — no human ever handles a key.

1
She clicks "Connect to BridalOp"

Plugin generates the credential BridalOp will need: a WooCommerce consumer key/secret (created programmatically — same thing the WooCommerce settings screen makes), or a scoped WordPress Application Password for FluentCart.

2
Plugin redirects to BridalOp

Carrying the site URL, the chosen platform, a signed state nonce, and a return URL. No credentials in the URL — those never travel through a browser address bar or a server log.

3
She logs in and approves

Usually she's already logged in, so this is one button. If she has more than one boutique she picks which, and which locations feed the site.

4
BridalOp redirects back with a one-time code

Short-lived, single-use. Still not a token.

5
Plugin exchanges the code server-to-server

WordPress POSTs the code back to BridalOp and receives the long-lived token, handing over its own WP credential in the same call. Both sides are now authenticated, and nothing sensitive ever touched the browser.

Two rules that keep this safe.

Use the exchange-code pattern, not a token in the redirect. Redirect URLs end up in browser history, referrer headers and access logs. A one-time code that's useless five minutes later doesn't matter if it leaks.

The BridalOp token never reaches the browser. The Vue app talks to your own WordPress REST routes; WordPress calls BridalOp server-side. If the token were in the front-end bundle, any admin-area XSS would hand over the boutique's whole account.

Yes — Vue

In plain terms

WordPress admin pages are just a blank div you're allowed to draw in. Load Vue into it and you can build whatever you want — it doesn't have to look like WordPress at all.

That's exactly what FluentCart and FluentCRM do. Their admin screens are Vue apps that ignore WordPress styling completely, which is why they look the way they do.

Vue 3 + Vite, same stack as the BridalOp app, so components and design tokens carry over. Mount into a single admin page and route client-side.

Settings → BridalOp
  ├── Connection      connect / disconnect, account + location
  ├── Platform        Woo or FluentCart  ← the selector
  ├── Catalogue       which products publish, stock buffer
  ├── Sync            last run, queue depth, manual "sync now"
  └── Activity        recent pushes, orders, conflicts

To actually look like FluentCart rather than like WordPress:

The platform selector

Detect what's actually installed and let her confirm rather than choosing blind:

DetectedBehaviour
Only WooCommerce activePreselect Woo, show it as detected, allow override
Only FluentCart activePreselect FluentCart
Both activeForce an explicit choice — no default. Syncing into both would double-sell against one pool of stock.
NeitherBlock connect, link to install either one

Store the choice on the BridalOp side too, in tenant_integrations.settings — the provider that gets used is decided by the server, not by whatever the plugin last reported.

Section 8 · Build second

Shopify, from scratch

In plain terms

Shopify isn't a plugin on someone's site — it's a hosted platform, and your code talks to it as a registered "app."

Two flavours. A custom app is built for one shop, installs in minutes, nobody reviews it. A public app lives in Shopify's App Store, any shop can install it, and Shopify reviews it first.

Start with custom apps. One per boutique. Move to a public app only when enough shops want it to justify the review.

How the connection is made

1
You create a Shopify Partner account

Free. This is where apps are created — as of 2026 that's the only route; the old in-admin custom apps were retired on 1 January.

2
Create an app, request scopes

You'll need write_inventory, read_products, write_products, read_orders. Scopes are permissions the merchant approves.

3
The boutique installs it via OAuth

Same handshake as your Square and QuickBooks integrations — she clicks approve, you receive a token, you store it encrypted. Custom and public apps both use OAuth now.

4
You call the API with that token

Reuse the token-refresh pattern from RefreshSquareTokens.

What's different from WooCommerce

WooShopify
REST, JSONGraphQL — you describe the fields you want. Different to write, not harder.
Set stock_quantity on the productStock is its own object. Adjust with the inventoryAdjustQuantities mutation against an inventory item at a location.
Stock lives on the productStock lives per location. Maps well onto BridalOp's location_id.
Webhooks retry, timing relaxed5-second ack ceiling. Verify HMAC, save, return 200, work on the queue.
Reconcile because you shouldReconcile because Shopify's docs tell you to. Drift is expected.

The one Shopify concept worth learning properly

Shopify separates product → variant → inventory item → inventory level (per location). A "variant" is the sellable thing; the "inventory item" is what stock attaches to; the "inventory level" is how many at one location. You'll store external_variant_id and external_inventory_id — that second one is what stock calls need, and it's the detail most first attempts get wrong.

Section 9

How it breaks

In plain terms

These are the ones that will actually happen. Design for them now rather than discovering them on a Saturday.

  • Double-sell. Same gown, floor and web, same moment. Mitigated by the row lock in Flow B and the exception queue — not eliminated.
  • Webhook never arrives. Platforms drop them. The fetchOrdersSince sweep is the backstop; without it a paid order silently never exists.
  • Webhook arrives twice. Handled by the unique key. Skip it and you get duplicate orders and double-decremented stock.
  • Someone edits in Woo. Reconcile overwrites it and they'll ask why. Say so plainly in the UI: this store is managed by BridalOp.
  • Product deleted on the platform. Links go stale and pushes 404. Detect, mark the link broken, surface it — don't retry forever.
  • Rate limits. Receiving a large PO can fire hundreds of changes. Debounce, batch, back off.
  • Token expiry. Shopify tokens can be revoked when a merchant uninstalls. Catch the 401, mark the integration error, notify — don't fail silently.
  • Refund on the platform. Money moves without BridalOp knowing. Phase 3 problem, but decide early whether a Woo refund restocks the unit.

Section 10

Milestones

Phase 0

Settle the six decisions

An afternoon with Section 3. No code. Everything downstream assumes these answers, and changing them later means reworking the flows.

Phase 1

Woo, one-way push

Products and stock out to WooCommerce. No order ingestion yet — the storefront is a catalogue that happens to be accurate.

  • Tables, EcomProvider contract, WooProvider
  • Sellable-count service + inventory observer
  • The WordPress plugin: one-click connect, platform selector, Vue settings screen
  • Connect approval screen on the BridalOp side (the CRM one is the template)
  • Nightly reconcile

Demoable and genuinely useful on its own — this is what show_on_ecom was added for, and it's the honest answer to Elizabeth's April request.

Phase 2

Woo order ingestion

Webhook → verify → queue → lock a unit → create the order. Plus the exception queue for oversells. This is where real money starts moving, and where the testing effort belongs.

Phase 3

FluentCart

The cheapest phase. A second translator behind the same contract for the push side, plus two extra add_action subscriptions in the plugin for orders and refunds. No new BridalOp endpoint, no new auth, no polling — the webhook question resolved in its favour.

Phase 4

Shopify

Partner account, OAuth install, GraphQL translator, HMAC webhooks. New platform knowledge, but the contract, tables, flows and exception handling are all already built and proven by then. Custom apps per boutique; public app only if demand justifies review.