@pylonsync/revenuecat 0.8.2

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/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@pylonsync/revenuecat",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.8.2",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json --noEmit",
12
+ "check": "tsc -p tsconfig.json --noEmit",
13
+ "test": "bun test"
14
+ },
15
+ "dependencies": {
16
+ "@pylonsync/sdk": "0.8.2",
17
+ "@pylonsync/functions": "0.8.2"
18
+ },
19
+ "peerDependencies": {
20
+ "bun-types": "*"
21
+ },
22
+ "peerDependenciesMeta": {
23
+ "bun-types": {
24
+ "optional": true
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,3 @@
1
+ export { revenuecatWebhookHandler } from "./webhook";
2
+ export { syncEntitlementsHandler } from "./sync";
3
+ export { upsertEntitlementHandler, INTERNAL_UPSERT } from "./internal";
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Internal mutation the webhook and the sync action write through.
3
+ * Internal functions bypass entity policies (clients can never insert an
4
+ * entitlement) and are not callable over HTTP.
5
+ */
6
+
7
+ import { mutation, v } from "@pylonsync/functions";
8
+
9
+ import { entitlementEntityName } from "../manifest";
10
+ import type { HandlerCtx, RevenueCatConfig } from "../types";
11
+
12
+ export const INTERNAL_UPSERT = "_pylonRcUpsertEntitlement";
13
+
14
+ export function resolveWebhookAuth(ctx: HandlerCtx, cfg: RevenueCatConfig): string | undefined {
15
+ if (cfg.getWebhookAuth) return cfg.getWebhookAuth(ctx);
16
+ return ctx.env.REVENUECAT_WEBHOOK_AUTH;
17
+ }
18
+
19
+ export function resolveApiKey(ctx: HandlerCtx, cfg: RevenueCatConfig): string | undefined {
20
+ if (cfg.getApiKey) return cfg.getApiKey(ctx);
21
+ return ctx.env.REVENUECAT_SECRET_KEY ?? ctx.env.REVENUECAT_PUBLIC_KEY;
22
+ }
23
+
24
+ export interface UpsertArgs {
25
+ userId: string;
26
+ entitlement: string;
27
+ productId: string;
28
+ status: "active" | "expired";
29
+ store: string;
30
+ environment?: string | null;
31
+ expiresAt?: string | null;
32
+ }
33
+
34
+ /** Upsert one (user, entitlement) row. Returns whether the row's status changed. */
35
+ export function upsertEntitlementHandler(cfg: RevenueCatConfig) {
36
+ const entityName = entitlementEntityName(cfg);
37
+ return mutation({
38
+ internal: true,
39
+ args: {
40
+ userId: v.string(),
41
+ entitlement: v.string(),
42
+ productId: v.string(),
43
+ status: v.string(),
44
+ store: v.string(),
45
+ environment: v.optional(v.string()),
46
+ expiresAt: v.optional(v.string()),
47
+ },
48
+ async handler(ctx, args: UpsertArgs) {
49
+ const existing = (await ctx.db.query(entityName, {
50
+ userId: args.userId,
51
+ entitlement: args.entitlement,
52
+ })) as Array<{ id: string; status: string }>;
53
+ const patch = {
54
+ productId: args.productId,
55
+ status: args.status,
56
+ store: args.store,
57
+ environment: args.environment ?? null,
58
+ expiresAt: args.expiresAt ?? null,
59
+ updatedAt: new Date().toISOString(),
60
+ };
61
+ if (existing.length > 0) {
62
+ const before = existing[0];
63
+ await ctx.db.update(entityName, before.id, patch);
64
+ return { changed: before.status !== args.status, created: false };
65
+ }
66
+ await ctx.db.insert(entityName, {
67
+ userId: args.userId,
68
+ entitlement: args.entitlement,
69
+ ...patch,
70
+ });
71
+ return { changed: args.status === "active", created: true };
72
+ },
73
+ });
74
+ }
@@ -0,0 +1,86 @@
1
+ import { action } from "@pylonsync/functions";
2
+
3
+ import { INTERNAL_UPSERT, resolveApiKey } from "./internal";
4
+ import type { HandlerCtx, RevenueCatConfig } from "../types";
5
+
6
+ interface SubscriberResponse {
7
+ subscriber?: {
8
+ entitlements?: Record<
9
+ string,
10
+ { expires_date?: string | null; product_identifier?: string }
11
+ >;
12
+ };
13
+ }
14
+
15
+ /** Pure mapping of a subscriber payload to row writes, exported for tests. */
16
+ export function entitlementsFromSubscriber(
17
+ body: SubscriberResponse,
18
+ now: number,
19
+ ): Array<{ entitlement: string; productId: string; status: "active" | "expired"; expiresAt: string | null }> {
20
+ const out: Array<{
21
+ entitlement: string;
22
+ productId: string;
23
+ status: "active" | "expired";
24
+ expiresAt: string | null;
25
+ }> = [];
26
+ for (const [entitlement, info] of Object.entries(body.subscriber?.entitlements ?? {})) {
27
+ const expiresAt = info.expires_date ?? null;
28
+ const active = expiresAt == null || new Date(expiresAt).getTime() > now;
29
+ out.push({
30
+ entitlement,
31
+ productId: String(info.product_identifier ?? ""),
32
+ status: active ? "active" : "expired",
33
+ expiresAt,
34
+ });
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /**
40
+ * Re-read the caller's entitlements from RevenueCat and write the rows.
41
+ *
42
+ * The webhook is the steady-state path; this closes the two gaps in it:
43
+ * the purchasing device wants to unlock the moment the sheet dismisses
44
+ * (the webhook may take seconds), and in local development RevenueCat
45
+ * cannot reach the machine at all.
46
+ *
47
+ * Server-verified: the server asks RevenueCat about `ctx.auth.userId`;
48
+ * a client can trigger the sync but never assert an entitlement.
49
+ */
50
+ export function syncEntitlementsHandler(cfg: RevenueCatConfig) {
51
+ return action({
52
+ args: {},
53
+ async handler(ctx: HandlerCtx) {
54
+ const userId = ctx.auth.userId;
55
+ if (!userId) throw ctx.error("UNAUTHENTICATED", "sign in first");
56
+ const key = resolveApiKey(ctx, cfg);
57
+ if (!key) throw ctx.error("RC_NOT_CONFIGURED", "no RevenueCat API key on the server");
58
+
59
+ const res = await fetch(
60
+ `https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(userId)}`,
61
+ { headers: { Authorization: `Bearer ${key}`, Accept: "application/json" } },
62
+ );
63
+ if (!res.ok) {
64
+ throw ctx.error("RC_LOOKUP_FAILED", `RevenueCat returned ${res.status}`);
65
+ }
66
+ const body = (await res.json()) as SubscriberResponse;
67
+ const rows = entitlementsFromSubscriber(body, Date.now());
68
+ for (const row of rows) {
69
+ const result = await ctx.runMutation<{ changed: boolean }>(INTERNAL_UPSERT, {
70
+ userId,
71
+ entitlement: row.entitlement,
72
+ productId: row.productId,
73
+ status: row.status,
74
+ store: "sdk_sync",
75
+ expiresAt: row.expiresAt,
76
+ });
77
+ if (result.changed) {
78
+ const info = { userId, entitlement: row.entitlement, productId: row.productId, store: "sdk_sync" };
79
+ if (row.status === "active") await cfg.hooks?.onEntitlementActive?.(ctx, info);
80
+ else await cfg.hooks?.onEntitlementExpired?.(ctx, info);
81
+ }
82
+ }
83
+ return { applied: rows.length, active: rows.filter((r) => r.status === "active").map((r) => r.entitlement) };
84
+ },
85
+ });
86
+ }
@@ -0,0 +1,119 @@
1
+ import { action } from "@pylonsync/functions";
2
+
3
+ import { INTERNAL_UPSERT, resolveWebhookAuth } from "./internal";
4
+ import type { HandlerCtx, RevenueCatConfig, RevenueCatEvent } from "../types";
5
+
6
+ /** Event types after which the named entitlements are no longer active. */
7
+ const ENDS_ACCESS = new Set(["EXPIRATION", "REFUND"]);
8
+
9
+ /** Event types that carry no entitlement change. */
10
+ const INFORMATIONAL = new Set(["TEST", "TRANSFER", "SUBSCRIBER_ALIAS"]);
11
+
12
+ export interface WebhookOutcome {
13
+ ok: true;
14
+ type: string;
15
+ applied: number;
16
+ skipped?: string;
17
+ }
18
+
19
+ /**
20
+ * Pure event → row-status mapping, exported for tests.
21
+ *
22
+ * - INITIAL_PURCHASE / RENEWAL / NON_RENEWING_PURCHASE / UNCANCELLATION /
23
+ * PRODUCT_CHANGE / BILLING_ISSUE (still in grace) → active
24
+ * - CANCELLATION → still active: auto-renew is off, access lasts until
25
+ * the EXPIRATION event
26
+ * - EXPIRATION / REFUND → expired
27
+ */
28
+ export function statusForEvent(type: string): "active" | "expired" | null {
29
+ if (INFORMATIONAL.has(type)) return null;
30
+ return ENDS_ACCESS.has(type) ? "expired" : "active";
31
+ }
32
+
33
+ export function entitlementIdsOf(event: RevenueCatEvent): string[] {
34
+ if (Array.isArray(event.entitlement_ids) && event.entitlement_ids.length > 0) {
35
+ return event.entitlement_ids.map(String);
36
+ }
37
+ if (event.entitlement_id) return [String(event.entitlement_id)];
38
+ return [];
39
+ }
40
+
41
+ /** Check the shared secret. Accepts the raw value or `Bearer <value>`. */
42
+ export function webhookAuthorized(
43
+ headerValue: string | undefined,
44
+ expected: string | undefined,
45
+ ): boolean {
46
+ if (!expected) return false;
47
+ const got = headerValue ?? "";
48
+ return got === expected || got === `Bearer ${expected}`;
49
+ }
50
+
51
+ /**
52
+ * RevenueCat webhook receiver. Every App Store / Play Billing / Stripe
53
+ * event RevenueCat sees lands here and becomes entitlement rows.
54
+ *
55
+ * `app_user_id` must be the Pylon user id: the app calls
56
+ * `Purchases.logIn(userId)` before any purchase. Anonymous ids
57
+ * (`$RCAnonymousID:…`) are acknowledged and ignored.
58
+ */
59
+ export function revenuecatWebhookHandler(cfg: RevenueCatConfig) {
60
+ return action({
61
+ auth: "public",
62
+ args: {},
63
+ async handler(ctx: HandlerCtx): Promise<WebhookOutcome> {
64
+ if (!ctx.request) {
65
+ throw ctx.error("BAD_INVOCATION", "revenuecatWebhook requires HTTP request context");
66
+ }
67
+ const expected = resolveWebhookAuth(ctx, cfg);
68
+ if (!expected) {
69
+ throw ctx.error("RC_NOT_CONFIGURED", "REVENUECAT_WEBHOOK_AUTH is not set");
70
+ }
71
+ if (!webhookAuthorized(ctx.request.headers["authorization"], expected)) {
72
+ throw ctx.error("FORBIDDEN", "bad webhook authorization");
73
+ }
74
+
75
+ let event: RevenueCatEvent;
76
+ try {
77
+ const body = JSON.parse(ctx.request.rawBody) as { event?: RevenueCatEvent };
78
+ event = body.event ?? ({ type: "" } as RevenueCatEvent);
79
+ } catch {
80
+ throw ctx.error("BAD_BODY", "invalid JSON");
81
+ }
82
+ const type = String(event.type ?? "");
83
+ const userId = String(event.app_user_id ?? "");
84
+ if (!userId || userId.startsWith("$RCAnonymous")) {
85
+ return { ok: true, type, applied: 0, skipped: "no app user id" };
86
+ }
87
+ const status = statusForEvent(type);
88
+ const entitlements = entitlementIdsOf(event);
89
+ if (!status || entitlements.length === 0) {
90
+ await cfg.hooks?.onEvent?.(ctx, event);
91
+ return { ok: true, type, applied: 0, skipped: "no entitlement change" };
92
+ }
93
+
94
+ const productId = String(event.product_id ?? "");
95
+ const store = String(event.store ?? "").toLowerCase();
96
+ const expiresAt = event.expiration_at_ms
97
+ ? new Date(Number(event.expiration_at_ms)).toISOString()
98
+ : null;
99
+ for (const entitlement of entitlements) {
100
+ const result = await ctx.runMutation<{ changed: boolean }>(INTERNAL_UPSERT, {
101
+ userId,
102
+ entitlement,
103
+ productId,
104
+ status,
105
+ store,
106
+ environment: event.environment ?? null,
107
+ expiresAt,
108
+ });
109
+ if (result.changed) {
110
+ const info = { userId, entitlement, productId, store };
111
+ if (status === "active") await cfg.hooks?.onEntitlementActive?.(ctx, info);
112
+ else await cfg.hooks?.onEntitlementExpired?.(ctx, info);
113
+ }
114
+ }
115
+ await cfg.hooks?.onEvent?.(ctx, event);
116
+ return { ok: true, type, applied: entitlements.length };
117
+ },
118
+ });
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `@pylonsync/revenuecat` — native in-app purchases for Pylon apps.
3
+ *
4
+ * RevenueCat handles the App Store / Play Billing side (products,
5
+ * receipts, renewals, paywalls). This plugin turns its webhook events
6
+ * into an `RcEntitlement` row per (user, entitlement) that syncs to every
7
+ * device like any other row, so a purchase on the phone unlocks the
8
+ * tablet and the web app without a second receipt check.
9
+ *
10
+ * ```ts
11
+ * // lib/purchases.ts
12
+ * import { revenuecat } from "@pylonsync/revenuecat";
13
+ * export const purchases = revenuecat({ entitlements: ["pro"] });
14
+ * export const { revenuecatWebhook, syncEntitlements } = purchases.handlers;
15
+ * export const { _pylonRcUpsertEntitlement } = purchases.internals;
16
+ *
17
+ * // functions/revenuecatWebhook.ts
18
+ * export { revenuecatWebhook as default } from "../lib/purchases";
19
+ * // functions/syncEntitlements.ts
20
+ * export { syncEntitlements as default } from "../lib/purchases";
21
+ * // functions/_pylonRcUpsertEntitlement.ts
22
+ * export { _pylonRcUpsertEntitlement as default } from "../lib/purchases";
23
+ * ```
24
+ *
25
+ * Env:
26
+ * REVENUECAT_WEBHOOK_AUTH the Authorization value set on the dashboard webhook
27
+ * REVENUECAT_SECRET_KEY (or REVENUECAT_PUBLIC_KEY) for the subscriber read in syncEntitlements
28
+ *
29
+ * Point the RevenueCat webhook at `POST /api/fn/revenuecatWebhook`.
30
+ */
31
+
32
+ import { buildRevenueCatManifest, type RevenueCatManifestFragment } from "./manifest";
33
+ import {
34
+ INTERNAL_UPSERT,
35
+ revenuecatWebhookHandler,
36
+ syncEntitlementsHandler,
37
+ upsertEntitlementHandler,
38
+ } from "./handlers";
39
+ import type { RevenueCatConfig } from "./types";
40
+
41
+ export type {
42
+ RevenueCatConfig,
43
+ RevenueCatHooks,
44
+ RevenueCatEvent,
45
+ RcEntitlementRow,
46
+ EntitlementChange,
47
+ HandlerCtx,
48
+ } from "./types";
49
+ export type { RevenueCatManifestFragment } from "./manifest";
50
+ export { entitlementEntityName } from "./manifest";
51
+ export { statusForEvent, entitlementIdsOf, webhookAuthorized } from "./handlers/webhook";
52
+ export { entitlementsFromSubscriber } from "./handlers/sync";
53
+
54
+ export interface RevenueCatPlugin {
55
+ manifest: RevenueCatManifestFragment;
56
+ handlers: {
57
+ revenuecatWebhook: ReturnType<typeof revenuecatWebhookHandler>;
58
+ syncEntitlements: ReturnType<typeof syncEntitlementsHandler>;
59
+ };
60
+ internals: Record<string, ReturnType<typeof upsertEntitlementHandler>>;
61
+ }
62
+
63
+ export function revenuecat(cfg: RevenueCatConfig): RevenueCatPlugin {
64
+ return {
65
+ manifest: buildRevenueCatManifest(cfg),
66
+ handlers: {
67
+ revenuecatWebhook: revenuecatWebhookHandler(cfg),
68
+ syncEntitlements: syncEntitlementsHandler(cfg),
69
+ },
70
+ internals: {
71
+ [INTERNAL_UPSERT]: upsertEntitlementHandler(cfg),
72
+ },
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Client-side helper: is `entitlement` active in a list of rows the app
78
+ * read through sync? Treats a row with a past `expiresAt` as inactive even
79
+ * if the webhook has not delivered the expiration yet.
80
+ */
81
+ export function hasEntitlement(
82
+ rows: ReadonlyArray<{ entitlement: string; status: string; expiresAt?: string | null }>,
83
+ entitlement: string,
84
+ now: number = Date.now(),
85
+ ): boolean {
86
+ return rows.some(
87
+ (r) =>
88
+ r.entitlement === entitlement &&
89
+ r.status === "active" &&
90
+ (r.expiresAt == null || new Date(r.expiresAt).getTime() > now),
91
+ );
92
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Manifest fragment: the entitlement entity, its read policy, and the
3
+ * action declarations. App code spreads these into `buildManifest()`.
4
+ */
5
+
6
+ import {
7
+ type ActionDefinition,
8
+ type EntityDefinition,
9
+ type PolicyDefinition,
10
+ action,
11
+ entity,
12
+ field,
13
+ policy,
14
+ } from "@pylonsync/sdk";
15
+
16
+ import type { RevenueCatConfig } from "./types";
17
+
18
+ export interface RevenueCatManifestFragment {
19
+ entities: EntityDefinition[];
20
+ actions: ActionDefinition[];
21
+ policies: PolicyDefinition[];
22
+ }
23
+
24
+ export function entitlementEntityName(cfg: RevenueCatConfig): string {
25
+ return cfg.entities?.entitlement ?? "RcEntitlement";
26
+ }
27
+
28
+ /**
29
+ * ```ts
30
+ * const purchases = revenuecat({ entitlements: ["pro"] });
31
+ * buildManifest({
32
+ * entities: [User, ...purchases.manifest.entities],
33
+ * actions: [...fns.actions, ...purchases.manifest.actions],
34
+ * policies: [...purchases.manifest.policies],
35
+ * });
36
+ * ```
37
+ */
38
+ export function buildRevenueCatManifest(cfg: RevenueCatConfig): RevenueCatManifestFragment {
39
+ const name = entitlementEntityName(cfg);
40
+
41
+ // One row per (user, entitlement). Written only by the webhook and the
42
+ // server-side sync; clients read their own rows through sync, so a
43
+ // purchase on one device unlocks every device.
44
+ const Entitlement = entity(
45
+ name,
46
+ {
47
+ userId: field.id("User"),
48
+ entitlement: field.string(),
49
+ productId: field.string(),
50
+ // "active" | "expired"
51
+ status: field.string(),
52
+ // app_store | play_store | stripe | promotional | sdk_sync
53
+ store: field.string(),
54
+ environment: field.string().optional(),
55
+ expiresAt: field.string().optional(),
56
+ updatedAt: field.string(),
57
+ },
58
+ {
59
+ indexes: [
60
+ { name: "by_user_entitlement", fields: ["userId", "entitlement"], unique: true },
61
+ ],
62
+ },
63
+ );
64
+
65
+ const readOwn = policy({
66
+ name: `${name.toLowerCase()}_read_own`,
67
+ entity: name,
68
+ allowRead: "auth.userId == data.userId",
69
+ allowInsert: "false",
70
+ allowUpdate: "false",
71
+ allowDelete: "false",
72
+ });
73
+
74
+ return {
75
+ entities: [Entitlement],
76
+ policies: [readOwn],
77
+ actions: [
78
+ // Public by handler: RevenueCat posts anonymously; the shared
79
+ // Authorization header is the auth boundary.
80
+ action("revenuecatWebhook"),
81
+ // Signed-in user: re-read their entitlements from RevenueCat and
82
+ // write the rows. Called after a purchase or restore.
83
+ action("syncEntitlements"),
84
+ ],
85
+ };
86
+ }
package/src/types.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Public type surface for `@pylonsync/revenuecat`.
3
+ */
4
+
5
+ export interface RevenueCatConfig {
6
+ /**
7
+ * Entitlement identifiers the app gates on, as configured in the
8
+ * RevenueCat dashboard (for example `["pro"]`). Unknown identifiers on
9
+ * a webhook are still stored; this list documents what the app uses.
10
+ */
11
+ entitlements: string[];
12
+ /**
13
+ * The shared secret RevenueCat sends as the `Authorization` header on
14
+ * every webhook (set the same value in the dashboard). Resolved from
15
+ * `ctx.env.REVENUECAT_WEBHOOK_AUTH` unless overridden. When unset the
16
+ * webhook answers 403, so a fresh deploy cannot be fed forged
17
+ * entitlements.
18
+ */
19
+ getWebhookAuth?: (ctx: HandlerCtx) => string | undefined;
20
+ /**
21
+ * Key for `GET https://api.revenuecat.com/v1/subscribers/<id>`, used by
22
+ * `syncEntitlements` to re-read a user's entitlements after a purchase
23
+ * or restore. Resolved from `ctx.env.REVENUECAT_SECRET_KEY`, then
24
+ * `ctx.env.REVENUECAT_PUBLIC_KEY` (the public SDK key is enough for
25
+ * that read), unless overridden.
26
+ */
27
+ getApiKey?: (ctx: HandlerCtx) => string | undefined;
28
+ /** Entity name override. Default `RcEntitlement`. */
29
+ entities?: { entitlement?: string };
30
+ hooks?: RevenueCatHooks;
31
+ }
32
+
33
+ export interface EntitlementChange {
34
+ userId: string;
35
+ entitlement: string;
36
+ productId: string;
37
+ store: string;
38
+ }
39
+
40
+ export interface RevenueCatHooks {
41
+ /** An entitlement became active for a user (purchase, renewal, restore). */
42
+ onEntitlementActive?: (ctx: HandlerCtx, info: EntitlementChange) => Promise<void> | void;
43
+ /** An entitlement ended (expiration, refund, or a billing issue past grace). */
44
+ onEntitlementExpired?: (ctx: HandlerCtx, info: EntitlementChange) => Promise<void> | void;
45
+ /** Every webhook event, after the rows were written. */
46
+ onEvent?: (ctx: HandlerCtx, event: RevenueCatEvent) => Promise<void> | void;
47
+ }
48
+
49
+ /**
50
+ * The subset of a RevenueCat webhook event this plugin reads.
51
+ * https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields
52
+ */
53
+ export interface RevenueCatEvent {
54
+ type: string;
55
+ app_user_id?: string;
56
+ original_app_user_id?: string;
57
+ /** Every id RevenueCat has aliased for this subscriber. */
58
+ aliases?: string[];
59
+ product_id?: string;
60
+ entitlement_id?: string | null;
61
+ entitlement_ids?: string[] | null;
62
+ store?: string;
63
+ environment?: string;
64
+ expiration_at_ms?: number | null;
65
+ purchased_at_ms?: number | null;
66
+ period_type?: string;
67
+ cancel_reason?: string;
68
+ new_product_id?: string;
69
+ transaction_id?: string;
70
+ id?: string;
71
+ }
72
+
73
+ /** One row of the entitlement entity. */
74
+ export interface RcEntitlementRow {
75
+ id: string;
76
+ userId: string;
77
+ entitlement: string;
78
+ productId: string;
79
+ status: "active" | "expired";
80
+ store: string;
81
+ environment?: string | null;
82
+ expiresAt?: string | null;
83
+ updatedAt: string;
84
+ }
85
+
86
+ /** Function context shape the handlers use; structurally compatible with
87
+ * `@pylonsync/functions`' ctx. */
88
+ export interface HandlerCtx {
89
+ env: Record<string, string | undefined>;
90
+ auth: { userId?: string | null; tenantId?: string | null; isAdmin?: boolean };
91
+ request?: {
92
+ headers: Record<string, string | undefined>;
93
+ rawBody: string;
94
+ };
95
+ runQuery: <T>(name: string, args: Record<string, unknown>) => Promise<T>;
96
+ runMutation: <T = unknown>(name: string, args: Record<string, unknown>) => Promise<T>;
97
+ error: (code: string, message: string) => Error;
98
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { entitlementsFromSubscriber } from "./handlers/sync";
4
+ import { entitlementIdsOf, revenuecatWebhookHandler, statusForEvent, webhookAuthorized } from "./handlers/webhook";
5
+ import { hasEntitlement } from "./index";
6
+ import type { HandlerCtx } from "./types";
7
+
8
+ describe("event mapping", () => {
9
+ test("purchase-like events are active, expiration and refund end access, cancellation keeps it", () => {
10
+ for (const t of ["INITIAL_PURCHASE", "RENEWAL", "NON_RENEWING_PURCHASE", "UNCANCELLATION", "PRODUCT_CHANGE", "BILLING_ISSUE", "CANCELLATION"]) {
11
+ expect(statusForEvent(t)).toBe("active");
12
+ }
13
+ expect(statusForEvent("EXPIRATION")).toBe("expired");
14
+ expect(statusForEvent("REFUND")).toBe("expired");
15
+ expect(statusForEvent("TEST")).toBeNull();
16
+ expect(statusForEvent("TRANSFER")).toBeNull();
17
+ });
18
+
19
+ test("entitlement ids come from the list, then the single field", () => {
20
+ expect(entitlementIdsOf({ type: "RENEWAL", entitlement_ids: ["pro", "coach"] })).toEqual(["pro", "coach"]);
21
+ expect(entitlementIdsOf({ type: "RENEWAL", entitlement_id: "pro" })).toEqual(["pro"]);
22
+ expect(entitlementIdsOf({ type: "RENEWAL" })).toEqual([]);
23
+ });
24
+
25
+ test("the shared secret is required and accepted bare or as a bearer", () => {
26
+ expect(webhookAuthorized("s3cret", "s3cret")).toBe(true);
27
+ expect(webhookAuthorized("Bearer s3cret", "s3cret")).toBe(true);
28
+ expect(webhookAuthorized("wrong", "s3cret")).toBe(false);
29
+ expect(webhookAuthorized("s3cret", undefined)).toBe(false);
30
+ expect(webhookAuthorized(undefined, "s3cret")).toBe(false);
31
+ });
32
+
33
+ test("subscriber payloads map to active or expired by expires_date", () => {
34
+ const now = Date.parse("2026-09-05T00:00:00Z");
35
+ const rows = entitlementsFromSubscriber(
36
+ {
37
+ subscriber: {
38
+ entitlements: {
39
+ pro: { expires_date: "2026-10-01T00:00:00Z", product_identifier: "pro_monthly" },
40
+ coach: { expires_date: "2026-01-01T00:00:00Z", product_identifier: "coach_10" },
41
+ lifetime: { expires_date: null, product_identifier: "lifetime" },
42
+ },
43
+ },
44
+ },
45
+ now,
46
+ );
47
+ expect(rows.map((r) => [r.entitlement, r.status])).toEqual([
48
+ ["pro", "active"],
49
+ ["coach", "expired"],
50
+ ["lifetime", "active"],
51
+ ]);
52
+ });
53
+
54
+ test("hasEntitlement ignores rows whose expiry has passed", () => {
55
+ const rows = [
56
+ { entitlement: "pro", status: "active", expiresAt: "2026-01-01T00:00:00Z" },
57
+ { entitlement: "coach", status: "active", expiresAt: null },
58
+ ];
59
+ const now = Date.parse("2026-09-05T00:00:00Z");
60
+ expect(hasEntitlement(rows, "pro", now)).toBe(false);
61
+ expect(hasEntitlement(rows, "coach", now)).toBe(true);
62
+ expect(hasEntitlement(rows, "missing", now)).toBe(false);
63
+ });
64
+ });
65
+
66
+ function ctxWith(headers: Record<string, string>, body: unknown, env: Record<string, string>) {
67
+ const mutations: Array<Record<string, unknown>> = [];
68
+ const ctx = {
69
+ env,
70
+ auth: { userId: null },
71
+ request: { headers, rawBody: JSON.stringify(body) },
72
+ error: (code: string, message: string) => Object.assign(new Error(message), { code }),
73
+ runQuery: async () => [] as never,
74
+ runMutation: async (_name: string, args: Record<string, unknown>) => {
75
+ mutations.push(args);
76
+ return { changed: true } as never;
77
+ },
78
+ } as unknown as HandlerCtx;
79
+ return { ctx, mutations };
80
+ }
81
+
82
+ describe("webhook handler", () => {
83
+ const handler = revenuecatWebhookHandler({ entitlements: ["pro"] }) as unknown as {
84
+ handler: (ctx: HandlerCtx, args: Record<string, never>) => Promise<{ applied: number; skipped?: string }>;
85
+ };
86
+
87
+ test("refuses when the secret is unset or wrong", async () => {
88
+ const unset = ctxWith({ authorization: "x" }, { event: { type: "RENEWAL" } }, {});
89
+ await expect(handler.handler(unset.ctx, {})).rejects.toThrow("REVENUECAT_WEBHOOK_AUTH");
90
+ const wrong = ctxWith({ authorization: "nope" }, { event: { type: "RENEWAL" } }, { REVENUECAT_WEBHOOK_AUTH: "s3cret" });
91
+ await expect(handler.handler(wrong.ctx, {})).rejects.toThrow("bad webhook authorization");
92
+ expect(wrong.mutations).toHaveLength(0);
93
+ });
94
+
95
+ test("writes one active row per entitlement on a purchase", async () => {
96
+ const { ctx, mutations } = ctxWith(
97
+ { authorization: "s3cret" },
98
+ {
99
+ event: {
100
+ type: "INITIAL_PURCHASE",
101
+ app_user_id: "user_1",
102
+ product_id: "pro_monthly",
103
+ entitlement_ids: ["pro"],
104
+ store: "APP_STORE",
105
+ environment: "SANDBOX",
106
+ expiration_at_ms: Date.parse("2026-10-05T00:00:00Z"),
107
+ },
108
+ },
109
+ { REVENUECAT_WEBHOOK_AUTH: "s3cret" },
110
+ );
111
+ const out = await handler.handler(ctx, {});
112
+ expect(out.applied).toBe(1);
113
+ expect(mutations[0]).toMatchObject({
114
+ userId: "user_1",
115
+ entitlement: "pro",
116
+ productId: "pro_monthly",
117
+ status: "active",
118
+ store: "app_store",
119
+ environment: "SANDBOX",
120
+ expiresAt: "2026-10-05T00:00:00.000Z",
121
+ });
122
+ });
123
+
124
+ test("anonymous subscribers are acknowledged and ignored", async () => {
125
+ const { ctx, mutations } = ctxWith(
126
+ { authorization: "s3cret" },
127
+ { event: { type: "INITIAL_PURCHASE", app_user_id: "$RCAnonymousID:abc", entitlement_ids: ["pro"] } },
128
+ { REVENUECAT_WEBHOOK_AUTH: "s3cret" },
129
+ );
130
+ const out = await handler.handler(ctx, {});
131
+ expect(out.skipped).toBe("no app user id");
132
+ expect(mutations).toHaveLength(0);
133
+ });
134
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["bun-types"]
5
+ },
6
+ "include": [
7
+ "src"
8
+ ]
9
+ }