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:
| File | Holds | May import |
|---|---|---|
index.ts | The module's public surface: the service functions and types other modules may call. The only file another module imports. | its own files |
router.ts | The 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.ts | Business 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.ts | Prisma queries on the module's own tables only, every query taking a Scope argument when the data is scoped. | @hoco/db |
events.ts | Event types the module emits (typed payloads, schema version) and the handlers it subscribes to other modules' events. | own service |
jobs.ts | Job processors and job schedulers owned by the module. | own service |
policies.ts | Permission keys per operation, scope rules, transition guards, field-level visibility (e.g. purchase prices). | — |
Rules every module follows.
- 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. - 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.
- 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). - Contract first. A route exists in
packages/contractsbefore it exists in a router; routers never add fields the contract does not declare (API reference). - Permission and scope in one place.
policies.tsdeclares 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). - 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. - 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
| Module | In one line | Main tasks | Scope |
|---|---|---|---|
| identity | Accounts, Better Auth sessions, OTP, devices, guest access | SRV-05 | V1 |
| access | Roles, permissions, scope resolution | SRV-06 | V1 |
| geo | Wilayas, communes, zones, delegate coverage, distances, nearest commune | SRV-10 | V1 |
| crm | Resellers and prospects, customer type, lifecycle, assignment, timeline, follow-ups | SRV-14 | V1 |
| catalog | Brands, category tree, products, variants and their three prices, attributes, facets, guest-safe search | SRV-11 | V1 |
| pricing | Price selection by customer type, VAT, price history | SRV-12 | V1 (agreements V2) |
| inventory | Stock levels, reservations, deductions, automatic restock, ledger, alerts | SRV-13 | V1 |
| promotions | Promotions, targeting, schedule, caps, banners | SRV-19 | V1 (packs V2) |
| cart | Server-side cart, basket evaluation, checkout | SRV-15 | V1 |
| orders | Order lifecycle with mandatory back-office confirmation, assisted orders, returns | SRV-16, SRV-20 | V1 |
| fulfillment | Deliveries, picking, carriers, proof of delivery | SRV-17 | V1 (carrier API V2) |
| billing | Invoices, payments, credit, credit notes | SRV-18, SRV-20 | V1 (detailed credit V2) |
| loyalty | Points, levels, rewards, vouchers | SRV-30 | V2 |
| field | Delegates, objectives, tours, visits, reports, location | SRV-21 | V1 (optimisation V2) |
| invitations | Invitation codes, links, attribution funnel | SRV-22 | V1 |
| support | Tickets, after-sales claims, support regions, announcements | SRV-24, SRV-20 | V1 |
| notifications | Event → channel matrix, inbox, push, SMS, e-mail, the two SSE streams | SRV-08, SRV-26 | V1 (WhatsApp API V2) |
| analytics | KPIs, reports, aggregates, exports, global search | SRV-25 | V1 (advanced V2) |
| files | Uploads, signed downloads, thumbnails | SRV-07 | V1 |
| audit | Activity journal | SRV-07 | V1 |
| settings | Business parameters, reference lists, app versions, business numbers | SRV-07 | V1 |
| sync | Idempotency, offline batch replay, delta feed | SRV-23 | V1 |
| jobs | Queues, schedulers, outbox dispatcher | SRV-08 | V1 |
| integrations | Provider adapters, data imports, V2 external systems | SRV-27 | V1 + 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.
| Owns | User, Session, Account, Verification, Device |
| Operations | Group 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 |
| Emits | account.created, account.validated, account.rejected, account.suspended, account.reactivated, account.disabled, account.password_changed |
| Consumes | — (the CRM validation command calls identity.activateAccount() synchronously) |
| Jobs | identity.purgeExpiredSessions (hourly) |
| Settings | catalog.pricesVisibleBeforeValidation (prices of the declared type right away — D-18) |
| Integrations | SMS (OTP, through the notify-sms queue), e-mail (staff invitations, resets) |
| Used by | RA, 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).
| Owns | Role, Permission, RolePermission, UserRole |
| Operations | admin.roles.list, admin.roles.updatePermissions, admin.permissions.list; the permission and scope middleware used by every route |
| Emits | role.permissions_changed |
| Consumes | delegate.zones_changed, zone.updated, reseller.assigned (scope cache invalidation) |
| Jobs | — |
| Used by | BO (roles matrix BO-62); every app through me.get |
Risks:
- A repository query that forgets the scope leaks data.
repo.tsfunctions take a typedScopeargument, 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).
| Owns | Wilaya, Commune, Zone, ZoneArea, DelegateZone |
| Operations | reference.wilayas, reference.communes (ETag), delegate.zone, admin.zones.crud |
| Emits | zone.updated, delegate.zones_changed |
| Consumes | — |
| Integrations | Maps provider (geocoding, optional) |
| Used by | every 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.
| Owns | Reseller, ResellerAddress, ResellerDocument, ResellerPhoto, ResellerStatusChange, ResellerAssignment, Interaction, CrmTask, Segment, ResellerSegment |
| Operations | Reseller 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 |
| Emits | reseller.created, reseller.status_changed, reseller.assigned, reseller.customer_type_changed, reseller.validated, reseller.rejected, task.assigned, task.completed, task.due |
| Consumes | account.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) |
| Jobs | crm.inactivityScan (daily 02:00), crm.followUpReminders (daily 07:30) |
| Settings | crm.inactivityDays (60), crm.validationReminderHours, crm.followUpReminderTime |
| Used by | BO, 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_EXISTSconflict 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).
| Owns | Brand, Category, Product, ProductVariant, ProductImage, AttributeDefinition, VariantAttributeValue, ProductVisibilityRule, Favorite |
| Operations | Optional 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 |
| Emits | product.status_changed |
| Consumes | stock.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) |
| Settings | catalog.pricesVisibleBeforeValidation, catalog.newArrivalDays (the "new arrivals" filter) |
| Integrations | Public bucket for product images (via files) |
| Used by | RA, 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 BYon 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.
| Owns | PriceChange; V2 PriceList, PriceListItem |
| Operations | admin.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 | — |
| Settings | pricing.defaultVatRate (1900), pricing.displayTaxMode (TTC or HT shown to resellers — D-17) |
| Used by | catalog, 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_CHANGEDwith 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.
| Owns | Warehouse, StockLevel, StockMovement, InventorySession, InventoryLine |
| Operations | admin.stock.levels, admin.stock.movements, admin.stock.receipt, admin.stock.adjust, admin.inventory.validate. Internal: reserve, release, deduct, reenter, restockReturn, availability |
| Emits | stock.low, stock.out, stock.restocked |
| Consumes | — (called synchronously by orders) |
| Jobs | inventory.stockAlertDigest (daily 08:00) |
| Settings | stock.reserveOn (default VALIDATED), stock.deductOn (default SHIPPED) — D-22; stock.lowThresholdDefault |
| Integrations | V2 stock-system synchronisation through integrations (STK-09) |
| Used by | BO; RA, RW, DA through catalog and cart |
Risks:
- Overselling under concurrency: a reservation is a conditional
UPDATE … SET reserved = reserved + q WHERE physical - reserved >= qon theStockLevelrow, 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.
| Owns | Promotion, PromotionScope, PromotionAudience, Banner; V2 Pack, PackItem |
| Operations | Optional 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 |
| Emits | promotion.started, promotion.ended |
| Consumes | order.refused, order.cancelled, return.status_changed (give back cap consumption) |
| Jobs | promotions.applySchedule (every minute) |
| Settings | stacking mode and priorities (PRM-03) |
| Used by | RA, 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/endsAtitself, 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.
| Owns | Cart, CartItem |
| Operations | cart.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 | — |
| Settings | orders.minAmount, orders.minQtyPerLine, orders.maxQtyPerLine, enabled payment methods, delivery fees (delivery.feeMode, delivery.freeThreshold, DLV-06), credit policy |
| Used by | RA, 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_CHANGEDwith 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).
| Owns | Order, OrderItem, OrderStatusChange, OrderChange, ReturnRequest, ReturnItem |
| Operations | Reseller: 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 |
| Emits | order.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) |
| Jobs | orders.expireAssisted (every 15 minutes, with the reminder 24 h before expiry), orders.staleConfirmationAlert (every 15 minutes in working hours, proposal) |
| Settings | orders.assistedConfirmationHours (72, D-23), orders.cancelAllowedStatuses (SUBMITTED, PENDING), returns.windowDays (7, D-35), stock.reserveOn, stock.deductOn, billing.invoiceOn |
| Used by | RA, 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 gets409 ORDER_TRANSITION_NOT_ALLOWEDor409 VERSION_CONFLICT. - Assisted-order expiry against a late confirmation: the confirmation checks
assistedExpiresAtin 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.
| Owns | Delivery, Carrier |
| Operations | admin.deliveries.list, admin.deliveries.update, admin.deliveries.pickingList, admin.carriers.crud; internal createDelivery, recordProof, markFailed |
| Emits | delivery.failed |
| Consumes | — (called by orders) |
| Jobs | V2 integrations.pollCarrierTracking when a carrier has no webhooks |
| Settings | delivery.feeMode, delivery.flatFee, delivery.wilayaFees, delivery.freeThreshold (D-20) |
| Integrations | V2 carrier APIs (DLV-05) |
| Used by | BO |
Risks:
- Two status fields (order and delivery) can drift:
ordersis 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.
| Owns | Invoice, InvoiceLine, Payment, PaymentAllocation, CreditAccount, CreditLimitChange, CreditNote; V2 PaymentReminder |
| Operations | finance.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 |
| Emits | invoice.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) |
| Jobs | invoices.markOverdue (daily 01:00, V2), V2 billing.paymentReminders |
| Settings | billing.invoiceOn (default SHIPPED), enabled payment methods, credit-exceeded policy (BLOCK or REQUIRE_APPROVAL, cahier §4.7) |
| Integrations | V2 accounting export (FIN-09) |
| Used by | RA, RW, BO |
Risks:
- Legal numbering:
FAC-and credit-note numbers come from a transactionalNumberSequencerow 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
financeorpricingpermissions.
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).
| Owns | LoyaltyAccount, LoyaltyTransaction, Reward, RewardRedemption, Voucher |
| Operations | loyalty.summary, loyalty.redeem, admin.loyalty.settings, admin.loyalty.adjust, admin.rewards.crud |
| Emits | loyalty.points_earned, loyalty.level_changed, loyalty.reward_redeemed, loyalty.points_expiring |
| Consumes | order.status_changed (DELIVERED) or invoice.paid per loyalty.earnOn, order.cancelled, return.status_changed, credit_note.issued (reversals) |
| Jobs | loyalty.expirePoints (daily 01:30), loyalty.recomputeLevels (monthly) |
| Used by | RA, RW, BO |
| Tasks | SRV-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.
| Owns | DelegateProfile, Objective, Tour, TourStatusChange, Visit, VisitType, ChecklistTemplate, VisitChecklistItem, VisitPhoto, VisitReport, LocationPing |
| Operations | Delegate: 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 |
| Emits | tour.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) |
| Consumes | account.suspended (suspend the delegate's tour in progress, reason ACCOUNT_SUSPENDED) |
| Jobs | tours.activateToday (daily 00:05), field.autoSuspendOpenTours (daily 21:00, proposal), field.purgeLocationPings (daily 03:30) |
| Settings | field.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 |
| Integrations | none in V1 (haversine only); V2 routing engine (FLD-18) |
| Used by | DA, 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).
| Owns | Invitation, InvitationEvent |
| Operations | Public: invitations.resolve, invitations.attribute, invitations.landing (served by the web app). Delegate: delegate.invitations.create, delegate.invitations.markShared. Back-office: admin.invitations.list |
| Emits | invitation.created, invitation.shared, invitation.step_completed |
| Consumes | account.created (ACCOUNT_CREATED, PHONE_VERIFIED), order.submitted (FIRST_ORDER); the first catalog call of a new reseller records CATALOG_VIEWED |
| Used by | DA, 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.jsonare 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:
| Resolution | What the module does, in the resolution transaction |
|---|---|
REPLACEMENT | orders.createReplacementOrder() — a free order linked to the ticket, which then follows the normal flow, back-office confirmation included |
RETURN | orders.createReturnFromClaim() — a return request already approved for the claimed lines; reception restocks and issues the credit note |
CREDIT_NOTE | billing.issueCreditNote() without physical return |
REPAIR | ticket resolved; the repair is tracked in messages |
REJECTED | ticket 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.
| Owns | Ticket, TicketItem, TicketMessage, SupportRegion, SupportRegionWilaya, Announcement, AnnouncementRead |
| Operations | Reseller: 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 |
| Emits | ticket.created, ticket.replied, ticket.assigned, ticket.status_changed, ticket.resolved, ticket.rejected, announcement.published |
| Consumes | order.status_changed and return.status_changed of a claim's replacement order or return (system message on the ticket) |
| Jobs | support.publishScheduledAnnouncements (every minute), support.autoClose (daily 03:00) |
| Settings | support.defaultWhatsappNumber, support.autoCloseDays, FAQ entries; regions and numbers are D-36 |
| Used by | RA, RW (WhatsApp button for guests too), DA (announcements), BO |
Risks:
- Internal notes leaking to resellers: a visibility flag on
TicketMessageand 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.
| Owns | Notification, NotificationDelivery, NotificationTemplate, NotificationRule, NotificationPreference |
| Operations | notifications.list, notifications.unreadCount, notifications.preferences.update, events.stream, admin.events.stream, admin.notifications.rules, admin.notifications.templates, admin.notifications.log |
| Emits | notification.created (projected only to the recipient's own stream) |
| Consumes | every event with a rule in the notifications matrix or an SSE projection |
| Jobs | notifications.reconcileDeliveries (every 5 minutes), notifications.purgeHistory (daily); queues notify-push, notify-sms, notify-email |
| Integrations | FCM, SMS gateway, SMTP; V2 WhatsApp Business (NOT-08) |
| Used by | every app |
| Tasks | SRV-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.
| Owns | DashboardLayout, ExportJob; reporting views and daily aggregate tables created by SQL migrations (assumption — not Prisma models; to confirm in the database design) |
| Operations | admin.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 |
| Emits | export.completed |
| Consumes | order.*, 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 |
| Jobs | analytics.dailyAggregates (daily 02:00), analytics.recomputeDirty (debounced, on demand), exports.expire (daily 04:45) |
| Settings | exports.retentionDays (7) |
| Used by | BO, DA (performance) |
| Tasks | SRV-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.
| Owns | File |
| Operations | files.createUpload, files.completeUpload, files.downloadUrl |
| Emits | — |
| Jobs | files.purgeOrphans (hourly); queue files |
| Integrations | S3-compatible storage through integrations |
| Used by | every 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.
| Owns | AuditLog |
| Operations | admin.audit.list |
| Emits / consumes | — |
| Jobs | audit.purgeExpired (monthly; 3-year retention proposed by the database design, to validate with HOCO) |
| Used by | BO (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).
| Owns | Setting, ReferenceItem, AppVersionPolicy, NumberSequence |
| Operations | appConfig.get, reference.lists, admin.settings.get, admin.settings.update, admin.referenceLists.crud, admin.appVersions.crud |
| Emits | settings.changed |
| Consumes | — |
| Used by | every app |
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.
| Owns | IdempotencyRecord |
| Operations | sync.batch, sync.delta, sync.locationPings |
| Emits | — |
| Consumes | tour.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 |
| Jobs | sync.purgeIdempotencyRecords (daily 03:45) |
| Used by | DA (idempotency: every app) |
| Tasks | SRV-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.
| Owns | OutboxEvent |
| Operations | none public; internal health and metrics on the worker port |
| Emits / consumes | dispatches every event |
| Jobs | jobs.purgeDispatchedOutbox (daily 04:15), ops.verifyBackup (daily 06:00) |
| Used by | every module |
| Tasks | SRV-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.
| Owns | ImportJob |
| Operations | admin.imports.create, admin.imports.get, admin.products.import; V2 webhooks (carriers, WhatsApp) to add to the contract |
| Emits | import.completed |
| Jobs | queue imports; V2 integrations.syncStock, integrations.pollCarrierTracking |
| Settings | provider credentials from the environment (configuration) |
| Used by | BO; every module through the adapters |
| Tasks | SRV-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.
| Event | Emitted by — when | Key payload fields | Consumers |
|---|---|---|---|
account.created | identity — a user account is created (reseller sign-up or claim; staff or delegate created by an admin) | userId, userType, accountStatus, resellerId, phoneVerified, invitationCode | crm, invitations, notifications (validators; credentials), SSE account.pending, analytics |
account.validated | identity — pending reseller account activated after back-office approval | userId, resellerId, validatedBy | notifications (reseller) |
account.rejected | identity — reseller account refused | userId, resellerId, reasonCode | notifications (reseller) |
account.suspended · account.reactivated · account.disabled | identity — account status changed by an admin (sessions revoked on suspension) | userId, userType, reasonCode | field (suspend tour in progress), notifications |
account.password_changed | identity — password changed or reset | userId, via | notifications (SECURITY) |
role.permissions_changed | access — a role's permission set replaced | roleKey, added, removed | api permission caches |
zone.updated · delegate.zones_changed | geo — zone communes or a delegate's zones changed | zoneId, delegateUserId, communeIds | access (scope cache), sync (resellers scope reset + hint) |
reseller.created | crm — record created, any source | resellerId, number, source, createdByDelegateId, customerType, wilayaCode | analytics, notifications (validators for field prospects) |
reseller.status_changed | crm — lifecycle status changed | resellerId, from, to, automatic, reasonCode | notifications (assigned delegate), analytics |
reseller.assigned | crm — delegate or zone assignment changed | resellerId, fromDelegateId, toDelegateId, zoneId | notifications (new delegate), sync (both delegates), access |
reseller.customer_type_changed | crm — back-office changed the customer type | resellerId, from, to | notifications (reseller), analytics |
reseller.validated · reseller.rejected | crm — point-of-sale validation decided | resellerId, decidedBy, reasonCode, hasAccount | notifications (creating delegate) |
task.assigned | crm — follow-up created for or reassigned to a user | taskId, resellerId, assigneeId, type, dueDate, source | notifications (assignee), sync |
task.completed | crm — task done | taskId, resellerId, outcome | analytics |
task.due | crm job crm.followUpReminders — daily digest per assignee | assigneeId, date, dueCount, overdueCount | notifications (push + inbox) |
product.status_changed | catalog — status changed, including automatic OUT_OF_STOCK | productId, from, to, automatic | analytics |
stock.low · stock.out | inventory — availability crosses the threshold or reaches 0 | variantId, warehouseId, threshold | catalog (stock.out), notifications (stock managers), SSE stock.alert |
stock.restocked | inventory — available goes from 0 to positive (receipt, cancellation, return reception) | variantId, warehouseId | catalog (back to ACTIVE) |
promotion.started · promotion.ended | promotions — schedule boundary, cap reached or manual change | promotionId, type, reason, startsAt, endsAt | notifications (targeted resellers, PROMOTIONAL), analytics |
order.submitted | orders — an order is placed, any channel, including a reseller-confirmed assisted order | orderId, number, resellerId, channel, status, totalTtc, isFirstOrder, sourceDelegateId | notifications (reseller « Commande envoyée »), crm, invitations, analytics, SSE order.created |
order.to_confirm | orders — an order enters the back-office confirmation queue (SUBMITTED, or PENDING on hold) | orderId, number, status, pendingReasons, queuedAt | notifications (users with orders.validate, in-app), SSE order.status_changed |
order.validated | orders — confirmed by the back-office; stock reserved | orderId, validatedBy, overrides | notifications (reseller; delegate if assisted), analytics, SSE (both streams) |
order.refused | orders — refused with a reason | orderId, reasonCode | notifications, promotions (caps), analytics, SSE (both streams) |
order.modified | orders — controlled modification of lines | orderId, changeId, reasonCode, totalTtcBefore, totalTtcAfter | notifications (reseller), analytics, SSE (both streams) |
order.status_changed | orders — PREPARING, READY, SHIPPED, DELIVERED, RETURNED | orderId, from, to | notifications, crm (ACTIVE), support (claim follow-up), loyalty (V2), analytics, SSE (both streams) |
order.cancelled | orders — cancelled by the reseller, the back-office or the system; stock released or re-entered | orderId, by, reasonCode | notifications, promotions (caps), crm, loyalty (V2), analytics, SSE (both streams) |
assisted_order.awaiting_confirmation | orders — a delegate sends an assisted order to the reseller | orderId, resellerId, delegateId, expiresAt | notifications (reseller push + inbox), SSE (reseller's own stream; back-office order.created) |
assisted_order.confirmed | orders — confirmed by the reseller or by the administration (the order then emits order.submitted and order.to_confirm) | orderId, by | notifications (delegate), SSE order.status_changed |
assisted_order.rejected | orders — rejected by the reseller | orderId, reasonCode | notifications (delegate), SSE order.status_changed |
assisted_order.reminder_due | orders job — 24 h before expiry | orderId, resellerId, expiresAt | notifications (reseller) |
assisted_order.expired | orders job — no answer within 72 h | orderId, delegateId | notifications (delegate, reseller), SSE order.status_changed |
return.requested | orders — reseller requests a return, or an after-sales claim resolves as RETURN | returnId, number, orderId, resellerId, ticketId | notifications (back-office), crm, SSE return.requested |
return.status_changed | orders — APPROVED, IN_TRANSIT, RECEIVED, REFUNDED, REJECTED | returnId, orderId, from, to, reasonCode | notifications (reseller), promotions, support, loyalty (V2), analytics, SSE (reseller's own stream) |
delivery.failed | fulfillment — delivery attempt failed | deliveryId, orderId, reasonCode | notifications (reseller, delivery managers), analytics |
invoice.issued · invoice.paid · invoice.cancelled | billing — invoice lifecycle | invoiceId, number, orderId, resellerId, totalTtc | notifications (reseller), loyalty (V2, invoice.paid), analytics |
credit_note.issued | billing — credit note for a return reception or an after-sales resolution | creditNoteId, number, resellerId, returnId, ticketId, amountTtc | notifications (reseller), crm, loyalty (V2), analytics |
payment.declared | billing — reseller declares a transfer, CCP or BaridiMob payment | paymentId, number, resellerId, amount, method | notifications (finance) |
payment.recorded · payment.confirmed · payment.rejected | billing — payment recorded by finance, declaration confirmed or rejected | paymentId, resellerId, amount, reasonCode | notifications (reseller), crm, analytics |
invoice.overdue | billing job — V2 | invoiceId, daysOverdue, balance | notifications |
tour.planned · tour.updated · tour.cancelled · tour.reopened | field — back-office planning changes | tourId, number, delegateId, date, changes, reasonCode | notifications (delegate), sync |
tour.reordered | field — the delegate reorders a recommended tour | tourId, version | analytics; the back-office live view refreshes, the delegate's other devices get it through the delta feed |
tour.to_start | field job tours.activateToday | tourId, delegateId | sync (silent hint), analytics |
tour.started · tour.resumed | field — delegate starts or resumes | tourId, delegateId, occurredAt | SSE tour.started, analytics |
tour.suspended | field — delegate, end-of-day job or account suspension | tourId, reasonCode, by | analytics |
tour.closed | field — closed with the final report | tourId, delegateId, visitsDone, visitsNotDone, signups, ordersCount, revenueTtc, km | SSE tour.closed, analytics |
visit.added | field — unplanned visit (delegate) or stop added (back-office) | visitId, tourId, resellerId, addedBy | sync (when added by the back-office), analytics |
visit.checked_in | field — arrival recorded | visitId, tourId, resellerId, distanceMeters, withinTolerance, occurredAt | SSE visit.checked_in, analytics |
visit.started | field — visit timer started (explicitly, or implicitly by the first checklist, note, photo or report) | visitId, tourId, occurredAt | analytics |
visit.completed | field — visit DONE and locked | visitId, tourId, resellerId, durationSeconds, interestLevel, nextAction | crm, SSE visit.completed, analytics |
visit.not_done | field — not carried out, with reason | visitId, tourId, reasonCode | crm, SSE visit.completed (status NOT_DONE), analytics |
visit.unlocked | field — back-office unlocks a finished visit | visitId, unlockedBy, until | notifications (delegate), sync |
delegate.position | field — accepted location ping (ephemeral: published to Redis, not stored in the outbox) | delegateId, tourId, lat, lng, accuracy, recordedAt | SSE delegate.position only |
invitation.created · invitation.shared | invitations | invitationId, resellerId, delegateId, channel | analytics |
invitation.step_completed | invitations — funnel step reached | invitationId, resellerId, delegateId, step | crm, notifications (delegate), analytics |
ticket.created | support — ticket or after-sales claim opened | ticketId, number, resellerId, type, orderId | notifications (support staff), crm, analytics, SSE claim.created when type = AFTER_SALES |
ticket.replied | support — public reply by staff or reseller (internal notes excluded) | ticketId, messageId, authorType | notifications (the other party), SSE (reseller's own stream) |
ticket.assigned | support | ticketId, assigneeId | notifications (assignee) |
ticket.status_changed | support — status changed (IN_PROGRESS, WAITING_RESELLER, CLOSED…) | ticketId, from, to | notifications (reseller), analytics |
ticket.resolved | support — after-sales claim or ticket resolved | ticketId, resolution, replacementOrderId, returnId, creditNoteId | notifications (reseller), crm, analytics |
ticket.rejected | support — claim refused with a reason | ticketId, reasonCode | notifications (reseller), analytics |
announcement.published | support — published now or at its scheduled time | announcementId, audience, category | notifications (audience push + inbox), sync (delegates) |
notification.created | notifications — an inbox entry was created for a user | notificationId, userId, category, unreadCount | SSE (recipient's own stream) |
export.completed · import.completed | analytics · integrations — job finished | jobId, type, status, requestedBy, counts | notifications (requester in-app) |
settings.changed | settings — keys updated | keys, version | api caches, sync (field policy) |
V2 loyalty.* | loyalty — points earned, level changed, reward redeemed, points expiring | resellerId, points, level | notifications, 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 type | From domain events | Required permission | Dashboard reaction |
|---|---|---|---|
order.created | order.submitted, assisted_order.awaiting_confirmation | orders.view | Toast; refresh the orders list, the « commandes à confirmer » queue (BO-41) and the KPIs |
order.status_changed | order.to_confirm, order.validated, order.refused, order.modified, order.status_changed, order.cancelled, assisted_order.confirmed, assisted_order.rejected, assisted_order.expired | orders.view | Refresh order detail, lists, queue and delivery board |
return.requested | return.requested | orders.view | Refresh the returns queue (BO-69) |
claim.created | ticket.created with type = AFTER_SALES | support.view | Refresh the claims queue |
visit.checked_in | visit.checked_in | field.view | Refresh tour live detail (BO-23) and the live map |
visit.completed | visit.completed, visit.not_done | field.view | Same |
tour.started | tour.started, tour.resumed | field.view | Refresh live delegates |
tour.closed | tour.closed | field.view | Refresh tours and the field dashboard |
delegate.position | delegate.position | field.view | Move the marker (payload used directly) |
stock.alert | stock.low, stock.out | stock.view | Alerts badge, stock overview |
account.pending | account.created (reseller, PENDING_VALIDATION) | crm.validate | Validation 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 type | From domain events | Data | App reaction |
|---|---|---|---|
order.status_changed | order.validated, order.refused, order.status_changed, order.cancelled, assisted_order.confirmed, assisted_order.rejected, assisted_order.expired | orderId, number, status, at, public reason | Update the order card, timeline and status counters live (ORD-15) |
order.modified | order.modified | orderId, new totals | Refetch the order detail |
assisted_order.awaiting_confirmation | assisted_order.awaiting_confirmation | orderId, delegateFirstName, expiresAt | Show the confirmation banner |
ticket.replied | ticket.replied (staff replies only) | ticketId, messageId | Refresh the conversation |
return.status_changed | return.status_changed | returnId, status, at | Update the return tracking |
notification.created | notification.created | notificationId, unreadCount | Update 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.
Architecture and data
Application boundaries, shared packages and their dependency rules, the system of record per module, consistency, caching, file flows, the request lifecycle inside the server, errors, observability and configuration.
Database design
System-wide data model of the HOCO ecosystem: conventions, domain map, core diagram, numbering, migrations, seeds, indexing, volumes, retention and backups.