HOCO B2B
SRS

Service catalogue

The 24 modules of the HOCO API server — responsibility, owned models, operations, events, jobs, settings, consumers, tasks and risks — and the catalogue of domain events and their SSE projections.

@hoco/server is a modular monolith (ADR-004): one codebase, two processes (api and worker), 24 modules with hard boundaries. This page is the reference for those boundaries — what each module owns, exposes, emits and consumes. It is the input of SRV-01 (freeze of the service catalogue), which precedes the database schema (SRV-02) and the API contract (SRV-03).

The data each module owns is detailed in the database design; the cross-module rules (transactions, idempotency, caching, request lifecycle) are on Architecture and data; queues and schedules on Jobs and scheduler.

Design principles

A module is a folder. apps/server/src/modules/<name>/ with the same files everywhere, implementing its key of the contract (packages/contracts/src/routers/<name>.ts) and registered in apps/server/src/router.ts. The scaffold's modules/README.md prefixes the files with the module name (orders.router.ts, orders.service.ts, orders.test.ts); either spelling is fine as long as all 24 modules use the same one — the roles are:

FileHoldsMay import
index.tsThe module's public surface: the service functions and types other modules may call. The only file another module imports.its own files
router.tsThe oRPC implementation of the module's contract routes (implement(contract.<group>)): reads input, calls services, maps results. Routers are the composition layer.own service, other modules' index.ts
service.tsBusiness logic: state machines, rules, transactions (withTx), ctx.emit() of events, ctx.audit() of journal entries.own repo and policies, other modules' index.ts (services below it in the dependency graph)
repo.tsPrisma queries on the module's own tables only, every query taking a Scope argument when the data is scoped.@hoco/db
events.tsEvent types the module emits (typed payloads, schema version) and the handlers it subscribes to other modules' events.own service
jobs.tsJob processors and job schedulers owned by the module.own service
policies.tsPermission keys per operation, scope rules, transition guards, field-level visibility (e.g. purchase prices).—

Rules every module follows.

  1. Ownership. Each table has exactly one owning module (system of record). Only the owner writes it; other modules call the owner's service. The one read exception is analytics, which reads reporting views defined in migrations.
  2. Routers compose, services form a graph without cycles. A router may orchestrate several modules (the catalog listing combines catalog search, price selection and availability). A service calls only services below it in the dependency graph; when two modules need each other, the caller passes data in instead of the callee reading it back.
  3. Invariants are synchronous, reactions are events. Whatever must be true when the request returns (stock reserved at confirmation, credit checked, invoice number allocated, return restocked) runs in the same database transaction through service calls passing tx. Whatever can happen a second later (notifications, live streams, CRM lifecycle automation, analytics, sync hints) is a handler of an event written to the outbox in that transaction (ADR-007).
  4. Contract first. A route exists in packages/contracts before it exists in a router; routers never add fields the contract does not declare (API reference).
  5. Permission and scope in one place. policies.ts declares the permission key of each operation (orders.validate) and its scope (self, delegate, staff or public); the request pipeline enforces both before the handler runs, and repositories apply the scope as a query filter (request lifecycle).
  6. One event per fact. Events are named <aggregate>.<past_fact> in lower snake case (order.validated, assisted_order.awaiting_confirmation), are emitted by the module owning the aggregate, carry ids and a minimal snapshot (no personal data beyond ids and business numbers), and are versioned (v: 1). A command may emit several events when it establishes several facts.
  7. Handlers are idempotent and tolerate reordering. Delivery is at least once; a handler re-reads current state or compares versions instead of trusting arrival order (jobs and scheduler).

A service function, as every module writes it:

// modules/orders/service.ts (shape, not final code)
export async function validateOrder(ctx: Ctx, input: ValidateOrderInput) {
	return withTx(ctx, async (tx) => {
		const order = await repo.lockForUpdate(tx, input.orderId, ctx.scope);
		policies.assertTransition(order, "VALIDATED", ctx.actor); // 409 ORDER_TRANSITION_NOT_ALLOWED
		await inventory.reserve(tx, order.lines, { override: input.stockOverride }); // 422 STOCK_INSUFFICIENT
		await billing.assertCredit(tx, order, { override: input.creditOverride });
		const updated = await repo.transition(tx, order, "VALIDATED", ctx.actor, input.version); // 409 VERSION_CONFLICT
		ctx.audit(tx, { action: "VALIDATE", entity: updated });
		ctx.emit(tx, { key: "order.validated", aggregate: updated, payload: { orderId: updated.id } });
		return toDto(updated);
	});
}

Module map

ModuleIn one lineMain tasksScope
identityAccounts, Better Auth sessions, OTP, devices, guest accessSRV-05V1
accessRoles, permissions, scope resolutionSRV-06V1
geoWilayas, communes, zones, delegate coverage, distances, nearest communeSRV-10V1
crmResellers and prospects, customer type, lifecycle, assignment, timeline, follow-upsSRV-14V1
catalogBrands, category tree, products, variants and their three prices, attributes, facets, guest-safe searchSRV-11V1
pricingPrice selection by customer type, VAT, price historySRV-12V1 (agreements V2)
inventoryStock levels, reservations, deductions, automatic restock, ledger, alertsSRV-13V1
promotionsPromotions, targeting, schedule, caps, bannersSRV-19V1 (packs V2)
cartServer-side cart, basket evaluation, checkoutSRV-15V1
ordersOrder lifecycle with mandatory back-office confirmation, assisted orders, returnsSRV-16, SRV-20V1
fulfillmentDeliveries, picking, carriers, proof of deliverySRV-17V1 (carrier API V2)
billingInvoices, payments, credit, credit notesSRV-18, SRV-20V1 (detailed credit V2)
loyaltyPoints, levels, rewards, vouchersSRV-30V2
fieldDelegates, objectives, tours, visits, reports, locationSRV-21V1 (optimisation V2)
invitationsInvitation codes, links, attribution funnelSRV-22V1
supportTickets, after-sales claims, support regions, announcementsSRV-24, SRV-20V1
notificationsEvent → channel matrix, inbox, push, SMS, e-mail, the two SSE streamsSRV-08, SRV-26V1 (WhatsApp API V2)
analyticsKPIs, reports, aggregates, exports, global searchSRV-25V1 (advanced V2)
filesUploads, signed downloads, thumbnailsSRV-07V1
auditActivity journalSRV-07V1
settingsBusiness parameters, reference lists, app versions, business numbersSRV-07V1
syncIdempotency, offline batch replay, delta feedSRV-23V1
jobsQueues, schedulers, outbox dispatcherSRV-08V1
integrationsProvider adapters, data imports, V2 external systemsSRV-27V1 + V2

Dependencies

Service calls (an arrow means "calls the service of", inside the caller's transaction when needed). Platform modules — settings, audit, files, jobs, integrations — are used by every module and are not drawn. Dotted arrows are not service calls: sync invokes other modules' procedures, analytics reads reporting views.

Main event flows (emitter → consumers). Every event that has a rule in the notifications matrix or an SSE projection also flows to notifications.

Modules

identity

Accounts and authentication for every population: staff, delegates and resellers. Configures Better Auth (ADR-005): phone or e-mail + password, OTP send and verify, bearer sessions for mobile and cookies for the web, session listing and revocation, lockout and OTP limits. It runs reseller sign-up — including the declared customer type and the claim of a prospect created in the field — password reset and change, devices (FCM tokens, app version, platform), account-status gating, staff and delegate user administration, and the guest path: optional-auth operations run without a session.

OwnsUser, Session, Account, Verification, Device
OperationsGroup auth: auth.login, auth.otp.send, auth.otp.verify, auth.signup.reseller, auth.password.reset, auth.sessions.revoke. Group me: me.get, me.devices.register. Back-office users: admin.users.create, admin.users.setStatus, admin.users.resetPassword, admin.users.sessions
Emitsaccount.created, account.validated, account.rejected, account.suspended, account.reactivated, account.disabled, account.password_changed
Consumes— (the CRM validation command calls identity.activateAccount() synchronously)
Jobsidentity.purgeExpiredSessions (hourly)
Settingscatalog.pricesVisibleBeforeValidation (prices of the declared type right away — D-18)
IntegrationsSMS (OTP, through the notify-sms queue), e-mail (staff invitations, resets)
Used byRA, RW, DA, BO

Sign-up composes three modules in one transaction: identity creates the user, crm claims or creates the reseller with its declared customerType (AUTH-11), invitations records the attribution.

Risks:

  • SMS deliverability in Algeria gates sign-up and reset; the provider must be tested with the three mobile operators before the pilot.
  • Duplicate accounts when a field prospect and a self sign-up share a phone: claim matching on the normalised E.164 phone (normalizeDzPhone) and a unique index.
  • Lockout abuse (an attacker locking a reseller's phone): the lockout key is phone + IP, not phone alone.

access

Roles and permissions (module × action) with the editable matrix and its guard rails (a super admin cannot remove roles.administer from the last super admin). It provides the permission guard every procedure passes through, and the scope resolvers repositories use as query filters: self (the caller's reseller) and delegate (the delegate's zones and assigned resellers). It also holds field-level restrictions: purchase price, margins and credit visible only with the matching permissions (ACL-04).

OwnsRole, Permission, RolePermission, UserRole
Operationsadmin.roles.list, admin.roles.updatePermissions, admin.permissions.list; the permission and scope middleware used by every route
Emitsrole.permissions_changed
Consumesdelegate.zones_changed, zone.updated, reseller.assigned (scope cache invalidation)
Jobs—
Used byBO (roles matrix BO-62); every app through me.get

Risks:

  • A repository query that forgets the scope leaks data. repo.ts functions take a typed Scope argument, and the permission sweep of SRV-28 calls every operation with every role.
  • Cached permissions go stale after a matrix change: 60 s TTL plus invalidation on role.permissions_changed.

geo

The official wilaya and commune reference (FR/AR names, commune centroids) seeded from a versioned file, zones grouping communes, delegate coverage, distance helpers (haversine) and the nearest-commune lookup that turns a device position into a wilaya — used for regional WhatsApp support when the visitor is not signed in (SUP-05). Optional geocoding through the maps provider (GEO-04).

OwnsWilaya, Commune, Zone, ZoneArea, DelegateZone
Operationsreference.wilayas, reference.communes (ETag), delegate.zone, admin.zones.crud
Emitszone.updated, delegate.zones_changed
Consumes—
IntegrationsMaps provider (geocoding, optional)
Used byevery app (reference data), BO (zones editor BO-19), DA (my zone)

Risks:

  • Changes to the official reference (new wilayas or communes) need a versioned seed and an ETag change.
  • A commune in two zones makes delegate scope ambiguous. V1 rule: a commune belongs to at most one zone (unique ZoneArea.communeId) unless HOCO decides otherwise.

crm

The commercial record of every point of sale: reseller and prospect data (minimum data set of cahier §15.5), the unique normalised main phone, the customer type (SUPER_GROS, DEMI_GROS, DETAIL) declared at sign-up and changed only by the back-office, the 10-status lifecycle with automatic transitions driven by events, assignment to a delegate and a zone with history, segments, the interaction timeline, follow-up tasks, documents and photos, the validation queue, and the reseller's self-service profile and delivery addresses.

OwnsReseller, ResellerAddress, ResellerDocument, ResellerPhoto, ResellerStatusChange, ResellerAssignment, Interaction, CrmTask, Segment, ResellerSegment
OperationsReseller self-service: profile.get, profile.update, addresses.create. Delegate: delegate.resellers.create, delegate.resellers.checkPhone, delegate.resellers.update, delegate.tasks.complete. Back-office: admin.resellers.list, admin.resellers.validate, admin.resellers.setStatus, admin.resellers.assign, admin.resellers.update (customer type, audited), admin.resellers.timeline, admin.tasks.create
Emitsreseller.created, reseller.status_changed, reseller.assigned, reseller.customer_type_changed, reseller.validated, reseller.rejected, task.assigned, task.completed, task.due
Consumesaccount.created (link the user, ACCOUNT_CREATED), invitation.step_completed (APP_INSTALLED), order.submitted (FIRST_ORDER, timeline), order.status_changed (ACTIVE on the first delivered order), order.cancelled, return.requested, payment.confirmed, credit_note.issued, visit.completed, visit.not_done, ticket.created (timeline entries)
Jobscrm.inactivityScan (daily 02:00), crm.followUpReminders (daily 07:30)
Settingscrm.inactivityDays (60), crm.validationReminderHours, crm.followUpReminderTime
Used byBO, DA; RA and RW for the profile and addresses

Lifecycle automation only moves a reseller forward in the funnel and never lifts SUSPENDED (lifecycles); handlers recompute the status from facts (has an account, a first order, a delivered order) rather than from the event alone, so a late or duplicated event cannot regress it.

Risks:

  • Duplicates across four creation paths, one of them offline: a unique index on the normalised phone, a check on every path, and the PHONE_ALREADY_EXISTS conflict on replay (offline sync).
  • Reassignment history (ResellerAssignment.fromDelegateId) is also what tells a delegate's device that a point of sale left its scope (delta feed).
  • Changing a customer type re-prices open carts on their next read and never touches submitted orders (prices are snapshotted).
  • Assignment history drives reports and offline scope; it must be written on every reassignment, including bulk ones.

catalog

Brands, the category tree (categories and sub-categories), products and variants (SKU, barcode, quantity limits, and the three selling prices priceSuperGros, priceDemiGros, priceDetail plus vatRate, recommendedPrice and the restricted purchasePrice), images, specs, per-category filterable attributes and their values per variant, product lifecycle including the automatic OUT_OF_STOCK, visibility rules, favorites, and the reseller home aggregate. Search, combinable filters and facets with counts serve guests and signed-in callers alike; for guests every price is null and the price-range facet is absent (AUTH-10, CAT-12, CAT-13).

OwnsBrand, Category, Product, ProductVariant, ProductImage, AttributeDefinition, VariantAttributeValue, ProductVisibilityRule, Favorite
OperationsOptional auth: catalog.home, catalog.categories, catalog.products.list, catalog.facets, catalog.products.suggest, catalog.products.get, catalog.products.byBarcode. Signed in: favorites.add. Back-office: admin.products.create, admin.variants.crud (prices included), admin.attributes.crud, admin.categories.reorder, admin.products.setStatus, admin.products.visibility
Emitsproduct.status_changed
Consumesstock.out, stock.restocked (product OUT_OF_STOCK when every variant is at 0, back to ACTIVE on restock)
Jobs— (the search document is maintained by the database: generated tsvector with unaccent, trigram index)
Settingscatalog.pricesVisibleBeforeValidation, catalog.newArrivalDays (the "new arrivals" filter)
IntegrationsPublic bucket for product images (via files)
Used byRA, RW (guests included), DA (assisted orders), BO

The listing router composes three modules: catalog searches and filters, pricing selects the caller's price for each variant (none for guests), inventory adds the availability (IN_STOCK or OUT_OF_STOCK — never a quantity, STK-07). Saving a variant's prices calls pricing.recordPriceChange() in the same transaction.

Risks:

  • Results under 3 seconds on a mobile connection (CAT-07) with facets computed on every query: one filtered base query, facet counts with GROUP BY on indexed attribute values, a p95 budget of 300 ms server-side measured in the load test of SRV-28.
  • Guest responses must never leak a price or a quantity: one DTO mapper per audience, covered by contract tests.
  • Attribute definitions change after products are filled: values are typed (number with unit, enum, boolean, text) and a definition in use can be deactivated, never deleted.

pricing

V1 pricing is deliberately simple (PRC-01, PRC-02): each variant carries three selling prices, HT, one per customer type, and the reseller's customerType selects one; promotions then apply on top (cart engine); guests get no price. The module owns the selection function, VAT handling (basis points, 19 % by default per product), HT/TTC computation through the shared rounding helper, and the history of the three prices. Per-reseller agreements are V2 (PRC-03); quantity tiers are dropped.

OwnsPriceChange; V2 PriceList, PriceListItem
Operationsadmin.variants.priceHistory. Internal: selectPrice(variant, customerType), recordPriceChange(tx, before, after, actor). admin.priceLists.* become V2; admin.variants.tiers is dropped (product decisions of 17 Oct 2026)
Emits— (consumers select prices at read time; carts re-validate on every read)
Consumes—
Settingspricing.defaultVatRate (1900), pricing.displayTaxMode (TTC or HT shown to resellers — D-17)
Used bycatalog, cart and orders (in-process); BO price history

selectPrice is a pure function: callers pass the variant's prices, so pricing depends on no other module.

Risks:

  • HT/TTC rounding must be identical in catalog, cart, invoice and credit note — one helper in @hoco/shared, tested on real price lists.
  • A price change between the cart view and checkout: checkout re-evaluates and answers 409 CART_CHANGED with the new totals (lifecycles).

inventory

Warehouses and stock levels per variant × warehouse (physical, reserved, available = physical − reserved), reservations and deductions triggered by the configured order statuses (default: reserve at VALIDATED, the back-office confirmation; deduct at SHIPPED), automatic restock — reservation release on cancellation or refusal, re-entry of deducted quantities when an order is cancelled after deduction, RETURN movements at return reception — the movement ledger, thresholds and alerts, inventory sessions, and the availability function used by catalog and cart. Quantities are back-office only; everyone else sees IN_STOCK or OUT_OF_STOCK.

OwnsWarehouse, StockLevel, StockMovement, InventorySession, InventoryLine
Operationsadmin.stock.levels, admin.stock.movements, admin.stock.receipt, admin.stock.adjust, admin.inventory.validate. Internal: reserve, release, deduct, reenter, restockReturn, availability
Emitsstock.low, stock.out, stock.restocked
Consumes— (called synchronously by orders)
Jobsinventory.stockAlertDigest (daily 08:00)
Settingsstock.reserveOn (default VALIDATED), stock.deductOn (default SHIPPED) — D-22; stock.lowThresholdDefault
IntegrationsV2 stock-system synchronisation through integrations (STK-09)
Used byBO; RA, RW, DA through catalog and cart

Risks:

  • Overselling under concurrency: a reservation is a conditional UPDATE … SET reserved = reserved + q WHERE physical - reserved >= q on the StockLevel row, plus the ledger insert in the same transaction; a recorded override is the only way past it (STK-08).
  • Level and ledger drift: the nightly aggregates job also checks that levels equal the sum of movements and reports any difference.
  • Which warehouse to reserve from when HOCO has several: V1 uses one default warehouse per wilaya (setting) unless the cadrage decides otherwise.

promotions

Promotions (PERCENT_OFF, AMOUNT_OFF, FIXED_PRICE, FREE_DELIVERY) on products, categories or brands, audience targeting (wilaya, segment, customer type, volume; level once V2 loyalty exists), schedule, stock cap, stacking rules and priorities, daily offers and banners, and the preview simulation. The application function is pure: the cart passes lines and reseller facts in, promotions returns the applied discounts.

OwnsPromotion, PromotionScope, PromotionAudience, Banner; V2 Pack, PackItem
OperationsOptional auth (guests see them without prices): promotions.list, promotions.get, banners.list. Back-office: admin.promotions.crud, admin.promotions.setStatus, admin.promotions.preview, admin.banners.crud; V2 packs
Emitspromotion.started, promotion.ended
Consumesorder.refused, order.cancelled, return.status_changed (give back cap consumption)
Jobspromotions.applySchedule (every minute)
Settingsstacking mode and priorities (PRM-03)
Used byRA, RW (guests see promotions without prices), DA, BO

Risks:

  • Stacking ambiguities: the rules are fixed in business rules and tested with the preview endpoint.
  • Cap races at checkout: the cap counter row is locked in the checkout transaction.
  • Time boundaries: the cart engine evaluates startsAt/endsAt itself, so a late schedule job only delays the displayed status, never the price.

cart

The server-side cart per reseller, shared by the app and the web (ORD-01), and the basket evaluator reused by checkout, assisted orders, back-office orders and order modifications: visibility and orderability of each line, price selection, promotions, availability, quantity rules, minimum amount, maximum per order, credit availability, delivery fee. cart.checkout (idempotent) hands the evaluated basket to orders. Guests cannot have a cart.

OwnsCart, CartItem
Operationscart.get, cart.items.add, cart.items.update, cart.checkoutOptions, cart.checkout (Idempotency-Key required); V2 cart.packs.add
Emits— (the order it creates emits order.submitted and order.to_confirm)
Consumes—
Settingsorders.minAmount, orders.minQtyPerLine, orders.maxQtyPerLine, enabled payment methods, delivery fees (delivery.feeMode, delivery.freeThreshold, DLV-06), credit policy
Used byRA, RW; DA and BO through the evaluator

Risks:

  • Double submission: required Idempotency-Key, plus a row lock on the cart during checkout.
  • Totals changing between display and submission: checkout compares the totals the reseller saw and answers 409 CART_CHANGED with the re-evaluated cart.
  • Evaluation cost: lines are evaluated in batched queries (one per module), never per line.

orders

The 11-status lifecycle with its transition table and actors (lifecycles). No order is ever confirmed automatically: every order — app, web, assisted, phone — lands as SUBMITTED (or PENDING when on hold) in the back-office « commandes à confirmer » queue and waits for admin.orders.validate (ORD-14). The module also covers controlled modification, refusal and cancellation (with automatic restock), assisted orders (draft → awaiting the reseller's confirmation → confirmed or rejected or expired after 72 h), first-order attribution to the reseller and the source delegate, history, document orchestration (invoice and delivery note), free replacement orders created from after-sales claims, and returns (V1): request on a delivered order, back-office decision, reception that restocks automatically and triggers the credit note (ORD-13).

OwnsOrder, OrderItem, OrderStatusChange, OrderChange, ReturnRequest, ReturnItem
OperationsReseller: orders.list, orders.get, orders.cancel, orders.confirmAssisted, orders.rejectAssisted, returns.create, returns.get. Delegate: delegate.assisted.create, delegate.assisted.submit. Back-office: admin.orders.toConfirm, admin.orders.validate, admin.orders.refuse, admin.orders.modify, admin.orders.transition, admin.orders.cancel, admin.orders.create, admin.returns.decide, admin.returns.receive; admin.deliveries.proof is orchestrated here because it moves the order to DELIVERED
Emitsorder.submitted, order.to_confirm, order.validated, order.refused, order.modified, order.status_changed, order.cancelled, assisted_order.awaiting_confirmation, assisted_order.confirmed, assisted_order.rejected, assisted_order.expired, assisted_order.reminder_due, return.requested, return.status_changed
Consumes— (orders orchestrates; others react to it)
Jobsorders.expireAssisted (every 15 minutes, with the reminder 24 h before expiry), orders.staleConfirmationAlert (every 15 minutes in working hours, proposal)
Settingsorders.assistedConfirmationHours (72, D-23), orders.cancelAllowedStatuses (SUBMITTED, PENDING), returns.windowDays (7, D-35), stock.reserveOn, stock.deductOn, billing.invoiceOn
Used byRA, RW, DA, BO

Transactions it opens: confirmation reserves stock (inventory), checks credit (billing) and creates the delivery record (fulfillment); cancellation releases or re-enters stock; return reception posts RETURN movements and asks billing for the credit note — all in the same commit as the status change.

Risks:

  • The transition table must be exhaustive per actor; property tests generate every (status, actor, action) triple.
  • Concurrent back-office confirmation and reseller cancellation: row lock plus version; the loser gets 409 ORDER_TRANSITION_NOT_ALLOWED or 409 VERSION_CONFLICT.
  • Assisted-order expiry against a late confirmation: the confirmation checks assistedExpiresAt in its own transaction (409 ASSISTED_ORDER_EXPIRED), so the job's 15-minute granularity never matters.
  • The confirmation queue is now on the critical path of every order: its size and age are monitored (analytics), and validators get in-app and SSE alerts.

fulfillment

Delivery records per order (mirroring the order from VALIDATED on), preparation and picking lists, carrier assignment (own fleet or carrier company) and tracking numbers, proof of delivery (photo, recipient), failed deliveries, the delivery-fee policy, and the carrier and tracking of a return's way back when HOCO collects it. V2 adds carrier APIs.

OwnsDelivery, Carrier
Operationsadmin.deliveries.list, admin.deliveries.update, admin.deliveries.pickingList, admin.carriers.crud; internal createDelivery, recordProof, markFailed
Emitsdelivery.failed
Consumes— (called by orders)
JobsV2 integrations.pollCarrierTracking when a carrier has no webhooks
Settingsdelivery.feeMode, delivery.flatFee, delivery.wilayaFees, delivery.freeThreshold (D-20)
IntegrationsV2 carrier APIs (DLV-05)
Used byBO

Risks:

  • Two status fields (order and delivery) can drift: orders is the only writer of order status and updates the delivery in the same transaction.
  • Proof photos are taken on warehouse devices through the web uploader; the private bucket and signed URLs apply.

billing

Invoices generated from orders with legal mentions and PDF, invoice statuses, payment recording, reseller payment declarations with proof and their verification, allocation of payments to invoices, credit accounts (limit, used, available) and the credit check used at checkout and confirmation, the reseller finance summary, and credit notes (V1, FIN-08) issued for return receptions and after-sales CREDIT_NOTE resolutions. V2 adds payment terms and due dates, reminders and accounting export.

OwnsInvoice, InvoiceLine, Payment, PaymentAllocation, CreditAccount, CreditLimitChange, CreditNote; V2 PaymentReminder
Operationsfinance.summary, invoices.get, payments.declare, admin.payments.record, admin.payments.verify, admin.payments.allocate, admin.credit.update, admin.invoices.cancel; internal issueInvoice, issueCreditNote, assertCredit; V2 admin.finance.overdue, admin.finance.export
Emitsinvoice.issued, invoice.paid, invoice.cancelled, credit_note.issued, payment.declared, payment.recorded, payment.confirmed, payment.rejected; V2 invoice.overdue
Consumes— (orders and support call it synchronously)
Jobsinvoices.markOverdue (daily 01:00, V2), V2 billing.paymentReminders
Settingsbilling.invoiceOn (default SHIPPED), enabled payment methods, credit-exceeded policy (BLOCK or REQUIRE_APPROVAL, cahier §4.7)
IntegrationsV2 accounting export (FIN-09)
Used byRA, RW, BO

Risks:

  • Legal numbering: FAC- and credit-note numbers come from a transactional NumberSequence row lock, gapless per year.
  • Legal mentions (NIF, NIS, RC, AI, VAT) and layout come from DQA-09; PDFs are rendered by the worker from the stored snapshot, never from live data.
  • Financial data is restricted (ACL-04): DTOs drop purchase prices and margins without finance or pricing permissions.

loyalty

V2. The points ledger (append-only), earning rules (on delivery or payment per setting), bonuses, expiry, reversals on cancellation and returns, levels Bronze, Silver, Gold, VIP over a period with HOCO's thresholds, the rewards catalogue, redemptions and vouchers usable at checkout (cahier §4.6, §5.5).

OwnsLoyaltyAccount, LoyaltyTransaction, Reward, RewardRedemption, Voucher
Operationsloyalty.summary, loyalty.redeem, admin.loyalty.settings, admin.loyalty.adjust, admin.rewards.crud
Emitsloyalty.points_earned, loyalty.level_changed, loyalty.reward_redeemed, loyalty.points_expiring
Consumesorder.status_changed (DELIVERED) or invoice.paid per loyalty.earnOn, order.cancelled, return.status_changed, credit_note.issued (reversals)
Jobsloyalty.expirePoints (daily 01:30), loyalty.recomputeLevels (monthly)
Used byRA, RW, BO
TasksSRV-30, contract SRV-29

Risks: the policy itself is an open decision of the cahier (§15 « politique de fidélité »); reversing points already redeemed needs a rule (negative balance or debt).

field

Everything the delegate does and the back-office plans: delegate profiles, objectives per period, tour planning (daily, weekly or monthly series; ordered stops, imposed or recommended; priorities and time slots), the tour lifecycle (only the assigned delegate starts), visits with GPS check-in tolerance, checklists, notes, photos, structured reports that create follow-ups, not-done reasons, visit locking and unlocking, the closing summary and KPIs, location pings and trails, live follow-up, the recommended order (nearest neighbour), visit types and checklist templates, the delegate home and performance aggregates.

OwnsDelegateProfile, Objective, Tour, TourStatusChange, Visit, VisitType, ChecklistTemplate, VisitChecklistItem, VisitPhoto, VisitReport, LocationPing
OperationsDelegate: delegate.home, delegate.tours.today, delegate.tours.start, delegate.tours.close, delegate.visits.arrive, delegate.visits.report, delegate.visits.end, delegate.visits.notDone. Back-office: admin.tours.create, admin.tours.setStops, admin.tours.recommendOrder, admin.tours.reopen, admin.live.delegates, admin.visits.unlock. Location ingestion behind sync.locationPings
Emitstour.planned, tour.updated, tour.reordered, tour.to_start, tour.started, tour.suspended, tour.resumed, tour.closed, tour.cancelled, tour.reopened, visit.added, visit.checked_in, visit.started, visit.completed, visit.not_done, visit.unlocked, delegate.position (ephemeral, outside the outbox)
Consumesaccount.suspended (suspend the delegate's tour in progress, reason ACCOUNT_SUSPENDED)
Jobstours.activateToday (daily 00:05), field.autoSuspendOpenTours (daily 21:00, proposal), field.purgeLocationPings (daily 03:30)
Settingsfield.gpsToleranceMeters (150), field.gpsMaxAccuracyMeters (100), field.allowOutOfToleranceCheckIn, field.reportRequiredToEndVisit, field.visitUnlockHours, field.locationIntervalSeconds (300), field.locationDistanceMeters (200), field.locationRetentionDays (90), field.reopenMaxDays (7), field.closingRequiresSync — see business rules
Integrationsnone in V1 (haversine only); V2 routing engine (FLD-18)
Used byDA, BO

A visit report that plans a next action creates the follow-up through crm.upsertFollowUpFromReport() in the same transaction; the task id comes back in the response (or the replayed item's result), and the delta feed brings the task to the device.

Risks:

  • The largest module (SRV-21, size L): offline semantics, GPS accuracy in dense centres and indoors, battery, and privacy (no position outside a tour — ADR-016).
  • KPIs computed from data that arrives up to 72 h late: tour summaries are recomputed when late visits arrive, and the daily aggregates rebuild the last three days.

invitations

Invitation codes (8 characters, no ambiguous letters), signed links and QR payloads, share texts, share tracking, attribution of installs (Play install referrer on first open, code typed at sign-up) and the activation funnel: sent → app installed → account created → phone verified → catalog viewed → first order (IVT-03).

OwnsInvitation, InvitationEvent
OperationsPublic: invitations.resolve, invitations.attribute, invitations.landing (served by the web app). Delegate: delegate.invitations.create, delegate.invitations.markShared. Back-office: admin.invitations.list
Emitsinvitation.created, invitation.shared, invitation.step_completed
Consumesaccount.created (ACCOUNT_CREATED, PHONE_VERIFIED), order.submitted (FIRST_ORDER); the first catalog call of a new reseller records CATALOG_VIEWED
Used byDA, RA, RW, BO

Risks:

  • No install referrer on iOS: attribution relies on the code at sign-up.
  • Brute force on the public resolve endpoint: 30 requests per minute per IP, and the 8-character alphabet gives about 10^12 codes.
  • The landing /i/{code} and /.well-known/assetlinks.json are served by the reseller web: a web outage breaks invitation links, so the landing is covered by the uptime monitor (architecture).

support

Reseller tickets (type, related order, messages, attachments, statuses), back-office handling (assignment, internal notes, replies, statuses), and since the product decisions after-sales claims in V1 (SUP-04): a ticket of type AFTER_SALES names the order, the claimed lines and quantities (TicketItem), the issue (AfterSalesIssue) and photos; the back-office diagnoses and resolves it with an AfterSalesResolution:

ResolutionWhat the module does, in the resolution transaction
REPLACEMENTorders.createReplacementOrder() — a free order linked to the ticket, which then follows the normal flow, back-office confirmation included
RETURNorders.createReturnFromClaim() — a return request already approved for the claimed lines; reception restocks and issues the credit note
CREDIT_NOTEbilling.issueCreditNote() without physical return
REPAIRticket resolved; the repair is tracked in messages
REJECTEDticket REJECTED with a reason (out of warranty, misuse…)

The module also owns support regions (SUP-05, ADM-08): wilayas grouped into regions, each with a WhatsApp number and hours. support.whatsapp resolves the caller's region from the wilaya declared on their account, else from the device position (geo.nearestCommune() → wilaya → region), else from a wilayaCode picked by the user, else the default number. Announcements to delegates or resellers with targeting and schedule (« Messages ») complete the module.

OwnsTicket, TicketItem, TicketMessage, SupportRegion, SupportRegionWilaya, Announcement, AnnouncementRead
OperationsReseller: tickets.create, tickets.reply, tickets.close, support.whatsapp (optional auth), support.contacts, announcements.list. Back-office: admin.tickets.update, admin.tickets.reply, admin.tickets.resolve, admin.supportRegions.crud, admin.announcements.send
Emitsticket.created, ticket.replied, ticket.assigned, ticket.status_changed, ticket.resolved, ticket.rejected, announcement.published
Consumesorder.status_changed and return.status_changed of a claim's replacement order or return (system message on the ticket)
Jobssupport.publishScheduledAnnouncements (every minute), support.autoClose (daily 03:00)
Settingssupport.defaultWhatsappNumber, support.autoCloseDays, FAQ entries; regions and numbers are D-36
Used byRA, RW (WhatsApp button for guests too), DA (announcements), BO

Risks:

  • Internal notes leaking to resellers: a visibility flag on TicketMessage and separate DTOs, covered by contract tests.
  • A claim on lines already claimed or returned: claimed quantity plus returned quantity can never exceed the delivered quantity (checked in the claim and return transactions).
  • Region resolution from a position needs commune centroids for every commune in the seed; without a match the user gets the wilaya picker, never an error.

notifications

Turns domain events into notifications: the event → channel → recipient matrix (NotificationRule), localised templates per event, channel and locale, user preferences (TRANSACTIONAL and SECURITY cannot be disabled), the in-app inbox, delivery through push (FCM), SMS and e-mail (V2 WhatsApp Business), the delivery log and retries — and the two SSE streams: the back-office stream and each user's own stream (ADR-008). It also sends the silent "sync hint" pushes requested by sync.

OwnsNotification, NotificationDelivery, NotificationTemplate, NotificationRule, NotificationPreference
Operationsnotifications.list, notifications.unreadCount, notifications.preferences.update, events.stream, admin.events.stream, admin.notifications.rules, admin.notifications.templates, admin.notifications.log
Emitsnotification.created (projected only to the recipient's own stream)
Consumesevery event with a rule in the notifications matrix or an SSE projection
Jobsnotifications.reconcileDeliveries (every 5 minutes), notifications.purgeHistory (daily); queues notify-push, notify-sms, notify-email
IntegrationsFCM, SMS gateway, SMTP; V2 WhatsApp Business (NOT-08)
Used byevery app
TasksSRV-08, SRV-26, V2 SRV-34

Risks:

  • SMS cost and deliverability: SMS only where the matrix says so (OTP, account decisions, critical order events), never for promotions.
  • Notification fatigue: dedupe per (event, recipient, channel) and daily digests for follow-ups and stock alerts.
  • FCM token churn: tokens reported invalid by FCM are deactivated at once.
  • Long-lived SSE connections: heartbeats, Traefik timeouts above 60 s, and one Redis consumer per api instance.

analytics

KPI and chart queries with permission-aware filters, dashboard layouts per role, daily aggregates and their rebuild for late offline data, the essential report catalogue, asynchronous exports (PDF, XLSX, CSV), global back-office search (fan-out to each module's search function, filtered by permission), the delegate performance aggregates, and monitoring of the confirmation queue (count and age of orders to confirm). V2 adds advanced analytics.

OwnsDashboardLayout, ExportJob; reporting views and daily aggregate tables created by SQL migrations (assumption — not Prisma models; to confirm in the database design)
Operationsadmin.dashboard.kpis, admin.dashboard.charts, admin.dashboard.layout.update, admin.reports.run, admin.exports.create, admin.exports.get, admin.search; export wrappers admin.resellers.export and admin.products.export; computation behind delegate.performance
Emitsexport.completed
Consumesorder.*, return.status_changed, credit_note.issued, visit.*, tour.closed, invitation.step_completed, reseller.status_changed, account.created — each marks the affected (day, dataset) cells dirty for recomputation
Jobsanalytics.dailyAggregates (daily 02:00), analytics.recomputeDirty (debounced, on demand), exports.expire (daily 04:45)
Settingsexports.retentionDays (7)
Used byBO, DA (performance)
TasksSRV-25, V2 SRV-38

Risks:

  • Dashboard latency on large ranges: aggregates for history, live queries only for "today".
  • Numbers of the last three days move as offline data lands: those days are marked provisional on the dashboards.
  • Large exports: streamed XLSX/CSV generation in a sandboxed worker process, row limit per export.

files

Upload sessions (pre-signed PUT valid 10 minutes), completion checks (size, type and purpose rules of FilePurpose), metadata stripping and thumbnails in the worker, private downloads through signed GET URLs valid 5 minutes after an access check against the owning record, public assets on the public bucket, storage of generated documents, orphan cleanup.

OwnsFile
Operationsfiles.createUpload, files.completeUpload, files.downloadUrl
Emits—
Jobsfiles.purgeOrphans (hourly); queue files
IntegrationsS3-compatible storage through integrations
Used byevery app

Risks: a pre-signed PUT cannot cap the size, so completion verifies it and deletes oversize objects; polymorphic ownership makes the access check the most security-sensitive code of the module — one function per owner type, tested per role.

audit

The activity journal (AUD-01, cahier §10): logins, creations, modifications, validations, exports, deletions, permission changes and overrides, with actor, time, app, IP or device, entity and a before/after diff. Entries are written inside the business transaction (ctx.audit()), never through events.

OwnsAuditLog
Operationsadmin.audit.list
Emits / consumes—
Jobsaudit.purgeExpired (monthly; 3-year retention proposed by the database design, to validate with HOCO)
Used byBO (BO-63)

Risks: volume (indexes on entity and on actor + time), personal data in diffs (passwords and tokens never logged, phones masked in diffs), tamper resistance (the application role has no UPDATE grant on the table, and DELETE only through the retention job's role).

settings

Typed business parameters grouped by domain with validation and audit (ADM-03), reference lists by listKey, app version policies and maintenance mode (ADM-07), the public app configuration, and business number sequences (REV, CMD, FAC, BL, PAY, TRN, VIS, TKT, RET).

Risks: one Zod schema per setting key in the contract (unknown keys rejected, defaults explicit); number allocation serialises writers on one row per (type, year) — acceptable at HOCO's volume; reference items in use are deactivated, never deleted.

sync

The server side of offline sync for the delegate app (offline sync design): idempotency records and the idempotency middleware every mutation passes through, ordered batch replay with per-item results, the delta feed with tombstones derived from history (no tombstone table), the location-ping route (stored by field), occurredAt plausibility, and sync hints (silent pushes) when the back-office changes something in a delegate's scope.

OwnsIdempotencyRecord
Operationssync.batch, sync.delta, sync.locationPings
Emits—
Consumestour.planned, tour.updated, tour.cancelled, tour.reopened, visit.added, visit.unlocked, reseller.assigned, task.assigned, delegate.zones_changed, zone.updated, settings.changed (field policy), announcement.published → sync hint to the affected delegates
Jobssync.purgeIdempotencyRecords (daily 03:45)
Used byDA (idempotency: every app)
TasksSRV-23, middleware in SRV-04

Risks: see the offline sync design — conflict rates, late data, devices with wrong clocks.

jobs

The asynchronous backbone: queue registry (names and default options), BullMQ connections, the worker bootstrap, job schedulers (cron in Africa/Algiers), the transactional outbox (emit() helper and dispatcher), the event subscription registry, dead-letter handling and replay tooling, queue and outbox metrics, the backup verification ping.

OwnsOutboxEvent
Operationsnone public; internal health and metrics on the worker port
Emits / consumesdispatches every event
Jobsjobs.purgeDispatchedOutbox (daily 04:15), ops.verifyBackup (daily 06:00)
Used byevery module
TasksSRV-08

Risks: Redis persistence (AOF, noeviction), outbox lag, poison events, overlapping cron runs — all handled on Jobs and scheduler.

integrations

Adapters (ports) for every external system, each with a development simulator: SMS gateway (SIMULATE_SMS), SMTP e-mail (Mailpit locally), FCM, maps geocoding, the S3 client. It also runs data imports — products, resellers, users, stock — from Excel/CSV templates with a dry-run validation report, idempotent apply and an error-rows export (ADM-06, CAT-06); imported rows go through the owning modules' services. V2: carrier APIs, WhatsApp Business, ERP/accounting exchange, stock-system sync.

OwnsImportJob
Operationsadmin.imports.create, admin.imports.get, admin.products.import; V2 webhooks (carriers, WhatsApp) to add to the contract
Emitsimport.completed
Jobsqueue imports; V2 integrations.syncStock, integrations.pollCarrierTracking
Settingsprovider credentials from the environment (configuration)
Used byBO; every module through the adapters
TasksSRV-27, adapters in SRV-08, V2 SRV-34, SRV-37

Risks: providers not chosen yet (costs and quotas to validate, INT-07); initial data quality from HOCO's spreadsheets (phones that do not normalise, duplicates, customer types missing) — the dry-run report is the gate before go-live (LCH-01).

Events catalogue

Every event is an OutboxEvent row written in the transaction of the change (ADR-007) — except delegate.position, which is ephemeral (see below). The envelope is the same for all (the OutboxEvent row as handlers see it; its id is also the SSE event id):

{
  "id": "01935c4e-8a1f-7c3e-9b2a-4f6d8e0a1b2c",
  "eventKey": "order.validated",
  "v": 1,
  "aggregateType": "Order",
  "aggregateId": "01935c4d-1e2f-7a9b-8c3d-5e6f7a8b9c0d",
  "occurredAt": "2026-11-03T08:12:00Z",
  "actor": { "userId": "01935a10-2b3c-7d4e-9f50-6a7b8c9d0e1f", "app": "DASHBOARD" },
  "requestId": "req_01J9Z4K7M2",
  "payload": { "orderId": "01935c4d-1e2f-7a9b-8c3d-5e6f7a8b9c0d", "number": "CMD-2026-000158", "resellerId": "01935b22-3c4d-7e5f-8a6b-7c8d9e0f1a2b" }
}

Payloads carry ids, business numbers and the few values consumers need to route or render (status, amounts in centimes, reason codes); consumers load anything else from the owning module. Which events notify whom, on which channel, is the job of the notifications matrix; the consumers listed below are the modules that react.

EventEmitted by — whenKey payload fieldsConsumers
account.createdidentity — a user account is created (reseller sign-up or claim; staff or delegate created by an admin)userId, userType, accountStatus, resellerId, phoneVerified, invitationCodecrm, invitations, notifications (validators; credentials), SSE account.pending, analytics
account.validatedidentity — pending reseller account activated after back-office approvaluserId, resellerId, validatedBynotifications (reseller)
account.rejectedidentity — reseller account refuseduserId, resellerId, reasonCodenotifications (reseller)
account.suspended · account.reactivated · account.disabledidentity — account status changed by an admin (sessions revoked on suspension)userId, userType, reasonCodefield (suspend tour in progress), notifications
account.password_changedidentity — password changed or resetuserId, vianotifications (SECURITY)
role.permissions_changedaccess — a role's permission set replacedroleKey, added, removedapi permission caches
zone.updated · delegate.zones_changedgeo — zone communes or a delegate's zones changedzoneId, delegateUserId, communeIdsaccess (scope cache), sync (resellers scope reset + hint)
reseller.createdcrm — record created, any sourceresellerId, number, source, createdByDelegateId, customerType, wilayaCodeanalytics, notifications (validators for field prospects)
reseller.status_changedcrm — lifecycle status changedresellerId, from, to, automatic, reasonCodenotifications (assigned delegate), analytics
reseller.assignedcrm — delegate or zone assignment changedresellerId, fromDelegateId, toDelegateId, zoneIdnotifications (new delegate), sync (both delegates), access
reseller.customer_type_changedcrm — back-office changed the customer typeresellerId, from, tonotifications (reseller), analytics
reseller.validated · reseller.rejectedcrm — point-of-sale validation decidedresellerId, decidedBy, reasonCode, hasAccountnotifications (creating delegate)
task.assignedcrm — follow-up created for or reassigned to a usertaskId, resellerId, assigneeId, type, dueDate, sourcenotifications (assignee), sync
task.completedcrm — task donetaskId, resellerId, outcomeanalytics
task.duecrm job crm.followUpReminders — daily digest per assigneeassigneeId, date, dueCount, overdueCountnotifications (push + inbox)
product.status_changedcatalog — status changed, including automatic OUT_OF_STOCKproductId, from, to, automaticanalytics
stock.low · stock.outinventory — availability crosses the threshold or reaches 0variantId, warehouseId, thresholdcatalog (stock.out), notifications (stock managers), SSE stock.alert
stock.restockedinventory — available goes from 0 to positive (receipt, cancellation, return reception)variantId, warehouseIdcatalog (back to ACTIVE)
promotion.started · promotion.endedpromotions — schedule boundary, cap reached or manual changepromotionId, type, reason, startsAt, endsAtnotifications (targeted resellers, PROMOTIONAL), analytics
order.submittedorders — an order is placed, any channel, including a reseller-confirmed assisted orderorderId, number, resellerId, channel, status, totalTtc, isFirstOrder, sourceDelegateIdnotifications (reseller « Commande envoyée »), crm, invitations, analytics, SSE order.created
order.to_confirmorders — an order enters the back-office confirmation queue (SUBMITTED, or PENDING on hold)orderId, number, status, pendingReasons, queuedAtnotifications (users with orders.validate, in-app), SSE order.status_changed
order.validatedorders — confirmed by the back-office; stock reservedorderId, validatedBy, overridesnotifications (reseller; delegate if assisted), analytics, SSE (both streams)
order.refusedorders — refused with a reasonorderId, reasonCodenotifications, promotions (caps), analytics, SSE (both streams)
order.modifiedorders — controlled modification of linesorderId, changeId, reasonCode, totalTtcBefore, totalTtcAfternotifications (reseller), analytics, SSE (both streams)
order.status_changedorders — PREPARING, READY, SHIPPED, DELIVERED, RETURNEDorderId, from, tonotifications, crm (ACTIVE), support (claim follow-up), loyalty (V2), analytics, SSE (both streams)
order.cancelledorders — cancelled by the reseller, the back-office or the system; stock released or re-enteredorderId, by, reasonCodenotifications, promotions (caps), crm, loyalty (V2), analytics, SSE (both streams)
assisted_order.awaiting_confirmationorders — a delegate sends an assisted order to the resellerorderId, resellerId, delegateId, expiresAtnotifications (reseller push + inbox), SSE (reseller's own stream; back-office order.created)
assisted_order.confirmedorders — confirmed by the reseller or by the administration (the order then emits order.submitted and order.to_confirm)orderId, bynotifications (delegate), SSE order.status_changed
assisted_order.rejectedorders — rejected by the resellerorderId, reasonCodenotifications (delegate), SSE order.status_changed
assisted_order.reminder_dueorders job — 24 h before expiryorderId, resellerId, expiresAtnotifications (reseller)
assisted_order.expiredorders job — no answer within 72 horderId, delegateIdnotifications (delegate, reseller), SSE order.status_changed
return.requestedorders — reseller requests a return, or an after-sales claim resolves as RETURNreturnId, number, orderId, resellerId, ticketIdnotifications (back-office), crm, SSE return.requested
return.status_changedorders — APPROVED, IN_TRANSIT, RECEIVED, REFUNDED, REJECTEDreturnId, orderId, from, to, reasonCodenotifications (reseller), promotions, support, loyalty (V2), analytics, SSE (reseller's own stream)
delivery.failedfulfillment — delivery attempt faileddeliveryId, orderId, reasonCodenotifications (reseller, delivery managers), analytics
invoice.issued · invoice.paid · invoice.cancelledbilling — invoice lifecycleinvoiceId, number, orderId, resellerId, totalTtcnotifications (reseller), loyalty (V2, invoice.paid), analytics
credit_note.issuedbilling — credit note for a return reception or an after-sales resolutioncreditNoteId, number, resellerId, returnId, ticketId, amountTtcnotifications (reseller), crm, loyalty (V2), analytics
payment.declaredbilling — reseller declares a transfer, CCP or BaridiMob paymentpaymentId, number, resellerId, amount, methodnotifications (finance)
payment.recorded · payment.confirmed · payment.rejectedbilling — payment recorded by finance, declaration confirmed or rejectedpaymentId, resellerId, amount, reasonCodenotifications (reseller), crm, analytics
invoice.overduebilling job — V2invoiceId, daysOverdue, balancenotifications
tour.planned · tour.updated · tour.cancelled · tour.reopenedfield — back-office planning changestourId, number, delegateId, date, changes, reasonCodenotifications (delegate), sync
tour.reorderedfield — the delegate reorders a recommended tourtourId, versionanalytics; the back-office live view refreshes, the delegate's other devices get it through the delta feed
tour.to_startfield job tours.activateTodaytourId, delegateIdsync (silent hint), analytics
tour.started · tour.resumedfield — delegate starts or resumestourId, delegateId, occurredAtSSE tour.started, analytics
tour.suspendedfield — delegate, end-of-day job or account suspensiontourId, reasonCode, byanalytics
tour.closedfield — closed with the final reporttourId, delegateId, visitsDone, visitsNotDone, signups, ordersCount, revenueTtc, kmSSE tour.closed, analytics
visit.addedfield — unplanned visit (delegate) or stop added (back-office)visitId, tourId, resellerId, addedBysync (when added by the back-office), analytics
visit.checked_infield — arrival recordedvisitId, tourId, resellerId, distanceMeters, withinTolerance, occurredAtSSE visit.checked_in, analytics
visit.startedfield — visit timer started (explicitly, or implicitly by the first checklist, note, photo or report)visitId, tourId, occurredAtanalytics
visit.completedfield — visit DONE and lockedvisitId, tourId, resellerId, durationSeconds, interestLevel, nextActioncrm, SSE visit.completed, analytics
visit.not_donefield — not carried out, with reasonvisitId, tourId, reasonCodecrm, SSE visit.completed (status NOT_DONE), analytics
visit.unlockedfield — back-office unlocks a finished visitvisitId, unlockedBy, untilnotifications (delegate), sync
delegate.positionfield — accepted location ping (ephemeral: published to Redis, not stored in the outbox)delegateId, tourId, lat, lng, accuracy, recordedAtSSE delegate.position only
invitation.created · invitation.sharedinvitationsinvitationId, resellerId, delegateId, channelanalytics
invitation.step_completedinvitations — funnel step reachedinvitationId, resellerId, delegateId, stepcrm, notifications (delegate), analytics
ticket.createdsupport — ticket or after-sales claim openedticketId, number, resellerId, type, orderIdnotifications (support staff), crm, analytics, SSE claim.created when type = AFTER_SALES
ticket.repliedsupport — public reply by staff or reseller (internal notes excluded)ticketId, messageId, authorTypenotifications (the other party), SSE (reseller's own stream)
ticket.assignedsupportticketId, assigneeIdnotifications (assignee)
ticket.status_changedsupport — status changed (IN_PROGRESS, WAITING_RESELLER, CLOSED…)ticketId, from, tonotifications (reseller), analytics
ticket.resolvedsupport — after-sales claim or ticket resolvedticketId, resolution, replacementOrderId, returnId, creditNoteIdnotifications (reseller), crm, analytics
ticket.rejectedsupport — claim refused with a reasonticketId, reasonCodenotifications (reseller), analytics
announcement.publishedsupport — published now or at its scheduled timeannouncementId, audience, categorynotifications (audience push + inbox), sync (delegates)
notification.creatednotifications — an inbox entry was created for a usernotificationId, userId, category, unreadCountSSE (recipient's own stream)
export.completed · import.completedanalytics · integrations — job finishedjobId, type, status, requestedBy, countsnotifications (requester in-app)
settings.changedsettings — keys updatedkeys, versionapi caches, sync (field policy)
V2 loyalty.*loyalty — points earned, level changed, reward redeemed, points expiringresellerId, points, levelnotifications, analytics

delegate.position is the one event that bypasses the outbox: one ping every 5 minutes per delegate is low-value and last-value-wins, so after the LocationPing insert commits, the api publishes it straight to the back-office stream. A lost position is replaced by the next one.

SSE projection

Both streams carry a small set of stable types derived from domain events; clients ignore types they do not know, so types can be added without breaking anyone. The authoritative lists are on admin.events.stream and events.stream.

Back-office stream — GET /v1/admin/events, filtered by permission; events are invalidation hints.

SSE typeFrom domain eventsRequired permissionDashboard reaction
order.createdorder.submitted, assisted_order.awaiting_confirmationorders.viewToast; refresh the orders list, the « commandes à confirmer » queue (BO-41) and the KPIs
order.status_changedorder.to_confirm, order.validated, order.refused, order.modified, order.status_changed, order.cancelled, assisted_order.confirmed, assisted_order.rejected, assisted_order.expiredorders.viewRefresh order detail, lists, queue and delivery board
return.requestedreturn.requestedorders.viewRefresh the returns queue (BO-69)
claim.createdticket.created with type = AFTER_SALESsupport.viewRefresh the claims queue
visit.checked_invisit.checked_infield.viewRefresh tour live detail (BO-23) and the live map
visit.completedvisit.completed, visit.not_donefield.viewSame
tour.startedtour.started, tour.resumedfield.viewRefresh live delegates
tour.closedtour.closedfield.viewRefresh tours and the field dashboard
delegate.positiondelegate.positionfield.viewMove the marker (payload used directly)
stock.alertstock.low, stock.outstock.viewAlerts badge, stock overview
account.pendingaccount.created (reseller, PENDING_VALIDATION)crm.validateValidation queue badge

Own stream — GET /v1/events, routed to the recipients of each event (the reseller's users; the delegate for assisted orders); events carry the new state.

SSE typeFrom domain eventsDataApp reaction
order.status_changedorder.validated, order.refused, order.status_changed, order.cancelled, assisted_order.confirmed, assisted_order.rejected, assisted_order.expiredorderId, number, status, at, public reasonUpdate the order card, timeline and status counters live (ORD-15)
order.modifiedorder.modifiedorderId, new totalsRefetch the order detail
assisted_order.awaiting_confirmationassisted_order.awaiting_confirmationorderId, delegateFirstName, expiresAtShow the confirmation banner
ticket.repliedticket.replied (staff replies only)ticketId, messageIdRefresh the conversation
return.status_changedreturn.status_changedreturnId, status, atUpdate the return tracking
notification.creatednotification.creatednotificationId, unreadCountUpdate the bell badge and the inbox

The own stream never carries back-office-only data (internal notes, purchase prices, other resellers). Both streams send resync when Last-Event-ID is older than their retention window.

On this page