@open-loyalty/mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +654 -0
- package/dist/client/http.d.ts +8 -0
- package/dist/client/http.js +69 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +40 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +20 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +334 -0
- package/dist/tools/achievement.d.ts +983 -0
- package/dist/tools/achievement.js +311 -0
- package/dist/tools/admin.d.ts +153 -0
- package/dist/tools/admin.js +193 -0
- package/dist/tools/analytics.d.ts +162 -0
- package/dist/tools/analytics.js +245 -0
- package/dist/tools/apikey.d.ts +72 -0
- package/dist/tools/apikey.js +78 -0
- package/dist/tools/audit.d.ts +107 -0
- package/dist/tools/audit.js +90 -0
- package/dist/tools/badge.d.ts +135 -0
- package/dist/tools/badge.js +165 -0
- package/dist/tools/campaign.d.ts +1775 -0
- package/dist/tools/campaign.js +724 -0
- package/dist/tools/export.d.ts +110 -0
- package/dist/tools/export.js +147 -0
- package/dist/tools/import.d.ts +110 -0
- package/dist/tools/import.js +126 -0
- package/dist/tools/index.d.ts +22 -0
- package/dist/tools/index.js +527 -0
- package/dist/tools/member.d.ts +345 -0
- package/dist/tools/member.js +358 -0
- package/dist/tools/member.test.d.ts +1 -0
- package/dist/tools/member.test.js +213 -0
- package/dist/tools/points.d.ts +188 -0
- package/dist/tools/points.js +306 -0
- package/dist/tools/points.test.d.ts +1 -0
- package/dist/tools/points.test.js +292 -0
- package/dist/tools/reward.d.ts +261 -0
- package/dist/tools/reward.js +371 -0
- package/dist/tools/reward.test.d.ts +1 -0
- package/dist/tools/reward.test.js +240 -0
- package/dist/tools/role.d.ts +161 -0
- package/dist/tools/role.js +160 -0
- package/dist/tools/segment.d.ts +797 -0
- package/dist/tools/segment.js +299 -0
- package/dist/tools/store.d.ts +101 -0
- package/dist/tools/store.js +117 -0
- package/dist/tools/tierset.d.ts +288 -0
- package/dist/tools/tierset.js +244 -0
- package/dist/tools/transaction.d.ts +357 -0
- package/dist/tools/transaction.js +242 -0
- package/dist/tools/transaction.test.d.ts +1 -0
- package/dist/tools/transaction.test.js +235 -0
- package/dist/tools/wallet-type.d.ts +32 -0
- package/dist/tools/wallet-type.js +58 -0
- package/dist/tools/webhook.d.ts +179 -0
- package/dist/tools/webhook.js +171 -0
- package/dist/types/schemas/achievement.d.ts +1116 -0
- package/dist/types/schemas/achievement.js +172 -0
- package/dist/types/schemas/admin.d.ts +263 -0
- package/dist/types/schemas/admin.js +99 -0
- package/dist/types/schemas/analytics.d.ts +542 -0
- package/dist/types/schemas/analytics.js +130 -0
- package/dist/types/schemas/badge.d.ts +131 -0
- package/dist/types/schemas/badge.js +48 -0
- package/dist/types/schemas/campaign.d.ts +2005 -0
- package/dist/types/schemas/campaign.js +189 -0
- package/dist/types/schemas/common.d.ts +52 -0
- package/dist/types/schemas/common.js +26 -0
- package/dist/types/schemas/export.d.ts +127 -0
- package/dist/types/schemas/export.js +43 -0
- package/dist/types/schemas/import.d.ts +344 -0
- package/dist/types/schemas/import.js +68 -0
- package/dist/types/schemas/member.d.ts +443 -0
- package/dist/types/schemas/member.js +92 -0
- package/dist/types/schemas/points.d.ts +188 -0
- package/dist/types/schemas/points.js +54 -0
- package/dist/types/schemas/reward.d.ts +278 -0
- package/dist/types/schemas/reward.js +69 -0
- package/dist/types/schemas/role.d.ts +260 -0
- package/dist/types/schemas/role.js +75 -0
- package/dist/types/schemas/segment.d.ts +592 -0
- package/dist/types/schemas/segment.js +114 -0
- package/dist/types/schemas/tierset.d.ts +552 -0
- package/dist/types/schemas/tierset.js +87 -0
- package/dist/types/schemas/transaction.d.ts +1022 -0
- package/dist/types/schemas/transaction.js +63 -0
- package/dist/types/schemas/wallet-type.d.ts +99 -0
- package/dist/types/schemas/wallet-type.js +17 -0
- package/dist/types/schemas/webhook.d.ts +195 -0
- package/dist/types/schemas/webhook.js +39 -0
- package/dist/utils/cursor.d.ts +84 -0
- package/dist/utils/cursor.js +117 -0
- package/dist/utils/errors.d.ts +12 -0
- package/dist/utils/errors.js +69 -0
- package/dist/utils/pagination.d.ts +39 -0
- package/dist/utils/pagination.js +77 -0
- package/package.json +65 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiGet, apiPost } from "../client/http.js";
|
|
3
|
+
import { formatApiError, OpenLoyaltyError } from "../utils/errors.js";
|
|
4
|
+
import { getConfig } from "../config.js";
|
|
5
|
+
import { buildPaginationParams } from "../utils/pagination.js";
|
|
6
|
+
// Input Schemas
|
|
7
|
+
export const PointsAddInputSchema = {
|
|
8
|
+
storeCode: z.string().optional().describe("Store code. If not provided, uses the default store code from configuration."),
|
|
9
|
+
memberId: z.string().describe("The member ID (UUID) to add points to."),
|
|
10
|
+
points: z.number().describe("Number of points to add (positive number)."),
|
|
11
|
+
walletCode: z.string().optional().describe("Wallet type code (e.g., 'default'). Uses default wallet if not specified."),
|
|
12
|
+
comment: z.string().optional().describe("Reason for adding points (max 500 chars)."),
|
|
13
|
+
expiresInDays: z.number().optional().describe("Days until points expire (1-9999)."),
|
|
14
|
+
lockedUntilDays: z.number().optional().describe("Days until points become available (1-9999)."),
|
|
15
|
+
};
|
|
16
|
+
export const PointsSpendInputSchema = {
|
|
17
|
+
storeCode: z.string().optional().describe("Store code. If not provided, uses the default store code from configuration."),
|
|
18
|
+
memberId: z.string().describe("The member ID (UUID) to spend points from."),
|
|
19
|
+
points: z.number().describe("Number of points to spend (positive number)."),
|
|
20
|
+
walletCode: z.string().optional().describe("Wallet type code (e.g., 'default'). Uses default wallet if not specified."),
|
|
21
|
+
comment: z.string().optional().describe("Reason for spending points (max 500 chars)."),
|
|
22
|
+
};
|
|
23
|
+
export const PointsTransferInputSchema = {
|
|
24
|
+
storeCode: z.string().optional().describe("Store code. If not provided, uses the default store code from configuration."),
|
|
25
|
+
senderId: z.string().describe("The sender member ID (UUID)."),
|
|
26
|
+
receiverId: z.string().describe("The receiver member ID (UUID)."),
|
|
27
|
+
points: z.number().describe("Number of points to transfer."),
|
|
28
|
+
};
|
|
29
|
+
export const PointsBalanceInputSchema = {
|
|
30
|
+
storeCode: z.string().optional().describe("Store code. If not provided, uses the default store code from configuration."),
|
|
31
|
+
memberId: z.string().describe("The member ID (UUID) to get balance for."),
|
|
32
|
+
walletCode: z.string().optional().describe("Wallet type code. Returns all wallets if not specified."),
|
|
33
|
+
};
|
|
34
|
+
export const PointsHistoryInputSchema = {
|
|
35
|
+
storeCode: z.string().optional().describe("Store code. If not provided, uses the default store code from configuration."),
|
|
36
|
+
memberId: z.string().describe("The member ID (UUID) to get history for."),
|
|
37
|
+
cursor: z.string().optional().describe("Pagination cursor from previous response. If provided, page/perPage are ignored."),
|
|
38
|
+
page: z.number().optional().describe("Page number (default: 1)."),
|
|
39
|
+
perPage: z.number().optional().describe("Items per page (default: 10)."),
|
|
40
|
+
type: z.enum(["adding", "spending", "p2p_spending", "p2p_adding", "blocked", "expired"]).optional().describe("Filter by transfer type."),
|
|
41
|
+
};
|
|
42
|
+
export const PointsHistogramInputSchema = {
|
|
43
|
+
storeCode: z.string().optional().describe("Store code. If not provided, uses the default store code from configuration."),
|
|
44
|
+
memberId: z.string().describe("The member ID (UUID) to get histogram for."),
|
|
45
|
+
pointType: z.enum(["spent", "earned", "expired", "pending"]).describe("Filter by point type (required). One of: spent, earned, expired, pending."),
|
|
46
|
+
walletCode: z.string().optional().describe("Wallet type code. Uses default if not specified."),
|
|
47
|
+
interval: z.enum(["day", "week", "month"]).optional().describe("Time interval for aggregation (default: month)."),
|
|
48
|
+
dateFrom: z.string().optional().describe("Start date for histogram (ISO format: YYYY-MM-DD)."),
|
|
49
|
+
dateTo: z.string().optional().describe("End date for histogram (ISO format: YYYY-MM-DD)."),
|
|
50
|
+
};
|
|
51
|
+
// Handler functions
|
|
52
|
+
export async function pointsAdd(input) {
|
|
53
|
+
const config = getConfig();
|
|
54
|
+
const storeCode = input.storeCode || config.defaultStoreCode;
|
|
55
|
+
const payload = {
|
|
56
|
+
points: input.points,
|
|
57
|
+
};
|
|
58
|
+
if (input.walletCode)
|
|
59
|
+
payload.walletCode = input.walletCode;
|
|
60
|
+
if (input.comment)
|
|
61
|
+
payload.comment = input.comment;
|
|
62
|
+
if (input.expiresInDays)
|
|
63
|
+
payload.expiresInDays = input.expiresInDays;
|
|
64
|
+
if (input.lockedUntilDays)
|
|
65
|
+
payload.lockedUntilDays = input.lockedUntilDays;
|
|
66
|
+
try {
|
|
67
|
+
const response = await apiPost(`/${storeCode}/points/add`, { transfer: { customer: input.memberId, ...payload } });
|
|
68
|
+
return { transferId: response.transferId };
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw formatApiError(error, "openloyalty_points_add");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function pointsSpend(input) {
|
|
75
|
+
const config = getConfig();
|
|
76
|
+
const storeCode = input.storeCode || config.defaultStoreCode;
|
|
77
|
+
const payload = {
|
|
78
|
+
points: input.points,
|
|
79
|
+
};
|
|
80
|
+
if (input.walletCode)
|
|
81
|
+
payload.walletCode = input.walletCode;
|
|
82
|
+
if (input.comment)
|
|
83
|
+
payload.comment = input.comment;
|
|
84
|
+
try {
|
|
85
|
+
const response = await apiPost(`/${storeCode}/points/spend`, { transfer: { customer: input.memberId, ...payload } });
|
|
86
|
+
return { transferId: response.transferId };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
// Check for insufficient points error
|
|
90
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
91
|
+
if (errorMessage.includes("NotEnoughPoints") || errorMessage.includes("insufficient")) {
|
|
92
|
+
throw new OpenLoyaltyError({
|
|
93
|
+
code: "INSUFFICIENT_BALANCE",
|
|
94
|
+
message: "Member does not have enough points to complete this operation",
|
|
95
|
+
hint: `Use points_get_balance(memberId: "${input.memberId}") to check available balance before spending.`,
|
|
96
|
+
relatedTool: "openloyalty_points_spend",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
throw formatApiError(error, "openloyalty_points_spend");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function pointsTransfer(input) {
|
|
103
|
+
const config = getConfig();
|
|
104
|
+
const storeCode = input.storeCode || config.defaultStoreCode;
|
|
105
|
+
if (input.senderId === input.receiverId) {
|
|
106
|
+
throw new Error("Cannot transfer points to yourself. senderId and receiverId must be different.");
|
|
107
|
+
}
|
|
108
|
+
const payload = {
|
|
109
|
+
transfer: {
|
|
110
|
+
sender: input.senderId,
|
|
111
|
+
receiver: input.receiverId,
|
|
112
|
+
points: input.points,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
const response = await apiPost(`/${storeCode}/points/transfer`, payload);
|
|
117
|
+
return { transferId: response.transferId };
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
121
|
+
if (errorMessage.includes("NotEnoughPoints") || errorMessage.includes("insufficient")) {
|
|
122
|
+
throw new OpenLoyaltyError({
|
|
123
|
+
code: "INSUFFICIENT_BALANCE",
|
|
124
|
+
message: "Sender does not have enough points to transfer",
|
|
125
|
+
hint: `Use points_get_balance(memberId: "${input.senderId}") to check sender's available balance before transferring.`,
|
|
126
|
+
relatedTool: "openloyalty_points_transfer",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
throw formatApiError(error, "openloyalty_points_transfer");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function pointsGetBalance(input) {
|
|
133
|
+
const config = getConfig();
|
|
134
|
+
const storeCode = input.storeCode || config.defaultStoreCode;
|
|
135
|
+
const params = new URLSearchParams();
|
|
136
|
+
if (input.walletCode)
|
|
137
|
+
params.append("walletType:code", input.walletCode);
|
|
138
|
+
const queryString = params.toString();
|
|
139
|
+
const url = `/${storeCode}/member/${input.memberId}/wallet${queryString ? `?${queryString}` : ""}`;
|
|
140
|
+
try {
|
|
141
|
+
const response = await apiGet(url);
|
|
142
|
+
// Handle different response formats - may return a list of wallets or aggregated data
|
|
143
|
+
const items = response.items;
|
|
144
|
+
if (items && items.length > 0) {
|
|
145
|
+
// Aggregate from wallet list - data is in items[].account object
|
|
146
|
+
let activeUnits = 0, earnedUnits = 0, spentUnits = 0, lockedUnits = 0, expiredUnits = 0;
|
|
147
|
+
for (const wallet of items) {
|
|
148
|
+
// Check for nested account object first (actual API format)
|
|
149
|
+
const account = wallet.account;
|
|
150
|
+
if (account) {
|
|
151
|
+
activeUnits += account.activeUnits ?? 0;
|
|
152
|
+
earnedUnits += account.earnedUnits ?? 0;
|
|
153
|
+
spentUnits += account.spentUnits ?? 0;
|
|
154
|
+
lockedUnits += account.lockedUnits ?? 0;
|
|
155
|
+
expiredUnits += account.expiredUnits ?? 0;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// Fallback for flat structure
|
|
159
|
+
activeUnits += wallet.activeUnits ?? 0;
|
|
160
|
+
earnedUnits += wallet.earnedUnits ?? 0;
|
|
161
|
+
spentUnits += wallet.spentUnits ?? 0;
|
|
162
|
+
lockedUnits += wallet.lockedUnits ?? 0;
|
|
163
|
+
expiredUnits += wallet.expiredUnits ?? 0;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { activeUnits, earnedUnits, spentUnits, lockedUnits, expiredUnits };
|
|
167
|
+
}
|
|
168
|
+
// Fallback for single wallet or different response format
|
|
169
|
+
const account = response.account;
|
|
170
|
+
return {
|
|
171
|
+
activeUnits: account?.activeUnits ?? response.activeUnits ?? 0,
|
|
172
|
+
earnedUnits: account?.earnedUnits ?? response.earnedUnits ?? 0,
|
|
173
|
+
spentUnits: account?.spentUnits ?? response.spentUnits ?? 0,
|
|
174
|
+
lockedUnits: account?.lockedUnits ?? response.lockedUnits ?? 0,
|
|
175
|
+
expiredUnits: account?.expiredUnits ?? response.expiredUnits ?? 0,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
throw formatApiError(error, "openloyalty_points_get_balance");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export async function pointsGetHistory(input) {
|
|
183
|
+
const config = getConfig();
|
|
184
|
+
const storeCode = input.storeCode || config.defaultStoreCode;
|
|
185
|
+
const params = new URLSearchParams();
|
|
186
|
+
params.append("member:id", input.memberId);
|
|
187
|
+
// Use buildPaginationParams for cursor/page handling
|
|
188
|
+
buildPaginationParams({ cursor: input.cursor, page: input.page, perPage: input.perPage }, params);
|
|
189
|
+
if (input.type)
|
|
190
|
+
params.append("type", input.type);
|
|
191
|
+
const queryString = params.toString();
|
|
192
|
+
const url = `/${storeCode}/points?${queryString}`;
|
|
193
|
+
try {
|
|
194
|
+
const response = await apiGet(url);
|
|
195
|
+
const items = response.items || response.transfers || [];
|
|
196
|
+
const transfers = items.map((t) => {
|
|
197
|
+
const transfer = t;
|
|
198
|
+
return {
|
|
199
|
+
transferId: transfer.transferId,
|
|
200
|
+
type: transfer.type,
|
|
201
|
+
points: transfer.value ?? transfer.points,
|
|
202
|
+
comment: transfer.comment,
|
|
203
|
+
createdAt: transfer.createdAt,
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
const total = response.total || {};
|
|
207
|
+
return {
|
|
208
|
+
transfers,
|
|
209
|
+
total: {
|
|
210
|
+
all: typeof total.all === 'number' ? total.all : undefined,
|
|
211
|
+
filtered: typeof total.filtered === 'number' ? total.filtered : undefined,
|
|
212
|
+
},
|
|
213
|
+
cursor: response.scroll,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
throw formatApiError(error, "openloyalty_points_get_history");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export async function pointsGetHistogram(input) {
|
|
221
|
+
const config = getConfig();
|
|
222
|
+
const storeCode = input.storeCode || config.defaultStoreCode;
|
|
223
|
+
const params = new URLSearchParams();
|
|
224
|
+
params.append("member:id", input.memberId);
|
|
225
|
+
// pointType is required by the API: spent|earned|expired|pending
|
|
226
|
+
params.append("pointType", input.pointType);
|
|
227
|
+
if (input.walletCode)
|
|
228
|
+
params.append("walletType:code", input.walletCode);
|
|
229
|
+
if (input.interval)
|
|
230
|
+
params.append("interval", input.interval);
|
|
231
|
+
if (input.dateFrom) {
|
|
232
|
+
// Calculate days from dateFrom to now
|
|
233
|
+
const from = new Date(input.dateFrom);
|
|
234
|
+
const now = new Date();
|
|
235
|
+
const diffDays = Math.ceil((now.getTime() - from.getTime()) / (1000 * 60 * 60 * 24));
|
|
236
|
+
params.append("lastDays", String(diffDays));
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
params.append("lastDays", "30");
|
|
240
|
+
}
|
|
241
|
+
params.append("futureDays", "0");
|
|
242
|
+
const queryString = params.toString();
|
|
243
|
+
const url = `/${storeCode}/points/history?${queryString}`;
|
|
244
|
+
try {
|
|
245
|
+
const response = await apiGet(url);
|
|
246
|
+
const items = Array.isArray(response) ? response : (response.items || []);
|
|
247
|
+
return items.map((item) => {
|
|
248
|
+
const data = item;
|
|
249
|
+
return {
|
|
250
|
+
date: data.date,
|
|
251
|
+
earned: data.earned ?? 0,
|
|
252
|
+
spent: data.spent ?? 0,
|
|
253
|
+
balance: data.balance ?? 0,
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
throw formatApiError(error, "openloyalty_points_get_histogram");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Tool definitions
|
|
262
|
+
export const pointsToolDefinitions = [
|
|
263
|
+
{
|
|
264
|
+
name: "openloyalty_points_add",
|
|
265
|
+
description: "Add points to member wallet. Points can have optional expiration and lock period. " +
|
|
266
|
+
"Use for welcome bonuses, manual adjustments, or custom rewards. Returns transferId.",
|
|
267
|
+
inputSchema: PointsAddInputSchema,
|
|
268
|
+
handler: pointsAdd,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: "openloyalty_points_spend",
|
|
272
|
+
description: "Deduct points from member wallet. Fails if insufficient balance. " +
|
|
273
|
+
"Use points_get_balance first to verify available points. Returns transferId.",
|
|
274
|
+
inputSchema: PointsSpendInputSchema,
|
|
275
|
+
handler: pointsSpend,
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "openloyalty_points_transfer",
|
|
279
|
+
description: "Transfer points from one member to another (P2P transfer). " +
|
|
280
|
+
"Sender must have sufficient balance. Returns transferId.",
|
|
281
|
+
inputSchema: PointsTransferInputSchema,
|
|
282
|
+
handler: pointsTransfer,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: "openloyalty_points_get_balance",
|
|
286
|
+
description: "Get member points balance breakdown. activeUnits is available for spending. " +
|
|
287
|
+
"earnedUnits shows lifetime earnings, lockedUnits shows pending points.",
|
|
288
|
+
inputSchema: PointsBalanceInputSchema,
|
|
289
|
+
handler: pointsGetBalance,
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: "openloyalty_points_get_history",
|
|
293
|
+
description: "Get points transaction history for a member. Filter by type: adding, spending, " +
|
|
294
|
+
"p2p_spending, p2p_adding, blocked, expired. Returns paginated list of transfers. " +
|
|
295
|
+
"Supports cursor pagination: provide 'cursor' from previous response to get next page.",
|
|
296
|
+
inputSchema: PointsHistoryInputSchema,
|
|
297
|
+
handler: pointsGetHistory,
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: "openloyalty_points_get_histogram",
|
|
301
|
+
description: "Get points histogram data for visualization. Shows earning and spending patterns over time. " +
|
|
302
|
+
"Use interval (day/week/month) to aggregate data and dateFrom/dateTo to filter range.",
|
|
303
|
+
inputSchema: PointsHistogramInputSchema,
|
|
304
|
+
handler: pointsGetHistogram,
|
|
305
|
+
},
|
|
306
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// src/tools/points.test.ts
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { setupMockAxios, teardownMockAxios, getMockAxios } from '../../tests/mocks/http.mock.js';
|
|
4
|
+
import { pointsFixtures } from '../../tests/fixtures/points.fixtures.js';
|
|
5
|
+
import { pointsAdd, pointsSpend, pointsTransfer, pointsGetBalance, pointsGetHistory, pointsGetHistogram, } from './points.js';
|
|
6
|
+
describe('Points Operations', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
setupMockAxios();
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
teardownMockAxios();
|
|
12
|
+
});
|
|
13
|
+
describe('pointsAdd', () => {
|
|
14
|
+
it('should add points with transfer wrapper', async () => {
|
|
15
|
+
const mockAxios = getMockAxios();
|
|
16
|
+
mockAxios.onPost('/default/points/add').reply(200, pointsFixtures.addResponse);
|
|
17
|
+
const result = await pointsAdd({
|
|
18
|
+
memberId: 'member-uuid',
|
|
19
|
+
points: 100,
|
|
20
|
+
});
|
|
21
|
+
expect(result.transferId).toBe('123e4567-e89b-12d3-a456-426614174000');
|
|
22
|
+
// Verify request body format with transfer wrapper
|
|
23
|
+
const requestData = JSON.parse(mockAxios.history.post[0].data);
|
|
24
|
+
expect(requestData).toEqual({
|
|
25
|
+
transfer: {
|
|
26
|
+
customer: 'member-uuid',
|
|
27
|
+
points: 100,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
it('should include optional fields in transfer wrapper', async () => {
|
|
32
|
+
const mockAxios = getMockAxios();
|
|
33
|
+
mockAxios.onPost('/default/points/add').reply(200, pointsFixtures.addResponse);
|
|
34
|
+
await pointsAdd({
|
|
35
|
+
memberId: 'member-uuid',
|
|
36
|
+
points: 500,
|
|
37
|
+
walletCode: 'bonus',
|
|
38
|
+
comment: 'Welcome bonus',
|
|
39
|
+
expiresInDays: 90,
|
|
40
|
+
lockedUntilDays: 7,
|
|
41
|
+
});
|
|
42
|
+
const requestData = JSON.parse(mockAxios.history.post[0].data);
|
|
43
|
+
expect(requestData.transfer.customer).toBe('member-uuid');
|
|
44
|
+
expect(requestData.transfer.points).toBe(500);
|
|
45
|
+
expect(requestData.transfer.walletCode).toBe('bonus');
|
|
46
|
+
expect(requestData.transfer.comment).toBe('Welcome bonus');
|
|
47
|
+
expect(requestData.transfer.expiresInDays).toBe(90);
|
|
48
|
+
expect(requestData.transfer.lockedUntilDays).toBe(7);
|
|
49
|
+
});
|
|
50
|
+
it('should use custom storeCode when provided', async () => {
|
|
51
|
+
const mockAxios = getMockAxios();
|
|
52
|
+
mockAxios.onPost('/custom-store/points/add').reply(200, pointsFixtures.addResponse);
|
|
53
|
+
await pointsAdd({
|
|
54
|
+
storeCode: 'custom-store',
|
|
55
|
+
memberId: 'member-uuid',
|
|
56
|
+
points: 100,
|
|
57
|
+
});
|
|
58
|
+
expect(mockAxios.history.post[0].url).toBe('/custom-store/points/add');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('pointsSpend', () => {
|
|
62
|
+
it('should spend points with transfer wrapper', async () => {
|
|
63
|
+
const mockAxios = getMockAxios();
|
|
64
|
+
mockAxios.onPost('/default/points/spend').reply(200, pointsFixtures.spendResponse);
|
|
65
|
+
const result = await pointsSpend({
|
|
66
|
+
memberId: 'member-uuid',
|
|
67
|
+
points: 50,
|
|
68
|
+
});
|
|
69
|
+
expect(result.transferId).toBe('223e4567-e89b-12d3-a456-426614174001');
|
|
70
|
+
const requestData = JSON.parse(mockAxios.history.post[0].data);
|
|
71
|
+
expect(requestData).toEqual({
|
|
72
|
+
transfer: {
|
|
73
|
+
customer: 'member-uuid',
|
|
74
|
+
points: 50,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
it('should include optional fields', async () => {
|
|
79
|
+
const mockAxios = getMockAxios();
|
|
80
|
+
mockAxios.onPost('/default/points/spend').reply(200, pointsFixtures.spendResponse);
|
|
81
|
+
await pointsSpend({
|
|
82
|
+
memberId: 'member-uuid',
|
|
83
|
+
points: 100,
|
|
84
|
+
walletCode: 'default',
|
|
85
|
+
comment: 'Reward redemption',
|
|
86
|
+
});
|
|
87
|
+
const requestData = JSON.parse(mockAxios.history.post[0].data);
|
|
88
|
+
expect(requestData.transfer.walletCode).toBe('default');
|
|
89
|
+
expect(requestData.transfer.comment).toBe('Reward redemption');
|
|
90
|
+
});
|
|
91
|
+
it('should throw error for insufficient points', async () => {
|
|
92
|
+
const mockAxios = getMockAxios();
|
|
93
|
+
mockAxios.onPost('/default/points/spend').reply(400, {
|
|
94
|
+
message: 'NotEnoughPoints',
|
|
95
|
+
code: 400,
|
|
96
|
+
});
|
|
97
|
+
// API returns 400 error which gets thrown
|
|
98
|
+
await expect(pointsSpend({
|
|
99
|
+
memberId: 'member-uuid',
|
|
100
|
+
points: 10000,
|
|
101
|
+
})).rejects.toThrow();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('pointsTransfer', () => {
|
|
105
|
+
it('should transfer points between members', async () => {
|
|
106
|
+
const mockAxios = getMockAxios();
|
|
107
|
+
mockAxios.onPost('/default/points/transfer').reply(200, pointsFixtures.transferResponse);
|
|
108
|
+
const result = await pointsTransfer({
|
|
109
|
+
senderId: 'sender-uuid',
|
|
110
|
+
receiverId: 'receiver-uuid',
|
|
111
|
+
points: 100,
|
|
112
|
+
});
|
|
113
|
+
expect(result.transferId).toBe('323e4567-e89b-12d3-a456-426614174002');
|
|
114
|
+
const requestData = JSON.parse(mockAxios.history.post[0].data);
|
|
115
|
+
expect(requestData).toEqual({
|
|
116
|
+
transfer: {
|
|
117
|
+
sender: 'sender-uuid',
|
|
118
|
+
receiver: 'receiver-uuid',
|
|
119
|
+
points: 100,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
it('should throw error when sender equals receiver', async () => {
|
|
124
|
+
await expect(pointsTransfer({
|
|
125
|
+
senderId: 'same-uuid',
|
|
126
|
+
receiverId: 'same-uuid',
|
|
127
|
+
points: 100,
|
|
128
|
+
})).rejects.toThrow('Cannot transfer points to yourself');
|
|
129
|
+
});
|
|
130
|
+
it('should throw error for insufficient balance on transfer', async () => {
|
|
131
|
+
const mockAxios = getMockAxios();
|
|
132
|
+
mockAxios.onPost('/default/points/transfer').reply(400, {
|
|
133
|
+
message: 'NotEnoughPoints',
|
|
134
|
+
});
|
|
135
|
+
// API returns 400 error which gets thrown
|
|
136
|
+
await expect(pointsTransfer({
|
|
137
|
+
senderId: 'sender-uuid',
|
|
138
|
+
receiverId: 'receiver-uuid',
|
|
139
|
+
points: 10000,
|
|
140
|
+
})).rejects.toThrow();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('pointsGetBalance', () => {
|
|
144
|
+
it('should get balance from wallet list response', async () => {
|
|
145
|
+
const mockAxios = getMockAxios();
|
|
146
|
+
mockAxios.onGet('/default/member/member-uuid/wallet').reply(200, pointsFixtures.walletResponse);
|
|
147
|
+
const result = await pointsGetBalance({ memberId: 'member-uuid' });
|
|
148
|
+
expect(result.activeUnits).toBe(750);
|
|
149
|
+
expect(result.earnedUnits).toBe(1000);
|
|
150
|
+
expect(result.spentUnits).toBe(200);
|
|
151
|
+
expect(result.lockedUnits).toBe(50);
|
|
152
|
+
expect(result.expiredUnits).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
it('should filter by wallet code', async () => {
|
|
155
|
+
const mockAxios = getMockAxios();
|
|
156
|
+
mockAxios.onGet(/\/default\/member\/member-uuid\/wallet/).reply(200, pointsFixtures.walletResponse);
|
|
157
|
+
await pointsGetBalance({
|
|
158
|
+
memberId: 'member-uuid',
|
|
159
|
+
walletCode: 'bonus',
|
|
160
|
+
});
|
|
161
|
+
const url = mockAxios.history.get[0].url;
|
|
162
|
+
expect(url).toContain('walletType%3Acode=bonus');
|
|
163
|
+
});
|
|
164
|
+
it('should aggregate multiple wallets from nested account objects', async () => {
|
|
165
|
+
const mockAxios = getMockAxios();
|
|
166
|
+
mockAxios.onGet('/default/member/member-uuid/wallet').reply(200, {
|
|
167
|
+
items: [
|
|
168
|
+
{ account: { activeUnits: 100, earnedUnits: 200, spentUnits: 50, lockedUnits: 25, expiredUnits: 10 } },
|
|
169
|
+
{ account: { activeUnits: 200, earnedUnits: 300, spentUnits: 100, lockedUnits: 0, expiredUnits: 0 } },
|
|
170
|
+
],
|
|
171
|
+
total: { all: 2, filtered: 2 },
|
|
172
|
+
});
|
|
173
|
+
const result = await pointsGetBalance({ memberId: 'member-uuid' });
|
|
174
|
+
expect(result.activeUnits).toBe(300);
|
|
175
|
+
expect(result.earnedUnits).toBe(500);
|
|
176
|
+
expect(result.spentUnits).toBe(150);
|
|
177
|
+
expect(result.lockedUnits).toBe(25);
|
|
178
|
+
expect(result.expiredUnits).toBe(10);
|
|
179
|
+
});
|
|
180
|
+
it('should handle single wallet response format', async () => {
|
|
181
|
+
const mockAxios = getMockAxios();
|
|
182
|
+
mockAxios.onGet('/default/member/member-uuid/wallet').reply(200, {
|
|
183
|
+
account: {
|
|
184
|
+
activeUnits: 500,
|
|
185
|
+
earnedUnits: 800,
|
|
186
|
+
spentUnits: 300,
|
|
187
|
+
lockedUnits: 0,
|
|
188
|
+
expiredUnits: 0,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const result = await pointsGetBalance({ memberId: 'member-uuid' });
|
|
192
|
+
expect(result.activeUnits).toBe(500);
|
|
193
|
+
expect(result.earnedUnits).toBe(800);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
describe('pointsGetHistory', () => {
|
|
197
|
+
it('should get history and map items correctly', async () => {
|
|
198
|
+
const mockAxios = getMockAxios();
|
|
199
|
+
mockAxios.onGet(/\/default\/points/).reply(200, pointsFixtures.historyResponse);
|
|
200
|
+
const result = await pointsGetHistory({ memberId: 'member-uuid' });
|
|
201
|
+
expect(result.transfers).toHaveLength(2);
|
|
202
|
+
expect(result.transfers[0].transferId).toBe('transfer-uuid-1');
|
|
203
|
+
expect(result.transfers[0].type).toBe('adding');
|
|
204
|
+
expect(result.transfers[0].points).toBe(100);
|
|
205
|
+
expect(result.transfers[0].comment).toBe('Welcome bonus');
|
|
206
|
+
});
|
|
207
|
+
it('should include member:id in query params', async () => {
|
|
208
|
+
const mockAxios = getMockAxios();
|
|
209
|
+
mockAxios.onGet(/\/default\/points/).reply(200, pointsFixtures.historyResponse);
|
|
210
|
+
await pointsGetHistory({ memberId: 'member-uuid' });
|
|
211
|
+
const url = mockAxios.history.get[0].url;
|
|
212
|
+
expect(url).toContain('member%3Aid=member-uuid');
|
|
213
|
+
});
|
|
214
|
+
it('should include pagination params', async () => {
|
|
215
|
+
const mockAxios = getMockAxios();
|
|
216
|
+
mockAxios.onGet(/\/default\/points/).reply(200, pointsFixtures.historyResponse);
|
|
217
|
+
await pointsGetHistory({
|
|
218
|
+
memberId: 'member-uuid',
|
|
219
|
+
page: 2,
|
|
220
|
+
perPage: 25,
|
|
221
|
+
});
|
|
222
|
+
const url = mockAxios.history.get[0].url;
|
|
223
|
+
expect(url).toContain('_page=2');
|
|
224
|
+
expect(url).toContain('_itemsOnPage=25');
|
|
225
|
+
});
|
|
226
|
+
it('should filter by transfer type', async () => {
|
|
227
|
+
const mockAxios = getMockAxios();
|
|
228
|
+
mockAxios.onGet(/\/default\/points/).reply(200, pointsFixtures.historyResponse);
|
|
229
|
+
await pointsGetHistory({
|
|
230
|
+
memberId: 'member-uuid',
|
|
231
|
+
type: 'spending',
|
|
232
|
+
});
|
|
233
|
+
const url = mockAxios.history.get[0].url;
|
|
234
|
+
expect(url).toContain('type=spending');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
describe('pointsGetHistogram', () => {
|
|
238
|
+
it('should get histogram data', async () => {
|
|
239
|
+
const mockAxios = getMockAxios();
|
|
240
|
+
mockAxios.onGet(/\/default\/points\/history/).reply(200, [
|
|
241
|
+
{ date: '2026-01-01', earned: 100, spent: 50, balance: 50 },
|
|
242
|
+
{ date: '2026-01-02', earned: 200, spent: 75, balance: 175 },
|
|
243
|
+
]);
|
|
244
|
+
const result = await pointsGetHistogram({ memberId: 'member-uuid' });
|
|
245
|
+
expect(result).toHaveLength(2);
|
|
246
|
+
expect(result[0].date).toBe('2026-01-01');
|
|
247
|
+
expect(result[0].earned).toBe(100);
|
|
248
|
+
expect(result[0].spent).toBe(50);
|
|
249
|
+
expect(result[0].balance).toBe(50);
|
|
250
|
+
});
|
|
251
|
+
it('should include member:id in query params', async () => {
|
|
252
|
+
const mockAxios = getMockAxios();
|
|
253
|
+
mockAxios.onGet(/\/default\/points\/history/).reply(200, []);
|
|
254
|
+
await pointsGetHistogram({ memberId: 'member-uuid' });
|
|
255
|
+
const url = mockAxios.history.get[0].url;
|
|
256
|
+
expect(url).toContain('member%3Aid=member-uuid');
|
|
257
|
+
});
|
|
258
|
+
it('should set interval parameter', async () => {
|
|
259
|
+
const mockAxios = getMockAxios();
|
|
260
|
+
mockAxios.onGet(/\/default\/points\/history/).reply(200, []);
|
|
261
|
+
await pointsGetHistogram({
|
|
262
|
+
memberId: 'member-uuid',
|
|
263
|
+
interval: 'week',
|
|
264
|
+
});
|
|
265
|
+
const url = mockAxios.history.get[0].url;
|
|
266
|
+
expect(url).toContain('interval=week');
|
|
267
|
+
});
|
|
268
|
+
it('should handle object response format with items', async () => {
|
|
269
|
+
const mockAxios = getMockAxios();
|
|
270
|
+
mockAxios.onGet(/\/default\/points\/history/).reply(200, {
|
|
271
|
+
items: [
|
|
272
|
+
{ date: '2026-01-01', earned: 100, spent: 50, balance: 50 },
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
const result = await pointsGetHistogram({ memberId: 'member-uuid' });
|
|
276
|
+
expect(result).toHaveLength(1);
|
|
277
|
+
expect(result[0].date).toBe('2026-01-01');
|
|
278
|
+
});
|
|
279
|
+
it('should calculate lastDays from dateFrom', async () => {
|
|
280
|
+
const mockAxios = getMockAxios();
|
|
281
|
+
mockAxios.onGet(/\/default\/points\/history/).reply(200, []);
|
|
282
|
+
// Set a fixed dateFrom to test calculation
|
|
283
|
+
await pointsGetHistogram({
|
|
284
|
+
memberId: 'member-uuid',
|
|
285
|
+
dateFrom: '2026-01-01',
|
|
286
|
+
});
|
|
287
|
+
const url = mockAxios.history.get[0].url;
|
|
288
|
+
expect(url).toContain('lastDays=');
|
|
289
|
+
expect(url).toContain('futureDays=0');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|