@payware/mcp-server 1.2.0 → 1.3.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.
Files changed (58) hide show
  1. package/README.md +5 -0
  2. package/package.json +69 -66
  3. package/src/auth/sandbox-setup.js +186 -186
  4. package/src/isv/index.js +29 -1
  5. package/src/isv/invitations/index.js +471 -0
  6. package/src/isv/oauth2/obtain-token.js +19 -8
  7. package/src/isv/oauth2/token-helper.js +5 -2
  8. package/src/isv/poi/cancel-price.js +2 -10
  9. package/src/isv/poi/create.js +19 -0
  10. package/src/isv/poi/get-poi.js +2 -10
  11. package/src/isv/poi/get-qrcode.js +2 -10
  12. package/src/isv/poi/get-status.js +2 -10
  13. package/src/isv/poi/index.js +5 -0
  14. package/src/isv/poi/list-pois.js +2 -10
  15. package/src/isv/poi/set-price.js +217 -225
  16. package/src/isv/products/create-product.js +313 -313
  17. package/src/isv/products/update-product.js +264 -264
  18. package/src/isv/reference/index.js +249 -0
  19. package/src/isv/shops/index.js +22 -0
  20. package/src/isv/transactions/cancel-transaction.js +41 -10
  21. package/src/isv/transactions/create-transaction.js +20 -4
  22. package/src/isv/transactions/get-status.js +18 -11
  23. package/src/isv/transactions/process-transaction.js +160 -42
  24. package/src/isv/transactions/simulate-callback.js +128 -109
  25. package/src/isv/transactions/transaction-history.js +86 -90
  26. package/src/merchant/data/generate-report.js +19 -1
  27. package/src/merchant/index.js +21 -1
  28. package/src/merchant/logs/index.js +366 -0
  29. package/src/merchant/products/create-product.js +287 -287
  30. package/src/merchant/products/update-product.js +281 -281
  31. package/src/merchant/shops/index.js +18 -0
  32. package/src/merchant/transactions/cancel-transaction.js +10 -13
  33. package/src/merchant/transactions/create-transaction.js +88 -8
  34. package/src/merchant/transactions/get-status.js +40 -11
  35. package/src/merchant/transactions/process-transaction.js +2 -10
  36. package/src/merchant/transactions/simulate-callback.js +68 -9
  37. package/src/merchant/transactions/transaction-history.js +275 -257
  38. package/src/payment-institution/transactions/create-pi-transaction.js +2 -10
  39. package/src/payment-institution/transactions/finalize-transaction.js +333 -340
  40. package/src/payment-institution/transactions/get-transaction-history.js +2 -10
  41. package/src/payment-institution/transactions/get-transaction-status.js +38 -11
  42. package/src/payment-institution/transactions/process-pi-transaction.js +2 -10
  43. package/src/payment-institution/transactions/simulate-pi-callback.js +49 -9
  44. package/src/payment-institution/transactions/soundbite-transaction.js +2 -10
  45. package/src/shared/api-errors.js +188 -0
  46. package/src/shared/auth-headers.js +62 -0
  47. package/src/shared/data/data-api.js +11 -10
  48. package/src/shared/deep-links/api.js +5 -4
  49. package/src/shared/logs/logs-api.js +242 -0
  50. package/src/shared/poi/poi-create-api.js +419 -0
  51. package/src/shared/products/products-api.js +17 -16
  52. package/src/shared/shops/shops-api.js +155 -0
  53. package/src/tools/advanced-code-generator.js +2255 -2246
  54. package/src/utils/code-examples/common/auth-examples.js +1484 -1481
  55. package/src/utils/code-examples/common/helpers.js +7 -4
  56. package/src/utils/code-examples/isv/oauth2.js +458 -600
  57. package/src/utils/code-examples/isv/transactions.js +815 -812
  58. package/MCP-SETUP.md +0 -190
@@ -0,0 +1,471 @@
1
+ import axios from 'axios';
2
+ import { createJWTToken } from '../../core/auth/jwt-token.js';
3
+ import { createMinimizedJSON } from '../../core/utils/json-serializer.js';
4
+ import { apiErrorResult } from '../../shared/api-errors.js';
5
+ import { getSandboxUrl, getProductionUrl, getPartnerIdSafe, getPrivateKeySafe } from '../../config/env.js';
6
+
7
+ /**
8
+ * Merchant invitations - how an ISV onboards a merchant onto payware.
9
+ *
10
+ * This is the flow that produces the `merchantPartnerId` + `oauth2Token` pair every other ISV tool
11
+ * here already requires, and until now nothing in this server could start it: the tools could act on
12
+ * behalf of a merchant but not obtain a merchant to act for.
13
+ *
14
+ * **payware sends no email.** The API returns an `invitationLink` and the ISV delivers it through
15
+ * its own channels. An assistant that creates an invitation and stops has not finished the job - the
16
+ * link is the deliverable.
17
+ *
18
+ * **Authenticated as the ISV itself.** There is no merchant to act on behalf of yet, so these calls
19
+ * carry a plain ISV JWT with no `aud`/`sub` on-behalf claims.
20
+ */
21
+
22
+ const INVITATION_STATUSES = ['PENDING', 'COMPLETED', 'EXPIRED', 'CANCELLED'];
23
+
24
+ async function isvRequest({ method, path, body, partnerId, privateKey, useSandbox }) {
25
+ const minimizedBody = body ? createMinimizedJSON(body) : undefined;
26
+ const tokenData = createJWTToken(partnerId, privateKey, minimizedBody);
27
+ const headers = {
28
+ 'Authorization': `Bearer ${tokenData.token}`,
29
+ 'Content-Type': 'application/json',
30
+ 'Api-Version': '1'
31
+ };
32
+
33
+ const baseUrl = useSandbox ? getSandboxUrl() : getProductionUrl();
34
+ return axios({
35
+ method,
36
+ url: `${baseUrl}${path}`,
37
+ headers,
38
+ ...(minimizedBody !== undefined && {
39
+ data: minimizedBody,
40
+ transformRequest: [(data) => data]
41
+ })
42
+ });
43
+ }
44
+
45
+ /** Create an invitation. Returns the link the ISV must deliver. */
46
+ export async function createInvitation({ partnerId, privateKey, useSandbox = true, ...fields }) {
47
+ if (!fields.email) {
48
+ throw new Error('email is required - it is what decides whether this is a REGISTRATION or an AUTHORIZATION_ONLY invitation');
49
+ }
50
+ if (fields.jurisdictionCode && !/^[a-zA-Z]{2}$/.test(fields.jurisdictionCode)) {
51
+ // Not cosmetic: jurisdictionCode 'BG' triggers auto-population from the Bulgarian commercial
52
+ // registry, so a malformed code silently takes a different path instead of failing.
53
+ throw new Error('jurisdictionCode must be a two-letter ISO 3166-1 alpha-2 code, e.g. BG');
54
+ }
55
+
56
+ const body = Object.fromEntries(
57
+ Object.entries(fields).filter(([, v]) => v !== undefined && v !== null && v !== '')
58
+ );
59
+
60
+ try {
61
+ const response = await isvRequest({
62
+ method: 'post', path: '/isv/invitations', body, partnerId, privateKey, useSandbox
63
+ });
64
+ return {
65
+ success: true,
66
+ invitation: response.data,
67
+ requestId: response.headers['x-request-id'],
68
+ timestamp: new Date().toISOString()
69
+ };
70
+ } catch (error) {
71
+ return apiErrorResult(error);
72
+ }
73
+ }
74
+
75
+ /** List this ISV's invitations, newest first. */
76
+ export async function listInvitations({ status, page = 0, size = 20, partnerId, privateKey, useSandbox = true }) {
77
+ const query = new URLSearchParams();
78
+ if (Array.isArray(status)) {
79
+ status.forEach(s => query.append('status', s));
80
+ } else if (status) {
81
+ query.append('status', status);
82
+ }
83
+ query.set('page', String(page));
84
+ // The server caps size at 100 regardless; capping here means the response matches what was asked
85
+ // for rather than silently differing from it.
86
+ query.set('size', String(Math.min(size, 100)));
87
+
88
+ try {
89
+ const response = await isvRequest({
90
+ method: 'get', path: `/isv/invitations?${query.toString()}`, partnerId, privateKey, useSandbox
91
+ });
92
+ return {
93
+ success: true,
94
+ invitations: response.data?.content ?? [],
95
+ totalElements: response.data?.totalElements ?? 0,
96
+ totalPages: response.data?.totalPages ?? 0,
97
+ page: response.data?.number ?? page,
98
+ requestId: response.headers['x-request-id'],
99
+ timestamp: new Date().toISOString()
100
+ };
101
+ } catch (error) {
102
+ return apiErrorResult(error);
103
+ }
104
+ }
105
+
106
+ /** Full detail for one invitation, including the link. */
107
+ export async function getInvitation({ invitationId, partnerId, privateKey, useSandbox = true }) {
108
+ if (invitationId === undefined || invitationId === null) {
109
+ throw new Error('invitationId is required');
110
+ }
111
+ try {
112
+ const response = await isvRequest({
113
+ method: 'get', path: `/isv/invitations/${invitationId}`, partnerId, privateKey, useSandbox
114
+ });
115
+ return {
116
+ success: true,
117
+ invitation: response.data,
118
+ requestId: response.headers['x-request-id'],
119
+ timestamp: new Date().toISOString()
120
+ };
121
+ } catch (error) {
122
+ return apiErrorResult(error);
123
+ }
124
+ }
125
+
126
+ /** Cancel a PENDING invitation. The link stops working immediately. */
127
+ export async function cancelInvitation({ invitationId, partnerId, privateKey, useSandbox = true }) {
128
+ if (invitationId === undefined || invitationId === null) {
129
+ throw new Error('invitationId is required');
130
+ }
131
+ try {
132
+ const response = await isvRequest({
133
+ method: 'delete', path: `/isv/invitations/${invitationId}`, partnerId, privateKey, useSandbox
134
+ });
135
+ return {
136
+ success: true,
137
+ invitationId,
138
+ requestId: response.headers['x-request-id'],
139
+ timestamp: new Date().toISOString()
140
+ };
141
+ } catch (error) {
142
+ return apiErrorResult(error);
143
+ }
144
+ }
145
+
146
+ const AUTH_PROPERTIES = {
147
+ partnerId: {
148
+ type: 'string',
149
+ description: 'ISV partner ID. Uses PAYWARE_PARTNER_ID env var as default.'
150
+ },
151
+ privateKey: {
152
+ type: 'string',
153
+ description: 'RSA private key for JWT signing. Uses the environment-specific key as default.'
154
+ },
155
+ useSandbox: {
156
+ type: 'boolean',
157
+ description: 'Use sandbox environment for testing',
158
+ default: true
159
+ }
160
+ };
161
+
162
+ function resolveAuth(args) {
163
+ const partnerId = args.partnerId || getPartnerIdSafe();
164
+ const privateKey = args.privateKey || getPrivateKeySafe(args.useSandbox ?? true);
165
+ if (!partnerId) throw new Error("Partner ID is required. Provide 'partnerId' or set PAYWARE_PARTNER_ID.");
166
+ if (!privateKey) throw new Error("Private key is required. Provide 'privateKey' or set PAYWARE_PRIVATE_KEY.");
167
+ return { partnerId, privateKey, useSandbox: args.useSandbox ?? true };
168
+ }
169
+
170
+ function errorText(title, result) {
171
+ return `❌ **${title}**
172
+
173
+ - Message: ${result.error.message}
174
+ - Status: ${result.error.status || 'N/A'}
175
+ - Code: ${result.error.errorCode || 'N/A'}
176
+ ${result.error.correlationId ? `- Correlation ID: ${result.error.correlationId}` : ''}
177
+ ${result.error.guidance ? `\n${result.error.guidance}` : ''}
178
+
179
+ **Timestamp:** ${result.timestamp}`;
180
+ }
181
+
182
+ export const createInvitationTool = {
183
+ name: 'payware_isv_create_invitation',
184
+ description: `Invite a merchant to join payware through this ISV. **This is the start of the ISV onboarding flow.**
185
+
186
+ **Endpoint:** POST /isv/invitations -> 201 Created
187
+
188
+ ⚠️ **payware sends no email. You must deliver the link.** The response carries an \`invitationLink\`,
189
+ and distributing it - email, SMS, in-app, a printed QR, whatever your channel is - is the ISV's
190
+ responsibility. Creating the invitation and stopping there means the merchant never hears about it.
191
+
192
+ **The type is auto-detected from the email**, and you do not choose it:
193
+ - **REGISTRATION** - the email is new to payware. The merchant registers and authorizes you in one flow.
194
+ - **AUTHORIZATION_ONLY** - a merchant with that email already exists. They are asked only to authorize
195
+ you, not to register again.
196
+
197
+ Everything except \`email\` is optional and pre-fills the merchant's form. Pre-filling is worth the
198
+ effort: it is the difference between a merchant confirming details and a merchant typing them.
199
+
200
+ 💡 **\`jurisdictionCode: "BG"\` + \`registeredId\` auto-populates from the Bulgarian commercial
201
+ registry** - company name, address and legal form arrive without the merchant typing anything. Only
202
+ Bulgaria has this today.
203
+
204
+ 💡 **\`legalFormId\` and \`businessActivityId\` are ids, not text.** Get them from
205
+ \`payware_reference_lookup\` (datasets \`legal-forms\` and \`business-activities\`) - and note that
206
+ legal forms are jurisdiction-specific, so fetch them for the merchant's own jurisdiction.
207
+
208
+ **409 responses are meaningful, not transient:** a pending invitation already exists for that email,
209
+ or the merchant has already authorized you. Neither is fixed by retrying - list the invitations and
210
+ look at what is already there.
211
+
212
+ **After the merchant accepts,** you hold an authorization and can obtain the OAuth2 token the
213
+ on-behalf tools need with \`payware_authorization_oauth2_obtain_token\`.`,
214
+
215
+ inputSchema: {
216
+ type: 'object',
217
+ required: ['email'],
218
+ properties: {
219
+ email: {
220
+ type: 'string',
221
+ description: "Merchant's email address. REQUIRED, and it decides the invitation type: unknown email -> REGISTRATION, existing merchant -> AUTHORIZATION_ONLY.",
222
+ format: 'email',
223
+ maxLength: 254
224
+ },
225
+ phone: { type: 'string', description: "Merchant's phone number, e.g. +35912345678", maxLength: 20 },
226
+ contactName: { type: 'string', description: "Merchant contact's first name", maxLength: 100 },
227
+ contactSurname: { type: 'string', description: "Merchant contact's surname", maxLength: 100 },
228
+ companyName: { type: 'string', description: 'Company legal name', maxLength: 255 },
229
+ vatId: { type: 'string', description: 'VAT identification number, e.g. BG123456789', maxLength: 50 },
230
+ registeredId: {
231
+ type: 'string',
232
+ description: 'Company registration ID (EIK in Bulgaria). Combined with jurisdictionCode "BG" this auto-populates the company details from the Bulgarian commercial registry.',
233
+ maxLength: 50
234
+ },
235
+ street: { type: 'string', description: 'Street address', maxLength: 255 },
236
+ streetNumber: { type: 'string', description: 'Street number', maxLength: 20 },
237
+ zip: { type: 'string', description: 'ZIP / postal code', maxLength: 20 },
238
+ city: { type: 'string', description: 'City', maxLength: 100 },
239
+ legalFormId: {
240
+ type: 'integer',
241
+ description: "Legal form id from payware_reference_lookup (dataset 'legal-forms'). Jurisdiction-specific - fetch for the merchant's jurisdiction, not yours."
242
+ },
243
+ businessActivityId: {
244
+ type: 'integer',
245
+ description: "Business activity id from payware_reference_lookup (dataset 'business-activities')."
246
+ },
247
+ jurisdictionCode: {
248
+ type: 'string',
249
+ description: "ISO 3166-1 alpha-2 country code, e.g. BG. Two letters. 'BG' plus registeredId triggers Bulgarian commercial registry auto-population.",
250
+ pattern: '^[a-zA-Z]{2}$'
251
+ },
252
+ preferredLanguageCode: {
253
+ type: 'string',
254
+ description: "Language for the invitation, e.g. bg or bg-BG. Valid values come from payware_reference_lookup (dataset 'languages').",
255
+ maxLength: 5
256
+ },
257
+ ...AUTH_PROPERTIES
258
+ }
259
+ },
260
+
261
+ async handler(args) {
262
+ const auth = resolveAuth(args);
263
+ const { partnerId, privateKey, useSandbox, ...rest } = { ...args, ...auth };
264
+ const result = await createInvitation({ partnerId, privateKey, useSandbox, ...rest });
265
+
266
+ if (!result.success) {
267
+ return { content: [{ type: 'text', text: errorText('Failed to Create Invitation', result) }] };
268
+ }
269
+
270
+ const inv = result.invitation || {};
271
+ return {
272
+ content: [{
273
+ type: 'text',
274
+ text: `✅ **Invitation Created**
275
+
276
+ - Invitation ID: \`${inv.invitationId}\`
277
+ - Type: **${inv.type}** ${inv.type === 'REGISTRATION' ? '(new merchant - they register and authorize in one flow)' : '(existing merchant - they only authorize you)'}
278
+ - Status: ${inv.status}
279
+ - Email: ${inv.email}
280
+ - Expires: ${inv.expiresAt || 'N/A'}
281
+
282
+ ## 🔗 Deliver this link to the merchant
283
+
284
+ \`\`\`
285
+ ${inv.invitationLink}
286
+ \`\`\`
287
+
288
+ **payware does not send it.** Nothing further happens until the merchant opens this link, so send it
289
+ through your own channel now.
290
+
291
+ **Then:** once they accept, obtain the OAuth2 token with \`payware_authorization_oauth2_obtain_token\`
292
+ to start acting on their behalf.
293
+
294
+ **Request ID:** ${result.requestId || 'N/A'}
295
+ **Timestamp:** ${result.timestamp}`
296
+ }]
297
+ };
298
+ }
299
+ };
300
+
301
+ export const listInvitationsTool = {
302
+ name: 'payware_isv_list_invitations',
303
+ description: `List the merchant invitations this ISV has created, newest first.
304
+
305
+ **Endpoint:** GET /isv/invitations - paginated
306
+
307
+ **Use it to answer "what happened to that invitation":** filter by \`PENDING\` to find merchants who
308
+ have not acted yet and may need the link re-sent, or by \`COMPLETED\` to find merchants you can now
309
+ obtain a token for.
310
+
311
+ **Statuses:** ${INVITATION_STATUSES.map(s => `\`${s}\``).join(', ')}. Only \`PENDING\` ones can be cancelled.
312
+
313
+ **Paging:** zero-based \`page\`, \`size\` up to 100 (larger values are capped, not rejected).`,
314
+
315
+ inputSchema: {
316
+ type: 'object',
317
+ properties: {
318
+ status: {
319
+ type: 'array',
320
+ description: 'Filter by one or more statuses. Omit for all.',
321
+ items: { type: 'string', enum: INVITATION_STATUSES }
322
+ },
323
+ page: { type: 'integer', description: 'Page number, zero-based', minimum: 0, default: 0 },
324
+ size: { type: 'integer', description: 'Page size, max 100', minimum: 1, maximum: 100, default: 20 },
325
+ ...AUTH_PROPERTIES
326
+ }
327
+ },
328
+
329
+ async handler(args) {
330
+ const auth = resolveAuth(args);
331
+ const result = await listInvitations({
332
+ status: args.status, page: args.page ?? 0, size: args.size ?? 20, ...auth
333
+ });
334
+
335
+ if (!result.success) {
336
+ return { content: [{ type: 'text', text: errorText('Failed to List Invitations', result) }] };
337
+ }
338
+
339
+ const emoji = { PENDING: '⏳', COMPLETED: '✅', EXPIRED: '⌛', CANCELLED: '🚫' };
340
+ const rows = result.invitations.map(inv =>
341
+ `${emoji[inv.status] || '•'} \`${inv.invitationId}\` ${inv.email} - **${inv.status}** (${inv.type})${inv.companyName ? ` - ${inv.companyName}` : ''}`
342
+ ).join('\n');
343
+
344
+ return {
345
+ content: [{
346
+ type: 'text',
347
+ text: `📨 **Invitations** - page ${result.page + 1} of ${Math.max(result.totalPages, 1)}, ${result.totalElements} total
348
+
349
+ ${rows || 'No invitations match this filter.'}
350
+
351
+ **Status meanings:** ⏳ PENDING - link sent, merchant has not acted. ✅ COMPLETED - merchant accepted;
352
+ obtain a token with \`payware_authorization_oauth2_obtain_token\`. ⌛ EXPIRED - the link lapsed; create
353
+ a new invitation. 🚫 CANCELLED - withdrawn by you.
354
+
355
+ **Request ID:** ${result.requestId || 'N/A'}
356
+ **Timestamp:** ${result.timestamp}`
357
+ }]
358
+ };
359
+ }
360
+ };
361
+
362
+ export const getInvitationTool = {
363
+ name: 'payware_isv_get_invitation',
364
+ description: `Get full details of one invitation, including its link and the merchant data it pre-fills.
365
+
366
+ **Endpoint:** GET /isv/invitations/{invitationId}
367
+
368
+ **This is how you recover an invitation link** you no longer have - the link is not derivable from
369
+ the id, so re-reading the invitation is the only way to get it back short of creating a new one.
370
+
371
+ **404** means no invitation with that id belongs to this ISV. Invitations are scoped to their
372
+ creator, so another ISV's id reads as not-found rather than as forbidden.`,
373
+
374
+ inputSchema: {
375
+ type: 'object',
376
+ required: ['invitationId'],
377
+ properties: {
378
+ invitationId: { type: 'integer', description: 'The invitation id, from payware_isv_list_invitations or the create response.' },
379
+ ...AUTH_PROPERTIES
380
+ }
381
+ },
382
+
383
+ async handler(args) {
384
+ const auth = resolveAuth(args);
385
+ const result = await getInvitation({ invitationId: args.invitationId, ...auth });
386
+
387
+ if (!result.success) {
388
+ return { content: [{ type: 'text', text: errorText('Failed to Get Invitation', result) }] };
389
+ }
390
+
391
+ const inv = result.invitation || {};
392
+ return {
393
+ content: [{
394
+ type: 'text',
395
+ text: `📨 **Invitation \`${inv.invitationId}\`**
396
+
397
+ - Status: **${inv.status}**
398
+ - Type: ${inv.type}
399
+ - Email: ${inv.email}
400
+ - Company: ${inv.companyName || '(not pre-filled)'}
401
+ - Expires: ${inv.expiresAt || 'N/A'}
402
+
403
+ ${inv.invitationLink ? `## 🔗 Invitation link
404
+
405
+ \`\`\`
406
+ ${inv.invitationLink}
407
+ \`\`\`
408
+ ` : ''}
409
+ **Request ID:** ${result.requestId || 'N/A'}
410
+ **Timestamp:** ${result.timestamp}`
411
+ }]
412
+ };
413
+ }
414
+ };
415
+
416
+ export const cancelInvitationTool = {
417
+ name: 'payware_isv_cancel_invitation',
418
+ description: `Cancel a PENDING invitation. **The link stops working immediately.**
419
+
420
+ **Endpoint:** DELETE /isv/invitations/{invitationId} -> 204 No Content
421
+
422
+ **Only PENDING invitations can be cancelled.** A COMPLETED, EXPIRED or already-CANCELLED one answers
423
+ **409** - and for a COMPLETED one that is the right answer, not an obstacle: the merchant has already
424
+ registered and authorized you, and cancelling the invitation would not undo either. To end an
425
+ existing relationship the merchant revokes the authorization from their portal; revocation is theirs
426
+ to make and is final, so a new invitation cannot restore it.
427
+
428
+ **Cancel when:** the invitation went to the wrong address, or the deal is off. Not as a way to
429
+ "resend" - create a fresh invitation for that.`,
430
+
431
+ inputSchema: {
432
+ type: 'object',
433
+ required: ['invitationId'],
434
+ properties: {
435
+ invitationId: { type: 'integer', description: 'The invitation id to cancel. Must currently be PENDING.' },
436
+ ...AUTH_PROPERTIES
437
+ }
438
+ },
439
+
440
+ async handler(args) {
441
+ const auth = resolveAuth(args);
442
+ const result = await cancelInvitation({ invitationId: args.invitationId, ...auth });
443
+
444
+ if (!result.success) {
445
+ const extra = result.error.status === 409
446
+ ? '\n\n**409 means it is not PENDING.** Read it with `payware_isv_get_invitation` to see its actual status - a COMPLETED invitation cannot be cancelled, and cancelling would not revoke the authorization anyway.'
447
+ : '';
448
+ return { content: [{ type: 'text', text: errorText('Failed to Cancel Invitation', result) + extra }] };
449
+ }
450
+
451
+ return {
452
+ content: [{
453
+ type: 'text',
454
+ text: `🚫 **Invitation \`${result.invitationId}\` cancelled**
455
+
456
+ The invitation link is invalid from now on. If the merchant still needs to be onboarded, create a
457
+ fresh invitation with \`payware_isv_create_invitation\` and deliver the new link.
458
+
459
+ **Request ID:** ${result.requestId || 'N/A'}
460
+ **Timestamp:** ${result.timestamp}`
461
+ }]
462
+ };
463
+ }
464
+ };
465
+
466
+ export const isvInvitationTools = [
467
+ createInvitationTool,
468
+ listInvitationsTool,
469
+ getInvitationTool,
470
+ cancelInvitationTool
471
+ ];
@@ -125,22 +125,33 @@ Auth: Requires ISV JWT token with aud: "https://payware.eu"`,
125
125
  errorDetails.httpStatus = error.response.status;
126
126
  errorDetails.responseData = error.response.data;
127
127
 
128
- if (error.response.data?.error) {
129
- const errorCode = error.response.data.error;
128
+ // The code payware branches on is `errorCode`. `error` is RFC 6749 section 5.2's field,
129
+ // which the token endpoint began sending in 2026-08 alongside `errorCode` - it carries the
130
+ // RFC's closed vocabulary (invalid_client, invalid_request, unsupported_grant_type), NOT a
131
+ // payware ERR_* code. Reading the mapping off `error` therefore looked up an RFC value in a
132
+ // table keyed by ERR_* and fell through to "Error: invalid_client" for every failure.
133
+ const errorCode = error.response.data?.errorCode;
134
+ const rfcError = error.response.data?.error;
135
+
136
+ if (errorCode || rfcError) {
130
137
  const errorMappings = {
131
138
  'ERR_UNSUPPORTED_GRANT_TYPE': 'Grant type must be "client_credentials"',
132
139
  'ERR_MISSING_CLIENT_ID': 'Client ID (merchant partnerId) is required',
133
140
  'ERR_MISSING_CLIENT_SECRET': 'Client secret (base64 encoded merchant secret) is required',
134
141
  'ERR_CLIENT_NOT_FOUND': 'Merchant not found with provided client ID',
135
- 'ERR_CLIENT_DISABLED': 'Merchant account is disabled',
136
- 'ERR_TOKEN_EXISTS': 'Token already exists for this merchant. Only one token per merchant is allowed.'
142
+ 'ERR_CLIENT_DISABLED': 'Merchant account is disabled'
137
143
  };
138
144
 
139
- errorMessage = errorMappings[errorCode] || `Error: ${errorCode}`;
145
+ // ERR_TOKEN_EXISTS is gone from this table on purpose: the server no longer throws it
146
+ // (the exception was deleted as never-thrown), so the branch that explained how to
147
+ // recover an existing token could never run and only misled whoever read it next.
148
+ errorMessage = errorMappings[errorCode]
149
+ || error.response.data?.message
150
+ || `Error: ${errorCode || rfcError}`;
140
151
 
141
- // Special handling for ERR_TOKEN_EXISTS - try to get existing token info
142
- if (errorCode === 'ERR_TOKEN_EXISTS') {
143
- errorMessage += '\\n\\n🔍 **To find your existing token:**\\n1. Check your secure storage where you saved the previous token\\n2. Or use payware_oauth2_get_token_info if you know the token\\n3. Contact merchant to revoke existing token if you need a new one';
152
+ if (rfcError) {
153
+ errorDetails.oauth2Error = rfcError;
154
+ errorDetails.oauth2ErrorDescription = error.response.data?.error_description;
144
155
  }
145
156
  } else {
146
157
  errorMessage = `HTTP ${error.response.status}: ${error.response.statusText}`;
@@ -63,7 +63,7 @@ Use this when you need a token but aren't sure if one exists or what it is.`,
63
63
  **Option 1: Try creating a token**
64
64
  Use: \`payware_oauth2_obtain_token\`
65
65
  - If successful: New token will be clearly displayed
66
- - If error "ERR_TOKEN_EXISTS": A token already exists
66
+ - On failure the server names the reason (e.g. client not found, client disabled)
67
67
 
68
68
  **Option 2: Check a known token**
69
69
  Use: \`payware_oauth2_get_token_info\` with your saved token
@@ -84,7 +84,10 @@ Use: \`payware_oauth2_get_token_info\` with your saved token
84
84
  // Try to create a token
85
85
  const result = await obtainTokenTool.handler({ clientId, clientSecret, useSandbox });
86
86
 
87
- // Check if the result indicates an ERR_TOKEN_EXISTS error
87
+ // Dead since the server stopped throwing ERR_TOKEN_EXISTS - the exception was deleted as
88
+ // never-thrown - and kept only so the guidance below has a home if one-token-per-merchant is
89
+ // ever enforced again. Matching on the substring of a rendered message was always fragile:
90
+ // it also fires on any text that merely mentions the code.
88
91
  if (result.content?.[0]?.text?.includes('ERR_TOKEN_EXISTS')) {
89
92
  return {
90
93
  content: [{
@@ -1,6 +1,7 @@
1
1
  import axios from 'axios';
2
2
  import { createJWTForPartner } from '../../core/auth/jwt-factory.js';
3
3
  import { getSandboxUrl, getProductionUrl, getPartnerIdSafe, getPrivateKeySafe } from '../../config/env.js';
4
+ import { apiErrorResult } from '../../shared/api-errors.js';
4
5
 
5
6
  /**
6
7
  * Cancel pending price on a POI via ISV authentication
@@ -47,16 +48,7 @@ export async function cancelPOIPrice({ poiId, merchantPartnerId, oauth2Token, us
47
48
  timestamp: new Date().toISOString()
48
49
  };
49
50
  } catch (error) {
50
- return {
51
- success: false,
52
- error: {
53
- message: error.response?.data?.message || error.message,
54
- status: error.response?.status,
55
- code: error.response?.data?.code,
56
- details: error.response?.data
57
- },
58
- timestamp: new Date().toISOString()
59
- };
51
+ return apiErrorResult(error);
60
52
  }
61
53
  }
62
54
 
@@ -0,0 +1,19 @@
1
+ import { buildCreatePOITool, buildCreatePOIBatchTool } from '../../shared/poi/poi-create-api.js';
2
+ import { getPartnerIdSafe, getPrivateKeySafe } from '../../config/env.js';
3
+
4
+ /**
5
+ * POI creation for the ISV role.
6
+ *
7
+ * Signed as an on-behalf call. The shop named must be inside the scope the merchant assigned this
8
+ * ISV - anything else is 403 ERR_SHOP_NOT_IN_SCOPE, and for the batch that refuses the whole batch,
9
+ * not the offending entry.
10
+ */
11
+ const resolveAuth = (args) => ({
12
+ isvPartnerId: getPartnerIdSafe(),
13
+ privateKey: getPrivateKeySafe(args.useSandbox ?? true),
14
+ merchantPartnerId: args.merchantPartnerId,
15
+ oauth2Token: args.oauth2Token
16
+ });
17
+
18
+ export const createPOITool = buildCreatePOITool({ partnerType: 'isv', resolveAuth });
19
+ export const createPOIBatchTool = buildCreatePOIBatchTool({ partnerType: 'isv', resolveAuth });
@@ -1,6 +1,7 @@
1
1
  import axios from 'axios';
2
2
  import { createJWTForPartner } from '../../core/auth/jwt-factory.js';
3
3
  import { getSandboxUrl, getProductionUrl, getPartnerIdSafe, getPrivateKeySafe } from '../../config/env.js';
4
+ import { apiErrorResult } from '../../shared/api-errors.js';
4
5
 
5
6
  /**
6
7
  * Get POI details via ISV authentication
@@ -51,16 +52,7 @@ export async function getPOI({ poiId, merchantPartnerId, oauth2Token, useSandbox
51
52
  timestamp: new Date().toISOString()
52
53
  };
53
54
  } catch (error) {
54
- return {
55
- success: false,
56
- error: {
57
- message: error.response?.data?.message || error.message,
58
- status: error.response?.status,
59
- code: error.response?.data?.code,
60
- details: error.response?.data
61
- },
62
- timestamp: new Date().toISOString()
63
- };
55
+ return apiErrorResult(error);
64
56
  }
65
57
  }
66
58
 
@@ -1,5 +1,6 @@
1
1
  import axios from 'axios';
2
2
  import { getSandboxUrl, getProductionUrl } from '../../config/env.js';
3
+ import { apiErrorResult } from '../../shared/api-errors.js';
3
4
 
4
5
  /**
5
6
  * Get POI QR code image
@@ -39,16 +40,7 @@ export async function getPOIQRCode({ poiId, format = 'PNG', useSandbox = true })
39
40
  timestamp: new Date().toISOString()
40
41
  };
41
42
  } catch (error) {
42
- return {
43
- success: false,
44
- error: {
45
- message: error.response?.data?.message || error.message,
46
- status: error.response?.status,
47
- code: error.response?.data?.code,
48
- details: error.response?.data
49
- },
50
- timestamp: new Date().toISOString()
51
- };
43
+ return apiErrorResult(error);
52
44
  }
53
45
  }
54
46
 
@@ -1,6 +1,7 @@
1
1
  import axios from 'axios';
2
2
  import { createJWTForPartner } from '../../core/auth/jwt-factory.js';
3
3
  import { getSandboxUrl, getProductionUrl, getPartnerIdSafe, getPrivateKeySafe } from '../../config/env.js';
4
+ import { apiErrorResult } from '../../shared/api-errors.js';
4
5
 
5
6
  /**
6
7
  * Get POI status via ISV authentication
@@ -48,16 +49,7 @@ export async function getPOIStatus({ poiId, merchantPartnerId, oauth2Token, useS
48
49
  timestamp: new Date().toISOString()
49
50
  };
50
51
  } catch (error) {
51
- return {
52
- success: false,
53
- error: {
54
- message: error.response?.data?.message || error.message,
55
- status: error.response?.status,
56
- code: error.response?.data?.code,
57
- details: error.response?.data
58
- },
59
- timestamp: new Date().toISOString()
60
- };
52
+ return apiErrorResult(error);
61
53
  }
62
54
  }
63
55
 
@@ -9,11 +9,14 @@ import { getPOIStatusTool } from './get-status.js';
9
9
  import { setPOIPriceTool } from './set-price.js';
10
10
  import { cancelPOIPriceTool } from './cancel-price.js';
11
11
  import { getPOIQRCodeTool } from './get-qrcode.js';
12
+ import { createPOITool, createPOIBatchTool } from './create.js';
12
13
 
13
14
  /**
14
15
  * All POI tools for ISV
15
16
  */
16
17
  export const isvPOITools = [
18
+ createPOITool,
19
+ createPOIBatchTool,
17
20
  listPOIsTool,
18
21
  getPOITool,
19
22
  getPOIStatusTool,
@@ -23,6 +26,8 @@ export const isvPOITools = [
23
26
  ];
24
27
 
25
28
  export {
29
+ createPOITool,
30
+ createPOIBatchTool,
26
31
  listPOIsTool,
27
32
  getPOITool,
28
33
  getPOIStatusTool,