Merge branch 'feat/revenuecat-addons' into fix/attach-revenuecat-bugs
This commit is contained in:
@@ -10,6 +10,7 @@ import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService";
|
||||
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
|
||||
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
import { getOrCreateCustomer } from "../../../internal/customers/cusUtils/getOrCreateCustomer";
|
||||
|
||||
@@ -69,8 +70,16 @@ export const resolveRevenuecatResources = async ({
|
||||
}),
|
||||
]);
|
||||
|
||||
// If the customer has a product from a different processor than RevenueCat and it has no subscriptions, throw an error
|
||||
// If the customer has a product from a different processor than RevenueCat and it has no subscriptions, throw an error.
|
||||
//
|
||||
// Exception: true one-off purchases (no recurring intervals) are safe to mix
|
||||
// across processors because they create a parallel cus_product without
|
||||
// replacing the customer's existing subscription. This lets a Stripe-subscribed
|
||||
// customer buy a one-off pack via RevenueCat (and vice versa).
|
||||
const incomingIsOneOff = pricesOnlyOneOff(product.prices);
|
||||
|
||||
if (
|
||||
!incomingIsOneOff &&
|
||||
customer.customer_products.some(
|
||||
(cp) =>
|
||||
cp.processor?.type !== ProcessorType.RevenueCat &&
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
AttachBranch,
|
||||
type AttachConfig,
|
||||
BillingType,
|
||||
cusProductToProcessorType,
|
||||
ErrCode,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
TierBehavior,
|
||||
type UsagePriceConfig,
|
||||
@@ -20,6 +18,7 @@ import {
|
||||
import { notNullish, nullOrUndefined } from "@/utils/genUtils.js";
|
||||
import type { AttachParams } from "../../cusProducts/AttachParams.js";
|
||||
import type { AttachFlags } from "../models/AttachFlags.js";
|
||||
import { handleExternalPSPErrors } from "./handleAttachErrors/handleExternalPSPErrors.js";
|
||||
import { handleMultiAttachErrors } from "./handleAttachErrors/handleMultiAttachErrors.js";
|
||||
|
||||
const handleNonCheckoutErrors = ({
|
||||
@@ -157,23 +156,6 @@ export const handleCustomPaymentMethodErrors = ({
|
||||
}
|
||||
};
|
||||
|
||||
export const handleExternalPSPErrors = ({
|
||||
attachParams,
|
||||
}: {
|
||||
attachParams: AttachParams;
|
||||
}) => {
|
||||
if (
|
||||
attachParams.customer.customer_products.some(
|
||||
(cp) => cusProductToProcessorType(cp) !== ProcessorType.Stripe,
|
||||
)
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handlePrepaidVolumeErrors = ({
|
||||
attachParams,
|
||||
}: {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { AttachBranch } from "@autumn/shared";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import {
|
||||
handleCustomPaymentMethodErrors,
|
||||
handleExternalPSPErrors,
|
||||
} from "../handleAttachErrors.js";
|
||||
import { handleCustomPaymentMethodErrors } from "../handleAttachErrors.js";
|
||||
import { handleExternalPSPErrors } from "./handleExternalPSPErrors.js";
|
||||
|
||||
export const handleCheckoutErrors = ({
|
||||
attachParams,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
cusProductToProcessorType,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
|
||||
export const handleExternalPSPErrors = ({
|
||||
attachParams,
|
||||
strict = false,
|
||||
}: {
|
||||
attachParams: AttachParams;
|
||||
/**
|
||||
* When true, never bypass the cross-processor guard. Use for MultiAttach
|
||||
* where the customer's whole subscription state could change.
|
||||
*/
|
||||
strict?: boolean;
|
||||
}) => {
|
||||
// Safe path: a single-product attach for a true one-off product can mix
|
||||
// across processors. One-offs create a parallel cus_product and never
|
||||
// replace an existing subscription, so a customer with an active RC sub
|
||||
// can still buy a Stripe-billed top-up (and vice versa).
|
||||
const oneOffEscape =
|
||||
!strict &&
|
||||
attachParams.products.length === 1 &&
|
||||
pricesOnlyOneOff(attachParams.prices);
|
||||
|
||||
if (oneOffEscape) return;
|
||||
|
||||
if (
|
||||
attachParams.customer.customer_products.some(
|
||||
(cp) => cusProductToProcessorType(cp) !== ProcessorType.Stripe,
|
||||
)
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@autumn/shared";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { handleExternalPSPErrors } from "./handleExternalPSPErrors.js";
|
||||
|
||||
export const handleMultiAttachErrors = async ({
|
||||
attachParams,
|
||||
@@ -20,6 +21,10 @@ export const handleMultiAttachErrors = async ({
|
||||
}) => {
|
||||
const { products, prices, productsList } = attachParams;
|
||||
|
||||
// MultiAttach must stay fully blocked for cross-processor customers — even
|
||||
// for one-off products, because the batch may include recurring main products.
|
||||
handleExternalPSPErrors({ attachParams, strict: true });
|
||||
|
||||
const usagePrice = prices.find((p: Price) => isUsagePrice({ price: p }));
|
||||
|
||||
// 1. Don't support usage prices just yet...
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* TDD: Allow one-off purchases across processors when the other processor
|
||||
* already manages an active subscription for the customer.
|
||||
*
|
||||
* Today we strictly block any cross-processor activity to avoid edge cases
|
||||
* with mixed subscriptions. One-offs are safe because they don't replace the
|
||||
* existing subscription — they create a parallel cus_product.
|
||||
*
|
||||
* Red-failure mode (current behavior):
|
||||
* Test 1 — Stripe sub + RC one-off top-up:
|
||||
* resolveRevenuecatResources() throws "Customer already has a product from
|
||||
* a different processor than RevenueCat." → webhook returns 500.
|
||||
*
|
||||
* Test 2 — RC sub + Stripe one-off top-up:
|
||||
* handleExternalPSPErrors() throws "This customer is billed outside of
|
||||
* Stripe..." on autumnV1.attach().
|
||||
*
|
||||
* Test 3 — Negative guards:
|
||||
* Recurring product cross-processor must STILL be rejected after the fix.
|
||||
*
|
||||
* Green-success criteria (after fix):
|
||||
* Tests 1 & 2 succeed; both products end up active on the customer.
|
||||
* Test 3 sub-cases continue to throw the cross-processor error.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { AppEnv, customers } from "@autumn/shared";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService";
|
||||
import { OrgService } from "@/internal/orgs/OrgService";
|
||||
import { encryptData } from "@/utils/encryptUtils";
|
||||
import {
|
||||
expectWebhookSuccess,
|
||||
RevenueCatWebhookClient,
|
||||
} from "./utils/revenue-cat-webhook-client";
|
||||
|
||||
const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_xproc";
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Stripe sub + RC one-off top-up
|
||||
//
|
||||
// Customer has an active Stripe subscription (proMonthly attached via Stripe).
|
||||
// They make a one-off in-app top-up via RevenueCat.
|
||||
// Expected (after fix): RC webhook succeeds; both products are active.
|
||||
// Currently (red): resolver guard rejects → 500 from webhook.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("revenuecat cross-processor 1: stripe sub + rc one-off top-up")}`,
|
||||
async () => {
|
||||
const customerId = "rc-xproc-1";
|
||||
|
||||
// RevenueCat product ID for the in-app top-up
|
||||
const RC_TOP_UP_ID = "com.app.rc_xproc_top_up_pack";
|
||||
|
||||
// Autumn products
|
||||
const proMonthly = products.pro({
|
||||
id: "rc-xproc-pro-monthly",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
// True one-off add-on: pricesOnlyOneOff(prices) === true
|
||||
const topUpPack = products.oneOff({
|
||||
id: "rc-xproc-top-up-pack",
|
||||
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||
isAddOn: true,
|
||||
});
|
||||
|
||||
// Setup org with RevenueCat config
|
||||
await setupRevenueCatOrg();
|
||||
|
||||
// Initialize scenario: customer with payment method, Stripe attaches proMonthly
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proMonthly, topUpPack] }),
|
||||
],
|
||||
actions: [s.attach({ productId: proMonthly.id })],
|
||||
});
|
||||
|
||||
// Map RC product → Autumn one-off top-up product
|
||||
await RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: topUpPack.id,
|
||||
revenuecat_product_ids: [RC_TOP_UP_ID],
|
||||
},
|
||||
});
|
||||
|
||||
const rcClient = new RevenueCatWebhookClient({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
webhookSecret: RC_WEBHOOK_SECRET,
|
||||
});
|
||||
|
||||
// Sanity check: customer exists and has the Stripe sub
|
||||
const dbCustomer = await ctx.db.query.customers.findFirst({
|
||||
where: eq(customers.id, customerId),
|
||||
});
|
||||
expect(dbCustomer).toBeDefined();
|
||||
|
||||
// Action: RC fires NON_RENEWING_PURCHASE for the mapped one-off product
|
||||
const result = await rcClient.nonRenewingPurchase({
|
||||
productId: RC_TOP_UP_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "rc_xproc_1_topup_tx_001",
|
||||
});
|
||||
|
||||
// PRIMARY ASSERTION (red here): webhook should succeed
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
// State assertion: customer now has both products active
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer.products).toHaveLength(2);
|
||||
const productIds = customer.products
|
||||
.map((p: { id: string }) => p.id)
|
||||
.sort();
|
||||
expect(productIds).toEqual([proMonthly.id, topUpPack.id].sort());
|
||||
},
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: RC sub + Stripe one-off top-up
|
||||
//
|
||||
// Customer has an active RevenueCat subscription (proMonthly via RC webhook).
|
||||
// They buy a one-off pack via Stripe (autumnV1.attach).
|
||||
// Expected (after fix): attach succeeds; both products are active.
|
||||
// Currently (red): handleExternalPSPErrors throws.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("revenuecat cross-processor 2: rc sub + stripe one-off top-up")}`,
|
||||
async () => {
|
||||
const customerId = "rc-xproc-2";
|
||||
|
||||
const RC_PRO_MONTHLY_ID = "com.app.rc_xproc_pro_monthly";
|
||||
|
||||
// RC-managed recurring product
|
||||
const rcProMonthly = products.pro({
|
||||
id: "rc-xproc-2-pro-monthly",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
// Stripe-side true one-off add-on
|
||||
const webTopUp = products.oneOff({
|
||||
id: "rc-xproc-2-web-top-up",
|
||||
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||
isAddOn: true,
|
||||
});
|
||||
|
||||
await setupRevenueCatOrg();
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [rcProMonthly, webTopUp] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: rcProMonthly.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: RC initial purchase puts customer on the RC-managed subscription
|
||||
const rcResult = await rcClient.initialPurchase({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "rc_xproc_2_tx_001",
|
||||
});
|
||||
expectWebhookSuccess(rcResult);
|
||||
|
||||
// Confirm pre-state: 1 product active (RC sub)
|
||||
let customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(rcProMonthly.id);
|
||||
|
||||
// PRIMARY ACTION (red here): attach the Stripe one-off top-up
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: webTopUp.id,
|
||||
});
|
||||
|
||||
customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer.products).toHaveLength(2);
|
||||
const productIds = customer.products
|
||||
.map((p: { id: string }) => p.id)
|
||||
.sort();
|
||||
expect(productIds).toEqual([rcProMonthly.id, webTopUp.id].sort());
|
||||
},
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Negative guards — recurring cross-processor must STILL be blocked
|
||||
//
|
||||
// Sub-case A: Stripe-subscribed customer → RC INITIAL_PURCHASE for a recurring
|
||||
// RC-mapped Autumn product. Webhook must fail (non-200).
|
||||
// Sub-case B: RC-subscribed customer → autumnV1.attach of a recurring Stripe
|
||||
// product. Attach must throw the cross-processor error.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("revenuecat cross-processor 3: negative guards (recurring still blocked)")}`,
|
||||
async () => {
|
||||
// ─── Sub-case A ────────────────────────────────────────────────────────────
|
||||
const customerIdA = "rc-xproc-3a";
|
||||
const RC_RECURRING_ID = "com.app.rc_xproc_3a_recurring";
|
||||
|
||||
const stripeProMonthly = products.pro({
|
||||
id: "rc-xproc-3a-stripe-pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
const rcRecurring = products.pro({
|
||||
id: "rc-xproc-3a-rc-recurring",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
await setupRevenueCatOrg();
|
||||
|
||||
await initScenario({
|
||||
customerId: customerIdA,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [stripeProMonthly, rcRecurring] }),
|
||||
],
|
||||
actions: [s.attach({ productId: stripeProMonthly.id })],
|
||||
});
|
||||
|
||||
await RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: rcRecurring.id,
|
||||
revenuecat_product_ids: [RC_RECURRING_ID],
|
||||
},
|
||||
});
|
||||
|
||||
const rcClient = new RevenueCatWebhookClient({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
webhookSecret: RC_WEBHOOK_SECRET,
|
||||
});
|
||||
|
||||
// RC tries to start a recurring sub on a Stripe-subscribed customer → must fail
|
||||
const recurringResult = await rcClient.initialPurchase({
|
||||
productId: RC_RECURRING_ID,
|
||||
appUserId: customerIdA,
|
||||
originalTransactionId: "rc_xproc_3a_tx_001",
|
||||
});
|
||||
expect(recurringResult.response.status).not.toBe(200);
|
||||
|
||||
// ─── Sub-case B ────────────────────────────────────────────────────────────
|
||||
const customerIdB = "rc-xproc-3b";
|
||||
const RC_PRO_ID_B = "com.app.rc_xproc_3b_pro";
|
||||
|
||||
const rcProB = products.pro({
|
||||
id: "rc-xproc-3b-rc-pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
// Recurring add-on (NOT a one-off): cross-processor attach should still throw
|
||||
const stripeRecurringAddOn = products.recurringAddOn({
|
||||
id: "rc-xproc-3b-recurring-addon",
|
||||
items: [items.monthlyMessages({ includedUsage: 50 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId: customerIdB,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [rcProB, stripeRecurringAddOn] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: rcProB.id,
|
||||
revenuecat_product_ids: [RC_PRO_ID_B],
|
||||
},
|
||||
});
|
||||
|
||||
const rcResult = await rcClient.initialPurchase({
|
||||
productId: RC_PRO_ID_B,
|
||||
appUserId: customerIdB,
|
||||
originalTransactionId: "rc_xproc_3b_tx_001",
|
||||
});
|
||||
expectWebhookSuccess(rcResult);
|
||||
|
||||
// Now attempt to attach a recurring Stripe add-on → must throw
|
||||
await expect(
|
||||
autumnV1.attach({
|
||||
customer_id: customerIdB,
|
||||
product_id: stripeRecurringAddOn.id,
|
||||
}),
|
||||
).rejects.toThrow(/Stripe|RevenueCat|external|managed/i);
|
||||
},
|
||||
);
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
AppEnv,
|
||||
CusProductStatus,
|
||||
customers,
|
||||
revenuecatMappings,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, arrayOverlaps, eq } from "drizzle-orm";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
@@ -176,13 +177,50 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => {
|
||||
webhookSecret: RC_WEBHOOK_SECRET,
|
||||
});
|
||||
|
||||
// 2-4. Create products, mappings, and customer concurrently
|
||||
// IMPORTANT: clean up stale RC mappings before re-upserting.
|
||||
//
|
||||
// `addPrefixToProducts` was changed in Jan from prefix-style
|
||||
// (`${prefix}_${id}`) to suffix-style (`${id}_${prefix}`). Old runs left
|
||||
// rows in `revenuecat_mappings` keyed by the prefix-style autumn_product_id
|
||||
// (e.g. `rc1_rc1-pro-monthly`). The mapping table PK includes
|
||||
// `autumn_product_id`, so a fresh upsert under the new ID does NOT
|
||||
// overwrite the stale row. The resolver's `arrayContains` lookup can then
|
||||
// return the stale row, causing `ProductNotFoundError`.
|
||||
//
|
||||
// Clear by `revenuecat_product_ids` overlap so the cleanup is independent
|
||||
// of whatever stale autumn_product_id format was used previously.
|
||||
await ctx.db
|
||||
.delete(revenuecatMappings)
|
||||
.where(
|
||||
and(
|
||||
eq(revenuecatMappings.org_id, ctx.org.id),
|
||||
eq(revenuecatMappings.env, AppEnv.Sandbox),
|
||||
arrayOverlaps(revenuecatMappings.revenuecat_product_ids, [
|
||||
RC_PRO_MONTHLY_ID,
|
||||
RC_PRO_YEARLY_ID,
|
||||
RC_ADD_ON_ID,
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// Create products + customer concurrently. Products must finish before
|
||||
// we read their (post-prefix) IDs for the mappings below.
|
||||
await Promise.all([
|
||||
initProductsV0({
|
||||
ctx,
|
||||
products: [proMonthly, proYearly, addOnPack],
|
||||
prefix: testCase,
|
||||
}),
|
||||
initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Now that initProductsV0 has mutated the product IDs (suffix-style),
|
||||
// upsert mappings with the final, correct autumn_product_id values.
|
||||
await Promise.all([
|
||||
RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
@@ -210,11 +248,6 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => {
|
||||
revenuecat_product_ids: [RC_PRO_YEARLY_ID],
|
||||
},
|
||||
}),
|
||||
initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const dbCustomer = await ctx.db.query.customers.findFirst({
|
||||
|
||||
Reference in New Issue
Block a user