feat: 🎸 send all webhooks in all paths
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user