feat: 🎸 send all webhooks in all paths

This commit is contained in:
amianthus
2026-04-15 16:39:04 +01:00
parent 09ccb09c70
commit 10db63d0c6
6 changed files with 909 additions and 72 deletions

View File

@@ -9,6 +9,7 @@ import {
import { createStripeCli } from "@/external/connect/createStripeCli";
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
@@ -41,11 +42,23 @@ export const handleRenewal = async ({
const now = Date.now();
// If same product exists and is active, this is just a renewal - nothing to do
// If same product exists and is active, this is just a renewal - send webhook only
if (curSameProduct && ACTIVE_STATUSES.includes(curSameProduct.status)) {
logger.info(
`Renewal for existing active product ${product.id}, no action needed`,
`Renewal for existing active product ${product.id}, sending webhook`,
);
// Send webhook for simple renewal (no state change)
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: curSameProduct.internal_customer_id,
org,
env,
customerId: customer.id || "",
scenario: AttachScenario.Renew,
cusProduct: curSameProduct,
});
return { success: true };
} else if (
curSameProduct &&
@@ -61,6 +74,18 @@ export const handleRenewal = async ({
status: CusProductStatus.Active,
},
});
// Send webhook for past_due → active recovery
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: curSameProduct.internal_customer_id,
org,
env,
customerId: customer.id || "",
scenario: AttachScenario.Renew,
cusProduct: curSameProduct,
});
logger.info(`Marked past due product as active: ${curSameProduct.id}`);
return { success: true };
}
@@ -94,6 +119,17 @@ export const handleRenewal = async ({
},
});
// Send webhook for the expired product
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: curMainProduct.internal_customer_id,
org,
env,
customerId: customer.id || "",
scenario: AttachScenario.Expired,
cusProduct: curMainProduct,
});
logger.info(`Expired old cus_product: ${curMainProduct.id}`);
} else if (curSameProduct) {
// Reactivate the same product if it was expired/cancelled
@@ -108,6 +144,17 @@ export const handleRenewal = async ({
},
});
// Send webhook for reactivation
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: curSameProduct.internal_customer_id,
org,
env,
customerId: customer.id || "",
scenario: AttachScenario.Renew,
cusProduct: curSameProduct,
});
logger.info(`Reactivated cus_product: ${curSameProduct.id}`);
return { success: true };
}

View File

@@ -5,8 +5,8 @@ import { CusProductStatus } from "@shared/models/cusProductModels/cusProductEnum
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import {
ACTIVE_STATUSES,
CusProductService,
} from "@/internal/customers/cusProducts/CusProductService";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
import { resolveRevenuecatResources } from "../misc/resolveRevenuecatResources";
@@ -17,10 +17,10 @@ export const handleBillingIssue = async ({
event: WebhookBillingIssue;
ctx: RevenueCatWebhookContext;
}) => {
const { db, logger } = ctx;
const { logger } = ctx;
const { product_id, app_user_id } = event;
const { product, cusProducts } = await resolveRevenuecatResources({
const { product, customer, cusProducts } = await resolveRevenuecatResources({
ctx,
revenuecatProductId: product_id,
customerId: app_user_id,
@@ -47,12 +47,10 @@ export const handleBillingIssue = async ({
}
if (ACTIVE_STATUSES.includes(curSameProduct.status)) {
await CusProductService.update({
await customerProductActions.markPastDue({
ctx,
cusProductId: curSameProduct.id,
updates: {
status: CusProductStatus.PastDue,
},
customerProduct: curSameProduct,
fullCustomer: customer,
});
return { success: true };

View File

@@ -2,7 +2,7 @@ import type { WebhookCancellation } from "@puzzmo/revenue-cat-webhook-types";
import { ErrCode, RecaseError } from "@shared/index";
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
export const handleCancellation = async ({
@@ -12,11 +12,11 @@ export const handleCancellation = async ({
event: WebhookCancellation;
ctx: RevenueCatWebhookContext;
}) => {
const { db, logger } = ctx;
const { logger } = ctx;
const { product_id, original_app_user_id, app_user_id, expiration_at_ms } =
event;
const { product, cusProducts } = await resolveRevenuecatResources({
const { product, customer, cusProducts } = await resolveRevenuecatResources({
ctx,
revenuecatProductId: product_id,
customerId: app_user_id ?? original_app_user_id,
@@ -35,14 +35,11 @@ export const handleCancellation = async ({
});
}
await CusProductService.update({
await customerProductActions.cancel({
ctx,
cusProductId: curSameProduct.id,
updates: {
canceled_at: Date.now(),
canceled: true,
ended_at: expiration_at_ms,
},
customerProduct: curSameProduct,
fullCustomer: customer,
endedAt: expiration_at_ms,
});
logger.info(

View File

@@ -1,9 +1,8 @@
import type { WebhookExpiration } from "@puzzmo/revenue-cat-webhook-types";
import { CusProductStatus, ErrCode, RecaseError } from "@shared/index";
import { ErrCode, RecaseError } from "@shared/index";
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
export const handleExpiration = async ({
@@ -13,7 +12,7 @@ export const handleExpiration = async ({
event: WebhookExpiration;
ctx: RevenueCatWebhookContext;
}) => {
const { db, logger } = ctx;
const { logger } = ctx;
const { product_id, original_app_user_id, app_user_id } = event;
const { product, customer, cusProducts } = await resolveRevenuecatResources({
@@ -35,36 +34,11 @@ export const handleExpiration = async ({
});
}
// Expire the cus_product
await CusProductService.update({
await customerProductActions.expireAndActivateDefault({
ctx,
cusProductId: curSameProduct.id,
updates: {
status: CusProductStatus.Expired,
ended_at: event.expiration_at_ms,
canceled: !!curSameProduct.canceled_at,
},
});
logger.info(`Expired cus_product: ${curSameProduct.id}`);
await customerProductActions.activateFreeSuccessor({
ctx,
fromCustomerProduct: curSameProduct,
customerProduct: curSameProduct,
fullCustomer: customer,
});
// Activate default product if this was a main product
// const isMain = !product.is_add_on;
// const isOneOffProduct = isOneOff(product.prices);
// if (isMain && !isOneOffProduct) {
// // await activateDefaultProduct({
// // ctx,
// // productGroup: product.group,
// // fullCus: customer,
// // curCusProduct: curSameProduct,
// // });
// }
logger.info(`Expired cus_product: ${curSameProduct.id}`);
};

View File

@@ -1,13 +1,8 @@
import type { WebhookUnCancellation } from "@puzzmo/revenue-cat-webhook-types";
import {
CusProductStatus,
ErrCode,
ProcessorType,
RecaseError,
} from "@shared/index";
import { ErrCode, ProcessorType, RecaseError } from "@shared/index";
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
export const handleUncancellation = async ({
event,
@@ -16,10 +11,10 @@ export const handleUncancellation = async ({
event: WebhookUnCancellation;
ctx: RevenueCatWebhookContext;
}) => {
const { db } = ctx;
const { logger } = ctx;
const { product_id, original_app_user_id, app_user_id } = event;
const { product, cusProducts } = await resolveRevenuecatResources({
const { product, customer, cusProducts } = await resolveRevenuecatResources({
ctx,
revenuecatProductId: product_id,
customerId: app_user_id ?? original_app_user_id,
@@ -31,22 +26,19 @@ export const handleUncancellation = async ({
cp.processor?.type === ProcessorType.RevenueCat,
);
if (cusProduct) {
await CusProductService.update({
ctx,
cusProductId: cusProduct.id,
updates: {
canceled_at: null,
canceled: false,
ended_at: null,
status: CusProductStatus.Active,
},
});
} else {
if (!cusProduct) {
throw new RecaseError({
message: "Cus product not found",
code: ErrCode.CusProductNotFound,
statusCode: 404,
});
}
await customerProductActions.uncancel({
ctx,
customerProduct: cusProduct,
fullCustomer: customer,
});
logger.info(`Uncancelled cus_product ${cusProduct.id}`);
};

View File

@@ -0,0 +1,829 @@
/**
* RevenueCat Webhook Integration Tests
*
* Tests that RevenueCat webhook events trigger the correct Autumn
* customer.products.updated webhooks via Svix Play.
*
* Each test sends a RevenueCat webhook event and verifies that the
* corresponding outgoing webhook contains the correct scenario:
* - Initial purchase → new
* - Renewal → renew
* - Upgrade (monthly → yearly) → upgrade
* - Downgrade (yearly → monthly) → downgrade
* - Cancellation → cancel
* - Uncancellation → renew
* - Billing issue → past_due
* - Expiration → expire
* - Non-renewing purchase → new
*/
import { afterAll, beforeAll, expect, test } from "bun:test";
import type { ApiCustomerV3, ApiProduct } from "@autumn/shared";
import { AppEnv } from "@autumn/shared";
import {
getPlayHistory,
getTestSvixAppId,
parseEventBody,
setupWebhookTest,
type WebhookTestSetup,
waitForWebhook,
} from "@tests/integration/utils/svixWebhookTestUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService";
import { OrgService } from "@/internal/orgs/OrgService";
import { encryptData } from "@/utils/encryptUtils";
import { RevenueCatWebhookClient } from "./utils/revenue-cat-webhook-client";
// ─── Types ───────────────────────────────────────────────────────────────────
type RevenueCatScenario =
| "new"
| "renew"
| "upgrade"
| "downgrade"
| "cancel"
| "uncancel"
| "past_due"
| "expired";
type CustomerProductsUpdatedPayload = {
type: string;
data: {
scenario: RevenueCatScenario;
customer: ApiCustomerV3;
updated_product: ApiProduct;
entity?: any;
};
};
const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345";
// ─── Helpers ─────────────────────────────────────────────────────────────────
const setupRevenueCatOrg = async () => {
if (
ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !==
RC_WEBHOOK_SECRET
) {
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: {
processor_configs: {
...ctx.org.processor_configs,
revenuecat: {
api_key: encryptData("mock_rc_api_key_live"),
sandbox_api_key: encryptData("mock_rc_api_key_sandbox"),
project_id: "mock_project_live",
sandbox_project_id: "mock_project_sandbox",
webhook_secret: RC_WEBHOOK_SECRET,
sandbox_webhook_secret: RC_WEBHOOK_SECRET,
},
},
},
});
}
};
// ─── Svix Play Setup ─────────────────────────────────────────────────────────
let webhook: WebhookTestSetup;
let playToken: string;
beforeAll(async () => {
await setupRevenueCatOrg();
const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config });
webhook = await setupWebhookTest({
appId,
filterTypes: ["customer.products.updated"],
});
playToken = webhook.playToken;
});
afterAll(async () => {
await webhook?.cleanup();
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Initial Purchase → scenario: new
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("rc-webhook: initial purchase → scenario: new")}`, async () => {
const customerId = "rc-webhook-initial-purchase";
const RC_PRO_MONTHLY_ID = "com.app.rcwh1_pro_monthly";
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const proMonthly = products.pro({
id: "pro-monthly",
items: [messagesItem],
});
await initScenario({
customerId,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [proMonthly] }),
],
actions: [],
});
await RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: proMonthly.id,
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
},
});
const rcClient = new RevenueCatWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
webhookSecret: RC_WEBHOOK_SECRET,
});
await rcClient.initialPurchase({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh1_tx_001",
});
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "new",
timeoutMs: 15000,
});
expect(result).not.toBeNull();
const { data } = result!.payload;
expect(data.scenario).toBe("new");
expect(data.updated_product.id).toBe(proMonthly.id);
expect(data.customer.id).toBe(customerId);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Renewal → scenario: renew
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("rc-webhook: renewal → scenario: renew")}`, async () => {
const customerId = "rc-webhook-renewal";
const RC_PRO_MONTHLY_ID = "com.app.rcwh2_pro_monthly";
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const proMonthly = products.pro({
id: "pro-monthly",
items: [messagesItem],
});
await initScenario({
customerId,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [proMonthly] }),
],
actions: [],
});
await RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: proMonthly.id,
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
},
});
const rcClient = new RevenueCatWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
webhookSecret: RC_WEBHOOK_SECRET,
});
// First: initial purchase to create the subscription
await rcClient.initialPurchase({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh2_tx_001",
});
// Wait for the initial purchase webhook to arrive before triggering renewal
await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "new",
timeoutMs: 15000,
});
// Then: renewal
await rcClient.renewal({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh2_tx_001",
});
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "renew",
timeoutMs: 15000,
});
expect(result).not.toBeNull();
const { data } = result!.payload;
expect(data.scenario).toBe("renew");
expect(data.updated_product.id).toBe(proMonthly.id);
expect(data.customer.id).toBe(customerId);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Upgrade → scenario: upgrade
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("rc-webhook: upgrade (monthly → yearly) → scenario: upgrade")}`, async () => {
const customerId = "rc-webhook-upgrade";
const RC_PRO_MONTHLY_ID = "com.app.rcwh3_pro_monthly";
const RC_PRO_YEARLY_ID = "com.app.rcwh3_pro_yearly";
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const proMonthly = products.pro({
id: "pro-monthly",
items: [messagesItem],
});
const proYearly = products.proAnnual({
id: "pro-yearly",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
await initScenario({
customerId,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [proMonthly, proYearly] }),
],
actions: [],
});
await Promise.all([
RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: proMonthly.id,
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
},
}),
RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: proYearly.id,
revenuecat_product_ids: [RC_PRO_YEARLY_ID],
},
}),
]);
const rcClient = new RevenueCatWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
webhookSecret: RC_WEBHOOK_SECRET,
});
// First: initial purchase on monthly
await rcClient.initialPurchase({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh3_tx_001",
});
await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "new",
timeoutMs: 15000,
});
// Then: renewal to yearly (upgrade)
await rcClient.renewal({
productId: RC_PRO_YEARLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh3_tx_001",
});
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "upgrade",
timeoutMs: 15000,
});
expect(result).not.toBeNull();
const { data } = result!.payload;
expect(data.scenario).toBe("upgrade");
expect(data.updated_product.id).toBe(proYearly.id);
expect(data.customer.id).toBe(customerId);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Downgrade → scenario: downgrade
// Uses premium ($50/mo) → pro ($20/mo) so the price decrease is a genuine downgrade
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) → scenario: downgrade")}`, async () => {
const customerId = "rc-webhook-downgrade";
const RC_PRO_ID = "com.app.rcwh4_pro";
const RC_PREMIUM_ID = "com.app.rcwh4_premium";
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const premium = products.premium({
id: "premium",
items: [messagesItem],
});
await initScenario({
customerId,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [pro, premium] }),
],
actions: [],
});
await Promise.all([
RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: pro.id,
revenuecat_product_ids: [RC_PRO_ID],
},
}),
RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: premium.id,
revenuecat_product_ids: [RC_PREMIUM_ID],
},
}),
]);
const rcClient = new RevenueCatWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
webhookSecret: RC_WEBHOOK_SECRET,
});
// First: initial purchase on premium ($50/mo)
await rcClient.initialPurchase({
productId: RC_PREMIUM_ID,
appUserId: customerId,
originalTransactionId: "rcwh4_tx_001",
});
await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "new",
timeoutMs: 15000,
});
// Then: renewal to pro ($20/mo) — genuine downgrade
await rcClient.renewal({
productId: RC_PRO_ID,
appUserId: customerId,
originalTransactionId: "rcwh4_tx_001",
});
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "downgrade",
timeoutMs: 15000,
});
expect(result).not.toBeNull();
const { data } = result!.payload;
expect(data.scenario).toBe("downgrade");
expect(data.updated_product.id).toBe(pro.id);
expect(data.customer.id).toBe(customerId);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Cancellation → scenario: cancel
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("rc-webhook: cancellation → scenario: cancel")}`, async () => {
const customerId = "rc-webhook-cancel";
const RC_PRO_MONTHLY_ID = "com.app.rcwh5_pro_monthly";
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const proMonthly = products.pro({
id: "pro-monthly",
items: [messagesItem],
});
await initScenario({
customerId,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [proMonthly] }),
],
actions: [],
});
await RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: proMonthly.id,
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
},
});
const rcClient = new RevenueCatWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
webhookSecret: RC_WEBHOOK_SECRET,
});
// First: initial purchase
await rcClient.initialPurchase({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh5_tx_001",
});
await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "new",
timeoutMs: 15000,
});
// Then: cancellation
await rcClient.cancellation({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh5_tx_001",
expirationAtMs: Date.now() + 1000 * 60 * 60 * 24 * 30,
});
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "cancel",
timeoutMs: 15000,
});
expect(result).not.toBeNull();
const { data } = result!.payload;
expect(data.scenario).toBe("cancel");
expect(data.updated_product.id).toBe(proMonthly.id);
expect(data.customer.id).toBe(customerId);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Uncancellation → scenario: renew
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("rc-webhook: uncancellation → scenario: renew")}`, async () => {
const customerId = "rc-webhook-uncancel";
const RC_PRO_MONTHLY_ID = "com.app.rcwh6_pro_monthly";
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const proMonthly = products.pro({
id: "pro-monthly",
items: [messagesItem],
});
await initScenario({
customerId,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [proMonthly] }),
],
actions: [],
});
await RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: proMonthly.id,
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
},
});
const rcClient = new RevenueCatWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
webhookSecret: RC_WEBHOOK_SECRET,
});
// Step 1: initial purchase
await rcClient.initialPurchase({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh6_tx_001",
});
await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "new",
timeoutMs: 15000,
});
// Step 2: cancellation
await rcClient.cancellation({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh6_tx_001",
expirationAtMs: Date.now() + 1000 * 60 * 60 * 24 * 30,
});
await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "cancel",
timeoutMs: 15000,
});
// Step 3: uncancellation
await rcClient.uncancellation({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
});
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "renew",
timeoutMs: 15000,
});
expect(result).not.toBeNull();
const { data } = result!.payload;
expect(data.scenario).toBe("renew");
expect(data.updated_product.id).toBe(proMonthly.id);
expect(data.customer.id).toBe(customerId);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7: Billing Issue → scenario: past_due
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("rc-webhook: billing issue → scenario: past_due")}`, async () => {
const customerId = "rc-webhook-billing-issue";
const RC_PRO_MONTHLY_ID = "com.app.rcwh7_pro_monthly";
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const proMonthly = products.pro({
id: "pro-monthly",
items: [messagesItem],
});
await initScenario({
customerId,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [proMonthly] }),
],
actions: [],
});
await RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: proMonthly.id,
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
},
});
const rcClient = new RevenueCatWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
webhookSecret: RC_WEBHOOK_SECRET,
});
// First: initial purchase
await rcClient.initialPurchase({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh7_tx_001",
});
await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "new",
timeoutMs: 15000,
});
// Then: billing issue
await rcClient.billingIssue({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh7_tx_001",
});
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "past_due",
timeoutMs: 15000,
});
expect(result).not.toBeNull();
const { data } = result!.payload;
expect(data.scenario).toBe("past_due");
expect(data.updated_product.id).toBe(proMonthly.id);
expect(data.customer.id).toBe(customerId);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 8: Expiration → scenario: expire
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("rc-webhook: expiration → scenario: expired")}`, async () => {
const customerId = "rc-webhook-expire";
const RC_PRO_MONTHLY_ID = "com.app.rcwh8_pro_monthly";
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const proMonthly = products.pro({
id: "pro-monthly",
items: [messagesItem],
});
await initScenario({
customerId,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [proMonthly] }),
],
actions: [],
});
await RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: proMonthly.id,
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
},
});
const rcClient = new RevenueCatWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
webhookSecret: RC_WEBHOOK_SECRET,
});
// First: initial purchase
await rcClient.initialPurchase({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh8_tx_001",
});
await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "new",
timeoutMs: 15000,
});
// Then: expiration
await rcClient.expiration({
productId: RC_PRO_MONTHLY_ID,
appUserId: customerId,
originalTransactionId: "rcwh8_tx_001",
});
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "expired",
timeoutMs: 15000,
});
expect(result).not.toBeNull();
const { data } = result!.payload;
expect(data.scenario).toBe("expired");
expect(data.updated_product.id).toBe(proMonthly.id);
expect(data.customer.id).toBe(customerId);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 9: Non-Renewing Purchase → scenario: new
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("rc-webhook: non-renewing purchase (add-on) → scenario: new")}`, async () => {
const customerId = "rc-webhook-non-renewing";
const RC_ADD_ON_ID = "com.app.rcwh9_add_on_pack";
const addOnPack = products.base({
id: "add-on",
items: [items.lifetimeMessages({ includedUsage: 100 })],
isAddOn: true,
});
await initScenario({
customerId,
setup: [
s.customer({ testClock: false, skipWebhooks: true }),
s.products({ list: [addOnPack] }),
],
actions: [],
});
await RCMappingService.upsert({
db: ctx.db,
data: {
org_id: ctx.org.id,
env: AppEnv.Sandbox,
autumn_product_id: addOnPack.id,
revenuecat_product_ids: [RC_ADD_ON_ID],
},
});
const rcClient = new RevenueCatWebhookClient({
orgId: ctx.org.id,
env: ctx.env,
webhookSecret: RC_WEBHOOK_SECRET,
});
await rcClient.nonRenewingPurchase({
productId: RC_ADD_ON_ID,
appUserId: customerId,
originalTransactionId: "rcwh9_addon_tx_001",
});
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "new",
timeoutMs: 15000,
});
expect(result).not.toBeNull();
const { data } = result!.payload;
expect(data.scenario).toBe("new");
expect(data.updated_product.id).toBe(addOnPack.id);
expect(data.customer.id).toBe(customerId);
});