Skip to content

Commit df843a7

Browse files
authored
feat(revenuecat): add RevenueCat provider (#79)
## Summary - Add RevenueCat REST API v2 provider with Bearer API key authentication. - Add 12 locally executable actions for projects, customers, subscriptions, entitlements, offerings, products, and metrics. - Add credential validation, pagination, path encoding, response normalization, and runtime tests. ## Verification - npm run generate:catalog - npm run fix-check - npm test All checks pass. Live RevenueCat credentials were not used; provider request behavior is covered with mocks.
1 parent 25e29ef commit df843a7

4 files changed

Lines changed: 579 additions & 0 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import type { ProviderActionDefinition } from "../../core/provider-definition.ts";
2+
3+
import { s } from "../../core/json-schema.ts";
4+
import { defineProviderAction } from "../../core/provider-definition.ts";
5+
6+
const service = "revenuecat" as const;
7+
8+
const identifier = (description: string) => s.string({ minLength: 1, maxLength: 1500, description });
9+
10+
const paginationFields = {
11+
startingAfter: s.string("Return records after this RevenueCat cursor."),
12+
limit: s.integer("Maximum number of records to return.", { minimum: 1, maximum: 100 }),
13+
};
14+
15+
const rawObject = (description: string) => s.looseObject(description);
16+
17+
const listOutput = (description: string, itemDescription: string) =>
18+
s.object(description, {
19+
object: s.literal("list", { description: "RevenueCat list response marker." }),
20+
items: s.array(rawObject(itemDescription), { description: "Records returned by RevenueCat." }),
21+
nextPage: s.nullableString("URL for the next page, or null when there are no more records."),
22+
url: s.string("URL of the current RevenueCat list response."),
23+
});
24+
25+
const singleOutput = (description: string, field: string, itemDescription: string) =>
26+
s.object(description, {
27+
[field]: rawObject(itemDescription),
28+
});
29+
30+
const expandCustomerFields = s.array(s.stringEnum(["attributes"], { description: "A customer field to expand." }), {
31+
maxItems: 1,
32+
description: "Optional customer fields to expand in the response.",
33+
});
34+
35+
const expandOfferingFields = s.array(
36+
s.stringEnum(["items.package", "items.package.product"], {
37+
description: "An Offering field to expand.",
38+
}),
39+
{
40+
maxItems: 2,
41+
description: "Optional Offering fields to expand in the response.",
42+
},
43+
);
44+
45+
const currency = s.stringEnum(
46+
["USD", "EUR", "GBP", "AUD", "CAD", "JPY", "BRL", "KRW", "CNY", "MXN", "SEK", "PLN", "NZD", "CHF"],
47+
{ description: "Currency used for the returned RevenueCat metrics." },
48+
);
49+
50+
const projectInput = (description: string, properties: Record<string, object>, required: string[] = ["projectId"]) =>
51+
s.object(description, { projectId: identifier("RevenueCat project ID."), ...properties }, { required });
52+
53+
export const revenueCatActions: readonly ProviderActionDefinition[] = [
54+
defineProviderAction(service, {
55+
name: "list_projects",
56+
description: "List RevenueCat projects accessible to the configured secret API key.",
57+
requiredScopes: ["project_configuration:projects:read"],
58+
inputSchema: s.object("Pagination for RevenueCat projects.", paginationFields),
59+
outputSchema: listOutput("A paginated list of RevenueCat projects.", "A RevenueCat project."),
60+
}),
61+
defineProviderAction(service, {
62+
name: "list_customers",
63+
description: "List customers in a RevenueCat project, optionally searching by email or customer identifier.",
64+
requiredScopes: ["customer_information:customers:read"],
65+
inputSchema: projectInput("Filters for RevenueCat customers.", {
66+
...paginationFields,
67+
search: s.string("Search by customer email, app user ID, store transaction identifier, or Apple order ID.", {
68+
minLength: 1,
69+
maxLength: 255,
70+
}),
71+
}),
72+
outputSchema: listOutput("A paginated list of RevenueCat customers.", "A RevenueCat customer."),
73+
}),
74+
defineProviderAction(service, {
75+
name: "get_customer",
76+
description: "Retrieve a RevenueCat customer and optionally expand the customer's attributes.",
77+
requiredScopes: ["customer_information:customers:read"],
78+
inputSchema: projectInput(
79+
"Input for retrieving a RevenueCat customer.",
80+
{
81+
customerId: identifier("RevenueCat customer or app user ID."),
82+
expand: expandCustomerFields,
83+
},
84+
["projectId", "customerId"],
85+
),
86+
outputSchema: singleOutput("A RevenueCat customer response.", "customer", "A RevenueCat customer."),
87+
}),
88+
defineProviderAction(service, {
89+
name: "list_customer_subscriptions",
90+
description: "List subscriptions belonging to a RevenueCat customer.",
91+
requiredScopes: ["customer_information:subscriptions:read"],
92+
inputSchema: projectInput(
93+
"Pagination for a customer's RevenueCat subscriptions.",
94+
{
95+
customerId: identifier("RevenueCat customer or app user ID."),
96+
...paginationFields,
97+
},
98+
["projectId", "customerId"],
99+
),
100+
outputSchema: listOutput("A paginated list of customer subscriptions.", "A RevenueCat subscription."),
101+
}),
102+
defineProviderAction(service, {
103+
name: "get_subscription",
104+
description: "Retrieve a RevenueCat subscription by its subscription ID.",
105+
requiredScopes: ["customer_information:subscriptions:read"],
106+
inputSchema: projectInput(
107+
"Input for retrieving a RevenueCat subscription.",
108+
{
109+
subscriptionId: identifier("RevenueCat subscription ID."),
110+
},
111+
["projectId", "subscriptionId"],
112+
),
113+
outputSchema: singleOutput("A RevenueCat subscription response.", "subscription", "A RevenueCat subscription."),
114+
}),
115+
defineProviderAction(service, {
116+
name: "search_subscriptions",
117+
description:
118+
"Find subscriptions by a store subscription identifier such as an Apple transaction ID or Google order ID.",
119+
requiredScopes: ["customer_information:subscriptions:read"],
120+
inputSchema: projectInput(
121+
"Input for searching RevenueCat subscriptions.",
122+
{
123+
storeSubscriptionIdentifier: identifier("Store subscription identifier to search for."),
124+
includeScheduled: s.boolean("Whether to include subscriptions scheduled to start in the future."),
125+
},
126+
["projectId", "storeSubscriptionIdentifier"],
127+
),
128+
outputSchema: listOutput("A list of subscriptions matching the store identifier.", "A RevenueCat subscription."),
129+
}),
130+
defineProviderAction(service, {
131+
name: "list_customer_active_entitlements",
132+
description: "List the entitlements currently active for a RevenueCat customer.",
133+
requiredScopes: ["customer_information:customers:read"],
134+
inputSchema: projectInput(
135+
"Pagination for a customer's active RevenueCat entitlements.",
136+
{
137+
customerId: identifier("RevenueCat customer or app user ID."),
138+
...paginationFields,
139+
},
140+
["projectId", "customerId"],
141+
),
142+
outputSchema: listOutput(
143+
"A paginated list of active customer entitlements.",
144+
"An active RevenueCat customer entitlement.",
145+
),
146+
}),
147+
defineProviderAction(service, {
148+
name: "list_entitlements",
149+
description: "List entitlement definitions configured in a RevenueCat project.",
150+
requiredScopes: ["project_configuration:entitlements:read"],
151+
inputSchema: projectInput("Pagination for RevenueCat entitlements.", paginationFields),
152+
outputSchema: listOutput("A paginated list of RevenueCat entitlements.", "A RevenueCat entitlement definition."),
153+
}),
154+
defineProviderAction(service, {
155+
name: "list_offerings",
156+
description: "List offerings configured in a RevenueCat project, optionally expanding packages and products.",
157+
requiredScopes: ["project_configuration:offerings:read"],
158+
inputSchema: projectInput("Pagination and expansion options for RevenueCat offerings.", {
159+
...paginationFields,
160+
expand: expandOfferingFields,
161+
}),
162+
outputSchema: listOutput("A paginated list of RevenueCat offerings.", "A RevenueCat offering."),
163+
}),
164+
defineProviderAction(service, {
165+
name: "list_products",
166+
description: "List products configured in a RevenueCat project.",
167+
requiredScopes: ["project_configuration:products:read"],
168+
inputSchema: projectInput("Pagination for RevenueCat products.", paginationFields),
169+
outputSchema: listOutput("A paginated list of RevenueCat products.", "A RevenueCat product."),
170+
}),
171+
defineProviderAction(service, {
172+
name: "get_overview_metrics",
173+
description: "Retrieve overview metrics for a RevenueCat project.",
174+
requiredScopes: ["charts_metrics:overview:read"],
175+
inputSchema: projectInput("Input for retrieving RevenueCat overview metrics.", { currency }),
176+
outputSchema: singleOutput(
177+
"RevenueCat overview metrics response.",
178+
"metrics",
179+
"RevenueCat project overview metrics.",
180+
),
181+
}),
182+
defineProviderAction(service, {
183+
name: "get_revenue_metric",
184+
description: "Retrieve total RevenueCat project revenue for an inclusive date range.",
185+
requiredScopes: ["charts_metrics:overview:read"],
186+
inputSchema: projectInput(
187+
"Input for retrieving RevenueCat revenue metrics.",
188+
{
189+
startDate: s.date("Inclusive start date in ISO 8601 format."),
190+
endDate: s.date("Inclusive end date in ISO 8601 format."),
191+
currency,
192+
revenueType: s.stringEnum(["revenue", "revenue_net_of_taxes", "proceeds"], {
193+
description: "Revenue definition returned as the metric value.",
194+
}),
195+
},
196+
["projectId", "startDate", "endDate"],
197+
),
198+
outputSchema: singleOutput("RevenueCat revenue metric response.", "metric", "RevenueCat revenue metric data."),
199+
}),
200+
];
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ProviderDefinition } from "../../core/types.ts";
2+
3+
import { revenueCatActions } from "./actions.ts";
4+
5+
const service = "revenuecat";
6+
7+
export const provider: ProviderDefinition = {
8+
service,
9+
displayName: "RevenueCat",
10+
description: "Manage RevenueCat projects, customers, subscriptions, entitlements, offerings, products, and metrics.",
11+
categories: ["Finance", "Developer Tools", "Subscriptions"],
12+
authTypes: ["api_key"],
13+
auth: [
14+
{
15+
type: "api_key",
16+
label: "V2 Secret API Key",
17+
placeholder: "sk_...",
18+
description:
19+
"RevenueCat REST API v2 secret API key sent as a Bearer token. Create a V2 secret key in the RevenueCat project settings API keys page: https://www.revenuecat.com/docs/welcome/authentication.",
20+
extraFields: [],
21+
},
22+
],
23+
homepageUrl: "https://www.revenuecat.com",
24+
actions: revenueCatActions,
25+
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { ApiKeyProviderContext } from "../provider-runtime.ts";
2+
3+
import { describe, expect, it } from "vitest";
4+
import { revenueCatActionHandlers } from "./executors.ts";
5+
6+
describe("RevenueCat executors", () => {
7+
it("sends bearer authentication and normalizes paginated customer responses", async () => {
8+
let requestUrl = "";
9+
let requestHeaders: Headers | undefined;
10+
const context = createContext(async (input, init) => {
11+
requestUrl = String(input);
12+
requestHeaders = new Headers(init?.headers);
13+
return new Response(
14+
JSON.stringify({
15+
object: "list",
16+
items: [{ id: "customer_1" }],
17+
next_page: "/v2/projects/proj_1/customers?starting_after=customer_1",
18+
url: "/v2/projects/proj_1/customers",
19+
}),
20+
{ status: 200, headers: { "content-type": "application/json" } },
21+
);
22+
});
23+
24+
const output = await revenueCatActionHandlers.list_customers(
25+
{
26+
projectId: "proj/1",
27+
startingAfter: "cursor_1",
28+
limit: 2,
29+
search: "person@example.com",
30+
},
31+
context,
32+
);
33+
34+
expect(requestUrl).toBe(
35+
"https://api.revenuecat.com/v2/projects/proj%2F1/customers?starting_after=cursor_1&limit=2&search=person%40example.com",
36+
);
37+
expect(requestHeaders?.get("authorization")).toBe("Bearer secret-key");
38+
expect(output).toEqual({
39+
object: "list",
40+
items: [{ id: "customer_1" }],
41+
nextPage: "/v2/projects/proj_1/customers?starting_after=customer_1",
42+
url: "/v2/projects/proj_1/customers",
43+
});
44+
});
45+
46+
it("serializes expandable customer fields as repeated query parameters", async () => {
47+
let requestUrl = "";
48+
const context = createContext(async (input) => {
49+
requestUrl = String(input);
50+
return new Response(JSON.stringify({ id: "customer_1", attributes: { items: [] } }), {
51+
status: 200,
52+
headers: { "content-type": "application/json" },
53+
});
54+
});
55+
56+
await revenueCatActionHandlers.get_customer(
57+
{ projectId: "proj_1", customerId: "customer/1", expand: ["attributes"] },
58+
context,
59+
);
60+
61+
expect(requestUrl).toBe("https://api.revenuecat.com/v2/projects/proj_1/customers/customer%2F1?expand=attributes");
62+
});
63+
});
64+
65+
function createContext(fetcher: typeof fetch): ApiKeyProviderContext {
66+
return {
67+
apiKey: "secret-key",
68+
fetcher,
69+
};
70+
}

0 commit comments

Comments
 (0)