@payware/mcp-server 1.3.2 → 1.4.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/.env.example +4 -0
- package/README.md +18 -0
- package/package.json +7 -9
- package/src/auth/rsa-keys.js +12 -6
- package/src/auth/sandbox-setup.js +2 -0
- package/src/config/toolsets.js +104 -0
- package/src/core/auth/jwt-factory.js +4 -1
- package/src/core/auth/jwt-token.js +4 -0
- package/src/index.js +10 -6
- package/src/isv/data/index.js +15 -14
- package/src/isv/deep-links/delete-product-link.js +3 -2
- package/src/isv/deep-links/delete-transaction-link.js +3 -2
- package/src/isv/deep-links/get-product-link.js +8 -6
- package/src/isv/deep-links/get-transaction-link.js +8 -6
- package/src/isv/index.js +6 -0
- package/src/isv/invitations/index.js +5 -0
- package/src/isv/oauth2/get-token-info.js +10 -5
- package/src/isv/oauth2/obtain-token.js +9 -4
- package/src/isv/oauth2/token-helper.js +28 -26
- package/src/isv/poi/cancel-price.js +9 -2
- package/src/isv/poi/get-poi.js +4 -3
- package/src/isv/poi/get-qrcode.js +40 -9
- package/src/isv/poi/get-status.js +196 -168
- package/src/isv/poi/list-pois.js +6 -6
- package/src/isv/poi/set-price.js +40 -53
- package/src/isv/products/audios.js +8 -2
- package/src/isv/products/create-product.js +2 -0
- package/src/isv/products/delete-product.js +2 -0
- package/src/isv/products/get-product-image.js +2 -0
- package/src/isv/products/get-product.js +2 -0
- package/src/isv/products/list-products.js +2 -0
- package/src/isv/products/schedules.js +6 -0
- package/src/isv/products/update-product.js +2 -0
- package/src/isv/reference/index.js +2 -0
- package/src/isv/transactions/cancel-transaction.js +2 -0
- package/src/isv/transactions/create-transaction.js +2 -0
- package/src/isv/transactions/get-status.js +3 -1
- package/src/isv/transactions/process-transaction.js +2 -0
- package/src/isv/transactions/simulate-callback.js +21 -10
- package/src/isv/transactions/transaction-history.js +2 -0
- package/src/merchant/data/export-report.js +2 -0
- package/src/merchant/data/generate-report.js +2 -0
- package/src/merchant/data/get-export-list.js +2 -0
- package/src/merchant/data/get-report-requests.js +2 -0
- package/src/merchant/data/get-report-status.js +2 -0
- package/src/merchant/data/index.js +15 -10
- package/src/merchant/data/list-reports.js +2 -0
- package/src/merchant/deep-links/delete-product-link.js +3 -2
- package/src/merchant/deep-links/delete-transaction-link.js +3 -2
- package/src/merchant/deep-links/get-product-link.js +8 -6
- package/src/merchant/deep-links/get-transaction-link.js +8 -6
- package/src/merchant/index.js +7 -1
- package/src/merchant/logs/index.js +4 -0
- package/src/merchant/products/audios.js +15 -14
- package/src/merchant/products/create-product.js +3 -2
- package/src/merchant/products/delete-product.js +3 -2
- package/src/merchant/products/get-product-image.js +3 -2
- package/src/merchant/products/get-product.js +3 -2
- package/src/merchant/products/list-products.js +3 -2
- package/src/merchant/products/schedules.js +11 -10
- package/src/merchant/products/update-product.js +3 -2
- package/src/merchant/transactions/cancel-transaction.js +3 -2
- package/src/merchant/transactions/create-transaction.js +3 -1
- package/src/merchant/transactions/get-status.js +4 -3
- package/src/merchant/transactions/process-transaction.js +5 -4
- package/src/merchant/transactions/simulate-callback.js +325 -313
- package/src/merchant/transactions/transaction-history.js +7 -6
- package/src/payment-institution/data/index.js +15 -14
- package/src/payment-institution/deep-links/delete-transaction-link.js +3 -2
- package/src/payment-institution/deep-links/get-transaction-link.js +8 -6
- package/src/payment-institution/transactions/create-pi-transaction.js +4 -3
- package/src/payment-institution/transactions/finalize-transaction.js +6 -3
- package/src/payment-institution/transactions/get-transaction-history.js +8 -5
- package/src/payment-institution/transactions/get-transaction-status.js +16 -13
- package/src/payment-institution/transactions/process-pi-transaction.js +7 -6
- package/src/payment-institution/transactions/simulate-pi-callback.js +388 -375
- package/src/payment-institution/transactions/soundbite-transaction.js +6 -5
- package/src/proxy-server.js +25 -0
- package/src/shared/placements/api.js +51 -0
- package/src/shared/placements/tools.js +519 -0
- package/src/shared/poi/poi-create-api.js +4 -1
- package/src/shared/products/products-api.js +36 -25
- package/src/shared/shops/shops-api.js +2 -0
- package/src/shared/tool-annotations.js +53 -0
- package/src/tools/advanced-code-generator.js +17 -265
- package/src/utils/code-examples/common/auth-examples.js +1 -1
- package/src/utils/code-examples/isv/products.js +1 -1
- package/src/utils/code-examples/isv/transactions.js +1 -1
- package/src/utils/code-examples/merchant/transactions.js +1 -1
- package/src/utils/format-request.js +2 -0
- package/src/utils/json-formatter.js +2 -0
- package/src/utils/server-info.js +2 -0
- package/src/utils/code-examples.js +0 -646
|
@@ -4,6 +4,7 @@ import { createJWTToken } from '../../core/auth/jwt-token.js';
|
|
|
4
4
|
import { getSandboxUrl, getProductionUrl, getPartnerIdSafe, getPrivateKeySafe } from '../../config/env.js';
|
|
5
5
|
import { readFileSync } from 'fs';
|
|
6
6
|
import { apiErrorResult } from '../../shared/api-errors.js';
|
|
7
|
+
import { CREATES } from '../../shared/tool-annotations.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Get transaction information using Soundbite file via payware API
|
|
@@ -73,6 +74,7 @@ export async function getSoundbiteTransaction({
|
|
|
73
74
|
*/
|
|
74
75
|
export const soundbiteTransactionTool = {
|
|
75
76
|
name: "payware_operations_soundbite_transaction",
|
|
77
|
+
annotations: CREATES,
|
|
76
78
|
description: `Get transaction information using a Soundbite SDK output file. This allows payment institutions to process transactions using audio-based identifiers.
|
|
77
79
|
|
|
78
80
|
**Soundbite Integration:**
|
|
@@ -105,8 +107,7 @@ export const soundbiteTransactionTool = {
|
|
|
105
107
|
},
|
|
106
108
|
privateKey: {
|
|
107
109
|
type: "string",
|
|
108
|
-
description: "RSA private key for JWT token creation. Uses environment-specific private key as default."
|
|
109
|
-
default: getPrivateKeySafe()
|
|
110
|
+
description: "RSA private key for JWT token creation. Uses environment-specific private key as default."
|
|
110
111
|
},
|
|
111
112
|
useSandbox: {
|
|
112
113
|
type: "boolean",
|
|
@@ -148,7 +149,7 @@ export const soundbiteTransactionTool = {
|
|
|
148
149
|
|
|
149
150
|
if (result.success) {
|
|
150
151
|
const tx = result.transaction;
|
|
151
|
-
const createdDate = tx.created ? new Date(tx.created
|
|
152
|
+
const createdDate = tx.created ? new Date(tx.created).toISOString() : 'N/A';
|
|
152
153
|
const typeDisplay = tx.transactionType || 'DEFAULT';
|
|
153
154
|
const initiatorDisplay = tx.initiatedBy || 'UNKNOWN';
|
|
154
155
|
|
|
@@ -199,8 +200,8 @@ ${JSON.stringify(result.transaction, null, 2)}
|
|
|
199
200
|
1. Transaction successfully identified from soundbite file
|
|
200
201
|
2. Use transaction ID: \`${tx.transactionId || 'N/A'}\` for further operations
|
|
201
202
|
3. ${!tx.payerAccount ? 'Process this transaction if you are the payer' : 'Transaction ready for finalization'}
|
|
202
|
-
4. Use \`
|
|
203
|
-
5. Use \`
|
|
203
|
+
4. Use \`payware_operations_process_transaction\` to process payment
|
|
204
|
+
5. Use \`payware_operations_finalize_transaction\` to confirm/decline
|
|
204
205
|
|
|
205
206
|
**♿ Accessibility Note:**
|
|
206
207
|
- Soundbite provides audio-based alternative to QR/barcode scanning
|
package/src/proxy-server.js
CHANGED
|
@@ -33,6 +33,31 @@ class PaywareMCPProxy {
|
|
|
33
33
|
const allowedOrigins = (process.env.PAYWARE_MCP_PROXY_ORIGINS
|
|
34
34
|
|| `http://localhost:${this.port},http://127.0.0.1:${this.port}`)
|
|
35
35
|
.split(',').map((o) => o.trim()).filter(Boolean);
|
|
36
|
+
|
|
37
|
+
// DNS rebinding and cross-site requests. The CORS allowlist below decides which pages may READ a
|
|
38
|
+
// response; it never refuses a request. A page whose hostname has been rebound to 127.0.0.1 is
|
|
39
|
+
// same-origin with this server, so CORS does not apply to it at all, and a cross-site form POST
|
|
40
|
+
// needs no preflight - either way the route runs and signs as the partner. Two checks refuse them:
|
|
41
|
+
// Host must be a name this server answers to (a rebound page sends its own hostname there), and an
|
|
42
|
+
// Origin, which browsers send on every POST, must be allowlisted. Clients that are not browsers -
|
|
43
|
+
// curl, the bridge - send no Origin and pass. PAYWARE_MCP_PROXY_HOSTS (comma-separated host:port)
|
|
44
|
+
// widens the Host list deliberately.
|
|
45
|
+
const bindHost = process.env.PAYWARE_MCP_PROXY_HOST || '127.0.0.1';
|
|
46
|
+
const allowedHosts = new Set((process.env.PAYWARE_MCP_PROXY_HOSTS
|
|
47
|
+
|| `localhost:${this.port},127.0.0.1:${this.port},${bindHost}:${this.port}`)
|
|
48
|
+
.split(',').map((h) => h.trim().toLowerCase()).filter(Boolean));
|
|
49
|
+
this.app.use((req, res, next) => {
|
|
50
|
+
const host = (req.headers.host || '').toLowerCase();
|
|
51
|
+
if (!allowedHosts.has(host)) {
|
|
52
|
+
return res.status(403).json({ error: 'Forbidden', message: `Host ${host || '(none)'} is not allowed` });
|
|
53
|
+
}
|
|
54
|
+
const origin = req.headers.origin;
|
|
55
|
+
if (origin !== undefined && !allowedOrigins.includes(origin)) {
|
|
56
|
+
return res.status(403).json({ error: 'Forbidden', message: `Origin ${origin} is not allowed` });
|
|
57
|
+
}
|
|
58
|
+
next();
|
|
59
|
+
});
|
|
60
|
+
|
|
36
61
|
this.app.use(cors({
|
|
37
62
|
origin: allowedOrigins,
|
|
38
63
|
credentials: true
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { createMinimizedJSON } from '../../core/utils/json-serializer.js';
|
|
3
|
+
import { getSandboxUrl, getProductionUrl } from '../../config/env.js';
|
|
4
|
+
import { createAuthHeaders } from '../auth-headers.js';
|
|
5
|
+
import { apiErrorResult } from '../api-errors.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One call to the campaigns, placements and airings API, as a merchant or as an ISV acting for one.
|
|
9
|
+
*
|
|
10
|
+
* Built on the shared `createAuthHeaders`, so the on-behalf JWT (`aud` = the merchant, `sub` = its
|
|
11
|
+
* OAuth2 token) and the content hash come from the same code every newer tool uses. The body is
|
|
12
|
+
* serialized once and that exact string is both hashed and sent; `transformRequest` stops axios from
|
|
13
|
+
* serializing it a second time.
|
|
14
|
+
*
|
|
15
|
+
* Never throws for an HTTP error: it returns `apiErrorResult`, whose `details` keeps the response
|
|
16
|
+
* body, which is where a refused replace-all lists its rows.
|
|
17
|
+
*
|
|
18
|
+
* @param {Object} params
|
|
19
|
+
* @param {string} params.method HTTP verb
|
|
20
|
+
* @param {string} params.path path under the API root, ids already encoded
|
|
21
|
+
* @param {Object} [params.query] query parameters; undefined values are left out
|
|
22
|
+
* @param {*} [params.body] request body, serialized deterministically
|
|
23
|
+
* @param {boolean} [params.binary] read the response as bytes (images)
|
|
24
|
+
* @param {Object} params.auth from the role: partnerType, credentials and useSandbox
|
|
25
|
+
*/
|
|
26
|
+
export async function placementsRequest({ method, path, query, body, binary = false, auth }) {
|
|
27
|
+
const minimizedBody = body === undefined ? undefined : createMinimizedJSON(body);
|
|
28
|
+
const headers = await createAuthHeaders(auth.partnerType, { ...auth, requestBody: minimizedBody });
|
|
29
|
+
const baseUrl = auth.useSandbox ? getSandboxUrl() : getProductionUrl();
|
|
30
|
+
const params = query && Object.fromEntries(Object.entries(query).filter(([, v]) => v !== undefined && v !== null));
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const response = await axios({
|
|
34
|
+
method,
|
|
35
|
+
url: `${baseUrl}${path}`,
|
|
36
|
+
headers,
|
|
37
|
+
...(params && Object.keys(params).length > 0 && { params }),
|
|
38
|
+
...(minimizedBody !== undefined && { data: minimizedBody, transformRequest: [(data) => data] }),
|
|
39
|
+
...(binary && { responseType: 'arraybuffer' })
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
success: true,
|
|
43
|
+
status: response.status,
|
|
44
|
+
data: response.data,
|
|
45
|
+
contentType: response.headers['content-type'],
|
|
46
|
+
requestId: response.headers['x-request-id']
|
|
47
|
+
};
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return apiErrorResult(error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { getPartnerIdSafe, getPrivateKeySafe } from '../../config/env.js';
|
|
2
|
+
import { ADDS, CHANGES, CREATES, READS } from '../tool-annotations.js';
|
|
3
|
+
import { placementsRequest } from './api.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Campaigns, placements and airings, for a merchant and for an ISV acting for one.
|
|
7
|
+
*
|
|
8
|
+
* One definition per tool, built for either role by `placementTools(role)`. The two roles differ only
|
|
9
|
+
* in how they authenticate and in what an ISV may see, so writing each of the 22 tools twice, as the
|
|
10
|
+
* older product modules do, would be 22 chances for the copies to drift. The server routes are on
|
|
11
|
+
* branch feature/placements: CampaignController, PlacementController, AiringController and
|
|
12
|
+
* PlacementCalendarController; tests/tool-routes.test.js pins each tool to its route.
|
|
13
|
+
*
|
|
14
|
+
* Credentials come from the environment only. Unlike the older tools these take no `partnerId` or
|
|
15
|
+
* `privateKey` argument: a key passed as an argument travels through the model's context.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const USE_SANDBOX = {
|
|
19
|
+
type: 'boolean',
|
|
20
|
+
description: 'Use the sandbox (the default) rather than production',
|
|
21
|
+
default: true
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ROLES = {
|
|
25
|
+
merchant: {
|
|
26
|
+
properties: {},
|
|
27
|
+
required: [],
|
|
28
|
+
note: '',
|
|
29
|
+
auth(args) {
|
|
30
|
+
const useSandbox = args.useSandbox ?? true;
|
|
31
|
+
const partnerId = getPartnerIdSafe();
|
|
32
|
+
const privateKey = getPrivateKeySafe(useSandbox);
|
|
33
|
+
if (!partnerId || !privateKey) {
|
|
34
|
+
throw new Error('Partner credentials are not configured: set PAYWARE_PARTNER_ID and the private key path for this environment.');
|
|
35
|
+
}
|
|
36
|
+
return { partnerType: 'merchant', partnerId, privateKey, useSandbox };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
isv: {
|
|
40
|
+
properties: {
|
|
41
|
+
merchantPartnerId: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: "The merchant you act for: its 8-character partner ID, e.g. 'PZAYNMVE'"
|
|
44
|
+
},
|
|
45
|
+
oauth2Token: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'The OAuth2 access token the merchant granted you'
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
required: ['merchantPartnerId', 'oauth2Token'],
|
|
51
|
+
note: "\n\n**Acting for a merchant:** you see and manage the campaigns you created and the merchant's campaigns that hold placements in your shops, and only placements of products in your shops. The merchant's plan applies, not yours.",
|
|
52
|
+
auth(args) {
|
|
53
|
+
const useSandbox = args.useSandbox ?? true;
|
|
54
|
+
const isvPartnerId = getPartnerIdSafe();
|
|
55
|
+
const privateKey = getPrivateKeySafe(useSandbox);
|
|
56
|
+
if (!isvPartnerId || !privateKey) {
|
|
57
|
+
throw new Error('ISV credentials are not configured: set PAYWARE_PARTNER_ID and the private key path for this environment.');
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
partnerType: 'isv', isvPartnerId, privateKey, useSandbox,
|
|
61
|
+
merchantPartnerId: args.merchantPartnerId, oauth2Token: args.oauth2Token
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// --- Parameters ----------------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const PRODUCT_ID = {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: "The product's ID: pr + 8 letters or digits, e.g. pr7e4rCToG",
|
|
72
|
+
pattern: '^pr[0-9A-Za-z]{8}$'
|
|
73
|
+
};
|
|
74
|
+
const PLACEMENT_ID = {
|
|
75
|
+
type: 'string',
|
|
76
|
+
description: "The placement's ID: pl + 8 letters or digits, e.g. pl7e4rCToG",
|
|
77
|
+
pattern: '^pl[0-9A-Za-z]{8}$'
|
|
78
|
+
};
|
|
79
|
+
const CAMPAIGN_ID = {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: "The campaign's ID: cp + 8 letters or digits, e.g. cp7e4rCToG",
|
|
82
|
+
pattern: '^cp[0-9A-Za-z]{8}$'
|
|
83
|
+
};
|
|
84
|
+
const RULE_ID = {
|
|
85
|
+
type: 'integer',
|
|
86
|
+
description: "The airing rule's ID, from payware_products_list_airings",
|
|
87
|
+
minimum: 1
|
|
88
|
+
};
|
|
89
|
+
const DATE = { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' };
|
|
90
|
+
const RANGE_FORMS = 'a date (YYYY-MM-DD, the start of that day in UTC) or an ISO-8601 instant with an offset, e.g. 2026-10-19T00:00:00+03:00';
|
|
91
|
+
|
|
92
|
+
const STATS_RANGE = {
|
|
93
|
+
from: { type: 'string', description: `Start of the period: ${RANGE_FORMS}. Default: 30 days before to` },
|
|
94
|
+
to: { type: 'string', description: `End of the period, exclusive, in the same forms. Default: now. At most 400 days after from` }
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const CAMPAIGN_FIELDS = {
|
|
98
|
+
name: { type: 'string', description: 'Campaign name, up to 100 characters', maxLength: 100 },
|
|
99
|
+
startsOn: { ...DATE, description: 'First day, YYYY-MM-DD' },
|
|
100
|
+
endsOn: { ...DATE, description: 'Last day, YYYY-MM-DD' },
|
|
101
|
+
status: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
enum: ['DRAFT', 'ACTIVE', 'ENDED'],
|
|
104
|
+
description: 'Descriptive only: it does not switch the placements on or off'
|
|
105
|
+
},
|
|
106
|
+
externalRef: { type: 'string', description: "An agency's own campaign code, up to 100 characters", maxLength: 100 }
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const MEDIA = ['PRINT', 'PACKAGING', 'OUTDOOR', 'IN_STORE', 'EVENT', 'TV', 'RADIO', 'STREAMING', 'ONLINE', 'SOCIAL', 'EMAIL', 'SMS', 'OTHER'];
|
|
110
|
+
const CARRIERS = ['QR', 'BARCODE', 'NFC', 'BLE', 'LINK', 'SMS', 'SOUNDBITE'];
|
|
111
|
+
|
|
112
|
+
const PLACEMENT_FIELDS = {
|
|
113
|
+
name: { type: 'string', description: 'Placement name, up to 100 characters, e.g. "Sofia metro posters, October"', maxLength: 100 },
|
|
114
|
+
campaignId: { ...CAMPAIGN_ID, description: 'The campaign it belongs to, if any (cp + 8 characters)' },
|
|
115
|
+
medium: { type: 'string', enum: MEDIA, description: 'What the placement is' },
|
|
116
|
+
carriers: {
|
|
117
|
+
type: 'array',
|
|
118
|
+
items: { type: 'string', enum: CARRIERS },
|
|
119
|
+
minItems: 1,
|
|
120
|
+
maxItems: 7,
|
|
121
|
+
description: 'The methods that carry the placement ID to a payer. SOUNDBITE airs the product\'s audio and needs airing rules'
|
|
122
|
+
},
|
|
123
|
+
channel: { type: 'string', description: 'Descriptive, e.g. the TV channel or the website, up to 100 characters', maxLength: 100 },
|
|
124
|
+
location: { type: 'string', description: 'Descriptive, e.g. the venue or the address, up to 255 characters', maxLength: 255 },
|
|
125
|
+
externalRef: { type: 'string', description: "An agency's own code, up to 100 characters", maxLength: 100 },
|
|
126
|
+
regions: {
|
|
127
|
+
type: 'array',
|
|
128
|
+
items: { type: 'string', pattern: '^[A-Z]{2}$' },
|
|
129
|
+
description: "ISO 3166-1 alpha-2 codes of the countries it airs in, matched against the paying bank's country. Empty means anywhere"
|
|
130
|
+
},
|
|
131
|
+
active: {
|
|
132
|
+
type: 'boolean',
|
|
133
|
+
description: 'Whether it is in use. Governs airing and soundbite attribution; a printed code keeps resolving until the placement is deleted'
|
|
134
|
+
},
|
|
135
|
+
activeFrom: { type: 'string', description: "Start of its active window, ISO-8601 with an offset. Empty means the product's" },
|
|
136
|
+
activeTo: { type: 'string', description: "End of its active window, ISO-8601 with an offset. Empty means the product's" }
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const WEEKDAYS = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];
|
|
140
|
+
|
|
141
|
+
const AIRING_RULE_FIELDS = {
|
|
142
|
+
frequency: { type: 'string', enum: ['ONCE', 'DAILY', 'WEEKLY', 'MONTHLY'], description: 'How the rule repeats' },
|
|
143
|
+
repeatEvery: { type: 'integer', minimum: 1, maximum: 366, description: 'Every N days, weeks or months. Default 1' },
|
|
144
|
+
startDate: { ...DATE, description: 'First day, YYYY-MM-DD, in the rule\'s time zone' },
|
|
145
|
+
endDate: { ...DATE, description: 'Last day, YYYY-MM-DD, at most 366 days after startDate. Omit for open-ended' },
|
|
146
|
+
timeZone: { type: 'string', description: "IANA time zone, e.g. Europe/Sofia. Default: the product's" },
|
|
147
|
+
daysOfWeek: { type: 'array', items: { type: 'string', enum: WEEKDAYS }, description: 'WEEKLY: the days it airs on' },
|
|
148
|
+
monthDays: {
|
|
149
|
+
type: 'array',
|
|
150
|
+
items: { type: 'integer', minimum: 1, maximum: 31 },
|
|
151
|
+
description: 'MONTHLY by date: days of the month. A day a month lacks is skipped'
|
|
152
|
+
},
|
|
153
|
+
monthWeek: { type: 'integer', enum: [1, 2, 3, 4, 5, -1], description: 'MONTHLY by weekday: which week, -1 for the last. Use with monthWeekday' },
|
|
154
|
+
monthWeekday: { type: 'string', enum: WEEKDAYS, description: 'MONTHLY by weekday: the weekday' },
|
|
155
|
+
windows: {
|
|
156
|
+
type: 'array',
|
|
157
|
+
minItems: 1,
|
|
158
|
+
maxItems: 24,
|
|
159
|
+
items: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
properties: {
|
|
163
|
+
start: { type: 'string', pattern: '^\\d{2}:\\d{2}$', description: 'HH:mm' },
|
|
164
|
+
end: { type: 'string', pattern: '^\\d{2}:\\d{2}$', description: 'HH:mm, at most 6 hours after start. At or before start runs past midnight' }
|
|
165
|
+
},
|
|
166
|
+
required: ['start', 'end']
|
|
167
|
+
},
|
|
168
|
+
description: 'Time windows on each day it airs, local to the time zone: 1 to 24, each up to 6 hours'
|
|
169
|
+
},
|
|
170
|
+
excludedDates: { type: 'array', items: { ...DATE }, description: 'Days it does not air, YYYY-MM-DD' },
|
|
171
|
+
expectedAudience: { type: 'integer', minimum: 0, description: 'Expected audience per airing, for your own planning' }
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const AIRING_RULE = {
|
|
175
|
+
type: 'object',
|
|
176
|
+
additionalProperties: false,
|
|
177
|
+
properties: AIRING_RULE_FIELDS,
|
|
178
|
+
required: ['frequency', 'startDate', 'windows']
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// --- Building blocks -----------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
const enc = encodeURIComponent;
|
|
184
|
+
|
|
185
|
+
/** The arguments named in `fields` that were given, as a request body. */
|
|
186
|
+
function pick(args, fields) {
|
|
187
|
+
return Object.fromEntries(Object.keys(fields).filter(k => args[k] !== undefined).map(k => [k, args[k]]));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function json(title, data) {
|
|
191
|
+
return [{ type: 'text', text: `**${title}**\n\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\`` }];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function failure(toolName, result) {
|
|
195
|
+
const e = result.error;
|
|
196
|
+
const rows = Array.isArray(e.details?.rows) && e.details.rows.length > 0
|
|
197
|
+
? `\n\n**Refused rules (nothing was saved):**\n${e.details.rows.map(r => `- #${r.index}: ${r.errorCode} - ${r.message}`).join('\n')}`
|
|
198
|
+
: '';
|
|
199
|
+
const lines = [
|
|
200
|
+
`**${toolName} failed**`,
|
|
201
|
+
'',
|
|
202
|
+
`- Status: ${e.status ?? 'no response'}`,
|
|
203
|
+
`- Error code: ${e.errorCode ?? 'N/A'}`,
|
|
204
|
+
`- Message: ${e.message ?? 'N/A'}`,
|
|
205
|
+
...(e.correlationId ? [`- Correlation ID: ${e.correlationId} (quote it to payware support)`] : []),
|
|
206
|
+
...(e.guidance ? ['', e.guidance] : [])
|
|
207
|
+
];
|
|
208
|
+
return { content: [{ type: 'text', text: lines.join('\n') + rows }], isError: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A placement image as MCP image content, or as text when it is SVG or not an image at all. */
|
|
212
|
+
function image(placementId, result) {
|
|
213
|
+
const type = String(result.contentType || '').split(';')[0].trim();
|
|
214
|
+
const bytes = Buffer.from(result.data);
|
|
215
|
+
if (type === 'image/png' || type === 'image/jpeg') {
|
|
216
|
+
return [
|
|
217
|
+
{ type: 'image', data: bytes.toString('base64'), mimeType: type },
|
|
218
|
+
{ type: 'text', text: `Image of placement ${placementId}: ${type}, ${bytes.length} bytes.` }
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
return [{ type: 'text', text: `**Image of placement ${placementId}** (${type || 'unknown type'}, ${bytes.length} bytes)\n\n${bytes.toString('utf8')}` }];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function define(role, spec) {
|
|
225
|
+
const r = ROLES[role];
|
|
226
|
+
return {
|
|
227
|
+
name: spec.name,
|
|
228
|
+
annotations: spec.annotations,
|
|
229
|
+
description: `${spec.description}${r.note}`,
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
additionalProperties: false,
|
|
233
|
+
properties: { ...r.properties, ...spec.properties, useSandbox: USE_SANDBOX },
|
|
234
|
+
required: [...r.required, ...(spec.required ?? [])]
|
|
235
|
+
},
|
|
236
|
+
async handler(args) {
|
|
237
|
+
const auth = r.auth(args);
|
|
238
|
+
const result = await placementsRequest({ ...spec.request(args), auth });
|
|
239
|
+
if (!result.success) {
|
|
240
|
+
return failure(spec.name, result);
|
|
241
|
+
}
|
|
242
|
+
return { content: spec.present ? spec.present(result, args) : json(spec.title, result.data) };
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- The tools -----------------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
const PLAN_NOTE = 'Changing anything here needs the PRM plan, and below it a change answers 403 ERR_UNAUTHORIZED_OPERATION; reading works on every plan, results only on PRM. A merchant that leaves PRM keeps its placements read-only, and a printed placement code then pays as its plain product with no attribution.';
|
|
250
|
+
|
|
251
|
+
const SPECS = [
|
|
252
|
+
// Campaigns: /campaigns
|
|
253
|
+
{
|
|
254
|
+
name: 'payware_campaigns_list_campaigns',
|
|
255
|
+
annotations: READS,
|
|
256
|
+
title: 'Campaigns',
|
|
257
|
+
description: `List the merchant's campaigns. A campaign groups placements, possibly of different products, and reports their combined results.\n\n**Endpoint:** GET /campaigns\n\n${PLAN_NOTE}`,
|
|
258
|
+
properties: {},
|
|
259
|
+
request: () => ({ method: 'GET', path: '/campaigns' })
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: 'payware_campaigns_create_campaign',
|
|
263
|
+
annotations: CREATES,
|
|
264
|
+
title: 'Campaign created',
|
|
265
|
+
description: 'Create a campaign. Attach placements to it by giving its campaignId when you create or replace a placement.\n\n**Endpoint:** POST /campaigns (PRM plan)',
|
|
266
|
+
properties: CAMPAIGN_FIELDS,
|
|
267
|
+
required: ['name'],
|
|
268
|
+
request: (a) => ({ method: 'POST', path: '/campaigns', body: pick(a, CAMPAIGN_FIELDS) })
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: 'payware_campaigns_get_campaign',
|
|
272
|
+
annotations: READS,
|
|
273
|
+
title: 'Campaign',
|
|
274
|
+
description: 'Get a campaign, with the IDs of its placements you can see.\n\n**Endpoint:** GET /campaigns/{campaignId}',
|
|
275
|
+
properties: { campaignId: CAMPAIGN_ID },
|
|
276
|
+
required: ['campaignId'],
|
|
277
|
+
request: (a) => ({ method: 'GET', path: `/campaigns/${enc(a.campaignId)}` })
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: 'payware_campaigns_update_campaign',
|
|
281
|
+
annotations: CHANGES,
|
|
282
|
+
title: 'Campaign replaced',
|
|
283
|
+
description: '**Replace** a campaign\'s details. An omitted startsOn, endsOn or externalRef is cleared; an omitted status keeps the current one. Get the campaign first and send every value you want to keep.\n\n**Endpoint:** PUT /campaigns/{campaignId} (PRM plan)',
|
|
284
|
+
properties: { campaignId: CAMPAIGN_ID, ...CAMPAIGN_FIELDS },
|
|
285
|
+
required: ['campaignId', 'name'],
|
|
286
|
+
request: (a) => ({ method: 'PUT', path: `/campaigns/${enc(a.campaignId)}`, body: pick(a, CAMPAIGN_FIELDS) })
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: 'payware_campaigns_delete_campaign',
|
|
290
|
+
annotations: CHANGES,
|
|
291
|
+
title: 'Campaign deleted',
|
|
292
|
+
description: 'Delete a campaign. Its placements stay, detached from it, and keep working.\n\n**Endpoint:** DELETE /campaigns/{campaignId} (PRM plan)',
|
|
293
|
+
properties: { campaignId: CAMPAIGN_ID },
|
|
294
|
+
required: ['campaignId'],
|
|
295
|
+
request: (a) => ({ method: 'DELETE', path: `/campaigns/${enc(a.campaignId)}` }),
|
|
296
|
+
present: (r, a) => [{ type: 'text', text: `Campaign ${a.campaignId} deleted. Its placements are detached and keep working.` }]
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: 'payware_campaigns_get_campaign_stats',
|
|
300
|
+
annotations: READS,
|
|
301
|
+
title: 'Campaign results',
|
|
302
|
+
description: 'A campaign\'s results over [from, to): offers shown when a bank resolved one of its placement IDs (resolutions), offers shown after a recognised soundbite tap (recognitions), payments started (transactions) and confirmed, and confirmed amounts by currency.\n\n**Endpoint:** GET /campaigns/{campaignId}/stats (PRM plan)',
|
|
303
|
+
properties: { campaignId: CAMPAIGN_ID, ...STATS_RANGE },
|
|
304
|
+
required: ['campaignId'],
|
|
305
|
+
request: (a) => ({ method: 'GET', path: `/campaigns/${enc(a.campaignId)}/stats`, query: { from: a.from, to: a.to } })
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
// Placements: /products/{productId}/placements
|
|
309
|
+
{
|
|
310
|
+
name: 'payware_products_list_placements',
|
|
311
|
+
annotations: READS,
|
|
312
|
+
title: 'Placements',
|
|
313
|
+
description: `List a product's placements. A placement is one way the product is distributed - a poster, a TV spot, a packaging label - with its own ID (pl + 8 characters), so every payment it starts is attributed to it.\n\n**Endpoint:** GET /products/{productId}/placements\n\n${PLAN_NOTE}`,
|
|
314
|
+
properties: { productId: PRODUCT_ID },
|
|
315
|
+
required: ['productId'],
|
|
316
|
+
request: (a) => ({ method: 'GET', path: `/products/${enc(a.productId)}/placements` })
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: 'payware_products_create_placement',
|
|
320
|
+
annotations: CREATES,
|
|
321
|
+
title: 'Placement created',
|
|
322
|
+
description: 'Create a placement for a product; up to 1,000 per product. The response carries its placementId, which goes on the medium wherever the product ID would: a QR code (payware://{placementId}), an NFC tag, a BLE beacon, a link, a barcode or an SMS. A bank resolving it pays for the product, and the payment is attributed to the placement. For a SOUNDBITE carrier, add airing rules so taps during the airing are attributed.\n\n**Endpoint:** POST /products/{productId}/placements (PRM plan)',
|
|
323
|
+
properties: { productId: PRODUCT_ID, ...PLACEMENT_FIELDS },
|
|
324
|
+
required: ['productId', 'name', 'medium', 'carriers'],
|
|
325
|
+
request: (a) => ({ method: 'POST', path: `/products/${enc(a.productId)}/placements`, body: pick(a, PLACEMENT_FIELDS) })
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: 'payware_products_get_placement',
|
|
329
|
+
annotations: READS,
|
|
330
|
+
title: 'Placement',
|
|
331
|
+
description: 'Get a placement.\n\n**Endpoint:** GET /products/{productId}/placements/{placementId}',
|
|
332
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID },
|
|
333
|
+
required: ['productId', 'placementId'],
|
|
334
|
+
request: (a) => ({ method: 'GET', path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}` })
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: 'payware_products_update_placement',
|
|
338
|
+
annotations: CHANGES,
|
|
339
|
+
title: 'Placement replaced',
|
|
340
|
+
description: '**Replace** a placement\'s details. Every field is replaced, so an omitted optional field is cleared - an omitted campaignId detaches it from its campaign; an omitted active keeps the current value. Get the placement first and send every value you want to keep. The placement ID does not change, so printed codes keep working.\n\n**Endpoint:** PUT /products/{productId}/placements/{placementId} (PRM plan)',
|
|
341
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID, ...PLACEMENT_FIELDS },
|
|
342
|
+
required: ['productId', 'placementId', 'name', 'medium', 'carriers'],
|
|
343
|
+
request: (a) => ({
|
|
344
|
+
method: 'PUT',
|
|
345
|
+
path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}`,
|
|
346
|
+
body: pick(a, PLACEMENT_FIELDS)
|
|
347
|
+
})
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
name: 'payware_products_delete_placement',
|
|
351
|
+
annotations: CHANGES,
|
|
352
|
+
title: 'Placement deleted',
|
|
353
|
+
description: 'Delete a placement. **Its printed codes stop resolving**, its short link is retired and its ID is never reused. To stop airing and attribution but keep printed codes working, replace it with active false instead.\n\n**Endpoint:** DELETE /products/{productId}/placements/{placementId} (PRM plan)',
|
|
354
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID },
|
|
355
|
+
required: ['productId', 'placementId'],
|
|
356
|
+
request: (a) => ({ method: 'DELETE', path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}` }),
|
|
357
|
+
present: (r, a) => [{ type: 'text', text: `Placement ${a.placementId} deleted. Its codes no longer resolve and its short link is retired.` }]
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: 'payware_products_get_placement_image',
|
|
361
|
+
annotations: READS,
|
|
362
|
+
title: 'Placement image',
|
|
363
|
+
description: 'A placement\'s QR code (carrying payware://{placementId}) or Code 128 barcode (carrying the plain ID), for print. PNG and JPG come back as an image, SVG as its markup.\n\n**Endpoint:** GET /products/{productId}/placements/{placementId}/image',
|
|
364
|
+
properties: {
|
|
365
|
+
productId: PRODUCT_ID,
|
|
366
|
+
placementId: PLACEMENT_ID,
|
|
367
|
+
type: { type: 'string', enum: ['QR', 'BARCODE'], description: 'QR code or barcode. Default QR', default: 'QR' },
|
|
368
|
+
format: { type: 'string', enum: ['PNG', 'JPG', 'SVG'], description: 'Image format. Default PNG', default: 'PNG' }
|
|
369
|
+
},
|
|
370
|
+
required: ['productId', 'placementId'],
|
|
371
|
+
request: (a) => ({
|
|
372
|
+
method: 'GET',
|
|
373
|
+
path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/image`,
|
|
374
|
+
query: { type: a.type ?? 'QR', format: a.format ?? 'PNG' },
|
|
375
|
+
binary: true
|
|
376
|
+
}),
|
|
377
|
+
present: (r, a) => image(a.placementId, r)
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: 'payware_products_get_placement_link',
|
|
381
|
+
annotations: ADDS,
|
|
382
|
+
title: 'Placement link',
|
|
383
|
+
description: 'A placement\'s short link, https://go.payware.eu/{placementId}, made on the first request and the same afterwards. On a phone it opens payware:/{placementId} in the payment app; a payment through it is attributed to the placement.\n\n**Endpoint:** GET /products/{productId}/placements/{placementId}/link (PRM plan)',
|
|
384
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID },
|
|
385
|
+
required: ['productId', 'placementId'],
|
|
386
|
+
request: (a) => ({ method: 'GET', path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/link` }),
|
|
387
|
+
present: (r, a) => [{
|
|
388
|
+
type: 'text',
|
|
389
|
+
text: `Short link of placement ${a.placementId}:\n\n${typeof r.data === 'string' ? r.data : JSON.stringify(r.data)}`
|
|
390
|
+
}]
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: 'payware_products_get_placement_stats',
|
|
394
|
+
annotations: READS,
|
|
395
|
+
title: 'Placement results',
|
|
396
|
+
description: 'A placement\'s results over [from, to): offers shown when a bank resolved its ID (resolutions), offers shown after a recognised soundbite tap (recognitions), payments started (transactions) and confirmed, and confirmed amounts by currency.\n\n**Endpoint:** GET /products/{productId}/placements/{placementId}/stats (PRM plan)',
|
|
397
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID, ...STATS_RANGE },
|
|
398
|
+
required: ['productId', 'placementId'],
|
|
399
|
+
request: (a) => ({
|
|
400
|
+
method: 'GET',
|
|
401
|
+
path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/stats`,
|
|
402
|
+
query: { from: a.from, to: a.to }
|
|
403
|
+
})
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
// Airings: /products/{productId}/placements/{placementId}/airings, and /placements/calendar
|
|
407
|
+
{
|
|
408
|
+
name: 'payware_products_list_airings',
|
|
409
|
+
annotations: READS,
|
|
410
|
+
title: 'Airing rules',
|
|
411
|
+
description: 'List a placement\'s airing rules. A rule is a recurrence with time windows, in an IANA time zone; a soundbite tap during an airing is attributed to the placement.\n\n**Endpoint:** GET /products/{productId}/placements/{placementId}/airings',
|
|
412
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID },
|
|
413
|
+
required: ['productId', 'placementId'],
|
|
414
|
+
request: (a) => ({ method: 'GET', path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/airings` })
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'payware_products_create_airing',
|
|
418
|
+
annotations: CREATES,
|
|
419
|
+
title: 'Airing rule added',
|
|
420
|
+
description: 'Add an airing rule to a placement: up to 100 rules per placement. Example: "daily 08:00-08:30 and 15:40-16:10" is one DAILY rule with two windows. Check `warnings` in the response: AIRING_OVERLAP means another soundbite placement of the same product airs at the same time in an overlapping region, and a tap then is attributed to neither.\n\n**Endpoint:** POST /products/{productId}/placements/{placementId}/airings (PRM plan)',
|
|
421
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID, ...AIRING_RULE_FIELDS },
|
|
422
|
+
required: ['productId', 'placementId', 'frequency', 'startDate', 'windows'],
|
|
423
|
+
request: (a) => ({
|
|
424
|
+
method: 'POST',
|
|
425
|
+
path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/airings`,
|
|
426
|
+
body: pick(a, AIRING_RULE_FIELDS)
|
|
427
|
+
})
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
name: 'payware_products_replace_airings',
|
|
431
|
+
annotations: CHANGES,
|
|
432
|
+
title: 'Airing rules replaced',
|
|
433
|
+
description: '**Replace all** of a placement\'s airing rules with this list, all or nothing: when any rule is refused, nothing is saved and each refused rule is listed by its index. An empty list removes every rule. Suited to loading an agency\'s schedule in one call.\n\n**Endpoint:** PUT /products/{productId}/placements/{placementId}/airings (PRM plan)',
|
|
434
|
+
properties: {
|
|
435
|
+
productId: PRODUCT_ID,
|
|
436
|
+
placementId: PLACEMENT_ID,
|
|
437
|
+
rules: { type: 'array', maxItems: 100, items: AIRING_RULE, description: 'The complete new set of airing rules, up to 100' }
|
|
438
|
+
},
|
|
439
|
+
required: ['productId', 'placementId', 'rules'],
|
|
440
|
+
request: (a) => ({
|
|
441
|
+
method: 'PUT',
|
|
442
|
+
path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/airings`,
|
|
443
|
+
body: a.rules
|
|
444
|
+
})
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: 'payware_products_get_next_airings',
|
|
448
|
+
annotations: READS,
|
|
449
|
+
title: 'Next airings',
|
|
450
|
+
description: 'A placement\'s next airings, each in its rule\'s time zone so daylight-saving shifts are visible. Use it to check a rule does what was meant before relying on it.\n\n**Endpoint:** GET /products/{productId}/placements/{placementId}/airings/next',
|
|
451
|
+
properties: {
|
|
452
|
+
productId: PRODUCT_ID,
|
|
453
|
+
placementId: PLACEMENT_ID,
|
|
454
|
+
count: { type: 'integer', minimum: 1, maximum: 100, description: 'How many, 1 to 100. Default 10' }
|
|
455
|
+
},
|
|
456
|
+
required: ['productId', 'placementId'],
|
|
457
|
+
request: (a) => ({
|
|
458
|
+
method: 'GET',
|
|
459
|
+
path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/airings/next`,
|
|
460
|
+
query: { count: a.count }
|
|
461
|
+
})
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
name: 'payware_products_get_airing',
|
|
465
|
+
annotations: READS,
|
|
466
|
+
title: 'Airing rule',
|
|
467
|
+
description: 'Get one airing rule.\n\n**Endpoint:** GET /products/{productId}/placements/{placementId}/airings/{ruleId}',
|
|
468
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID, ruleId: RULE_ID },
|
|
469
|
+
required: ['productId', 'placementId', 'ruleId'],
|
|
470
|
+
request: (a) => ({ method: 'GET', path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/airings/${enc(a.ruleId)}` })
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: 'payware_products_update_airing',
|
|
474
|
+
annotations: CHANGES,
|
|
475
|
+
title: 'Airing rule replaced',
|
|
476
|
+
description: '**Replace** one airing rule: every field is replaced, so send the whole rule. Check `warnings` in the response for AIRING_OVERLAP.\n\n**Endpoint:** PUT /products/{productId}/placements/{placementId}/airings/{ruleId} (PRM plan)',
|
|
477
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID, ruleId: RULE_ID, ...AIRING_RULE_FIELDS },
|
|
478
|
+
required: ['productId', 'placementId', 'ruleId', 'frequency', 'startDate', 'windows'],
|
|
479
|
+
request: (a) => ({
|
|
480
|
+
method: 'PUT',
|
|
481
|
+
path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/airings/${enc(a.ruleId)}`,
|
|
482
|
+
body: pick(a, AIRING_RULE_FIELDS)
|
|
483
|
+
})
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
name: 'payware_products_delete_airing',
|
|
487
|
+
annotations: CHANGES,
|
|
488
|
+
title: 'Airing rule deleted',
|
|
489
|
+
description: 'Delete one airing rule.\n\n**Endpoint:** DELETE /products/{productId}/placements/{placementId}/airings/{ruleId} (PRM plan)',
|
|
490
|
+
properties: { productId: PRODUCT_ID, placementId: PLACEMENT_ID, ruleId: RULE_ID },
|
|
491
|
+
required: ['productId', 'placementId', 'ruleId'],
|
|
492
|
+
request: (a) => ({ method: 'DELETE', path: `/products/${enc(a.productId)}/placements/${enc(a.placementId)}/airings/${enc(a.ruleId)}` }),
|
|
493
|
+
present: (r, a) => [{ type: 'text', text: `Airing rule ${a.ruleId} of placement ${a.placementId} deleted.` }]
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: 'payware_products_get_airing_calendar',
|
|
497
|
+
annotations: READS,
|
|
498
|
+
title: 'Airing calendar',
|
|
499
|
+
description: 'Every airing of the merchant\'s placements between from and to, across all products, ordered by start time and each in its rule\'s time zone. Answers "what is on air this week" in one call. The range may span at most 400 days.\n\n**Endpoint:** GET /placements/calendar',
|
|
500
|
+
properties: {
|
|
501
|
+
from: { type: 'string', description: `Start of the range: ${RANGE_FORMS}` },
|
|
502
|
+
to: { type: 'string', description: 'End of the range, after from, in the same forms' }
|
|
503
|
+
},
|
|
504
|
+
required: ['from', 'to'],
|
|
505
|
+
request: (a) => ({ method: 'GET', path: '/placements/calendar', query: { from: a.from, to: a.to } })
|
|
506
|
+
}
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The campaign, placement and airing tools for a role.
|
|
511
|
+
* @param {'merchant'|'isv'} role
|
|
512
|
+
* @returns {Array} tool definitions
|
|
513
|
+
*/
|
|
514
|
+
export function placementTools(role) {
|
|
515
|
+
if (!ROLES[role]) {
|
|
516
|
+
throw new Error(`No placement tools for role ${role}`);
|
|
517
|
+
}
|
|
518
|
+
return SPECS.map(spec => define(role, spec));
|
|
519
|
+
}
|