--- title: PointTransaction tags: [data-model, mongoose] aliases: [Point Ledger, Loyalty Transaction, IPointTransaction] --- > **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report) # PointTransaction > **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)) Append-only ledger of loyalty point movements. Each row represents one `earn` / `spend` / `expire` event for a user, with a source attribution (`purchase` / `referral` / `bonus` / `admin` / `redemption`), the amount moved, and the resulting balance snapshot. Metadata is flexible to support different sources (order amount, commission, level changes, referenced [[PurchaseRequest]]). > [!warning] `type` enum is `earn` / `spend` / `expire` ONLY > There is **no `refund` type** (nor any other value). The `enum` at `PointTransaction.ts:35` is exactly `['earn', 'spend', 'expire']`. Referral earns are identified by `source: 'referral'` + `type: 'earn'`, **not** by a dedicated type. > [!danger] `expire` is defined but never produced > The `expiresAt` field and the `'expire'` type exist in the schema, and there is a sparse `{ expiresAt: 1 }` index intended for expiry sweeps — but **no service, cron job, or TTL ever creates an `expire`-type transaction**. Point expiry is **not enforced** anywhere in the codebase today; points effectively never expire. > [!note] Source > `backend/src/models/PointTransaction.ts:25` — schema definition > `backend/src/models/PointTransaction.ts:84` — model export ## Schema | Field | Type | Required | Default | Validation | Index | Description | | --- | --- | --- | --- | --- | --- | --- | | `user` | ObjectId → [[User]] | yes | — | — | yes (single + compound) | Owner of the transaction. | | `type` | String | yes | — | enum: `earn` / `spend` / `expire` | yes (compound) | Movement direction. | | `source` | String | yes | — | enum: `purchase` / `referral` / `bonus` / `admin` / `redemption` | yes (compound) | Source bucket. **Referral earns are identified by `source='referral'` (with `type='earn'`), not by type.** Redemptions use `source='redemption'`; admin grants use `source='admin'`. | | `amount` | Number | yes | — | — | — | Points moved (positive integer; semantics by `type`). | | `balance` | Number | yes | — | — | — | Available balance after the move. | | `order` | ObjectId → Order | no | — | — | — | Linked order id (legacy ref, see warning). | | `referredUser` | ObjectId → [[User]] | no | — | — | — | Referred user (for referral earns). | | `description` | String | yes | — | — | — | Human label. | | `metadata.orderAmount` | Number | no | — | — | — | Order amount snapshot. | | `metadata.commission` | Number | no | — | — | — | Commission snapshot. | | `metadata.levelBefore` | Number | no | — | — | — | Pre-level snapshot. | | `metadata.levelAfter` | Number | no | — | — | — | Post-level snapshot. | | `metadata.purchaseRequestId` | String | no | — | — | — | Linked [[PurchaseRequest]] id. | | `expiresAt` | Date | no | — | — | yes (sparse) | When the points expire (for `earn`). | | `createdAt` | Date | auto | — | — | yes (compound, desc) | Mongoose timestamp. | | `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | > [!warning] `order` reference > The schema declares `ref: 'Order'`, but there is no `Order` model in `backend/src/models/`. In practice this slot is used for the [[PurchaseRequest]] id; consumers should not rely on Mongoose `populate('order')` working. ## Virtuals None defined. ## Indexes Defined at `backend/src/models/PointTransaction.ts:80-82`. Plus the implicit index from `user` being declared with `index: true`: - `{ user: 1, createdAt: -1 }` — user ledger view. - `{ type: 1, source: 1 }` — analytics. - `{ expiresAt: 1 }` (sparse) — expiry sweeps. ## Pre/Post Hooks None declared. ## Instance Methods None defined. ## Static Methods None defined. ## Relationships - **References**: [[User]] (`user`, `referredUser`). - **Referenced by**: none. Loosely related to [[PurchaseRequest]] via `metadata.purchaseRequestId` (string). ## State Transitions No status field — entries are immutable once written. The schema anticipates a consumer scanning for `expiresAt < now` to create offsetting `type: 'expire'` rows, but **no such consumer exists**: nothing in the codebase ever writes an `expire` row, so in practice only `earn` and `spend` entries are ever created. ## Common Queries ```ts // User ledger PointTransaction.find({ user: userId }).sort({ createdAt: -1 }).limit(50); // Latest balance (most recent row) PointTransaction.findOne({ user: userId }).sort({ createdAt: -1 }); // Referral earnings PointTransaction.find({ user: userId, source: 'referral', type: 'earn' }); // Points expiring soon PointTransaction.find({ expiresAt: { $lte: oneWeekFromNow }, type: 'earn' }); // Analytics: total earned vs spent per source PointTransaction.aggregate([ { $group: { _id: { type: '$type', source: '$source' }, total: { $sum: '$amount' } } } ]); ``` Related: [[User]], [[LevelConfig]], [[PurchaseRequest]].