Appearance
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
| Topic | Reality |
|---|---|
| Integration tier | External 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 push | ✅ POST /stores/{s}/orders works once Order Management Module is licensed per POS |
| Order completion | Independent 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 environment | Live (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
| ShopCaisse | Upvendo | Cardinality | Notes |
|---|---|---|---|
| Organisation | Merchant | 1:1 | The merchant-facing "account" — one Upvendo Merchant per ShopCaisse Organisation regardless of internal Company/Store structure |
| Company | (catalog source) | n:1 per Merchant | Each 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 |
| Store | Location | 1:1 | Location.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 Store | Tracked 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:
| Concern | Reality |
|---|---|
| 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 Location | The 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.
| Operation | iPad app | Web backoffice | External 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 group | ✅ only 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 list3.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/familyIdvariations- 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}/familiesPOST /v1/companies/{c}/menusPOST /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_id → selected_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)
| # | Requirement | Cost | Failure mode if missing |
|---|---|---|---|
| 1 | Public API module subscription | €19.99/mo + VAT | No External Application can be created — no JWT to paste |
| 2 | Order Management Module per POS | TBD (paid; per device) | POST /orders returns HTTP 401 — "Pos {id} does not have an Order Management Module license" |
| 3 | Exit Discovery Mode | Free (one-time action) | iPad blocks "Track open orders" — orders stall in not_acked |
| 4 | iPad POS app installed + logged in + connected | Free | No 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 withorderIsAlreadyPaid: trueand balanced payment math. payment.receipt: null+internalOrderDocumentId: null= un-finalized- Once finalized:
/v1/stores/{s}/salesshows 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
| Purpose | Source endpoint |
|---|---|
| Real-time order tracking | GET /v1/stores/{s}/orders?status=done (default list filters done OUT — must explicitly include) |
| Real-time reconciliation | Cross-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
resourcesarray = what the JWT can ACT on (e.g., POST/PUT/DELETE allowed)organisation.*.infopermission = visibility into the FULL org tree viaGET /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
| Aspect | Detail |
|---|---|
| Event types | store.orders, company.items, etc. (event naming follows <entity>.<change>) |
| HMAC keying | Signed with HMAC, key = applicationId (from JWT) |
| Payload shape | {event, resource, content[]} |
| Retry policy | 5 attempts, every 30 minutes |
| Registration | ❌ Backoffice UI only — POST /webhooks returns 404 despite *.webhooks.use scope on token |
| URL per External Application | ONE 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— theThirdPartyIntegrationrecord: 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
| Capability | Status | Workaround |
|---|---|---|
| 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 Apps | Document the merchant-side configuration path in onboarding |
| Register webhook URLs programmatically | ❌ /webhooks 404 despite scope | Document the Backoffice UI registration step in onboarding |
| Activate Order Management Module from our side | ❌ Per-POS-device merchant action | Document 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 works | Manual merchant-side action on iPad |
| Programmatic NF525 fiscal-archive query/export | ❌ Not exposed | Merchant-side via Backoffice |
| Scheduled orders | ❌ /order-slots returns 404 at our tier | Pickup time captured via pickupTime field on order; native scheduling unavailable |
| Pre-validate merchant prerequisites before pushing orders | ❌ No API surface exposes config state | Surface failure messages clearly via "no orders received" diagnostic |
| Create modifier groups via API | ❌ iPad-only | Document 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 payload | Same — iPad-only structural creation |
| Bulk catalog import | ❌ No /bulk, /batch, /import, /upload endpoints exist | Iterate 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 refactorshopcaisse-state-and-path-to-enterprise.md— full empirical investigation log, state machine derivationsshopcaisse-api-capabilities-and-onboarding-data.md— endpoint-by-endpoint capability inventoryshopcaisse-hierarchy-and-product-sharing.md— entity-hierarchy investigation, Product Sharing semanticsshopcaisse-open-questions-for-gpt.md— outstanding partner-research questions
11. Comparison to other POS integrations
| POS | Catalog 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.