HOCO B2B
SRSDatabase design

Database design

System-wide data model of the HOCO ecosystem: conventions, domain map, core diagram, numbering, migrations, seeds, indexing, volumes, retention and backups.

Status — baseline v0, to be frozen in SRV-02

This is the data-model baseline that SRV-02 implements as the Prisma 7 schema and its first migration. It already applies the product decisions of 17 Oct 2026 (AUTHORING §1b: three customer-type prices, attributes and facets, support regions, after-sales and returns in V1, availability-only stock). The API reference mirrors these pages field for field; any change after the freeze goes through an expand/contract migration and an update of both.

PostgreSQL 17 is the single database of the ecosystem (cahier §2). It holds 106 models: 96 created by the V1 migration and 10 documented as the V2 baseline (not created before P4). Every model below uses exactly the canonical names of AUTHORING §3.

Conventions

Schema layout and tooling

prisma.config.ts
schema.prisma
enums.prisma
identity.prisma
geography.prisma
crm.prisma
catalog.prisma
stock.prisma
orders.prisma
finance.prisma
promotions.prisma
field.prisma
invitations.prisma
support.prisma
platform.prisma
  • Prisma 7, multi-file schema: one file per domain page of this section, plus schema.prisma (generator prisma-client with an explicit output, datasource postgresql) and enums.prisma (every enum, mirroring content/spec/enums.json). prisma.config.ts points schema to prisma/schema and migrations.path to prisma/migrations; the connection URL comes from the environment and the client uses the @prisma/adapter-pg driver adapter.
  • Names: models PascalCase singular (OrderItem); tables snake_case plural through @@map("order_items"); every camelCase field maps to its snake_case column through @map (subtotalHt → subtotal_ht) — this rule is mechanical, so column names are not repeated in the tables; Postgres enum types are snake_case singular through @@map (OrderStatus → order_status); enum values are UPPER_SNAKE_CASE exactly as in enums.json, identical in the database, the API and the apps.
  • Prisma relation fields are named after the foreign key without Id (resellerId → reseller, assignedDelegateId → assignedDelegate); every model page lists them with their onDelete.

Keys and identifiers

  • Primary key: id String @id @default(uuid(7)) @db.Uuid. UUIDv7 is time-ordered, so ORDER BY id DESC is a valid cursor for newest-first lists and B-tree inserts stay local.
  • Client-generated ids (offline creation, AUTHORING §3): Reseller (field prospects), Visit (ad-hoc), VisitPhoto, VisitReport, Interaction (notes), ResellerPhoto and File accept a UUIDv7 sent by the app; the server checks the version, upserts idempotently and answers 409 if the id exists for another creator.
  • Exceptions: Wilaya.code (official code), DelegateProfile.id (= the delegate's User.id), Setting.key, NumberSequence (key, year) and the link tables with a composite primary key — RolePermission, UserRole, DelegateZone, ResellerSegment, Favorite, AnnouncementRead, SupportRegionWilaya.
  • Business numbers come from NumberSequence (see numbering).

Timestamps

  • createdAt DateTime @default(now()) and updatedAt DateTime @updatedAt on every table, with three deliberate exceptions: append-only and link rows have no updatedAt because they are never updated — RolePermission, UserRole, ZoneArea, DelegateZone, ResellerStatusChange, ResellerAssignment, ResellerSegment, ProductVisibilityRule, Favorite, PriceChange, StockMovement, OrderStatusChange, OrderChange, InvoiceLine, CreditLimitChange, PromotionScope, PromotionAudience, PackItem (V2), LoyaltyTransaction (V2), TourStatusChange, VisitPhoto, LocationPing, InvitationEvent, TicketMessage, SupportRegionWilaya, AuditLog; Setting has no createdAt (scaffold shape); AnnouncementRead uses readAt as its creation time.
  • Every DateTime is @db.Timestamptz(3) (UTC instant); date-only values are @db.Date (tourDate, dueDate, periodStart). Business days are computed in Africa/Algiers.
  • Offline actions keep two times: occurredAt (device) and the server receipt time. On append-only rows the receipt time is createdAt — exposed by the API as receivedAt; on rows that change state (Visit) the key receipt time has its own column (checkInReceivedAt).

Value types

KindTypeRule
MoneyIntInteger centimes of DZD, HT unless the column name ends in Ttc or is a VAT amount (vatTotal, vatAmount). Int holds 21 474 836,47 DA per amount, enough for any order, invoice or payment; sums are computed as bigint. Exception: Objective.targetValue is BigInt.
QuantitiesIntUnits; signed only in ledgers (StockMovement.quantity, V2 LoyaltyTransaction.points).
PercentagesIntBasis points: 1900 = 19 %. Columns end in Bp or are named vatRate.
CoordinatesDecimal @db.Decimal(9,6)WGS 84 degrees in lat/lng pairs (checkInLat, checkInLng…), always both or neither (CHECK); accuracy and distances in metres as Int (…AccuracyMeters, distanceMeters). The API serialises them as JSON numbers.
PhonesString @db.VarChar(16)E.164 after normalisation (+213555123456).
E-mailsString @db.VarChar(254)Lower-cased on write.
Codes and numbers@db.VarChar(n) / @db.Char(n)Length is part of the contract (REV-000123, invitation codes, hashes). Other text is text, its maximum length enforced by Zod.
EnumsPrisma enumNamed exactly as in enums.json; new enums proposed by this design are listed in proposed enums.
ArraysString[], String[] @db.Uuid, Enum[]Only for tags, target lists and attachment ids; arrays of ids have no foreign key and are validated on write.
JSONJsonOnly where documented on the model page, each with a named Zod schema in packages/contracts (see JSON columns).

Deletion, history and concurrency

  • No hard deletes of business records. Records with a lifecycle change status (DISABLED, ARCHIVED, CANCELLED…); reference and configuration rows get archivedAt or isActive = false; nothing referenced is ever removed (SEC-06).
  • Rows that may be deleted (technical or link rows, with the change audited where it matters): Session, Verification, Device, CartItem, Favorite, ProductImage, ProductVisibilityRule, RolePermission, UserRole, DelegateZone, ZoneArea, ResellerSegment, SupportRegionWilaya, PaymentAllocation (replaced as a set), PromotionScope and PromotionAudience of an unpublished promotion, and a DRAFT assisted order discarded by its delegate.
  • onDelete policy: Cascade only for composition (lines of an order or invoice, items of a cart, areas of a zone…), SetNull for optional pointers to replaceable rows (avatar or image files, templates, devices), Restrict everywhere else. Since parents are never deleted, Cascade fires only on the allowed deletions above.
  • History tables: ResellerStatusChange, ResellerAssignment, OrderStatusChange, OrderChange, TourStatusChange, StockMovement, PriceChange, CreditLimitChange, InvitationEvent, AuditLog (and V2 LoyaltyTransaction) — see the lifecycles page.
  • Optimistic locking with version Int @default(1) on Order, Tour, Visit, CreditAccount (and Reseller, edited offline by delegates): every update is … SET version = version + 1 WHERE id = :id AND version = :expected; zero rows → 409 VERSION_CONFLICT (or a sync CONFLICT item). Counters that change under concurrency (stock levels, promotion caps, number sequences) use atomic UPDATE … SET x = x + :n instead.

JSON columns

ColumnContentZod schema
Product.specstechnical characteristics, display orderProductSpecs
AttributeDefinition.allowedValuesvalues of an ENUM attributeAttributeAllowedValues
Order.addressSnapshot · appliedPromotions · controlResultsfrozen delivery address · promotions applied · last control runAddressSnapshot · AppliedPromotions · OrderControlResults
OrderStatusChange.metadata · OrderChange.before/aftertransition details · modification diffOrderTransitionMeta · OrderChangeDiff
Tour.summaryfrozen closing reportTourSummary
VisitType.reportFields · ChecklistTemplate.itemsreport field configuration · checklist itemsVisitReportFieldConfig · ChecklistItems
Notification.datadeep link and contextNotificationData
InvitationEvent.metadataraw attribution dataInvitationAttribution
Setting.valueone schema per keySettingsRegistry
AuditLog.diff · IdempotencyRecord.responseBody · OutboxEvent.payloadbefore/after · stored response · versioned eventAuditDiff · (response schema) · one per event key
ExportJob.filters · ImportJob.report · DashboardLayout.cardsexport filters · row report · role cardsper report · ImportReport · DashboardCards

Derived values that are not stored

ValueComputed fromWhy not stored
Available stockphysicalQty − reservedQtytwo columns written in the same statement — a third could only drift; expression index covers alerts
Reseller-facing availabilityavailable > 0 → IN_STOCK, else OUT_OF_STOCKquantities never leave the back-office
Caller's pricevariant column of the reseller's customerType, then promotionsdepends on the caller; guests get none
Invoice balancetotalTtc + stampDuty − amountPaid − amountCreditedcheap, single writer of the inputs
Credit used and availableopen CREDIT invoice balances + un-invoiced CREDIT ordersspans billing and orders; computed under a row lock
Overdue task · late visit · expired invitation · not-started tourstatus + datesrule 6 of the lifecycles page
Objective achievement · KPIscounts over visits, orders, invitationslate syncs must always be reflected
Facet counts · category countsvisible products per filterper caller type; cached 5 min

Stored caches are named as such on their model page and have a single writer in the same transaction as their inputs: Reseller.lastOrderAt/lastVisitAt/supportRegionId, Invoice.amountPaid/amountCredited, CreditNote.amountApplied, Product.popularityScore, Promotion.usedQuantity, Visit.durationSeconds, Ticket.lastMessageAt, the Invitation step columns, V2 LoyaltyAccount.pointsBalance.

Domain map

Domain pageV1 modelsV2 models
Identity and accessUser, Session, Account, Verification, Role, Permission, RolePermission, UserRole, Device, NotificationPreference—
Geography and zonesWilaya, Commune, Zone, ZoneArea, DelegateProfile, DelegateZone—
CRMReseller, ResellerAddress, ResellerDocument, ResellerPhoto, ResellerStatusChange, ResellerAssignment, Interaction, CrmTask, Segment, ResellerSegment—
Catalog and pricingBrand, Category, Product, ProductVariant, ProductImage, AttributeDefinition, VariantAttributeValue, ProductVisibilityRule, Favorite, PriceChangePriceList, PriceListItem
StockWarehouse, StockLevel, StockMovement, InventorySession, InventoryLine—
Orders and deliveryCart, CartItem, Order, OrderItem, OrderStatusChange, OrderChange, Carrier, Delivery, ReturnRequest, ReturnItem—
FinanceInvoice, InvoiceLine, Payment, PaymentAllocation, CreditAccount, CreditLimitChange, CreditNotePaymentReminder
Promotions and loyaltyPromotion, PromotionScope, PromotionAudience, BannerPack, PackItem, LoyaltyAccount, LoyaltyTransaction, Reward, RewardRedemption, Voucher
Field operationsObjective, Tour, TourStatusChange, Visit, VisitType, ChecklistTemplate, VisitChecklistItem, VisitPhoto, VisitReport, LocationPing—
InvitationsInvitation, InvitationEvent—
Support and notificationsTicket, TicketItem, TicketMessage, SupportRegion, SupportRegionWilaya, Announcement, AnnouncementRead, Notification, NotificationDelivery, NotificationTemplate, NotificationRule—
PlatformFile, AuditLog, Setting, NumberSequence, ReferenceItem, AppVersionPolicy, DashboardLayout, IdempotencyRecord, OutboxEvent, ExportJob, ImportJob—

Core diagram

The core entities and how a point of sale, its orders and the field work connect. Each domain page has its own detailed diagram.

Business numbers

Human-readable numbers are allocated by NumberSequence inside the transaction that creates the record (INSERT … ON CONFLICT … DO UPDATE SET value = value + 1 RETURNING value): concurrent allocations serialise on one row and a rollback leaves no gap. Yearly sequences use the Africa/Algiers year; padding grows beyond 6 digits instead of wrapping.

RecordColumnFormatSequence key · yearAllocated when
ResellerReseller.numberREV-000123RESELLER · 0first received by the server
DelegateDelegateProfile.codeDLG-0007 (proposal)DELEGATE · 0profile creation
OrderOrder.numberCMD-2026-000158ORDER · yearthe order leaves DRAFT
Delivery noteDelivery.numberBL-2026-000123DELIVERY_NOTE · yeardelivery note generated (READY)
InvoiceInvoice.numberFAC-2026-000123INVOICE · yearinvoice issued — gapless
PaymentPayment.numberPAY-2026-000077PAYMENT · yearpayment recorded or declared
Credit noteCreditNote.numberAVO-2026-000004 (proposal)CREDIT_NOTE · yearcredit note issued — gapless
TourTour.numberTRN-2026-000045TOUR · yeartour planned
VisitVisit.numberVIS-2026-001234VISIT · yearfirst received by the server
TicketTicket.numberTKT-2026-000045TICKET · yearticket opened
ReturnReturnRequest.numberRET-2026-000012RETURN · yearreturn requested

Invitation codes are not sequential: 8 random characters without ambiguous letters, unique (Invitation.code).

Proposed enums

The design needs categorical columns that enums.json does not define yet. They are Prisma enums with these values; French labels are to be added to enums.json by Anis before SRV-02.

EnumValuesUsed byMeaning
DevicePlatformANDROID, IOS, WEBDevice.platform, AppVersionPolicy.platformPlatform of a client. The X-App-Platform header values android / ios map to ANDROID / IOS; WEB is used by the reseller web and back-office version policy.
DelegateStatusACTIVE, ON_LEAVE, INACTIVEDelegateProfile.statusField availability of a delegate, distinct from the login status (User.status): ON_LEAVE blocks tour planning and is the trigger for temporary reassignment; INACTIVE = left the field team (account usually DISABLED).
LocationSourceFIELD_SIGNUP, CHECK_IN_VALIDATED, BACK_OFFICE, IMPORTReseller.locationSourceWho set a store's position (SRV-42); same values as the API LocationSource.
StatusChangeTriggerMANUAL, AUTOMATIC, IMPORTResellerStatusChange.triggerWhat caused a reseller status change: a person, an event/job (with eventKey), or an import (names as on the lifecycles page).
PriceListTypeAGREEMENT, LEVELPriceList.type (V2)V2 (PRC-03): AGREEMENT = per-reseller agreement referenced by the V2 column Reseller.priceListId; LEVEL = loyalty-level prices.
AttributeTypeTEXT, NUMBER, BOOLEAN, ENUMAttributeDefinition.typeValue type of a filterable attribute (CAT-13): free text, number with an optional unit, yes/no, or one of allowedValues.
AttributeFilterWidgetCHECKBOXES, RANGE, TOGGLE, SWATCHES, NONEAttributeDefinition.filterWidgetHow the filter is drawn on web and mobile (CAT-12): value checkboxes with counts, a min–max range (numbers), a yes/no toggle, colour swatches.
VisibilityRuleEffectALLOW, DENYProductVisibilityRule.effectALLOW rules turn a product into a whitelist for the matching resellers; DENY rules hide it from them.
PriceFieldPRICE_SUPER_GROS, PRICE_DEMI_GROS, PRICE_DETAIL, RECOMMENDED_PRICE, PURCHASE_PRICE, VAT_RATEPriceChange.fieldWhich price a PriceChange row records. V2 adds AGREEMENT_PRICE and AGREEMENT_DISCOUNT for PriceListItem changes.
InventorySessionStatusOPEN, IN_REVIEW, VALIDATED, CANCELLEDInventorySession.statusOPEN while lines are counted; VALIDATED once the differences were posted as movements; CANCELLED discards the counts.
CreditOverLimitPolicyBLOCK, REQUIRE_APPROVALCreditAccount.overLimitPolicyWhat happens when an order would exceed the available credit (cahier §4.7): BLOCK refuses checkout on CREDIT (422 CREDIT_LIMIT_EXCEEDED); REQUIRE_APPROVAL lets it through as PENDING with the reason CREDIT_OVER_LIMIT for a finance decision.
BannerPlacementHOME_CAROUSEL, HOME_DAILY_OFFER, CATALOG_TOP, PROMOTIONS_TOPBanner.placementWhere a banner is shown: the reseller home carousel (RA-09, RW-02) or the top of the promotions screen (RA-24).
RedemptionStatusPENDING, FULFILLED, CANCELLEDRewardRedemption.statusV2 — a redemption is PENDING until the reward is handed over (gift, early access) or the voucher issued; CANCELLED refunds the points.
TicketPriorityLOW, NORMAL, HIGH, URGENTTicket.priorityBack-office triage priority (admin.tickets.update — assign, change status or priority).
NotificationDeliveryStatusQUEUED, SENT, DELIVERED, FAILED, SKIPPEDNotificationDelivery.statusOutcome of one channel delivery: SKIPPED = opted out, no device token or channel disabled; DELIVERED only when the provider reports it (SMS receipts).
FileVisibilityPUBLIC, PRIVATEFile.visibilityPUBLIC files (product images, brand logos, banners) are served by the CDN; PRIVATE ones only through 5-minute signed URLs after an access check (SEC-04). Fixed by the purpose.
FileStatusPENDING, READY, REJECTED, DELETEDFile.statusPENDING after files.createUpload; READY after files.completeUpload checks size, type and checksum; REJECTED when they fail; DELETED when the object was purged (the row stays).
ImportTypePRODUCTS, RESELLERS, USERS, STOCKImportJob.typeThe four initial/recurring imports of cahier §13.2 and ADM-06.

Migration strategy

  1. Prisma Migrate only. prisma migrate dev locally, prisma migrate deploy as the release step of the api service on Dokploy before the new image takes traffic; the worker waits for it.
  2. Never edit an applied migration. A mistake is fixed by a new migration. CI runs prisma migrate diff between the migrations folder and the schema and fails on drift.
  3. Expand / contract for every breaking change: add the new column or table (nullable or with a default) → deploy code that writes both → backfill in a job → switch reads → enforce NOT NULL / drop the old column in a later release. Renames are expand/contract too, never in place.
  4. Raw SQL inside migrations for what the Prisma schema cannot express — created with prisma migrate dev --create-only and edited before applying: extensions pg_trgm and unaccent with the immutable wrapper below, every CHECK constraint, every partial or expression index, BRIN indexes and the INSERT/SELECT-only grant on audit_logs. Each model page shows its raw SQL; the same statements are listed in a comment at the top of the corresponding .prisma file so the schema stays self-explanatory.
  5. Enum changes: adding a value is a migration (ALTER TYPE … ADD VALUE, outside a transaction block); a value is never removed while referenced.
  6. V2 tables (PriceList, PriceListItem, Pack, PackItem, loyalty, PaymentReminder) and the V2 columns mentioned on the model pages arrive with the P4 migrations, not in V1.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;
-- immutable wrapper so unaccent() can be used in index expressions
CREATE OR REPLACE FUNCTION f_unaccent(text) RETURNS text
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
  AS $$ SELECT public.unaccent('public.unaccent'::regdictionary, $1) $$;

Seed plan

Seeds are idempotent upserts on natural keys, split in two scripts: reference (every environment, re-runnable after each release) and demo (local and staging only, refused when the environment is production).

DataVolumeSource and rule
Wilayas69Official list in force: 58 wilayas plus the 11 created by the 2026 territorial reorganisation (law of April 2026 amending law 84-09, Journal officiel n° 25 of 2026; names and seats by executive decree 26-206 of 25 May 2026, Journal officiel n° 40), codes 01–69, French and Arabic names. Verify spellings and codes against the JO text at seed time.
Communes1,541Official list with code, wilaya, FR/AR names, postal code and centroid (centroidLat/centroidLng) — needed by the support-region resolution. Communes moved to the new wilayas take their new codes; ids stay stable.
Brands3HOCO, ACEFAST, BOROFONE with logos.
Categories8 top level + sub-categoriesChargers, cables, earphones, power banks, car holders, audio, watches, accessories (cahier §4.3) and HOCO's sub-categories (e.g. wall/car/wireless chargers; USB-C/Lightning/Micro-USB cables) — to confirm with HOCO.
Attribute definitions≈ 15Power (W), connector, cable length (m), colour, battery capacity (mAh), Bluetooth version, current (A)… per category (CAT-13).
Roles, permissions, grants8 roles, ≈ 90 permissionsFrom the permissions matrix; re-seeding never removes a grant added in BO-62.
Reference lists8 listsBUSINESS_TYPE, PERSON_ROLE, VISIT_NOT_DONE_REASON, VISIT_ISSUE_TAG, TOUR_SUSPEND_REASON, ORDER_REFUSAL_REASON, ORDER_CANCEL_REASON, PAYMENT_REJECT_REASON, with the system items (ASSISTED_EXPIRED, ASSISTED_REJECTED, OTHER).
Settings≈ 35 keysDefaults below.
Support regions≈ 4 + defaultProposal Centre, Est, Ouest, Sud covering the 69 wilayas, WhatsApp numbers from HOCO — to confirm (ADM-08).
Visit types and checklist3 types, 1 templatePROSPECTION, SUIVI, RECOUVREMENT (proposal) and the standard merchandising checklist.
Notification templates and rulesper event × channel × frFrom the notifications matrix.
Platform rows—Default warehouse, app version policies (reseller and delegate apps × Android/iOS, web), one dashboard layout per role.
Demo (dev/staging only)—≈ 60 products with variants, three prices and attribute values; 40 resellers in 3 wilayas (all statuses and customer types); 2 delegates with zones; today's tour; orders in every status; an after-sales ticket, a return and a credit note (OPS-01).

Setting keys seeded with their defaults (the values are tunable in BO-64; several are open decisions):

KeyDefault
orders.minAmount · orders.cancelAllowedStatuses · orders.assistedConfirmationHours · returns.windowDays0 (off) · SUBMITTED, PENDING · 72 · 7
stock.mode · stock.reserveOn · stock.deductOn · stock.defaultAlertThresholdGLOBAL · VALIDATED · SHIPPED · 10
catalog.pricesVisibleBeforeValidationtrue — prices of the declared type right after sign-up (AUTHORING §1b-6, decision to confirm)
billing.invoiceOn · billing.sellerIdentity · billing.deliveryVatRateBp · billing.stampDutySHIPPED · HOCO legal identity (legal name, address, NIF, NIS, RC, AI, RIB, optional logoFileId: a PNG or JPEG drawn on the documents) · 1900 · to confirm
credit.defaultLimit · credit.defaultTermsDays · credit.overLimitPolicy · payments.enabledMethods0 · 30 · REQUIRE_APPROVAL · all five methods
delivery.feesdefault fee, free threshold and per-wilaya overrides (DLV-06)
field.gpsToleranceMeters · field.gpsMaxAccuracyMeters · field.locationIntervalSeconds · field.locationDistanceMeters150 · 100 · 300 · 200
field.locationRetentionDays · field.visitUnlockHours · field.reopenMaxDays · field.closingRequiresSync · field.reportRequiredToEndVisit90 · 24 · 7 · true · true
crm.inactiveAfterDays · invitations.validityDays · support.autoCloseDays · exports.retentionDays60 · 30 · 7 · 7

There is deliberately no automatic order confirmation setting (ORD-14) and no stock display setting (STK-07).

Indexing strategy

Every foreign key used in a filter has an index; composite indexes follow the main queries. The model pages list the exact column lists; the important patterns:

QueryIndex
Catalog search by name or reference (under 3 s, CAT-07)GIN trigram on f_unaccent(products.name), f_unaccent(products.reference), product_variants.sku; exact barcode and sku uniques
Catalog filters and sortsproducts (status, category_id), (status, brand_id), (status, popularity_score DESC), (status, published_at DESC); one index per price column for the price range of the caller's type
Attribute facets and filters (CAT-12)variant_attribute_values (attribute_id, value_text), (attribute_id, value_number), (attribute_id, value_bool) + unique (variant_id, attribute_id)
Delegate scope (ACL-03)resellers (assigned_delegate_id, status), (zone_id, status), delegate_zones (zone_id), zone_areas uniques on wilaya_code and commune_id
Delegate sync deltas(assigned_delegate_id, updated_at) on resellers, (delegate_id, updated_at) on tours and visits, reseller_assignments (from_delegate_id, created_at)
Orders by status and dateorders (status, submitted_at DESC), (reseller_id, created_at DESC), partial orders_to_confirm (SUBMITTED, PENDING), trigram on number
KPI aggregates and reportsorders (validated_at), (delivered_at), (attributed_delegate_id, submitted_at), (wilaya_code, submitted_at), (zone_id, submitted_at); order_items (product_id); visits (delegate_id, planned_at); materialised views refreshed by the worker only if a report exceeds its p95 budget
Inbox and badgespartial notifications (user_id) WHERE read_at IS NULL, (user_id, created_at DESC)
Outbox and jobspartial outbox_events (available_at) WHERE dispatched_at IS NULL; idempotency_records (expires_at)
Large append-only tablesBRIN on audit_logs.at and location_pings.occurred_at for range scans and purges

Data volumes (planning assumptions)

Assumption — orders of magnitude for sizing, to be replaced by HOCO's figures at the cadrage workshop (PRJ-03):

EntityPilotAfter generalisation (per year unless stated)
Resellers and prospects3005 000 – 10 000 in total
Delegates · staff users5 · 1030 – 50 · 30
Products · variants800 · 2 0001 500 · 4 000 in total
Orders · order lines1 000 · 10 00070 000 · 700 000
Visits3 000100 000
Stock movements30 0002 000 000
Location pings60 000under 1 M live rows (90-day retention)
Notifications · deliveries20 000500 000 · 1 000 000
Audit log50 0002 000 000 (6 M over 3 years)

All of it fits one PostgreSQL instance with room to spare (tens of GB including indexes over three years); no table needs partitioning in V1. Files (photos, documents, PDFs) are in object storage, not in the database.

Retention

Proposals — the durations of personal data must be validated with HOCO (cahier §10, SEC-08, Law 18-07); they are settings where a job enforces them.

DataRetentionMechanism
Location pings90 daysnightly batched delete (field.locationRetentionDays)
Audit log3 yearsmonthly purge by at
Invoices, credit notes, payments, orders and their lines10 years (accounting documents, Code de commerce)never purged
Idempotency records30 daysdaily purge on expiresAt
Dispatched outbox events30 daysdaily purge
Notifications · delivery log12 months · 6 monthsdaily purge
Sessions · verificationsuntil expiryhourly purge
Pending uploads · export files24 h · 7 dayshourly / daily purge (File rows stay)
Prospects never converted (NOT_INTERESTED, no activity)3 years, then anonymisedyearly job (proposal)

Backups

  • Nightly logical dump (pg_dump -Fc) of production to object storage outside the VPS, encrypted, kept 7 daily, 4 weekly and 12 monthly copies (OPS-02, SEC-05).
  • Point-in-time recovery with continuous WAL archiving (pgBackRest or WAL-G) is recommended once orders flow in production: RPO of minutes instead of 24 h — decision to confirm with HOCO.
  • Object storage buckets are versioned and replicated to a second location; database rows and files are restored together.
  • A restore test into a fresh database is part of hardening (SRV-28) and is repeated quarterly; procedure in deployment and operations.

All tables

TableModelRelease
accountsAccountV1
announcement_readsAnnouncementReadV1
announcementsAnnouncementV1
app_version_policiesAppVersionPolicyV1
attribute_definitionsAttributeDefinitionV1
audit_logsAuditLogV1
bannersBannerV1
brandsBrandV1
carriersCarrierV1
cart_itemsCartItemV1
cartsCartV1
categoriesCategoryV1
checklist_templatesChecklistTemplateV1
communesCommuneV1
credit_accountsCreditAccountV1
credit_limit_changesCreditLimitChangeV1
credit_notesCreditNoteV1
crm_tasksCrmTaskV1
dashboard_layoutsDashboardLayoutV1
delegate_profilesDelegateProfileV1
delegate_zonesDelegateZoneV1
deliveriesDeliveryV1
devicesDeviceV1
export_jobsExportJobV1
favoritesFavoriteV1
filesFileV1
idempotency_recordsIdempotencyRecordV1
import_jobsImportJobV1
interactionsInteractionV1
inventory_linesInventoryLineV1
inventory_sessionsInventorySessionV1
invitation_eventsInvitationEventV1
invitationsInvitationV1
invoice_linesInvoiceLineV1
invoicesInvoiceV1
location_pingsLocationPingV1
loyalty_accountsLoyaltyAccountV2
loyalty_transactionsLoyaltyTransactionV2
notification_deliveriesNotificationDeliveryV1
notification_preferencesNotificationPreferenceV1
notification_rulesNotificationRuleV1
notification_templatesNotificationTemplateV1
notificationsNotificationV1
number_sequencesNumberSequenceV1
objectivesObjectiveV1
order_changesOrderChangeV1
order_itemsOrderItemV1
order_status_changesOrderStatusChangeV1
ordersOrderV1
outbox_eventsOutboxEventV1
pack_itemsPackItemV2
packsPackV2
payment_allocationsPaymentAllocationV1
payment_remindersPaymentReminderV2
paymentsPaymentV1
permissionsPermissionV1
price_changesPriceChangeV1
price_list_itemsPriceListItemV2
price_listsPriceListV2
product_imagesProductImageV1
product_variantsProductVariantV1
product_visibility_rulesProductVisibilityRuleV1
productsProductV1
promotion_audiencesPromotionAudienceV1
promotion_scopesPromotionScopeV1
promotionsPromotionV1
reference_itemsReferenceItemV1
reseller_addressesResellerAddressV1
reseller_assignmentsResellerAssignmentV1
reseller_documentsResellerDocumentV1
reseller_photosResellerPhotoV1
reseller_segmentsResellerSegmentV1
reseller_status_changesResellerStatusChangeV1
resellersResellerV1
return_itemsReturnItemV1
return_requestsReturnRequestV1
reward_redemptionsRewardRedemptionV2
rewardsRewardV2
role_permissionsRolePermissionV1
rolesRoleV1
segmentsSegmentV1
sessionsSessionV1
settingsSettingV1
stock_levelsStockLevelV1
stock_movementsStockMovementV1
support_region_wilayasSupportRegionWilayaV1
support_regionsSupportRegionV1
ticket_itemsTicketItemV1
ticket_messagesTicketMessageV1
ticketsTicketV1
tour_status_changesTourStatusChangeV1
toursTourV1
user_rolesUserRoleV1
usersUserV1
variant_attribute_valuesVariantAttributeValueV1
verificationsVerificationV1
visit_checklist_itemsVisitChecklistItemV1
visit_photosVisitPhotoV1
visit_reportsVisitReportV1
visit_typesVisitTypeV1
visitsVisitV1
vouchersVoucherV2
warehousesWarehouseV1
wilayasWilayaV1
zone_areasZoneAreaV1
zonesZoneV1

Assumptions to review

  1. Better Auth e-mail stays NOT NULL: phone-only users get <id>@users.hoco.invalid, hidden by the API (verify in SRV-05).
  2. Reseller.customerType is nullable for prospects and required for an approved account (CHECK); prices shown right after sign-up (catalog.pricesVisibleBeforeValidation = true).
  3. Reseller.supportRegionId is a stored derivation of the wilaya, maintained by the crm and support modules.
  4. Delegate profile key = user id, so every delegateId is a User.id.
  5. Prices are stored HT; TTC is computed (VAT per line, half-up). If HOCO sets round TTC prices, the editor converts and the TTC may differ by one centime.
  6. Stamp duty (Invoice.stampDuty) on cash-settled invoices — rates to confirm with HOCO's accountant.
  7. Zero-total replacement orders issue no invoice.
  8. Credit-note number format AVO-YYYY-NNNNNN and delegate code DLG-0007 are proposals.
  9. 17 proposed enums (table) need French labels in enums.json.
  10. Names aligned with the lifecycles page: assistedExpiresAt, pendingReasons, placedByUserId, trigger, inferred, unlockedUntil; that page's Tour.completedAt is closedAt here (orders are never auto-confirmed and returns are V1, per the 17 Oct decisions).

On this page