Points wallet
Background
On Shukah the app reads the point balance with GET /maeda/client/points, the earn-rate table with
GET /maeda/point-rate, and redeems a gift-card code with GET /maeda/giftcard-code/use. Locate
has a route for each, and the parity audit rates all three drop-in: none needs an app change
beyond the base URL and path. Each TL;DR still has to-watch lines, because a method, a token or the
data can differ:
- Balance (drop-in).
GET /api/v2/client/pointson order-v2 passes the call to the point service. It returns the same four keys as Shukah. To watch:pointscan carry four decimals, on dev the balance and the expiry date were not the same as Shukah's (2026-10-01 and 2026-10-02), and on dev today it accepts only a Shukah token, not the Locate JWT. - Earn rates and gift-card code (drop-in). order-v2 serves these two itself. It does not pass
them to the point service. To watch: the dev earn-rate table is not Shukah's, and the gift-card
code is now a
POSTwith the code in the body, a call the app already has. The app calls both only in Merchant mode.
Feature
The wallet is the screen where the client sees the point balance, the last point transactions, a box to enter a gift-card code, and the points FAQ. In the app snapshot of 2026-02-26 the calls happen here:
| Call | Where in the app |
|---|---|
Balance (#points) | When the app loads the client's info (at start-up and after login), and when the restaurant checkout opens its choice of payment method (to see if the client can pay with points). The Wallet screen shows the number of points, their value in SAR and the expiry date. |
Earn rates (#point-rates) | In the "Earn Points" card of the restaurant cart and of the merchant cart, which shows what the cart total earns. |
Gift-card code (#gift-card-code) | When the client enters a code in the box on the Wallet screen. On success the app shows a message with the code and the points, then reloads the balance and the history. On an error it shows a message with the error text. |
The balance and the gift-card code need the client's own token. The earn-rate table is public. For
the balance, only a Shukah token works on dev today: a Locate JWT got 401 on 2026-10-02, although
both tokens worked on 2026-10-01 (see "Which tokens work" in Details). The gift-card code uses the
same token check (via /api/v1/users), so it needs a Shukah token too (code reading; not called with
a token, because it credits points).
Merchant mode. The app calls the Locate routes for earn rates and gift-card code only when the
client settings say enableMerchantService: true. Without it the app keeps calling Shukah for both.
The app reads that setting from the Locate client-settings service when it starts. The balance call
has no Locate variant in the app today: it always calls Shukah, in both modes, so the app has to be
changed to call GET /api/v2/client/points.
Two balances. The balance this route returns comes from the point service. When the client pays
an order with points, order-v2 still checks the balance on Shukah (GET /maeda/client/points with
the client's token), not this one. During the parallel run the two can disagree, so the Wallet
screen and checkout can show different numbers. Whether that is intended: Not documented yet.
A spend is not synced. The two ledgers did not follow each other when points were spent (within seconds of the run; whether it arrives later was not checked). On dev on 2026-10-02 a pay-by-points gift order on Shukah cut the Shukah balance by 110 and left the Locate balance as it was; the same order on Locate, seconds later, cut only the Locate balance (see Point orders). If the app creates point orders on one side, it must read the balance and the history from that side, or the Wallet screen shows a wrong number.
GET /api/v2/client/points
GET /maeda/client/points✅ Replaced- 🟡 Locate JWT gets 401 "Invalid or expired token" on dev; send the Shukah token
- 🟡 Balance "514405.6788" vs Shukah "514853.64" (Locate is 447.96 lower); expiry 2026-11-28 vs 2026-11-30
- 🟡 points has up to 4 decimals; the app uses Number(points) || 0, so it parses; screens must round for display
- 🟡 401 body is {success,error:{code,message}} with no top-level message; the app would show the raw JSON string
Request
| Field | In | Type | Required | Constraints | Description |
|---|---|---|---|---|---|
Authorization | header | string | yes | Bearer <token>. A Shukah token works on dev. A Locate JWT gets 401 Invalid or expired token (captured on dev, 2026-10-02; it worked on 2026-10-01): see "Which tokens work" in Details. | The client's own token. The client id comes from it, so the app never sends one. |
| Any query key | query | — | no | Not read. order-v2 does not forward a query on this route, so the language the app adds is dropped. | — |
Response
200 with a bare object (no status or data wrapper):
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
id | string | no | The client id as text, for example "356259". | The client id. |
user_id | string | no | The same value as id. | The client id. |
points | string | no | A decimal number in text. Trailing zeros and a trailing dot are removed, and up to four decimals stay: "514405.6788". "0" when the client has no points account. Can be negative: the inbound sync from Shukah does not check for overdraft. | The current balance. |
pointsExpirationDate | string | yes | YYYY-MM-DD, a date in Asia/Riyadh. null unless the expiry rule below applies. | The day the balance expires through inactivity. |
The keys come in this order: id, user_id, points, pointsExpirationDate.
{
"id": "356259",
"user_id": "356259",
"points": "514405.6788",
"pointsExpirationDate": "2026-11-28"
}
Captured on dev, 2026-10-01 (client 356259), with a Shukah token. The same call with a Locate JWT
returned the same body that day. On 2026-10-02 the Shukah token got the same values, and the Locate
JWT got 401.
points format. Locate keeps points with four decimals and does not round them. The formatter
only strips trailing zeros, so "511712.9600" becomes "511712.96" and "0.0000" becomes "0".
On dev the same client and minute gave "514405.6788" on Locate and "128712.41" on Shukah on
2026-10-01, and "514405.6788" and "514853.64" on 2026-10-02. The app reads the value with
Number(points) || 0, so both formats parse.
pointsExpirationDate rule. The date is null when the point settings say expiry is never,
when the settings are not set, or when the client has no counted earn row. Otherwise it is the date
of the client's newest counted earn row plus the configured number of inactivity days, in
Asia/Riyadh. Locate only enforces the date when its expiry job is switched on (see Details). Dev
returned 2026-11-28.
Errors
| HTTP | Body | When |
|---|---|---|
401 | {"success":false,"error":{"code":"UNAUTHORIZED","message":"Missing or invalid authorization header"}} | There is no Authorization header, or it does not start with Bearer . Captured on dev, 2026-10-01, with no token. |
401 | {"success":false,"error":{"code":"UNAUTHORIZED","message":"Invalid or expired token"}}, or the same shape with message auth service unreachable: <error text> or auth response missing numeric client id. | The auth service refused the token, could not be reached (an auth outage looks like a bad token), or answered without a client id. The first body was captured on dev, 2026-10-02, with a Locate JWT. The other two were not captured. |
500 | {"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}} | Any unexpected failure in the point service, for example a database error. The message is AUTH_SERVICE_URL not set when the service is misconfigured. Not captured. |
502 | {"success":false,"error":{"code":"POINT_SERVICE_UNAVAILABLE","message":"Point service unavailable: <error text>"}} | order-v2 could not reach the point service. This body comes from order-v2, not from the point service. Not captured. |
504 | {"success":false,"error":{"code":"POINT_SERVICE_TIMEOUT","message":"Point service did not respond in time; the request outcome is unknown"}} | The point service did not answer within 10 seconds. This body comes from order-v2. Not captured. |
Every error from the point service has the envelope {"success":false,"error":{"code":"…","message":"…"}}
(a details key is added for validation errors). A success body has no envelope.
Try it
export ORDER_BASE_URL=https://dev.v2.api.order.locate.sa
export SHUKAH_TOKEN='<shukah-token>' # a Locate JWT gets 401 here today; see "Which tokens work" in Details
curl -s "$ORDER_BASE_URL/api/v2/client/points" -H "Authorization: Bearer $SHUKAH_TOKEN"
Expected: 200 and the object shown under Response. Use a Shukah token on dev today: a Locate JWT
gets the 401 Invalid or expired token shown under Errors (2026-10-02). With no token you get the
Missing or invalid authorization header 401.
Differences from Shukah
| Aspect | Shukah | Locate | Impact on the app |
|---|---|---|---|
| URL | GET $SHUKAH_BASE_URL/maeda/client/points | GET $ORDER_BASE_URL/api/v2/client/points | Change the base URL and the path. |
| Token | Shukah opaque token. | A Shukah token only on dev today. A Locate JWT gets 401 (captured on dev, 2026-10-02); on 2026-10-01 both got 200. | The app must keep sending a Shukah token here, even after it holds a Locate JWT for the auth service. For other environments: Not documented yet. |
points format | Two decimals: "514853.64" (captured on dev, 2026-10-02). | Up to four decimals: "514405.6788" (same client, same day). | The app parses with Number(points) || 0, so it works. Any screen that assumes two decimals must round. |
| Balance for client 356259 | "514853.64" on 2026-10-02 ("128712.41" on 2026-10-01: the Shukah dev data changed in between). | "514405.6788" on both days: 447.96 points, about 0.09%, below Shukah on 2026-10-02. | The balance on the Wallet screen would drop a little at the switch. Locate's newest ledger row is about two days older than Shukah's, which fits the gap (inference). Check that the feed from Shukah is current before switching. |
pointsExpirationDate | "2026-11-30". | "2026-11-28". Each date is 60 days after the newest ledger row on that side, and Locate's newest row is two days older (inference). | The date the app shows can move by days. Whether Locate runs the expiry on dev: Not documented yet. |
| Error body | {"name":"Unauthorized","message":"Your request was made with invalid credentials.","code":0,"status":401,"type":"Illuminate\\Auth\\AuthenticationException"} (captured on dev, 2026-10-01, no token). | The point-service envelope under Errors. | Anything that matches on the error text, name or type must change. |
Content-Type | application/json | application/json; charset=utf-8 | None. |
GET /api/v2/client/system_configs/point_rates
GET /maeda/point-rate✅ Replaced🚩 enableMerchantService- 🟡 Tiers differ: Locate 70-140:2, 140-251:4, 251-451:10, 451+:16; Shukah 0-139:2 ... 451+:30 (5 rows)
- 🟡 Locate amount_to equals the next amount_from (140, 251, 451); Shukah ends at 139, 153, 250, 450
- 🟡 Locate's first row starts at 70, Shukah's at 0; below 70 the app's added first row gives 0 points
- 🟡 Public on both sides (200, no token); the app calls the Locate route only when enableMerchantService is true
App-side switch: the app calls this Locate route only in Merchant mode (client settings enableMerchantService: true); otherwise it still calls Shukah.
order-v2 serves this route itself and does not pass it to the point service. So the proxy rules and the 502 and 504 errors of the other points pages do not apply to it. The app calls it only in Merchant mode.
Request
| Field | In | Type | Required | Constraints | Description |
|---|---|---|---|---|---|
Authorization | header | string | no | Not needed: dev answered 200 with no token and with a token. A token that is sent is not read. | — |
| Any query key | query | — | no | Accepted and ignored. | — |
Response
200 with a bare array, sorted by amount_from from low to high (a null counts as 0). It is []
when the rate option is not set:
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
amount_from | number | yes | The order total where the row starts. | Start of the range, in SAR. |
amount_to | number | yes | null on the last row. On dev each row ends at the value where the next one starts (140, 251, 451). | End of the range, in SAR. |
point_earn | number | no | A whole number on dev. | Points earned when the order total falls in the range. |
[
{"amount_from": 70, "amount_to": 140, "point_earn": 2},
{"amount_from": 140, "amount_to": 251, "point_earn": 4},
{"amount_from": 251, "amount_to": 451, "point_earn": 10},
{"amount_from": 451, "amount_to": null, "point_earn": 16}
]
Captured on dev, 2026-10-01, with no token (and again with a Shukah token).
How the app matches an order total to a row, and whether it treats amount_to as included: Not
documented yet, because the app code was not read for it.
Errors
| HTTP | Body | When |
|---|---|---|
500 | A Nest error body. Its exact keys were not captured. | The rate option cannot be read, or its value is not a list. Not captured. |
There is no 401: the route has no guard. Unknown query keys and headers are ignored.
Try it
export ORDER_BASE_URL=https://dev.v2.api.order.locate.sa
curl -s "$ORDER_BASE_URL/api/v2/client/system_configs/point_rates"
Expected: 200 and the array shown under Response. No token is needed.
Differences from Shukah
| Aspect | Shukah | Locate | Impact on the app |
|---|---|---|---|
| URL | GET $SHUKAH_BASE_URL/maeda/point-rate | GET $ORDER_BASE_URL/api/v2/client/system_configs/point_rates | Change the base URL and the path. The app already has this call for Merchant mode. |
| Token | None needed (200 with no token on dev, 2026-10-01). | None needed (200 with no token on dev, 2026-10-01). | None. |
| Rows on dev | Five rows: 0–139: 2, 140–153: 12, 154–250: 16, 251–450: 20, 451 and up: 30 (captured on dev, 2026-10-01). | Four rows: 70–140: 2, 140–251: 4, 251–451: 10, 451 and up: 16. | The "Earn Points" card would show different tiers after the switch. Which table is the right one: Not documented yet. |
| Range ends | Ranges do not overlap: 139 then 140. | Ranges share their end value: 140 ends one row and starts the next. | If the app treats amount_to as included, a total of exactly 140 or 251 matches two rows. |
| First row | Starts at 0. | Starts at 70. The app adds a row for totals below the first row, with point_earn 0. | A total under 70 matches that added row, so the card shows 0 points on Locate. |
| Empty answer | Not documented yet. | [] when the option is not set. | The app's code reads the first row without checking, so an empty array makes the call fail with the app's own 422 error. |
| Credit rule | Not applicable. | order-v2 still decides the points an order earns from Shukah's table, not this one. | The card can show a rate that differs from the points the order really earns. |
POST /api/v2/client/gift-card-code
GET /maeda/giftcard-code/use✅ Replaced🚩 enableMerchantService- 🟡 GET ?code= becomes POST {code}; the app already has both calls and picks by mode (points.service.ts:128)
- 🟡 Same token check as the point routes (user-profile-auth.guard.ts:60-62), so a JWT-only app gets 401
- 🟡 Only codes order-v2 holds count; a Shukah-issued code can answer 404 "The code not exist"
- 🟡 Credit goes to Shukah add-point unless usePointService is on, so the Locate balance moves later
- 🟡 Errors are Nest {message,statusCode}; the app shows message, so the text must be fit for users
App-side switch: the app calls this Locate route only in Merchant mode (client settings enableMerchantService: true); otherwise it still calls Shukah.
Locate takes POST with {code} instead of GET ?code= and checks codes against the gift-card codes Locate holds itself (it is not known whether Shukah-issued codes exist there); the app already calls it in Merchant mode. 0 calls in the production traffic sheet of 2026-10-02 (3 days).
order-v2 serves this route itself and does not pass it to the point service. So the proxy rules and the 502 and 504 errors of the other points pages do not apply to it. The app calls it only in Merchant mode.
Request
| Field | In | Type | Required | Constraints | Description |
|---|---|---|---|---|---|
Authorization | header | string | yes | Bearer <token>. Dev answered 401 with no token. It uses the same token check as the point routes (via /api/v1/users, with x-auth-from: SHUKAH), so on dev today a Shukah token works and a Locate JWT gets 401 (code reading; not called with a token). | The client's own token. The client id comes from it. |
Content-Type | header | string | yes | application/json. | The body is JSON. |
code | body | string | yes | A non-empty string. A missing, empty or non-string code is refused with 400 by the validation step. | The gift-card code. |
Response
201 is expected on success, but it was not captured: the call spends a code and credits points,
so it was not run. The handler sets no other status, and the framework default for a POST is
201. The body has six keys:
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
total_points | number | no | The code's amount times the exchange rate. The rate is the exchanged_points setting (point_rate divided by riyad_rate), or 4 when that setting is missing or unreadable. | Points added. |
date_created | string | no | yyyy-mm-dd hh:mm:ss in Arab time (UTC+3). | When the code was redeemed. |
description | string | no | Points received for the <code> gift card. | Text for the history. |
message | string | no | The fixed Arabic text تم شحن الرصيد بنجاح. | Success message. |
points_type | string | no | Always earn. | Row kind. |
trans_type | string | no | Always adjustment. | Transaction type. |
{
"total_points": 40,
"date_created": "2026-10-01 12:00:00",
"description": "Points received for the ABC12345 gift card",
"message": "تم شحن الرصيد بنجاح",
"points_type": "earn",
"trans_type": "adjustment"
}
Illustrative, not captured: built from the response type in locate-order-v2-api@d9e4e5f6. A live
call would use up a gift-card code and credit points. The values are made up.
Errors
| HTTP | Body | When |
|---|---|---|
401 | {"message":"Unauthorized","statusCode":401} | There is no usable token. Captured on dev, 2026-10-01, with no token. This is Nest's shape, not the point-service envelope. |
400 | A Nest validation error. Not captured. | code is missing, empty or not a string. |
404 | Message The code not exist, in Nest's error shape. Not captured. | No code with that value, or the code is still a draft. |
400 | Message This code has already been used, in Nest's error shape. Not captured. | The code already has a client or is marked used. |
400 | Message The code has expired, in Nest's error shape. Not captured. | The current time is past 24:00 Arab time (21:00 UTC) of the code's expired_at date. |
500 | Nest's error shape. Not captured. | The points credit failed three times in a row, one second apart. |
Try it
This call spends a gift-card code and credits points. Use a code made for testing.
export ORDER_BASE_URL=https://dev.v2.api.order.locate.sa
export SHUKAH_TOKEN='<shukah-token>' # a Locate JWT gets 401 here today; see "Which tokens work" in Details
curl -s -X POST "$ORDER_BASE_URL/api/v2/client/gift-card-code" -H "Authorization: Bearer $SHUKAH_TOKEN" -H "Content-Type: application/json" -d '{"code":"<test-code>"}'
Expected: the six-key body shown under Response. Use a Shukah token on dev today: a Locate JWT is
refused by the same check as the point routes (code reading). With no token you get the 401 shown
under Errors.
Differences from Shukah
| Aspect | Shukah | Locate | Impact on the app |
|---|---|---|---|
| Method and code | GET $SHUKAH_BASE_URL/maeda/giftcard-code/use?code=<code>. | POST $ORDER_BASE_URL/api/v2/client/gift-card-code with {"code":"<code>"} in the body. | Change the method, the URL and where the code goes. The app already has both calls and picks one by app mode. |
| Token | Shukah opaque token. | The same token check as the point routes (via /api/v1/users), so a Shukah token on dev today; a Locate JWT gets 401 (code reading; not called with a token). | The app already sends its Shukah token in Merchant mode. Keep it until the token check accepts a Locate JWT. |
| Code inventory | Shukah's own gift-card codes. | The codes in order-v2's merchant database. Whether Shukah-issued codes are there too: Not documented yet. | A code that works on Shukah may be refused on Locate. |
| Response | Not documented yet: no Shukah capture exists, because the call credits points. | Six keys: total_points, date_created, description, message, points_type, trans_type. | The app parses the same six keys for both modes. Compare the values before switching. |
| Where the credit lands | Not applicable. | Shukah or the point service, depending on a server flag. It sends the credit to Shukah when the flag is off (its default). The dev value: Not documented yet. | With the flag off the credit reaches Shukah first, so the Locate balance moves only after the sync. |
| Error body | Not documented yet. | 401 is Nest's {"message":"Unauthorized","statusCode":401}. | Anything that matches on error text must change. |
Watch out
- The points balance needs a Shukah token on dev today (a Locate JWT gets
401), its number has four decimals, and on 2026-10-02 it was 447.96 points below Shukah's for the same client. - Checkout still checks the Shukah balance when the client pays an order with points, and a points spend on one side is not taken off the other (dev, 2026-10-02, within seconds; later not checked): the Wallet screen and checkout can disagree, so read the balance where orders are created.
- The earn-rate table on dev differs from Shukah's, and the app's code fails on an empty array, which this route returns when the option is missing.
Details (for backend)
Only dev was checked, and only through order-v2 at https://dev.v2.api.order.locate.sa, prefix
/api/v2/client. The point service is at https://dev-point-api.locate.sa (named in the point
service's own docs). That dev order-v2 uses that host is not proven: the captures show a working
point service behind it, and the URL itself is Not documented yet. UAT and production hosts:
Not documented yet.
In this section OV2: means locate-order-v2-api@d9e4e5f6 (2026-10-01), PS: means
locate-point-service@2c44ce3 (2026-10-01) and AUTH: means locate-authentication-service@6757aa4
(all three are origin/develop, read again on 2026-10-02). Mobile citations name the repo in full.
How the proxied calls work
This section applies to the balance call and, with other routes, to the other points pages of this module. The earn-rate and gift-card routes below it are not proxied.
- The route. The app calls order-v2, which has no guard, no validation and no body type on
these routes. It passes the call to the point service and returns the status and body unchanged
(
OV2: src/modules/client/point/point.controller.ts:17-29,OV2: src/modules/client/point/point.service.ts:64). The balance call goes toGET /api/client/pointson the point service, with a 10 second limit (PS: services/api/src/routes/client.routes.ts:17,PS: services/api/src/handlers/client-points.ts:6-10). - What is forwarded. Only
Authorization(as received),Accept: application/jsonand, on aPOST,Content-Type: application/json. Not forwarded:x-from-application,from-application,x-from-version,Accept-Language, device ids and cookies, although the app sends several of them (OV2: src/modules/client/point/point.service.ts:54-60,mobile/locate-client@e960c3a: src/shared/api/helpers/getCommonHeaders.ts:4-11). The balance call forwards no query. - The two errors order-v2 makes. A transport error with code
ECONNABORTEDorETIMEDOUTbecomes504. Any other transport error becomes502. IfPOINT_SERVICE_URLis not set, every call is502(OV2: src/modules/client/point/point.service.ts:11-16,74-96). The502body also has amessage, which the order-v2 reference doc leaves out (OV2: docs/locate-point-client-api-reference.md:15-16). - Framework errors before the proxy runs (from a local reproduction on Nest 10.4.22; dev's
runtime version is not known): a malformed JSON body is
400in Nest's shape{"message":"…","error":"Bad Request","statusCode":400}, and a body over about 100 kB is413. These are not the point-service envelope. - Token check. The point service requires
Authorization: Bearer <token>and callsGET {AUTH_SERVICE_URL}/api/v1/userswith that token and anx-auth-fromvalue that isSHUKAHby default and can be changed with theAUTH_VERIFY_APPLICATIONsetting. The caller's ownx-auth-fromis never forwarded. Any non-2xx answer is401. The client id is the first ofclient_id,user_idandidin the answer, and it must be a positive integer (PS: services/api/src/middleware/client-auth.ts:7-18,PS: services/api/src/services/client-auth.service.ts:15-37). An unreachable auth service is also401(auth service unreachable: …), not5xx, although the order-v2 reference doc says500(PS: services/api/src/services/client-auth.service.ts:26-28,OV2: docs/locate-point-client-api-reference.md:201). - Request bodies are logged. order-v2 logs request bodies without redaction, including the
cvvof the card-pay call (OV2: src/main.ts:38,OV2: src/shared/lib/base.lib.ts:32-43).
Error envelope. Every point-service error is
{"success":false,"error":{"code":…,"message":…,"details"?:…}}. For VALIDATION_ERROR from the
schema, details is {"formErrors":[…],"fieldErrors":{"<field>":["<message>"]}}.
error.code | HTTP |
|---|---|
VALIDATION_ERROR | 400 |
UNAUTHORIZED | 401 |
FORBIDDEN | 403 |
NOT_FOUND | 404 |
CONFLICT | 409 |
INSUFFICIENT_POINTS | 422 |
INTERNAL_ERROR | 500 |
(PS: packages/core/src/errors/error.ts:19-26,42-47.) Any exception that is not an AppError
becomes INTERNAL_ERROR Internal server error
(PS: services/api/src/middleware/error-handler.ts:18-22).
Which tokens work
On dev, on 2026-10-02, GET /api/v2/client/points returned 200 for a Shukah opaque token and 401
{"success":false,"error":{"code":"UNAUTHORIZED","message":"Invalid or expired token"}} for a Locate
JWT. The history, offers, occasions and FAQ routes gave the same pair. The earn-rate route answered
200 to both, because it has no guard. On 2026-10-01 the balance route had answered 200 with an
identical body to both tokens, and GET /api/v1/users with x-auth-from: SHUKAH had too.
Why the Locate JWT fails now:
- order-v2 does not check the token on the proxied routes. It forwards
Authorization(OV2: src/modules/client/point/point.controller.ts:17-29,OV2: src/modules/client/point/point.service.ts:54-60). - The point service asks the auth service
GET /api/v1/userswithx-auth-from: SHUKAH, unlessAUTH_VERIFY_APPLICATIONis set, and turns any answer that is not2xxinto401 Invalid or expired token(PS: services/api/src/services/client-auth.service.ts:17,22-29). The message matches the capture, so the refusal comes from the auth service, not from a missing header. order-v2's own guard on the gift-card route makes the same call withSHUKAHhard-coded (OV2: src/auth/guards/user-profile-auth.guard.ts:60-62). - For
x-auth-from: SHUKAHthe auth service readsauth_source(AUTH: src/middlewares/user.js:35-37,101;AUTH: src/services/setting.js:46-50). Dev's public client settings returned"auth_source":"shukah"on 2026-10-02. AnAUTH_SOURCEenvironment variable would override that and cannot be read from outside, so this is very likely, not proven. In that branch the token is passed on to Shukah's/maeda/api/verify-token, and any failure is401(AUTH: src/middlewares/user.js:132-136,178-185). Dev Shukah accepts the Shukah token and refused the Locate JWT on 2026-10-02 (an inference: Shukah's verify-token was not called with the JWT itself). On 2026-10-01 it still accepted the JWT, probably through the push of new Locate JWTs to Shukah (see User detail). - With
auth_sourceset tolocate, every Locate JWT would get401as well:AUTH: src/services/client.js:12-17calls.cacheQueryon the Promise that theasyncClient.findByTokenreturns (AUTH: src/models/client.js:163).
So a Locate JWT works on these routes only if dev Shukah accepts it again, or if auth_source
becomes locate and that defect is fixed. Until then the app needs a Shukah token for every guarded
route on the points pages. Which tokens these routes accept in UAT and production: Not documented
yet.
The balance route
- It reads three things at once: the client's points account, the point settings, and the client's
newest counted earn row. It writes nothing, sends no push and does not call Shukah
(
PS: services/api/src/services/client-points.service.ts:14-24). - Why four decimals. The ledger stores points at four decimals and the balance is written as
text with four decimals.
formatShukahPointsonly strips trailing zeros and a trailing dot, so it never rounds (PS: packages/core/src/money/minor-units.ts:3-4,21-23,PS: services/api/src/services/client-points.mappers.ts:3-8; pinned byPS: services/api/src/services/client-points.mappers.test.ts:31-41). The value"514405.6788"is the stored balance as it is. Which ledger rows give it the.6788part: Not documented yet. - The gap to Shukah. On 2026-10-01 the balance differed by a factor of about 3.9966
(
514405.6788against128712.41). On 2026-10-02 Shukah returned514853.64, so the Shukah dev data had changed, and Locate was 447.96 points lower. Locate's newest earn row (shukah_id1351722, 2026-09-29 12:45:07) is 45 ids behind Shukah's (1351767, 2026-10-01 17:00:01), and the missing tail fits the gap (inference). The July parity reports had both sides at about 511,700 (PS: docs/reports/2026-07-17-client-api-parity.md:39-40). - Expiry rule.
nullunless the settings sayinactivity_daysand a counted earn row exists. Then the date is the newest counted earn row's creation time plusexpiration_inactivity_daysdays, shown as an Asia/Riyadh date (PS: services/api/src/services/client-points.mappers.ts:30-39; testPS: services/api/src/services/client-points.mappers.test.ts:18-29: 2026-07-16 plus 60 days gives2026-09-14). Old expiration rows that Shukah wrote as negative earns are not left out of "newest earn", so one of them can push the date out. New expirations are stored as expenses and do not (PS: services/api/src/services/client-points.service.ts:17-21,PS: packages/core/src/points/sync-shukah-points.ts:97-102). - Dev settings (inferred). Dev returned
2026-11-28. The newest Locate ledger row for the client is 2026-09-29 and 2026-09-29 plus 60 days is 2026-11-28, so dev behaves as 60 inactivity days. The real settings document can only be read through the admin route with a service key: Not documented yet. The settings are cached for about 10 seconds (PS: packages/core/src/db/point-config.singleton.ts:20,89). - Is the date enforced? The expiry job is a dry run (it simulates and writes nothing) when the
points source is not
merchant, when the expiry type is notinactivity_days, or when the window is under 1 day. If the source ismerchantandMERCHANT_FIRST_ENABLEDis nottrue, the job does not simulate: it refuses to run and fails withINTERNAL_ERROR(PS: packages/core/src/points/expire-points.ts:245-254,PS: packages/core/src/points/merchant-first-guard.ts:21-28). Shukah still writes its own expirations. When the job does apply, a balance expires once the date is today or earlier in Asia/Riyadh, and the job runs daily at 01:00 Asia/Riyadh (PS: packages/core/src/points/expire-points.ts:63-66,PS: docs/plans/2026-09-16-point-expiration-job.md:6). Whether it applies on dev: Not documented yet. - Two balances. For cart pricing and order checkout when the store accepts pay-by-points,
order-v2 calls
GET {SHUKAH_BASE_URL}/maeda/client/pointswith the client's token. It uses 0 if Shukah's expiry date has passed or the call fails. This does not depend on theusePointServiceflag (OV2: src/shared/lib/handle-points.lib.ts:326-350,OV2: src/modules/client/orders/orders.service.ts:1128-1136,5036-5047). - Spends are not synced. Live calls on dev, 2026-10-02 (client 356259): Shukah
514853.64and Locate514405.6788before;514743.64and514405.6788after apointorder on Shukah (order 1257);514743.64and514295.6788after the same order on Locate (order 100012). Each side tookpurchased_point_amount(110) from its own ledger only. A Locatepointorder queues the Shukah legs in an outbox that nothing drains on k8s today (see Point orders, Details). Whether a Shukah spend reaches Locate later through the inbound feed was not checked.
The earn-rate route
- The controller is
@Controller('system_configs')with@Get('/point_rates')and no guard (OV2: src/modules/client/system-configuration-options/system-configs.controller.ts:7-13). - The service reads the option
point_ratesformerchantId: 0from the merchant-v2 database, through a Redis hash cache. It parses the JSON, sorts byamount_from(anullcounts as 0), and returns[]when the option is missing. Text that is not valid JSON also gives[], because the parser falls back to an empty list. A500needs a read failure, or JSON that is not a list (so the sort throws) (OV2: src/modules/client/system-configuration-options/system-configs.service.ts:18-40,OV2: src/services/redis/system-config-cache.service.ts:35-62,OV2: src/shared/lib/base.lib.ts:58-75). It calls neither the point service nor Shukah. - It is not the table order-v2 credits from.
getPointsEarnreads Shukah's/maeda/point-rate(OV2: src/shared/lib/handle-points.lib.ts:25-43,98-107,OV2: src/services/points/points.service.ts:160). The point service has no rate route, only an unusedpoint_ratesmodel (PS: packages/core/src/db/models/point-settings.model.ts:14-19). - A third set of tiers. The points FAQ text on both services says 35–69 SR earns 1 point, 70–139 earns 2, 140–250 earns 4, 251–450 earns 8 and 451 and up earns 16. That matches neither table (see Points FAQ). Which table is the right one: Not documented yet.
- The app. In Merchant mode the app calls this route; the same post-processing runs on both
tables. It adds a first row with
amount_fromnull andamount_toequal to the first row'samount_fromminus one. The test for that row is always true (it is written with||), so the row is always added. It readsresponse.data?.[0].amount_fromwithout a guard, so[]throws (mobile/locate-client@e960c3a: src/entities/points/model/api/services/points.service.ts:78-126).
The gift-card route
@UseGuards(UserProfileGuard)and@Post(), with the body type{code}(@IsString(),@IsNotEmpty()) (OV2: src/modules/client/gift-card-code/gift-card-code.controller.ts:8-24,OV2: src/modules/client/gift-card-code/dtos/apply-code.dto.ts:4-9). The guard makes the same auth call as the point routes, withx-auth-fromhard-coded toSHUKAH, and a missing token gives Nest's{"message":"Unauthorized","statusCode":401}(OV2: src/auth/guards/user-profile-auth.guard.ts:58-72).applyCodelooks the code up in the merchant-v2 database with a status that is notDRAFT. An unknown code is404. A code with a client or theUSEDstatus is400. A code is expired from 24:00 Arab time (21:00 UTC) of itsexpired_atdate, and that is400. The points are the amount times the rate, with the rate from theexchanged_pointssetting or 4. The credit goes throughPointsService.addPoints, up to three tries one second apart, then500. Then the code is marked used (OV2: src/modules/core/gift-card-code/gift-card-code.service.ts:40-94,96-114,OV2: src/modules/core/gift-card-code/constants/exchange-point-rate.ts:1).- Where the credit goes.
addPointssends it to Shukah's/maeda/client/add-pointwhen theusePointServiceflag is false (the default, also used when the flag cannot be read), and to the point service when it is true (OV2: src/services/points/points.service.ts:29-73,OV2: src/services/user-points/user-points.service.ts:39-52). The point service's docs say the flag is false in every environment and that production has no plan to turn it on (PS: docs/integrations/2026-07-30-use-point-service-flag-routing.md:13-16,37). Dev's value was not read: Not documented yet. - The point service has no gift-card route, and its docs do not mention one.
- The response mirrors Shukah's shape: the app parses the same six keys in both modes
(
mobile/locate-client@e960c3a: src/entities/points/model/api/dto/points.dto.ts:215-222). The app calls Shukah withGETandcodein the query, and Locate withPOSTand{code}, chosen by app mode (mobile/locate-client@e960c3a: src/entities/points/model/api/services/points.service.ts:128-168).
Where the docs and the code disagree (code wins)
- The service docs say
pointsis the balance as it is stored, and that it is "Shukah-formatted" (PS: docs/integrations/2026-07-16-client-facing-point-apis.md:92,OV2: docs/locate-point-client-api-reference.md:97). That holds only for trailing zeros: the code keeps four decimals. - Both docs say
pointsExpirationDateisnulluntil the point settings are set (PS: docs/integrations/2026-07-16-client-facing-point-apis.md:463-467,OV2: docs/locate-point-client-api-reference.md:90,98,222-224). Dev now returns a date. - The point service doc says an opaque token is always forwarded to Shukah
(
PS: docs/integrations/2026-07-16-client-facing-point-apis.md:56-58). In the auth code that is only one branch (see "Which tokens work"). - The 2026-09-21 manual-test guide says dev order-v2 calls a point service on
localhost:4010, so every call would be502(PS: docs/plans/2026-09-21-point-order-manual-test-guide.md:24-32). On 2026-10-01 the dev proxy answered200with ledger data, so the guide is stale. - 2026-10-02: this page said a Shukah token and a Locate JWT both worked on the balance route, and
its Try it used a Locate JWT. On 2026-10-02 the JWT got
401; the page now says why, and the Try it uses a Shukah token. - 2026-10-02: this page said Locate's balance was about four times Shukah's (
"128712.41"). The Shukah dev balance is now"514853.64", 447.96 points above Locate's.
Sources
locate-order-v2-api@d9e4e5f6:src/modules/client/point/point.controller.ts:17-29,src/modules/client/point/point.service.ts:11-16,54-60,64,74-96,src/main.ts:38,src/shared/lib/base.lib.ts:32-43,58-75,src/modules/client/system-configuration-options/system-configs.controller.ts:7-13,src/modules/client/system-configuration-options/system-configs.service.ts:18-40,src/services/redis/system-config-cache.service.ts:35-62,src/modules/client/gift-card-code/gift-card-code.controller.ts:8-24,src/modules/client/gift-card-code/dtos/apply-code.dto.ts:4-9,src/modules/core/gift-card-code/gift-card-code.service.ts:40-114,src/modules/core/gift-card-code/constants/exchange-point-rate.ts:1,src/auth/guards/user-profile-auth.guard.ts:58-72,src/services/points/points.service.ts:29-73,160,src/services/user-points/user-points.service.ts:39-52,src/shared/lib/handle-points.lib.ts:25-43,98-107,326-350,src/modules/client/orders/orders.service.ts:1128-1136,5036-5047locate-order-v2-api@d9e4e5f6docs:docs/locate-point-client-api-reference.md:15-16,90,97,98,201,222-224locate-point-service@2c44ce3:services/api/src/routes/client.routes.ts:17,services/api/src/handlers/client-points.ts:6-10,services/api/src/middleware/client-auth.ts:7-18,services/api/src/services/client-auth.service.ts:15-37,services/api/src/middleware/error-handler.ts:18-22,packages/core/src/errors/error.ts:19-26,42-47,services/api/src/services/client-points.service.ts:14-24,services/api/src/services/client-points.mappers.ts:3-8,30-39,services/api/src/services/client-points.mappers.test.ts:18-29,31-41,packages/core/src/money/minor-units.ts:3-4,21-23,packages/core/src/db/point-config.singleton.ts:20,89,packages/core/src/points/expire-points.ts:63-66,245-254,packages/core/src/points/merchant-first-guard.ts:21-28,packages/core/src/points/sync-shukah-points.ts:97-102,packages/core/src/db/models/point-settings.model.ts:14-19locate-point-service@2c44ce3docs:docs/integrations/2026-07-16-client-facing-point-apis.md:56-58,92,463-467,docs/integrations/2026-07-30-use-point-service-flag-routing.md:13-16,37,docs/plans/2026-09-16-point-expiration-job.md:6,docs/plans/2026-09-21-point-order-manual-test-guide.md:24-32,docs/reports/2026-07-17-client-api-parity.md:39-40- Mobile snapshot
mobile/locate-client@e960c3a(branchdevelopment, 2026-02-26; the app's own code, not Locate):platforms/mobile/products/locate/screens/home/mainInner/Wallet/ui/Wallet/Wallet.tsx:78-85,src/features/PointsWallet/ui/PointsWallet/PointsWallet.tsx:29,src/entities/user/model/services/fetchUserInfo/fetchUserInfo.ts:55,src/features/selectRestaurantPaymentMethod/ui/SelectRestaurantPaymentMethod/SelectRestaurantPaymentMethod.container.tsx:64,platforms/mobile/products/locate/screens/restaurant/RestaurantCart/ui/RestaurantCart/RestaurantCart.tsx:301,platforms/mobile/shared/modules/core/cart/components/public/MerchantCartGeneralInfo/components/MerchantCartGeneralInfo/MerchantCartGeneralInfo.tsx:66,src/widgets/ApplyPointsPromoCode/model/services/applyPointsPromoCode.ts:27-50,src/entities/points/model/api/services/points.service.ts:30-44,78-168,src/entities/points/model/api/controllers/points.controller.ts:95-126,src/entities/points/model/api/dto/points.dto.ts:90-102,185-222,src/entities/settings/model/services/getServiceAvailable/getServiceAvailable.ts:48-54,src/shared/features/AppModeConfigs.ts:5-62,src/shared/api/helpers/getCommonHeaders.ts:4-11 locate-authentication-service@6757aa4:src/middlewares/user.js:35-37,101,132-136,178-185,src/services/setting.js:46-50,src/services/client.js:12-17,src/models/client.js:163locate-point-service@2c44ce3, token check:services/api/src/services/client-auth.service.ts:17,22-29;locate-order-v2-api@d9e4e5f6:src/auth/guards/user-profile-auth.guard.ts:60-62- Dev captures, 2026-10-02 (parity audit):
points--shukah,points--locate-st,points--locate-jwt,point-rate--shukah,point-rate--locate-st,point-rate--locate-jwt; dev client settings read fromhttps://dev.go.locate.sa/api/v1/settings/client - Dev captures, 2026-10-01:
locate-points,locate-points-locate-token,shukah-points,probe-locate-points,probe-shukah-points,locate-point-rates,probe-locate-point-rates,shukah-point-rate,probe-shukah-point-rate,probe-locate-gift-card-code,locate-points-faq,shukah-points-faq