Skip to main content

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/points on order-v2 passes the call to the point service. It returns the same four keys as Shukah. To watch: points can 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 POST with 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:

CallWhere 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​

Replaces ShukahGET /maeda/client/points✅ Replaced
Drop-in0 breaking · 4 to watch
  • 🟡 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​

FieldInTypeRequiredConstraintsDescription
AuthorizationheaderstringyesBearer <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 keyquery—noNot 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):

FieldTypeNullableFormat / constraintsDescription
idstringnoThe client id as text, for example "356259".The client id.
user_idstringnoThe same value as id.The client id.
pointsstringnoA 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.
pointsExpirationDatestringyesYYYY-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​

HTTPBodyWhen
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​

AspectShukahLocateImpact on the app
URLGET $SHUKAH_BASE_URL/maeda/client/pointsGET $ORDER_BASE_URL/api/v2/client/pointsChange the base URL and the path.
TokenShukah 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 formatTwo 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-Typeapplication/jsonapplication/json; charset=utf-8None.

GET /api/v2/client/system_configs/point_rates​

Replaces ShukahGET /maeda/point-rate✅ Replaced🚩 enableMerchantService
Drop-in0 breaking · 4 to watch
  • 🟡 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​

FieldInTypeRequiredConstraintsDescription
AuthorizationheaderstringnoNot needed: dev answered 200 with no token and with a token. A token that is sent is not read.—
Any query keyquery—noAccepted 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:

FieldTypeNullableFormat / constraintsDescription
amount_fromnumberyesThe order total where the row starts.Start of the range, in SAR.
amount_tonumberyesnull 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_earnnumbernoA 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​

HTTPBodyWhen
500A 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​

AspectShukahLocateImpact on the app
URLGET $SHUKAH_BASE_URL/maeda/point-rateGET $ORDER_BASE_URL/api/v2/client/system_configs/point_ratesChange the base URL and the path. The app already has this call for Merchant mode.
TokenNone needed (200 with no token on dev, 2026-10-01).None needed (200 with no token on dev, 2026-10-01).None.
Rows on devFive 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 endsRanges 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 rowStarts 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 answerNot 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 ruleNot 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​

Replaces ShukahGET /maeda/giftcard-code/use✅ Replaced🚩 enableMerchantService
Drop-in0 breaking · 5 to watch
  • 🟡 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​

FieldInTypeRequiredConstraintsDescription
AuthorizationheaderstringyesBearer <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-Typeheaderstringyesapplication/json.The body is JSON.
codebodystringyesA 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:

FieldTypeNullableFormat / constraintsDescription
total_pointsnumbernoThe 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_createdstringnoyyyy-mm-dd hh:mm:ss in Arab time (UTC+3).When the code was redeemed.
descriptionstringnoPoints received for the <code> gift card.Text for the history.
messagestringnoThe fixed Arabic text تم شحن الرصيد بنجاح.Success message.
points_typestringnoAlways earn.Row kind.
trans_typestringnoAlways 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​

HTTPBodyWhen
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.
400A Nest validation error. Not captured.code is missing, empty or not a string.
404Message The code not exist, in Nest's error shape. Not captured.No code with that value, or the code is still a draft.
400Message This code has already been used, in Nest's error shape. Not captured.The code already has a client or is marked used.
400Message 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.
500Nest'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​

AspectShukahLocateImpact on the app
Method and codeGET $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.
TokenShukah 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 inventoryShukah'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.
ResponseNot 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 landsNot 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 bodyNot 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 to GET /api/client/points on 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/json and, on a POST, 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 ECONNABORTED or ETIMEDOUT becomes 504. Any other transport error becomes 502. If POINT_SERVICE_URL is not set, every call is 502 (OV2: src/modules/client/point/point.service.ts:11-16,74-96). The 502 body also has a message, 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 400 in Nest's shape {"message":"…","error":"Bad Request","statusCode":400}, and a body over about 100 kB is 413. These are not the point-service envelope.
  • Token check. The point service requires Authorization: Bearer <token> and calls GET {AUTH_SERVICE_URL}/api/v1/users with that token and an x-auth-from value that is SHUKAH by default and can be changed with the AUTH_VERIFY_APPLICATION setting. The caller's own x-auth-from is never forwarded. Any non-2xx answer is 401. The client id is the first of client_id, user_id and id in 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 also 401 (auth service unreachable: …), not 5xx, although the order-v2 reference doc says 500 (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 cvv of 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.codeHTTP
VALIDATION_ERROR400
UNAUTHORIZED401
FORBIDDEN403
NOT_FOUND404
CONFLICT409
INSUFFICIENT_POINTS422
INTERNAL_ERROR500

(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:

  1. 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).
  2. The point service asks the auth service GET /api/v1/users with x-auth-from: SHUKAH, unless AUTH_VERIFY_APPLICATION is set, and turns any answer that is not 2xx into 401 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 with SHUKAH hard-coded (OV2: src/auth/guards/user-profile-auth.guard.ts:60-62).
  3. For x-auth-from: SHUKAH the auth service reads auth_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. An AUTH_SOURCE environment 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 is 401 (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).
  4. With auth_source set to locate, every Locate JWT would get 401 as well: AUTH: src/services/client.js:12-17 calls .cacheQuery on the Promise that the async Client.findByToken returns (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. formatShukahPoints only 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 by PS: 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 .6788 part: Not documented yet.
  • The gap to Shukah. On 2026-10-01 the balance differed by a factor of about 3.9966 (514405.6788 against 128712.41). On 2026-10-02 Shukah returned 514853.64, so the Shukah dev data had changed, and Locate was 447.96 points lower. Locate's newest earn row (shukah_id 1351722, 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. null unless the settings say inactivity_days and a counted earn row exists. Then the date is the newest counted earn row's creation time plus expiration_inactivity_days days, shown as an Asia/Riyadh date (PS: services/api/src/services/client-points.mappers.ts:30-39; test PS: services/api/src/services/client-points.mappers.test.ts:18-29: 2026-07-16 plus 60 days gives 2026-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 not inactivity_days, or when the window is under 1 day. If the source is merchant and MERCHANT_FIRST_ENABLED is not true, the job does not simulate: it refuses to run and fails with INTERNAL_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/points with the client's token. It uses 0 if Shukah's expiry date has passed or the call fails. This does not depend on the usePointService flag (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.64 and Locate 514405.6788 before; 514743.64 and 514405.6788 after a point order on Shukah (order 1257); 514743.64 and 514295.6788 after the same order on Locate (order 100012). Each side took purchased_point_amount (110) from its own ledger only. A Locate point order 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_rates for merchantId: 0 from the merchant-v2 database, through a Redis hash cache. It parses the JSON, sorts by amount_from (a null counts 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. A 500 needs 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. getPointsEarn reads 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 unused point_rates model (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_from null and amount_to equal to the first row's amount_from minus one. The test for that row is always true (it is written with ||), so the row is always added. It reads response.data?.[0].amount_from without 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, with x-auth-from hard-coded to SHUKAH, and a missing token gives Nest's {"message":"Unauthorized","statusCode":401} (OV2: src/auth/guards/user-profile-auth.guard.ts:58-72).
  • applyCode looks the code up in the merchant-v2 database with a status that is not DRAFT. An unknown code is 404. A code with a client or the USED status is 400. A code is expired from 24:00 Arab time (21:00 UTC) of its expired_at date, and that is 400. The points are the amount times the rate, with the rate from the exchanged_points setting or 4. The credit goes through PointsService.addPoints, up to three tries one second apart, then 500. 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. addPoints sends it to Shukah's /maeda/client/add-point when the usePointService flag 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 with GET and code in the query, and Locate with POST and {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 points is 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 pointsExpirationDate is null until 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 be 502 (PS: docs/plans/2026-09-21-point-order-manual-test-guide.md:24-32). On 2026-10-01 the dev proxy answered 200 with 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-5047
  • locate-order-v2-api@d9e4e5f6 docs: docs/locate-point-client-api-reference.md:15-16,90,97,98,201,222-224
  • locate-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-19
  • locate-point-service@2c44ce3 docs: 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 (branch development, 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:163
  • locate-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 from https://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