Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/mcp/src/tools/blueprints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,23 @@ function requireOneStepSelector(args: { stepGuid?: unknown; stepIndex?: unknown
// update_blueprint_offer (G3). Config fields span all offer types; the server validates them against
// the offer's own offerType. Kept as one strict object (not a discriminated union) so offerType can be
// omitted for config-only edits and the tool schema stays a plain object.
const rebateFixedAmounts = z
.array(
z
.object({
currency: z
.string()
.regex(/^[A-Za-z]{3}$/)
.transform((currency) => currency.toLowerCase())
.describe('Three-letter invoice currency code.'),
amountMinor: z.number().int().min(0).describe("Fixed rebate in that currency's smallest unit."),
})
.strict(),
)
.refine((amounts) => new Set(amounts.map((amount) => amount.currency.toLowerCase())).size === amounts.length, {
message: 'Fixed rebate currencies must be unique.',
})

const offerConfig = z
.object({
couponId: z
Expand All @@ -171,6 +188,11 @@ const offerConfig = z
redirectUrl: z.string().optional().describe('REDIRECT: URL to send the customer to.'),
redirectLabel: z.string().optional().describe('REDIRECT: button label.'),
options: z.array(z.string()).optional().describe('PLAN_CHANGE: plan/price IDs the customer can switch to.'),
amountType: z.enum(['FIXED', 'PERCENT']).optional().describe('REBATE: fixed or percentage amount.'),
fixedAmounts: rebateFixedAmounts.optional().describe('REBATE: fixed rebate amounts by invoice currency.'),
percentAmount: z.number().int().min(0).max(100).optional().describe('REBATE: percentage of the paid invoice.'),
mbgWindowDays: z.number().int().min(0).optional().describe('REBATE: money-back guarantee window in days.'),
invoiceScope: z.enum(['FIRST_PAID', 'LATEST_PAID']).optional().describe('REBATE: which paid invoice is eligible.'),
})
.strict()
.describe("Offer-type-specific config. The server validates which fields are allowed against the offer's offerType.")
Expand All @@ -188,7 +210,7 @@ const updateOfferInput = blueprintIdInput.extend({
.optional()
.describe('Edit the offer attached to this structured follow-up option. Requires choiceGuid.'),
offerType: z
.enum(['PAUSE', 'DISCOUNT', 'CONTACT', 'PLAN_CHANGE', 'REDIRECT', 'TRIAL_EXTENSION'])
.enum(['PAUSE', 'DISCOUNT', 'CONTACT', 'PLAN_CHANGE', 'REDIRECT', 'TRIAL_EXTENSION', 'REBATE'])
.optional()
.describe('Change the offer type. Switching type seeds default config for the new type.'),
header: z.string().optional().describe('Offer headline. Clears stale offer translations.'),
Expand Down Expand Up @@ -322,7 +344,7 @@ export function blueprintTools(client: ChurnkeyClient): ToolDefinition[] {
description: [
'Patch the type and functional config of a single offer on a draft blueprint, without sending the full steps array. Offers attach in three places: an offer step (pass stepGuid only), a survey choice (pass stepGuid + choiceGuid), or a structured follow-up option (pass stepGuid + choiceGuid + optionGuid). The offer must already exist at that location. If you pass a published blueprint ID, the API resolves it to the working copy.',
'',
"Change offerType and/or its config: DISCOUNT (couponId, or customAmount in cents + customDuration, or autoOptimize), PAUSE (maxPauseLength + pauseInterval, datePicker), TRIAL_EXTENSION (trialExtensionDays), REDIRECT (redirectUrl, redirectLabel), PLAN_CHANGE (options), CONTACT (no config). You may also set header/description. Config is validated against the offer's offerType; switching type seeds default config.",
"Change offerType and/or its config: DISCOUNT (couponId, or customAmount in cents + customDuration, or autoOptimize), PAUSE (maxPauseLength + pauseInterval, datePicker), TRIAL_EXTENSION (trialExtensionDays), REDIRECT (redirectUrl, redirectLabel), PLAN_CHANGE (options), REBATE (fixedAmounts or percentAmount, mbgWindowDays, invoiceScope), CONTACT (no config). You may also set header/description. Config is validated against the offer's offerType; switching type seeds default config.",
'',
'Config changes do not affect translations; header/description changes clear stale offer translations (refreshed on publish).',
'',
Expand Down
51 changes: 51 additions & 0 deletions packages/mcp/tests/tools/blueprints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,57 @@ describe('blueprintTools', () => {
).rejects.toThrow('optionGuid requires choiceGuid.')
})

it('accepts currency-specific rebate configuration', async () => {
const { tool, post } = findTool('update_blueprint_offer')
const input = {
blueprintId: 'bp_123',
stepGuid: 'step_1',
offerType: 'REBATE' as const,
config: {
amountType: 'FIXED' as const,
fixedAmounts: [
{ currency: 'USD', amountMinor: 5000 },
{ currency: 'eur', amountMinor: 4500 },
],
mbgWindowDays: 30,
invoiceScope: 'LATEST_PAID' as const,
},
}

const parsed = tool.inputSchema.parse(input)
await tool.handler(parsed)
expect(post).toHaveBeenCalledWith('/data/blueprints/bp_123/offer', {
body: {
stepGuid: 'step_1',
offerType: 'REBATE',
config: {
...input.config,
fixedAmounts: [
{ currency: 'usd', amountMinor: 5000 },
{ currency: 'eur', amountMinor: 4500 },
],
},
},
})
expect(() =>
tool.inputSchema.parse({
...input,
config: { fixedAmounts: [{ currency: 'US', amountMinor: 5000 }] },
}),
).toThrow()
expect(() =>
tool.inputSchema.parse({
...input,
config: {
fixedAmounts: [
{ currency: 'usd', amountMinor: 5000 },
{ currency: 'USD', amountMinor: 4500 },
],
},
}),
).toThrow()
})

it('routes edit_survey_structure with the op body', async () => {
const { tool, post } = findTool('edit_survey_structure')

Expand Down
Loading