Loyalty (V2)
V2 loyalty programme for resellers — points balance and level progress, points history, rewards catalogue with eligibility, redemption and vouchers.
V2 — baseline contract
Loyalty is a V2 evolution (cahier §14.2, D-16). These operations enter the contract with SRV-29 and are built in SRV-30, MRA-20 and WEB-20. Until then catalog.home returns account.loyaltyPoints: null and the « Points » tile stays disabled. Earning rules, levels and expiry are HOCO's policy (D-05); the values below are examples.
The « Fidélité » and « Récompenses » screens (reference screen 7, cahier §4.6, §5.5): points earned on orders, Bronze → Silver → Gold → VIP levels over a period, rewards exchanged for points.
- Ledger, not a counter. The balance is the sum of
LoyaltyTransactionrows —EARN,BONUS,REDEEM,EXPIRE,ADJUST,REVERSAL— never edited (LOY-05, lifecycle). - Earning on the order total TTC without delivery fee when the order reaches
loyalty.earnOn(DELIVEREDor invoicePAID), reversed exactly on cancellation or return (LOY-01, LOY-02, BR-37). - Levels are computed on the points earned during the current period against thresholds validated by HOCO (LOY-03); they are independent of the customer type, which alone sets prices in V1.
- Rewards that are unavailable cannot be redeemed (cahier §5.5); eligibility is computed by the server and returned with each reward (LOY-04).
- Signed-in resellers only; guests get
401, other users403 PERMISSION_DENIED. When the programme is switched off (loyalty.enabled = false) every operation answers403 FEATURE_DISABLED(details.feature = "loyalty").
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| GET | /loyalty/summary | loyalty.summaryPoints balance, level, progress to next level (V2) | self | RA · RW | V2 |
| GET | /loyalty/transactions | loyalty.transactionsPoints history (V2) | self | RA · RW | V2 |
| GET | /loyalty/rewards | loyalty.rewardsRewards catalogue with eligibility (V2) | self | RA · RW | V2 |
| POST | /loyalty/rewards/{rewardId}/redeem | loyalty.redeemRedeem points for a reward (V2) | selfIdempotency-Key | RA · RW | V2 |
| GET | /loyalty/redemptions | loyalty.redemptionsMy redemptions and vouchers (V2) | self | RA · RW | V2 |
Every operation can also answer 401 UNAUTHENTICATED, 403 ACCOUNT_SUSPENDED, 426 UPGRADE_REQUIRED, 429 RATE_LIMITED, 500 INTERNAL_ERROR and 503 MAINTENANCE — see the error registry.
Schemas
interface LoyaltySummary {
points: number; // spendable balance
level: LoyaltyLevel; // BRONZE | SILVER | GOLD | VIP
nextLevel: LoyaltyLevel | null; // null at VIP
pointsToNextLevel: number | null;
periodPoints: number; // EARN + BONUS during the current period (drives the level)
levelProgress: { currentThreshold: number; nextThreshold: number | null; ratio: BasisPoints }; // periodPoints / nextThreshold
period: { startsOn: string; endsOn: string }; // YYYY-MM-DD
levels: { level: LoyaltyLevel; threshold: number; benefits: string[] }[];
earnRate: { amountTtc: Money; points: number }; // 1 point per 100,00 DA TTC
expiringSoon: { points: number; expiresOn: string } | null;
}
interface LoyaltyTransactionView {
id: Uuid;
type: LoyaltyTransactionType; // EARN, BONUS, REDEEM, EXPIRE, ADJUST, REVERSAL
points: number; // signed
balanceAfter: number;
label: string; // « Commande CMD-2027-000412 livrée »
order: { id: Uuid; number: string } | null;
redemptionId: Uuid | null;
expiresOn: string | null; // EARN and BONUS rows
occurredAt: Iso;
}
interface RewardView {
id: Uuid;
type: RewardType; // VOUCHER, FREE_DELIVERY, DISCOUNT, GIFT, EARLY_ACCESS
name: string;
description: string;
imageUrl: string | null;
costPoints: number;
minLevel: LoyaltyLevel | null;
value: { amountTtc: Money | null; percent: BasisPoints | null }; // VOUCHER: amount · DISCOUNT: percent
available: boolean; // in stock and within its dates
perResellerLimit: number | null; // per period
validUntil: Iso | null;
eligibility: {
eligible: boolean;
reasons: ("INSUFFICIENT_POINTS" | "LEVEL_TOO_LOW" | "UNAVAILABLE" | "LIMIT_REACHED")[];
missingPoints: number;
};
}
interface RedemptionView {
id: Uuid;
reward: { id: Uuid; type: RewardType; name: string; imageUrl: string | null };
qty: number;
pointsSpent: number;
status: "REQUESTED" | "FULFILLED" | "CANCELLED"; // RedemptionStatus — to add to enums.json
voucher: {
id: Uuid;
code: string; // "LIV-7K3M-9QX2"
type: "VOUCHER" | "FREE_DELIVERY" | "DISCOUNT";
value: { amountTtc: Money | null; percent: BasisPoints | null };
expiresAt: Iso;
status: "ACTIVE" | "USED" | "EXPIRED" | "CANCELLED"; // VoucherStatus — to add to enums.json
usedOnOrder: { id: Uuid; number: string } | null;
} | null;
fulfilment: string | null; // GIFT: « Remis avec votre prochaine commande »
balanceAfter: number;
createdAt: Iso;
fulfilledAt: Iso | null;
}Operations
GET /loyalty/summary
loyalty.summary · Access session — reseller (self) · Apps RA, RW · Idempotency-Key — · Release V2 · Requirements LOY-03
Purpose. « Mes points de fidélité » and « Mon niveau revendeur »: balance, level, progress bar to the next level.
Request — no parameters.
Response — 200 OK: a LoyaltySummary.
{
"points": 12450,
"level": "SILVER",
"nextLevel": "GOLD",
"pointsToNextLevel": 2550,
"periodPoints": 12450,
"levelProgress": { "currentThreshold": 5000, "nextThreshold": 15000, "ratio": 8300 },
"period": { "startsOn": "2027-01-01", "endsOn": "2027-12-31" },
"levels": [
{ "level": "BRONZE", "threshold": 0, "benefits": ["Cumul de points sur chaque commande"] },
{ "level": "SILVER", "threshold": 5000, "benefits": ["Bonus de 5 % sur les points gagnés"] },
{ "level": "GOLD", "threshold": 15000, "benefits": ["Bons d'achat exclusifs", "Bonus de 10 % sur les points gagnés"] },
{ "level": "VIP", "threshold": 40000, "benefits": ["Priorité sur les nouveautés", "Livraison offerte sur toutes les commandes"] }
],
"earnRate": { "amountTtc": 10000, "points": 1 },
"expiringSoon": { "points": 1200, "expiresOn": "2027-06-30" }
}| Field | Notes |
|---|---|
pointsToNextLevel, levelProgress | « Plus que 2 550 pts pour atteindre le niveau Gold » and the bar « 12 450 / 15 000 pts » of reference screen 7; ratio = periodPoints / nextThreshold in basis points. |
points vs periodPoints | Redeeming lowers points, never periodPoints: spending does not make a reseller lose their level. |
expiringSoon | Points expiring within 60 days (loyalty.pointsValidityMonths). |
Errors — 403 FEATURE_DISABLED.
Rules & side effects — read-only; the level is recomputed nightly and at each EARN; a level change sends loyalty.level_changed (push, inbox).
Used by — RA-38, RW-15; home tile through catalog.home → account.loyaltyPoints.
GET /loyalty/transactions
loyalty.transactions · Access session — reseller (self) · Apps RA, RW · Idempotency-Key — · Release V2 · Requirements LOY-05
Purpose. « Voir l'historique »: every points movement, newest first, traceable to its order or redemption (cahier §12 « Les points sont crédités, débités et historisés correctement »).
Request — type (LoyaltyTransactionType, repeatable), from, to (YYYY-MM-DD), cursor, limit (default 20, max 100).
Response — 200 OK: { items: LoyaltyTransactionView[], nextCursor }.
{
"items": [
{ "id": "01a57352-3900-7703-b1dc-6eb798606c23", "type": "REDEEM", "points": -3000, "balanceAfter": 12450, "label": "Échange : Livraison offerte", "order": null, "redemptionId": "01a57352-3900-72ce-8e18-68d2386178eb", "expiresOn": null, "occurredAt": "2027-05-12T10:00:00Z" },
{ "id": "01a56905-8100-7d86-9fa1-964817c87083", "type": "EARN", "points": 803, "balanceAfter": 15450, "label": "Commande CMD-2027-000412 livrée", "order": { "id": "01a55f2c-7a00-7b13-8c24-3d4e5f607182", "number": "CMD-2027-000412" }, "redemptionId": null, "expiresOn": "2028-05-31", "occurredAt": "2027-05-10T10:00:00Z" },
{ "id": "01a53886-f400-7bd1-806a-1f3535fae7b0", "type": "EXPIRE", "points": -300, "balanceAfter": 14647, "label": "Points expirés (gagnés en avril 2026)", "order": null, "redemptionId": null, "expiresOn": null, "occurredAt": "2027-05-01T00:00:00Z" },
{ "id": "01a52b39-3100-71c4-9f7a-09d750551b7e", "type": "BONUS", "points": 500, "balanceAfter": 14947, "label": "Bonus de lancement du programme fidélité", "order": null, "redemptionId": null, "expiresOn": "2028-04-30", "occurredAt": "2027-04-28T10:00:00Z" }
],
"nextCursor": "eyJjIjoiMjAyNy0wNC0yOFQxMDowMDowMFoiLCJpIjoiMDFhNTJiMzkifQ"
}The EARN row is the order of 80 380,00 DA TTC at 1 point per 100,00 DA (rounded down).
Errors — 400 VALIDATION_FAILED (unknown type, from after to), 403 FEATURE_DISABLED.
Rules & side effects — read-only; balanceAfter lets the app show the running balance without recomputing.
Used by — history of RA-38, RW-15.
GET /loyalty/rewards
loyalty.rewards · Access session — reseller (self) · Apps RA, RW · Idempotency-Key — · Release V2 · Requirements LOY-04
Purpose. « Récompenses disponibles »: the rewards catalogue with, for each reward, whether I can redeem it now and why not.
Request — eligibleOnly (boolean, default false), type (RewardType, repeatable), cursor, limit.
Response — 200 OK: { items: RewardView[], nextCursor }, cheapest first.
{
"items": [
{ "id": "01a49ff6-a208-7dcb-ae4e-a7708a7fe389", "type": "FREE_DELIVERY", "name": "Livraison offerte", "description": "Une livraison offerte sur votre prochaine commande, valable 90 jours.", "imageUrl": "https://cdn.hoco.dz/rewards/livraison-offerte.webp", "costPoints": 3000, "minLevel": null, "value": { "amountTtc": null, "percent": null }, "available": true, "perResellerLimit": 4, "validUntil": null, "eligibility": { "eligible": true, "reasons": [], "missingPoints": 0 } },
{ "id": "01a49ff6-9268-7a6c-ba93-b0c8566aa0b1", "type": "GIFT", "name": "Câble USB-C HOCO", "description": "Un câble HOCO X89 offert, remis avec votre prochaine commande.", "imageUrl": "https://cdn.hoco.dz/rewards/cable-usb-c.webp", "costPoints": 6000, "minLevel": null, "value": { "amountTtc": null, "percent": null }, "available": true, "perResellerLimit": null, "validUntil": null, "eligibility": { "eligible": true, "reasons": [], "missingPoints": 0 } },
{ "id": "01a49ff6-8e80-7770-93fe-b94b355feff6", "type": "GIFT", "name": "Chargeur HOCO 18W", "description": "Un chargeur HOCO 18W offert, remis avec votre prochaine commande.", "imageUrl": "https://cdn.hoco.dz/rewards/chargeur-18w.webp", "costPoints": 10000, "minLevel": null, "value": { "amountTtc": null, "percent": null }, "available": true, "perResellerLimit": null, "validUntil": null, "eligibility": { "eligible": true, "reasons": [], "missingPoints": 0 } },
{ "id": "01a49ff6-9650-7037-9a23-56db872ec25d", "type": "GIFT", "name": "Power Bank HOCO 10000mAh", "description": "Une power bank HOCO J86A offerte, remise avec votre prochaine commande.", "imageUrl": "https://cdn.hoco.dz/rewards/power-bank.webp", "costPoints": 12000, "minLevel": null, "value": { "amountTtc": null, "percent": null }, "available": true, "perResellerLimit": null, "validUntil": null, "eligibility": { "eligible": true, "reasons": [], "missingPoints": 0 } },
{ "id": "01a49ff6-9e20-7514-be23-606e20f62354", "type": "VOUCHER", "name": "Bon d'achat 2 000 DA", "description": "2 000,00 DA déduits de votre prochaine commande, réservé aux niveaux Gold et VIP.", "imageUrl": "https://cdn.hoco.dz/rewards/bon-2000.webp", "costPoints": 15000, "minLevel": "GOLD", "value": { "amountTtc": 200000, "percent": null }, "available": true, "perResellerLimit": 2, "validUntil": "2027-12-31T22:59:59Z", "eligibility": { "eligible": false, "reasons": ["LEVEL_TOO_LOW", "INSUFFICIENT_POINTS"], "missingPoints": 2550 } },
{ "id": "01a49ff6-9a38-703a-aff9-b053b98f7e05", "type": "GIFT", "name": "Écouteurs TWS HOCO", "description": "Une paire d'écouteurs HOCO EW41 offerte.", "imageUrl": "https://cdn.hoco.dz/rewards/ecouteurs-tws.webp", "costPoints": 18000, "minLevel": null, "value": { "amountTtc": null, "percent": null }, "available": true, "perResellerLimit": null, "validUntil": null, "eligibility": { "eligible": false, "reasons": ["INSUFFICIENT_POINTS"], "missingPoints": 5550 } }
],
"nextCursor": null
}Errors — 400 VALIDATION_FAILED, 403 FEATURE_DISABLED.
Rules & side effects — read-only. Unavailable rewards (out of gift stock, outside their dates) are listed with available: false and UNAVAILABLE so the reseller sees the catalogue, but cannot redeem them (cahier §5.5). No gift stock quantity is exposed.
Used by — RA-39, « Récompenses disponibles » of reference screen 7, RW-15.
POST /loyalty/rewards/{rewardId}/redeem
loyalty.redeem · Access session — reseller (self), AccountStatus ACTIVE · Apps RA, RW · Idempotency-Key required · Release V2 · Requirements LOY-04
Purpose. Exchanges points for a reward; vouchers are issued at once, gifts are delivered with the next order.
Request
interface RedeemRequest {
qty?: number; // default 1, within perResellerLimit
}POST /v1/loyalty/rewards/01a49ff6-a208-7dcb-ae4e-a7708a7fe389/redeem{ "qty": 1 }Response — 201 Created: the RedemptionView.
{
"id": "01a57352-3900-72ce-8e18-68d2386178eb",
"reward": { "id": "01a49ff6-a208-7dcb-ae4e-a7708a7fe389", "type": "FREE_DELIVERY", "name": "Livraison offerte", "imageUrl": "https://cdn.hoco.dz/rewards/livraison-offerte.webp" },
"qty": 1,
"pointsSpent": 3000,
"status": "FULFILLED",
"voucher": {
"id": "01a57352-3900-7ee9-b653-a32074077342",
"code": "LIV-7K3M-9QX2",
"type": "FREE_DELIVERY",
"value": { "amountTtc": null, "percent": null },
"expiresAt": "2027-08-10T22:59:59Z",
"status": "ACTIVE",
"usedOnOrder": null
},
"fulfilment": "Code à saisir au moment de valider votre commande.",
"balanceAfter": 12450,
"createdAt": "2027-05-12T10:00:00Z",
"fulfilledAt": "2027-05-12T10:00:00Z"
}Errors
| Status | Code | When |
|---|---|---|
| 403 | FEATURE_DISABLED | Programme switched off. |
| 403 | ACCOUNT_PENDING_VALIDATION | Account not validated. |
| 404 | NOT_FOUND | Unknown reward. |
| 422 | INSUFFICIENT_POINTS | Balance below costPoints × qty (details: { points, required, missingPoints }). |
| 422 | LOYALTY_LEVEL_TOO_LOW | Below minLevel (details: { level, minLevel }). |
| 422 | REWARD_UNAVAILABLE | Out of gift stock or outside its dates. |
| 422 | REWARD_LIMIT_REACHED | perResellerLimit for the period reached. |
Rules & side effects
- One transaction:
RewardRedemption,LoyaltyTransactionREDEEM(−pointsSpent, balance lockedFOR UPDATEso two devices cannot overspend), and per type:VOUCHER,FREE_DELIVERY,DISCOUNT→ aVoucherwith a unique code, statusFULFILLED;GIFT→ statusREQUESTED, the gift is added free to the next order by the back-office (admin.redemptions.list);EARLY_ACCESS→ flag on the account for the period,FULFILLED. - Vouchers are used at checkout: the V2
cart.checkoutbody gains an optionalvoucherCode(specified with SRV-29); a used voucher records its order. - Cancelling a
REQUESTEDredemption (back-office) or an unused voucher writes aREVERSALthat gives the points back. - Outbox
loyalty.redeemed→ inbox and back-office queue for gifts.
Used by — « Échanger » on RA-39, RW-15.
GET /loyalty/redemptions
loyalty.redemptions · Access session — reseller (self) · Apps RA, RW · Idempotency-Key — · Release V2 · Requirements LOY-04
Purpose. « Mes récompenses »: my redemptions, gifts to receive and vouchers with their codes.
Request — status (repeatable), cursor, limit.
Response — 200 OK: { items: RedemptionView[], nextCursor }.
{
"items": [
{
"id": "01a57352-3900-72ce-8e18-68d2386178eb",
"reward": { "id": "01a49ff6-a208-7dcb-ae4e-a7708a7fe389", "type": "FREE_DELIVERY", "name": "Livraison offerte", "imageUrl": "https://cdn.hoco.dz/rewards/livraison-offerte.webp" },
"qty": 1,
"pointsSpent": 3000,
"status": "FULFILLED",
"voucher": { "id": "01a57352-3900-7ee9-b653-a32074077342", "code": "LIV-7K3M-9QX2", "type": "FREE_DELIVERY", "value": { "amountTtc": null, "percent": null }, "expiresAt": "2027-08-10T22:59:59Z", "status": "ACTIVE", "usedOnOrder": null },
"fulfilment": "Code à saisir au moment de valider votre commande.",
"balanceAfter": 12450,
"createdAt": "2027-05-12T10:00:00Z",
"fulfilledAt": "2027-05-12T10:00:00Z"
},
{
"id": "01a50206-5100-78e9-93bb-33f5aa5421be",
"reward": { "id": "01a49ff6-9268-7a6c-ba93-b0c8566aa0b1", "type": "GIFT", "name": "Câble USB-C HOCO", "imageUrl": "https://cdn.hoco.dz/rewards/cable-usb-c.webp" },
"qty": 1,
"pointsSpent": 6000,
"status": "FULFILLED",
"voucher": null,
"fulfilment": "Remis avec la commande CMD-2027-000398.",
"balanceAfter": 14447,
"createdAt": "2027-04-20T10:00:00Z",
"fulfilledAt": "2027-04-24T09:30:00Z"
}
],
"nextCursor": null
}Errors — 400 VALIDATION_FAILED, 403 FEATURE_DISABLED.
Rules & side effects — read-only; voucher codes are shown in full only to their owner.
Invitations & attribution (public + reseller)
Public operations behind the delegates' invitation links and QR codes: resolving a code, attributing the app install to the inviting delegate, and the /i/ landing page that opens the right app or store.
Delegate home, profile, objectives & reports
The delegate app's read model of the day — home aggregate, profile and zones, objectives, performance and the history of visit and tour reports.