Skip to content

ShopCaisse Integration — Engineering Reference

This is the engineering reference for ShopCaisse — the POS we integrate with via JWT-bearer External Application API. It's written for backend developers building or maintaining the integration, and for AI agents fixing bugs in it.

Scope of this doc — read first. This page documents how ShopCaisse the product behaves — the empirical, externally-observed knowledge that is not derivable from our code: the iPad-vs-backoffice creation split, the API write-tier reality (what 404s / silently drops), the order/fiscal state machines, the JWT shape, and the merchant prerequisites. It is deliberately light on our implementation detail.

For how our integration code works — exact method/route/test names, file:line pointers, the current sync pipeline, money handling, and the live list of known gaps — the canonical, code-adjacent reference is upvendo-backend/docs/SHOPCAISSE_INTEGRATION.html (see backend PR #778). That doc lives next to the code and is updated in the same change, so it never drifts; this page intentionally does not duplicate file:line pointers that would rot.

For merchant-facing setup instructions see shared/integrations/shopcaisse.md. For high-level onboarding flow see merchant/onboarding/shopcaisse.md.


TL;DR

TopicReality
Integration tierExternal Application (JWT-bearer, partner-facing). Lower than the Default Application (admin-only) tier.
Catalog reads✅ Full — items, modifier groups, menus, families, prices, currency, hours, seating, stocks, sales, shifts, customers
Catalog writes⚠️ Limited — only type: SIMPLE item create + name/description/reference update + price update via price-list endpoint. No structural writes (modifier groups, formulas, families, images, variations all silently dropped or 404).
Order pushPOST /stores/{s}/orders works once Order Management Module is licensed per POS
Order completionIndependent state machines: kitchen (not_acked → accepted → ... → done) progresses with iPad taps; fiscal (/sales populated) requires manual "Process order" tap on iPad
Structural setup (modifier groups, formulas, variants)iPad-only. Web backoffice (bo.shopcaisse.com) has no creation UI for these — only the iPad app (Settings → Item management → Options and extras)
Test environmentLive (api.shop-caisse.com) only. Staging exists but the iPad app rejects staging credentials — staging is API-only, no full lifecycle test possible.

1. Entity hierarchy + Upvendo mapping

ShopCaisse hierarchy

text
Organisation (= "Group of establishments" in marketing copy)
  └── Company   (= the catalog-owning entity, also called "Établissement")
        └── Store   (= physical location: address, hours, seating, stocks)
              └── POS   (= iPad register device, has subscriptions)

Upvendo mapping

ShopCaisseUpvendoCardinalityNotes
OrganisationMerchant1:1The merchant-facing "account" — one Upvendo Merchant per ShopCaisse Organisation regardless of internal Company/Store structure
Company(catalog source)n:1 per MerchantEach Location's source Company + Store is recorded per-location in the merchant-level record's settings.store_location_map (legacy per-Location records use settings.selected_store_company_id); items sync from /companies/{thatCompany}/items. Multi-Company Organisations are supported transparently — see §1.1
StoreLocation1:1Location.external_ids.shopcaisse = store.id. Each Location independently carries its own category, branding profile, payment profile, Stripe customer
POS(operational, not surfaced)n:1 per StoreTracked via ShopCaisseIntegration.credentials.bundle.resources. POS is not selectable as a JWT resource in the External Application UI today — order push auto-routes to the only POS in the store.

1.1 Why multi-Company Organisations work transparently

Verified empirically against the live data model:

ConcernReality
Item collisions between Companies?None — Items carry location_id and are fully independent per Location
Menu collisions?None — Menus carry location_id
Business-type conflicts (Frituur vs Bakery)?None — Location.category is per-Location
Payment routing per Location?Each Location has its own payment_profile_id and stripe_customer_id
Item categories (the menu taxonomy)Shared at Merchant level — but benign (overlapping names unify, distinct names coexist); the category orchestrator strips location_id
Catalog source per LocationThe merchant-level store_location_map (or legacy selected_store_company_id) records the source Company per Location; syncItems() uses it

This is how merchant-level auto-create works today (shipped in backend #823): POST /shopcaisse/locations/sync does NOT block multi-Company JWTs. For each store.X in the JWT's resources it resolves the parent Company, finds-or-creates one Location bound to that Store, and records the Store + Company in store_location_map.


2. iPad vs backoffice creation matrix — critical for understanding what we can/can't automate

ShopCaisse is fundamentally an iPad-first POS. The web backoffice (bo.shopcaisse.com) is a reporting + account-admin companion, NOT a full catalog editor. Rich structural operations live in the iPad app at Settings → Item management.

OperationiPad appWeb backofficeExternal Application API
Create Simple item
Update item name/description/reference
Update item price (via price list)
Delete item
Create modifier group ("Options et extras")only here
Add modifiers to a grouponly here
Link modifier group to a parent item (modifierGroupProducts)⚠️ may have link UI on product edit, but creation is iPad-only❌ silently dropped on PUT
Create Formula (type: MENU with productMenuSteps)✅ (wizard exists)❌ silently downgraded to SIMPLE
Create Pack (type: PACK)✅ (wizard exists)❌ silently downgraded to SIMPLE
Create Variants (Déclinaisons)
Create Family
Upload item images✅ (pick from ShopCaisse's curated S3 library at master-catalog-product-images.s3.eu-west-3.amazonaws.com)❌ silently dropped
Mass edit (VAT, family, supplier, printer)

Rule of thumb: if it's structural (groups, formulas, variants, families, images), it can only be created via the iPad. If it's a row-level update on an existing record (item name, description, price), the API can do it.


3. API write tier reality — verified empirically

Probed against the live merchant account on 2026-06-05 with a 33-permission JWT including company.*.items.write, company.*.items.delete, company.*.prices.write, company.*.prices.delete. Activating UI features ("Options and supplements", "Images", "Product variations") did NOT unlock new API endpoints — feature activation is UI-only.

3.1 What works

text
POST   /v1/companies/{c}/items
  Required: {name: string, price: number}
  Returns: HTTP 201 + full item object
  Caveat: type is silently forced to SIMPLE regardless of payload

PUT    /v1/companies/{c}/items/{id}
  Writable fields: name, description, longDescription, reference
  All other fields silently dropped (HTTP 200 but no persistence)

DELETE /v1/companies/{c}/items/{id}
  Returns HTTP 200, item removed

POST   /v1/companies/{c}/prices
  Required: {store: <store-id>, ...}

PUT    /v1/companies/{c}/prices/{priceListId}/items/{id}
  Updates item price within a price list

3.2 What silently fails

These fields exist in GET response shapes but are silently dropped on PUT (returns 200, GET shows no change):

  • type (cannot change from SIMPLE to MENU/PACK/MODIFIER after creation)
  • modifierGroupProducts (5 different shapes tested — all dropped)
  • productMenuSteps (4 different shapes tested — all dropped)
  • images (object form, URL string form — all dropped; images come from ShopCaisse's curated S3 library)
  • family / familyId
  • variations
  • Any other arbitrary field — PUT returns 200 even for {thisFieldDoesNotExist: true} but ignores it

3.3 What returns 404

Probed exhaustively (15+ endpoint name variations each):

  • POST /v1/companies/{c}/modifiergroups (+ all naming variants: hyphen, camel, snake, options, groups, option-groups, …)
  • PUT/PATCH /v1/companies/{c}/modifiergroups/{id}
  • POST /v1/companies/{c}/items/{id}/modifiergroups (and variants)
  • POST /v1/companies/{c}/families
  • POST /v1/companies/{c}/menus
  • POST /v1/companies/{c}/items/bulk (+ /batch, /import)
  • POST /v1/companies/{c}/catalog/import (+ /upload)
  • POST /v1/companies/{c}/variations (+ /product-variations)
  • POST /v1/companies/{c}/images (and per-item variations)

Net: the External Application API is a maintenance API, not a creation API for structure. Merchants set up structure on the iPad; partners maintain item details + prices via API.

3.4 Existing speculative scaffolding in our code

ShopCaisseIntegration::getEffectiveItemsCompanyId() defines a fallback chain (catalog_company_idselected_store_company_id → JWT getCompanyId()) that reads a "shared catalog company" off the store object. Verified: none of those shared-catalog fields ever populate in live API responses today, so the chain always resolves to the store's own company. The scaffolding is harmless defensive future-proofing — but note the sync entry point doesn't actually call getEffectiveItemsCompanyId() yet (it resolves the company via the store map / JWT directly); see the "Known gaps" section of the canonical backend doc.


4. Order lifecycle

4.1 Required merchant prerequisites (each gates a specific failure)

#RequirementCostFailure mode if missing
1Public API module subscription€19.99/mo + VATNo External Application can be created — no JWT to paste
2Order Management Module per POSTBD (paid; per device)POST /orders returns HTTP 401 — "Pos {id} does not have an Order Management Module license"
3Exit Discovery ModeFree (one-time action)iPad blocks "Track open orders" — orders stall in not_acked
4iPad POS app installed + logged in + connectedFreeNo device to acknowledge orders → stall in not_acked

4.2 Full state machine (kitchen)

text
not_acked
  ↓ (iPad ingests queue — automatic once iPad is running + Discovery Mode is OFF)
auto_accepted_waiting

accepted
  ↓ (staff tap on iPad)
preparing
  ↓ (staff tap)
prepared
  ↓ (staff tap)
ready_for_pickup
  ↓ (staff tap)
done

(side branches: cancelled_by_pos, cancelled_by_channel, cancelled, merged, requires_attention)

4.3 Fiscal lifecycle (independent state machine)

Important: kitchen state and fiscal state progress INDEPENDENTLY. An order can be fiscally finalized (in /sales) while still in kitchen state accepted.

  • For real orders (testOrder: false), staff MUST tap "Process order" on the iPad to fiscally finalize. Even with orderIsAlreadyPaid: true and balanced payment math.
  • payment.receipt: null + internalOrderDocumentId: null = un-finalized
  • Once finalized: /v1/stores/{s}/sales shows the sale immediately (NOT delayed until Z-report as previously assumed)
  • Z-report at shift closure is the formal day-end ceremony but sales accrue in real-time
  • Test orders (testOrder: true) cannot be fiscally finalized — iPad returns "Error: Unable to modify a test order"

4.4 Reconciliation strategy

PurposeSource endpoint
Real-time order trackingGET /v1/stores/{s}/orders?status=done (default list filters done OUT — must explicitly include)
Real-time reconciliationCross-reference /orders?status=done against Upvendo Transaction completion
Fiscal / end-of-day reconciliation/v1/stores/{s}/sales — authoritative once shift is COMPLETED
Shift state visibility/v1/stores/{s}/shifts — states: OPENED, CLOSED_NOT_COMPLETED, COMPLETED

5. JWT structure + permissions

5.1 JWT payload shape (decode the middle segment with base64 + data wrapper)

json
{
  "data": {
    "id": "<sub-token-id>",
    "applicationId": "<external-app-id>",
    "namespace": "prod" | "staging",
    "userId": "<merchant-user-id>",
    "isSubToken": true,
    "resources": [
      "company.<uuid>",
      "store.<uuid>"
    ],
    "permissions": {
      "company.*.items": true,
      "company.*.items.read": true,
      "company.*.items.write": true,
      "store.*.orders.write": true,
      "organisation.*.info": true,
      ...
    },
    "hasSubscription": false
  },
  "iat": 1780569621
}

5.2 Scope behavior

  • resources array = what the JWT can ACT on (e.g., POST/PUT/DELETE allowed)
  • organisation.*.info permission = visibility into the FULL org tree via GET /v1/organisations/{id} even if the JWT can only act on a subset of it (we observed staging tree with 2 Companies; JWT only scoped to 1, but tree visible)
  • A typical broad-scope partner JWT has 33 permissions including all *.items.*, *.orders.*, *.sales.read, *.stocks.read, *.customers.*, *.webhooks.use
  • POS is NOT exposed as a tickable resource in the current External App creation UI (only Company + Store can be ticked) — order push works anyway because orders auto-route to the only POS

5.3 Auth call

text
GET /v1/authentication
  Authorization: Bearer <jwt>
  → 200 {id, applicationId, token, bundle}

6. Webhook contract

AspectDetail
Event typesstore.orders, company.items, etc. (event naming follows <entity>.<change>)
HMAC keyingSigned with HMAC, key = applicationId (from JWT)
Payload shape{event, resource, content[]}
Retry policy5 attempts, every 30 minutes
RegistrationBackoffice UI onlyPOST /webhooks returns 404 despite *.webhooks.use scope on token
URL per External ApplicationONE URL per External App — handles events across all stores under that Company. We resolve to the right Upvendo Location via the resource ID.

7. Code reference pointers

File/method/route/test names and line numbers drift as the integration evolves, so this page does not maintain them — they belong next to the code. The canonical, always-current code map is the upvendo-backend/docs/SHOPCAISSE_INTEGRATION.html "File map", "ShopCaisse API endpoints used", and "Testing" sections (backend PR #778), updated in the same change as the code.

The high-level shape (consult the backend doc for exact members):

  • app/Services/Common/ShopCaisseService.php — the HTTP client + all sync / order / webhook / image logic (catalog pull, pushItemToShopCaisse, createOrder/verifyOrder, store-hours fetch).
  • app/Services/BackOffice/ShopCaisseIntegrationService.php — backoffice orchestration: enable, locations sync, catalog-sync dispatch, test, store selection, token update, merchant status; JWT-namespace base-URL resolution.
  • app/RawModels/ShopCaisseIntegration.php — the ThirdPartyIntegration record: credential/resource extraction, permissions, merchant-level helpers (isMerchantLevel, storeContextForLocation, resolveForLocation), catalog-company resolution.
  • app/Services/DataMappers/ShopCaisseDataMapper.php — payload mappers (mapItem, mapModifier, mapModifierGroup, mapTransaction).
  • routes/api/backoffice/shopcaisse.php — both the merchant-level routes (POST /shopcaisse/locations/sync, GET /shopcaisse/status, POST /shopcaisse/sync, POST /shopcaisse/test) and the per-location routes under /shopcaisse/{locationId}/.... (The merchant-level prefix shipped in backend #823 — it is no longer "Phase 2 future".)

8. Known limitations + workarounds

CapabilityStatusWorkaround
Read module subscriptions / Sale Mode state❌ Lives in Default Application tier (admin-only)Onboarding checklist for merchants to self-confirm; surface clear error messages on order push failures
Read reception register config❌ Module settings invisible to External AppsDocument the merchant-side configuration path in onboarding
Register webhook URLs programmatically/webhooks 404 despite scopeDocument the Backoffice UI registration step in onboarding
Activate Order Management Module from our side❌ Per-POS-device merchant actionDocument in onboarding; forward the 401 error message verbatim to surface the issue clearly
Refund / void orders past not_acked❌ No documented refund API; only DELETE in not_acked worksManual merchant-side action on iPad
Programmatic NF525 fiscal-archive query/export❌ Not exposedMerchant-side via Backoffice
Scheduled orders/order-slots returns 404 at our tierPickup time captured via pickupTime field on order; native scheduling unavailable
Pre-validate merchant prerequisites before pushing orders❌ No API surface exposes config stateSurface failure messages clearly via "no orders received" diagnostic
Create modifier groups via API❌ iPad-onlyDocument iPad setup path in merchant onboarding; we sync inbound from whatever the merchant creates
Create Formula (type: MENU) products via API❌ Server forces type: SIMPLE regardless of payloadSame — iPad-only structural creation
Bulk catalog import❌ No /bulk, /batch, /import, /upload endpoints existIterate via POST /items (1 call per item); accept latency cost

9. Common gotchas (production-impacting)

9.1 Payment amount unit (was a real bug — fixed in production via #819)

mapTransaction() used to send payment amounts in cents but ShopCaisse expects decimal euros (every monetary field is decimal under the payload's decimalDigits: 2) — caused a 100× mismatch and "Payment amount < total price" rejection on every order. Fields affected: payments[0].amount, deliveryCost, tip, discountTotal, serviceCharge, bagFee, driverTip, and the item / modifier / combo price fields. The full fix landed as two coupled changes (payment/fee fields #817 + item/price fields #832) shipped together to production in #819. Watch the unit: PriceConverter::normalize() returns cents (storage format) — prices must be wrapped with PriceConverter::toDecimal(normalize(...)), and money values use ->decimal(). Always verify the unit when adding a new monetary field.

9.2 orderId is the dedup key — NOT subChannelOrderId

Same orderId POST'd twice returns the same order. If you generate a fresh dedup key for retries, use orderId.

9.3 orderType enum

Documented values: EAT_IN / PICKUP / DELIVERY / UNKNOWN. The API silently accepts other strings (ON_SITE, TAKE_AWAY) and echoes them back — no enum validation. Always use documented values.

9.4 /stores pagination cap

pageSize > 25 returns HTTP 400 — "Page must be less or equal to 25". Use a do/while loop with hasNextPage (see getStores() implementation).

9.5 /orders default filter excludes done

GET /v1/stores/{s}/orders filters out done orders by default. Must use ?status=done explicitly to see completed orders. Easy gotcha for reconciliation queries.

9.6 Per-modifier maxSelect and optional

These fields exist on the modifier entries within a modifier group (/modifiergroups/{id}.modifiers[]). Our combo-sync historically hardcoded max_selections: 1 which was wrong for multi-select groups — verify against modifier.maxSelect and modifier.optional when syncing.

9.7 Modifier-group name is empty; real name is on the linked item

json
{
  "id": "...",
  "name": "Sauce",
  "modifiers": [
    { "id": "...", "item": "<item-id>", "price": 0, "maxSelect": 1, "optional": true, "name": "" }
 ALWAYS empty
  ]
}

The display name shown to customers comes from the referenced item's name, not from modifier.name. Fetch the item to get the display name.

9.8 testOrder: true blocks fiscal finalization

Test orders flow through API and kitchen state machine but cannot be "Process order"-finalized on iPad. Integration tests must include occasional real (testOrder: false) orders to verify the full lifecycle reaches /sales.

9.9 subscriptions and storeModuleSettings always empty for our tier

Direct GET /stores/{s} returns subscriptions: [] and storeModuleSettings: [] for External Apps. Subscription state lives in the Default Application tier and is invisible to us. Do not gate behavior on these arrays being non-empty.

9.10 Staging environment has no iPad counterpart

Staging credentials are rejected by the iPad app. Testing against a live merchant account is the only viable path for full lifecycle validation. JWT namespace: prod / staging distinguishes them; different signing keys.


10. Investigation history + further reading

The deep empirical investigation that informed this doc lives in upvendo-backend/docs/:

  • shopcaisse-onboarding-poa.md — Plan of Action for Mpluskassa-parity onboarding refactor
  • shopcaisse-state-and-path-to-enterprise.md — full empirical investigation log, state machine derivations
  • shopcaisse-api-capabilities-and-onboarding-data.md — endpoint-by-endpoint capability inventory
  • shopcaisse-hierarchy-and-product-sharing.md — entity-hierarchy investigation, Product Sharing semantics
  • shopcaisse-open-questions-for-gpt.md — outstanding partner-research questions

11. Comparison to other POS integrations

POSCatalog architecture writable via partner API?
Mpluskassa✅ Yes — Articles + Articulations + Article Alterations all writable. Extensive bidirectional sync.
ShopCaisse⚠️ Half — type: SIMPLE items and prices yes, everything structural (modifier groups, formulas, packs, families, images) no. Merchant sets structure on iPad.
Lightspeed K-Series❌ Menu API is read-only at our tier — o/op/1/menu/... GET-only. Merchant sets up menu in Lightspeed backoffice; we mirror it.

ShopCaisse and Lightspeed K-Series both follow the "backoffice-as-source-of-truth, partner-API-as-maintenance" model, consistent with NF525 fiscal compliance posture. Only Mpluskassa exposes full write access at the partner tier.