Point history
Background
On Shukah the app reads the client's whole point history with one call,
GET /maeda/client/point-history: on dev, 8,134 rows for the test client came back in one array
(8,136 on 2026-10-02).
Locate's GET /api/v2/client/point-history returns the same two row shapes, but it gives one page at
a time: 20 rows by default and at most 100. The app sends no page or limit, so after a switch it
would see only the newest 20 rows. Three other things need care: the id of an expense row is a
24-character string, many earn rows have an empty description, and Locate lists every ledger row,
including reversals and rows that did not move the balance.
Feature
The history is a list of the client's point transactions. In the app snapshot of 2026-02-26 two
screens use it. They share one call, which sends no page or limit:
| Where in the app | What it does |
|---|---|
| The Wallet screen | The "last transactions" list calls it when the screen opens and shows the first 3 rows of the answer. |
| The Wallet history screen | The full list calls it when the screen opens and on pull-to-refresh, and shows the whole answer. |
The app also reloads the history after it redeems a gift-card code and after a gift is paid.
Only a Shukah token works on dev today: a Locate JWT got 401 Invalid or expired token on this
route on 2026-10-02, although both tokens worked on the balance route on 2026-10-01 (see
Which tokens work). order-v2 passes the call to the point
service and returns its answer unchanged.
Paging. page starts at 1 and limit defaults to 20, with a maximum of 100. The answer is a
bare array. It has no total, no "next page" marker and no paging header, so the app has to keep
asking until a page returns fewer rows than limit. Rows are sorted by creation time, newest first,
and rows with the same creation time have no fixed order, so a row can move between pages.
Two row shapes. Branch on points_type. An earn row has 7 keys and an expense row has 14.
What can be in the list. The service filters nothing:
- Gift rows. A gift paid with points writes an expense row for the sender and an earn row for the recipient. A gift paid by card writes the earn row for the recipient when the payment is captured.
- Expiration rows. Shukah records an expiration as a negative row. Rows copied from Shukah's
history keep that form: an earn row with a negative
total_points. Expirations since 2026-09-16 and those made by Locate's own job are expense rows with a positive amount. - Reversal rows. An admin can reverse a transaction. That adds a new row of the opposite kind
with the same amount, and the original stays. Nothing marks it: it looks like any other earn or
expense row, and its text starts with
reversal of. The app never creates one. - Rows that did not count. A row from Shukah whose status is not active, and a reversal of such
a row, is stored as not counted. It does not change the balance, but it is listed with its normal
total_points. An expense row of that kind still says"status": "active".
GET /api/v2/client/point-history
GET /maeda/client/point-history✅ Replaced- 🔴 Locate pages at 20 rows (max 100); Shukah sent all 8,136; the app sends no paging, so it stops at 20
- 🔴 No total or next-page marker; loop with page and limit (default 20, max 100) until a short page
- 🟡 Expense rows: 14 keys, total_points "360.00" (string), no shukah_id; the app reads 5 keys and prints it
- 🟡 8 of 9 earn rows have description "" (Shukah has text); the app renders a blank line under the date
- 🟡 Newest Locate row is 2026-09-29 12:45:07 (id 1351722); Shukah's is 2026-10-01 17:00:01 (id 1351767)
- 🟡 Rows with the same date_created have no fixed order across pages; the app keyExtractor is date + points
- 🟡 Locate JWT gets 401 on dev; send the Shukah token
Request
| Field | In | Type | Required | Constraints | Description |
|---|---|---|---|---|---|
Authorization | header | string | yes | Bearer <token>: a Shukah token on dev today. A Locate JWT gets 401 Invalid or expired token (captured on dev, 2026-10-02): see Which tokens work. | The client's own token. The client id comes from it. |
page | query | integer | no | Default 1. A positive integer after the text is turned into a number. | The page, counted from 1. |
limit | query | integer | no | Default 20. A positive integer, at most 100. | Rows per page. |
language | query | string | no | Accepted and ignored. The app adds it to every call. | Kept so the old Shukah query string still works. |
Values are turned into numbers before the check, so 1e1 is 10 and " 2 " is 2. An empty page=
or limit=, 0, a negative value, 1.5 and abc are refused with 400.
Response
200 with a bare array, newest first. It is [] when the page is past the end.
Earn row (points_type is earn), 7 keys in this order:
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
shukah_id | integer | yes | Shukah's id for the row when there is one. null for rows that Locate wrote itself. | The row id on the Shukah side. |
total_points | number | no | A JSON number. Negative on old expiration rows. | Points credited. |
date_created | string | no | YYYY-MM-DD HH:mm:ss, Asia/Riyadh clock time, no zone marker. | When the row was created. |
description | string | no | "" when there is no text. 8 of the 9 earn rows among the first 10 were "" on 2026-10-02, and one was manual credit. | Free text for the row. |
message | string | no | Always "". | Not used. |
points_type | string | no | Always "earn". | Row kind. |
trans_type | string | no | adjustment on every captured row. When the row has no type, adjustment. | The Shukah transaction type. |
{
"shukah_id": 1351722,
"total_points": 60,
"date_created": "2026-09-29 12:45:07",
"description": "",
"message": "",
"points_type": "earn",
"trans_type": "adjustment"
}
Captured on dev, 2026-10-01 (client 356259, page=1&limit=5): the first of five rows.
Expense row (points_type is expenses), 14 keys in this order:
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
id | string | no | The 24-character hex id of the ledger row. Shukah's is a number. | The row id. |
client_id | integer | no | The caller. | The client id. |
merchant_id | integer | no | 0 when there is no merchant. | The merchant. |
order_id | integer | no | 0 when there is no order. | The order. |
total_points | string | no | Exactly two decimals, for example "360.00". Note that an earn row sends a number. | Points spent. |
status | string | no | Always "active", even for a row that did not count. | Row status. |
date_created | string | no | YYYY-MM-DD HH:mm:ss, Asia/Riyadh. | When the row was created. |
ip_address | string | no | Always "". | Not used. |
total_points_amt | number | no | The SAR value of the points, 0.25 SAR each (360 points give 90). 0 when not set. | Value in SAR. |
trans_type | string | no | order on the captured row. When the row has no type, order. | The transaction type. |
points_type | string | no | Always "expenses". | Row kind. |
remarks | string | no | The same text as description. | Free text. |
external_id | null | yes | Always null. | Not used. |
description | string | no | "" when there is no text. | Free text for the row. |
{
"id": "6ab5fee68fd829e4e33785f9",
"client_id": 356259,
"merchant_id": 0,
"order_id": 0,
"total_points": "360.00",
"status": "active",
"date_created": "2026-09-25 07:56:06",
"ip_address": "",
"total_points_amt": 90,
"trans_type": "order",
"points_type": "expenses",
"remarks": "You have sent a gift Graduation of 300 points to Test User (*****0000) #100004",
"external_id": null,
"description": "You have sent a gift Graduation of 300 points to Test User (*****0000) #100004"
}
Captured on dev, 2026-10-01 (client 356259, page=1&limit=100): the one expense row among the first
ten stored rows; the recipient's name and phone digits replaced. It is a gift of 300 points that
cost the sender 360 points.
Gift text. The rows that Locate writes for a gift read You have sent a gift … to <name> (*****<last 4 digits>) #<order number> for the sender, and You have received a gift … from … for
the recipient. The text follows the owner's language. For an owner whose language is Arabic, the text
comes from a template in the point settings, and a blank template leaves description empty. Whether
the templates are filled on dev or production: Not documented yet.
Errors
| HTTP | Body | When |
|---|---|---|
400 | {"success":false,"error":{"code":"VALIDATION_ERROR","message":"Invalid query","details":{"formErrors":[],"fieldErrors":{"limit":["Number must be less than or equal to 100"]}}}} | page or limit fails the check. The example is for ?limit=101, from a local run of the service's schema, not captured live. The messages for the other values are Number must be greater than 0, Expected integer, received float and Expected number, received nan. |
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 | The same shape, with message Invalid or expired token, 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. Invalid or expired token was captured on dev, 2026-10-02, with a Locate JWT. |
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":"…"}}.
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" on Points wallet
curl -s "$ORDER_BASE_URL/api/v2/client/point-history?page=1&limit=5" -H "Authorization: Bearer $SHUKAH_TOKEN"
Expected: 200 and an array of at most five rows, newest first. Use a Shukah token on dev today: a
Locate JWT gets 401 Invalid or expired token (2026-10-02). With limit=101 you get the 400
shown under Errors.
Differences from Shukah
| Aspect | Shukah | Locate | Impact on the app |
|---|---|---|---|
| URL | GET $SHUKAH_BASE_URL/maeda/client/point-history | GET $ORDER_BASE_URL/api/v2/client/point-history | 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). | The app must keep sending a Shukah token here. For other environments: Not documented yet. |
| Paging | The whole history in one array: 8,134 rows for client 356259 (captured on dev, 2026-10-01, 2.3 s), 8,136 on 2026-10-02. | 20 rows by default, at most 100 (limit=100 returned 100 rows on dev). No total and no next-page marker. | The app sends no page or limit, so it would show only the newest 20 rows. It has to page until a page is shorter than limit. |
Expense row id | A number (July parity report; no expense row in Shukah's stored capture). | A 24-character string (captured on dev, 2026-10-01). | None for the app today: its history type has no id or shukah_id. |
| Earn row keys | 7 keys. | The same 7 keys in the same order (captured on dev, 2026-10-01). | None. |
Earn description | Text such as Points gained by buy item Order ID L07678739 on Shukah's newest rows. | "" on 8 of the 9 earn rows among the first 10, manual credit on one (captured on dev, 2026-10-02). The two sides share no row id among the newest rows, so the same rows were not compared. | The app would show a blank line for rows with no text. Whether Shukah's own copies of those rows have text: Not documented yet. |
| Newest row | 2026-10-01 17:00:01, shukah_id 1351767 (captured on dev, 2026-10-02). | 2026-09-29 12:45:07, shukah_id 1351722: 45 Shukah ids and about two days behind (inference: the copy from Shukah lags). | A transaction made in the last days may be missing from the list. Whether the feed is current on dev: Not documented yet. |
| Earn amounts | total_points of 2, 2, 2, 2, 10 on the newest rows (captured on dev, 2026-10-02). | 2, 2, 2, 10, 60 on the newest rows. | None: the same scale. On 2026-10-01 Shukah's newest rows were 0.5, 0.5, 2.5, 0.5, 4, which looked like a different scale. |
| Expiration rows | A negative earn row. | An expense row with a positive amount for expirations since 2026-09-16. Older ones are negative earn rows. | If the app sums or colours rows by points_type and sign, expirations look different. |
| Reversal and uncounted rows | Whether Shukah lists them: Not documented yet. | Listed as ordinary rows. | The app can show a row that never moved the balance. |
Expense status | Present (July 2026 parity report); values not captured. | Always "active". | None. |
| 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 error text, name or type must change. |
Watch out
- The app sends no
pageorlimit, so against Locate it would show only the newest 20 rows. There is no total, so it must page until a short page. - Earn rows send
total_pointsas a number and expense rows send it as a string with two decimals, and the app types it as a number. - Rows that did not count, and admin reversals, are listed like any other row.
Details (for backend)
Only dev was checked, through order-v2 at https://dev.v2.api.order.locate.sa, prefix /api/v2/client.
UAT and production hosts: Not documented yet. In this section OV2: means
locate-order-v2-api@d9e4e5f6 and PS: means locate-point-service@2c44ce3. How the proxy, the
token check and the error envelope work is described once, under
How the proxied calls work.
Route and proxy
GET /api/v2/client/point-history goes to GET /api/client/point-history on the point service. order-v2
forwards Authorization and the whole query (including language and any other key), with a 10 second
limit (OV2: src/modules/client/point/point.controller.ts:31-45,
PS: services/api/src/routes/client.routes.ts:18,
PS: services/api/src/handlers/client-point-history.ts:13-19). The point service reads only page and
limit, and the rest is ignored (PS: services/api/src/handlers/client-point-history.ts:7-10,14). The
service doc says the same about language
(PS: docs/integrations/2026-07-16-client-facing-point-apis.md:113).
The coercion rules come from a local run of the service's schema with zod 3.25.76 (the version in the
lockfile): an empty value coerces to 0 and fails, 0x10 becomes 16, 1.5 and abc fail.
The query
point_transactions.find({client_id}).sort({created_at: -1}).skip((page - 1) * limit).limit(limit)
(PS: services/api/src/services/client-points.service.ts:35-39). There is no filter on counted, on
the type or on reversal links. The sort key is created_at alone, so rows with an identical
created_at have no fixed order between pages. The response is the array and nothing else
(PS: services/api/src/handlers/client-point-history.ts:17-18). Which shape a row gets depends on its
ledger type (PS: services/api/src/services/client-points.mappers.ts:73-91).
Reversal rows
A reversal is a new ledger row of the opposite type with the same magnitude, reason of
reversal of <original correlation_id>, ref.source of reversal and a link to the original. The
original is never changed, and the ledger is append-only
(PS: packages/core/src/points/reverse-point-transaction.ts:50-55,145-148,
PS: packages/core/src/db/models/point-transaction.model.ts:55-63). Only an admin route creates
one: POST /api/admin/wallets/:client_id/transactions/:id/reverse
(PS: services/api/src/routes/admin.routes.ts:31). The mapper has no marker for it. Its trans_type
falls back to adjustment (earn) or order (expense) unless Shukah-first delivery recorded one
(PS: packages/core/src/points/reverse-point-transaction.ts:236-241).
Rows that did not count
counted: false rows are stored when a Shukah row's status is not active
(PS: packages/core/src/points/sync-shukah-points.ts:104) and when an uncounted row is reversed
(PS: packages/core/src/points/reverse-point-transaction.ts:54). They do not move the balance and are
ignored for the expiry date (PS: packages/core/src/points/apply-point-txn.ts:53,58-59), but they are
listed with their nominal total_points. Whether Shukah's history lists inactive rows: Not documented
yet; the question has been open since July
(PS: docs/integrations/2026-07-16-client-facing-point-apis.md:471-474).
Expiration rows
Shukah records an expiration as a negative row in its earn table. Rows copied from its history are
stored as negative earns, so they come back earn-shaped with a negative total_points. Rows pushed
live since 2026-09-16 and rows from the Locate expiration job are stored as expenses with a positive
magnitude, so they come back expense-shaped
(PS: packages/core/src/points/sync-shukah-points.ts:97-102,
PS: docs/plans/2026-09-16-point-expiration-job.md:53,59,
PS: docs/plans/2026-07-30-accept-negative-earn.md:6). Their text is
Points expired after <days> days of inactivity <YYYY-MM-DD>
(PS: packages/core/src/points/expire-points.ts:68-70).
Gift rows and their text
Rows that Locate writes for a gift (the legs of a pay-by-points order, and the credit of a card
order) carry ref.source of point_order and a transfer id. They have no shukah_id and no
trans_type of their own, so earn rows show adjustment and expense rows show order. English
owners get the English sentence. Arabic owners get the template from the point settings, and a blank
template leaves description empty (PS: packages/core/src/points/order-reason.ts:62-87,
PS: docs/plans/2026-10-01-app-language-and-arabic-reason.md:21-42). Of 118,029 live users, 117,965
have Arabic as their language (PS: packages/core/src/points/client-lookup.ts:8-9), so a blank
template matters. Whether the templates are filled: Not documented yet.
Empty earn descriptions and dates
Shukah builds the text of an earn row when it reads it, and the live push to Locate may not carry it,
so earn rows that arrived by that feed can have description: ""
(PS: docs/integrations/2026-07-16-client-facing-point-apis.md:486-489,
PS: docs/reports/2026-07-17-client-api-parity.md:22). date_created is formatted from the stored UTC
time. The +3 hour error that the July report found came from the inbound sync and was fixed there
(PS: packages/core/src/points/shukah-date.ts:4-13,
PS: packages/core/src/points/sync-shukah-points.ts:105). It cannot be checked on today's captures,
because none of the five Locate rows shares an id with the stored Shukah rows.
The app
The app's history type has five keys: total_points, date_created, description, points_type and
trans_type. It reads no id and no shukah_id, and the two list screens call the history with no
query (mobile/locate-client@e960c3a: src/entities/points/model/types/pointsHistory.server.ts:6-12,
mobile/locate-client@e960c3a: src/entities/points/model/api/dto/points.dto.ts:70-88,
mobile/locate-client@e960c3a: src/entities/points/model/api/services/points.service.ts:46-60).
Where the docs and the code disagree (code wins)
- The July parity report found Locate paged at 50 and Shukah returned everything, and that Locate lists
reversal and uncounted rows. Both still hold on 2026-10-01, with Locate's default now 20 and its
maximum 100 (
PS: docs/reports/2026-07-17-client-api-parity.md:62). - The July report's "fractional
total_points_amt" finding was two different rows compared by mistake; the recheck shows the same row with171on both sides (PS: docs/reports/2026-07-17-client-api-parity-recheck.md:97-134). - 2026-10-02: this page said a Shukah token and a Locate JWT both worked here. The JWT now gets
401, and the Try it uses a Shukah token (PS: services/api/src/services/client-auth.service.ts:22-29; see Which tokens work). - 2026-10-02: this page said Shukah's newest earn amounts (0.5, 0.5, 2.5, 0.5, 4) were on a different scale from Locate's. Shukah's newest rows are now 2, 2, 2, 2, 10: no scale gap. Its newest row is 2026-10-01 17:00:01, not 10:40:44, and it holds 8,136 rows, not 8,134.
- 2026-10-02: this page said four of five Locate earn rows had an empty
description. In the first 10 rows it is 8 of 9.
Sources
locate-order-v2-api@d9e4e5f6:src/modules/client/point/point.controller.ts:31-45locate-point-service@2c44ce3:services/api/src/routes/client.routes.ts:18,services/api/src/handlers/client-point-history.ts:7-19,services/api/src/services/client-points.service.ts:35-39,services/api/src/services/client-points.mappers.ts:10-28,73-91,services/api/src/routes/admin.routes.ts:31,packages/core/src/points/reverse-point-transaction.ts:50-55,145-148,236-241,packages/core/src/db/models/point-transaction.model.ts:55-63,packages/core/src/points/sync-shukah-points.ts:97-105,packages/core/src/points/apply-point-txn.ts:53,58-59,packages/core/src/points/expire-points.ts:68-70,packages/core/src/points/order-reason.ts:62-87,packages/core/src/points/client-lookup.ts:8-9,packages/core/src/points/shukah-date.ts:4-13locate-point-service@2c44ce3docs:docs/integrations/2026-07-16-client-facing-point-apis.md:113,471-474,486-489,docs/plans/2026-09-16-point-expiration-job.md:53,59,docs/plans/2026-07-30-accept-negative-earn.md:6,docs/plans/2026-10-01-app-language-and-arabic-reason.md:21-42,docs/reports/2026-07-17-client-api-parity.md:22,62,docs/reports/2026-07-17-client-api-parity-recheck.md:97-134- Mobile snapshot
mobile/locate-client@e960c3a(branchdevelopment, 2026-02-26; the app's own code, not Locate):src/entities/points/model/api/services/points.service.ts:46-60,src/entities/points/model/api/dto/points.dto.ts:70-88,src/entities/points/model/types/pointsHistory.server.ts:6-12,src/features/LastPointsTransactionsList/ui/LastPointsTransactionsList/LastPointsTransactionsList.tsx:34-36,src/entities/points/model/api/rtk/pointsApi.ts:48-68,src/features/PointsHistoryList/ui/PointsHistoryList/PointsHistoryList.tsx:27,platforms/mobile/products/locate/screens/home/mainInner/Wallet/ui/Wallet/Wallet.tsx:81,platforms/mobile/products/locate/screens/home/WalletHistory/ui/WalletHistory/WalletHistory.tsx:25,src/widgets/ApplyPointsPromoCode/model/services/applyPointsPromoCode.ts:27-50,src/widgets/PointGiftsCheckout/model/hooks/usePointGiftsCheckout.ts:221-232 - Dev captures, 2026-10-02 (parity audit):
point-history--shukah,point-history--locate-st,point-history--locate-jwt - Dev captures, 2026-10-01:
locate-point-history,locate-point-history-limit-100,shukah-point-history,probe-locate-point-history,probe-shukah-point-history