From 9cf9e263ac388a6b3a9636d0679c5afff52afaf0 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 17 Apr 2025 11:44:59 +0100 Subject: [PATCH] update: referral rewards don't trigger on free trials --- example/src/app/referrals/functions.tsx | 1 - example/src/app/referrals/page.tsx | 13 +- server/src/external/autumn/autumnCli.ts | 2 +- server/src/external/stripe/stripeWebhooks.ts | 1 + .../handleCheckoutCompleted.ts | 25 ++++ .../webhookHandlers/handleInvoicePaid.ts | 42 ++++-- .../add-product/createFullCusProduct.ts | 18 +-- .../customers/products/cusProductUtils.ts | 22 +++ server/src/internal/prices/PriceService.ts | 63 ++++++++- .../product-items/productItemInitUtils.ts | 43 +++++- .../internal/rewards/triggerCheckoutReward.ts | 21 ++- server/src/queue/queue.ts | 16 ++- server/tests/basic/referrals/referrals1.ts | 4 +- server/tests/basic/referrals/referrals3.ts | 4 +- server/tests/basic/referrals/referrals4.ts | 125 ++++++++++++++++++ .../reward-programs/RewardProgramsTable.tsx | 4 +- 16 files changed, 360 insertions(+), 44 deletions(-) create mode 100644 server/tests/basic/referrals/referrals4.ts diff --git a/example/src/app/referrals/functions.tsx b/example/src/app/referrals/functions.tsx index fd6fe3953..3db280e34 100644 --- a/example/src/app/referrals/functions.tsx +++ b/example/src/app/referrals/functions.tsx @@ -3,7 +3,6 @@ import { Autumn } from "@/sdk/autumn"; export const getReferralCode = async (customerId: string) => { - console.log("Getting referral code"); const autumn = new Autumn(); const referralCode = await autumn.referrals.createCode({ customerId, diff --git a/example/src/app/referrals/page.tsx b/example/src/app/referrals/page.tsx index de8cf1253..7e56ff93b 100644 --- a/example/src/app/referrals/page.tsx +++ b/example/src/app/referrals/page.tsx @@ -53,15 +53,11 @@ const useCustomer = (customerId: string) => { export default function ReferralsPage() { const { referralCode, isLoading } = useReferralCode("ayush"); - const { - entitlements, - refresh, - isLoading: isCustomerLoading, - } = useCustomer("ayush"); + const { entitlements } = useCustomer("ayush"); - let referrerId = "ayush"; - let referee1Id = "john"; - let [referral1Code, setReferral1Code] = useState(""); + const referrerId = "ayush"; + const referee1Id = "john"; + const [referral1Code, setReferral1Code] = useState(""); return (
@@ -156,6 +152,7 @@ export default function ReferralsPage() { toast.error("Something went wrong"); } } catch (error) { + console.log("Failed to redeem code", error); toast.error("Failed to redeem code"); } }} diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 0df00700e..40b66d4c4 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -329,7 +329,7 @@ export class Autumn { }; redemptions = { - get: async ({ redemptionId }: { redemptionId: string }) => { + get: async (redemptionId: string) => { const data = await this.get(`/redemptions/${redemptionId}`); return data; }, diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index 8840e0afd..62c81b8f7 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -111,6 +111,7 @@ stripeWebhookRouter.post( checkoutSession, org, env, + logger, }); break; diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index ca358cfed..ea7ad4691 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -29,6 +29,8 @@ import { createStripeSub } from "../stripeSubUtils/createStripeSub.js"; import { getAlignedIntervalUnix } from "@/internal/prices/billingIntervalUtils.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; import { generateId } from "@/utils/genUtils.js"; +import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; export const itemMetasToOptions = async ({ checkoutSession, @@ -100,11 +102,13 @@ export const handleCheckoutSessionCompleted = async ({ org, checkoutSession, env, + logger, }: { sb: SupabaseClient; org: Organization; checkoutSession: Stripe.Checkout.Session; env: AppEnv; + logger: any; }) => { const metadata = await getMetadataFromCheckoutSession(checkoutSession, sb); if (!metadata) { @@ -287,6 +291,27 @@ export const handleCheckoutSessionCompleted = async ({ } } + for (const product of attachParams.products) { + try { + console.log("Triggering checkout reward check for product: ", product.id); + await addTaskToQueue({ + jobName: JobName.TriggerCheckoutReward, + payload: { + customer: attachParams.customer, + product, + org, + env: attachParams.customer.env, + subId: checkoutSession.subscription as string, + }, + }); + } catch (error) { + logger.error( + `checkout.completed: failed to trigger checkout reward check` + ); + logger.error(error); + } + } + return; }; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts index 508587193..304042868 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts @@ -14,13 +14,15 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { createStripeCli } from "../utils.js"; import { RewardService } from "@/internal/rewards/RewardService.js"; import { Decimal } from "decimal.js"; -import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; +import { generateId, nullish } from "@/utils/genUtils.js"; import { getInvoiceDiscounts, getStripeExpandedInvoice, updateInvoiceIfExists, } from "../stripeInvoiceUtils.js"; import { getStripeSubs } from "../stripeSubUtils.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { JobName } from "@/queue/JobName.js"; const handleOneOffInvoicePaid = async ({ sb, @@ -209,18 +211,34 @@ export const handleInvoicePaid = async ({ invoice, }); - if (updated) { - return; + if (!updated) { + await InvoiceService.createInvoiceFromStripe({ + sb, + stripeInvoice: expandedInvoice, + internalCustomerId: activeCusProducts[0].internal_customer_id, + productIds: activeCusProducts.map((p) => p.product_id), + internalProductIds: activeCusProducts.map((p) => p.internal_product_id), + org: org, + }); } - await InvoiceService.createInvoiceFromStripe({ - sb, - stripeInvoice: expandedInvoice, - internalCustomerId: activeCusProducts[0].internal_customer_id, - productIds: activeCusProducts.map((p) => p.product_id), - internalProductIds: activeCusProducts.map((p) => p.internal_product_id), - org: org, - }); + for (const cusProd of activeCusProducts) { + try { + await addTaskToQueue({ + jobName: JobName.TriggerCheckoutReward, + payload: { + customer: cusProd.customer, + product: cusProd.product, + org, + env: cusProd.customer.env, + subId: cusProd.subscription_ids?.[0], + }, + }); + } catch (error) { + logger.error(`invoice.paid: failed to trigger checkout reward check`); + logger.error(error); + } + } } else { await handleOneOffInvoicePaid({ sb, @@ -229,8 +247,6 @@ export const handleInvoicePaid = async ({ logger: req.logger, }); } - - // Else, handle one-off invoice }; const handleInvoicePaidDiscount = async ({ diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 429223746..18964e830 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -434,15 +434,15 @@ export const createFullCusProduct = async ({ cusPrices, }); - await addTaskToQueue({ - jobName: JobName.TriggerCheckoutReward, - payload: { - customer, - product, - org, - env: customer.env, - }, - }); + // await addTaskToQueue({ + // jobName: JobName.TriggerCheckoutReward, + // payload: { + // customer, + // product, + // org, + // env: customer.env, + // }, + // }); return { ...cusProd, diff --git a/server/src/internal/customers/products/cusProductUtils.ts b/server/src/internal/customers/products/cusProductUtils.ts index 7d0285d17..172de954b 100644 --- a/server/src/internal/customers/products/cusProductUtils.ts +++ b/server/src/internal/customers/products/cusProductUtils.ts @@ -27,6 +27,7 @@ import { sortCusEntsForDeduction } from "../entitlements/cusEntUtils.js"; import { getRelatedCusEnt } from "../prices/cusPriceUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import { BREAK_API_VERSION } from "@/utils/constants.js"; +import { CusService } from "../CusService.js"; // 1. Delete future product export const uncancelCurrentProduct = async ({ @@ -461,3 +462,24 @@ export const searchCusProducts = ({ export const isTrialing = (cusProduct: FullCusProduct) => { return cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now(); }; + +export const getMainCusProduct = async ({ + sb, + internalCustomerId, +}: { + sb: SupabaseClient; + internalCustomerId: string; +}) => { + let cusProducts = await CusService.getFullCusProducts({ + sb, + internalCustomerId, + withPrices: true, + withProduct: true, + }); + + let mainCusProduct = cusProducts.find( + (cusProduct: FullCusProduct) => !cusProduct.product.is_add_on + ); + + return mainCusProduct; +}; diff --git a/server/src/internal/prices/PriceService.ts b/server/src/internal/prices/PriceService.ts index 6c27705f1..15fdab8e4 100644 --- a/server/src/internal/prices/PriceService.ts +++ b/server/src/internal/prices/PriceService.ts @@ -5,6 +5,63 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { StatusCodes } from "http-status-codes"; export class PriceService { + static async getInIds({ + sb, + entitlementIds, + }: { + sb: SupabaseClient; + entitlementIds: string[]; + }) { + const { data, error } = await sb + .from("prices") + .select("*") + .in("entitlement_id", entitlementIds); + + if (error) { + throw error; + } + + return data; + } + + static async getByEntitlementId({ + sb, + entitlementId, + }: { + sb: SupabaseClient; + entitlementId: string; + }) { + const { data, error } = await sb + .from("prices") + .select("*") + .eq("entitlement_id", entitlementId); + + if (error) { + throw error; + } + + return data; + } + + static async getById({ + sb, + priceId, + }: { + sb: SupabaseClient; + priceId: string; + }) { + const { data, error } = await sb + .from("prices") + .select("*") + .eq("id", priceId) + .single(); + + if (error) { + throw error; + } + + return data; + } static async getByOrg({ sb, orgId, @@ -14,7 +71,11 @@ export class PriceService { orgId: string; env: string; }) { - const { data, error } = await sb.from("prices").select("*, product:products!inner(*)").eq("product.org_id", orgId).eq("product.env", env); + const { data, error } = await sb + .from("prices") + .select("*, product:products!inner(*)") + .eq("product.org_id", orgId) + .eq("product.env", env); if (error) { throw error; diff --git a/server/src/internal/products/product-items/productItemInitUtils.ts b/server/src/internal/products/product-items/productItemInitUtils.ts index 3b0d760b8..5c3340b23 100644 --- a/server/src/internal/products/product-items/productItemInitUtils.ts +++ b/server/src/internal/products/product-items/productItemInitUtils.ts @@ -65,10 +65,49 @@ const updateDbPricesAndEnts = async ({ }), ]); - await EntitlementService.deleteByIds({ + // Check if any custom prices use this entitlement... + let deletedEntIds = deletedEnts.map((ent) => ent.id!); + let customPrices = await PriceService.getInIds({ sb, - entitlementIds: deletedEnts.map((ent) => ent.id!), + entitlementIds: deletedEntIds, }); + + if (customPrices.length == 0) { + // Update the entitlement to be custom... + await EntitlementService.deleteByIds({ + sb, + entitlementIds: deletedEntIds, + }); + } else { + let updateOrDelete: any = []; + for (const ent of deletedEnts) { + let hasCustomPrice = customPrices.some( + (price) => price.entitlement_id == ent.id + ); + + console.log("hasCustomPrice: ", hasCustomPrice); + if (hasCustomPrice) { + updateOrDelete.push( + EntitlementService.update({ + sb, + entitlementId: ent.id!, + updates: { + is_custom: true, + }, + }) + ); + } else { + updateOrDelete.push( + EntitlementService.deleteByIds({ + sb, + entitlementIds: [ent.id!], + }) + ); + } + } + + await Promise.all(updateOrDelete); + } }; const handleCustomProductItems = async ({ diff --git a/server/src/internal/rewards/triggerCheckoutReward.ts b/server/src/internal/rewards/triggerCheckoutReward.ts index f95566804..d6b86d0e6 100644 --- a/server/src/internal/rewards/triggerCheckoutReward.ts +++ b/server/src/internal/rewards/triggerCheckoutReward.ts @@ -3,6 +3,7 @@ import { RewardCategory, RewardTriggerEvent } from "@autumn/shared"; import { triggerFreeProduct, triggerRedemption } from "./referralUtils.js"; import { RewardProgramService } from "../rewards/RewardProgramService.js"; import { getRewardCat } from "./rewardUtils.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; export const runTriggerCheckoutReward = async ({ sb, payload, @@ -14,7 +15,12 @@ export const runTriggerCheckoutReward = async ({ }) => { try { // Customer redeeming code, product they're buying - let { customer, product, org, env } = payload; + let { customer, product, org, env, subId } = payload; + + let stripeCli = createStripeCli({ + org, + env, + }); // 1. Check if redemption exists let redemptions = await RewardRedemptionService.getByCustomer({ @@ -51,6 +57,19 @@ export const runTriggerCheckoutReward = async ({ return; } + // Check for trial + let hasTrial = false; + if (subId) { + let sub = await stripeCli.subscriptions.retrieve(subId); + // hasTrial = Boolean(sub.trial_end && sub.trial_end > Date.now()); + hasTrial = sub.status === "trialing"; + } + + if (hasTrial) { + logger.info(`Subscription is on trial, not triggering reward`); + return; + } + // Get redemption count let redemptionCount = await RewardProgramService.getCodeRedemptionCount({ sb, diff --git a/server/src/queue/queue.ts b/server/src/queue/queue.ts index 5064abc2c..9a07a6f06 100644 --- a/server/src/queue/queue.ts +++ b/server/src/queue/queue.ts @@ -84,7 +84,21 @@ const initWorker = ({ return; } + // TRIGGER CHECKOUT REWARD if (job.name == JobName.TriggerCheckoutReward) { + if ( + !(await acquireLock({ + customerId: `reward_trigger:${job.data.customer?.internal_id}`, + timeout: 10000, + useBackup, + })) + ) { + await queue.add(job.name, job.data, { + delay: 1000, + }); + return; + } + await runTriggerCheckoutReward({ payload: job.data, sb, @@ -94,7 +108,7 @@ const initWorker = ({ return; } - const { customerId } = job.data; + const { customerId } = job.data; // customerId is internal customer id while (!(await acquireLock({ customerId, timeout: 10000, useBackup }))) { await queue.add(job.name, job.data, { diff --git a/server/tests/basic/referrals/referrals1.ts b/server/tests/basic/referrals/referrals1.ts index 862a9620e..fc395eba6 100644 --- a/server/tests/basic/referrals/referrals1.ts +++ b/server/tests/basic/referrals/referrals1.ts @@ -162,9 +162,7 @@ describe(`${chalk.yellowBright( await timeout(3000); // Get redemption object - let redemption = await autumn.redemptions.get({ - redemptionId: redemptions[i].id, - }); + let redemption = await autumn.redemptions.get(redemptions[i].id); // Check if redemption is triggered let count = i + 1; diff --git a/server/tests/basic/referrals/referrals3.ts b/server/tests/basic/referrals/referrals3.ts index 90ed3b18b..dfcdab4cf 100644 --- a/server/tests/basic/referrals/referrals3.ts +++ b/server/tests/basic/referrals/referrals3.ts @@ -113,9 +113,7 @@ describe(`${chalk.yellowBright( await timeout(3000); // Get redemption object - let redemption = await autumn.redemptions.get({ - redemptionId: redemptions[i].id, - }); + let redemption = await autumn.redemptions.get(redemptions[i].id); // Check if redemption is triggered let count = i + 1; diff --git a/server/tests/basic/referrals/referrals4.ts b/server/tests/basic/referrals/referrals4.ts new file mode 100644 index 000000000..d6a5b165d --- /dev/null +++ b/server/tests/basic/referrals/referrals4.ts @@ -0,0 +1,125 @@ +import { features, products, referralPrograms } from "../../global.js"; +import { assert } from "chai"; +import chalk from "chalk"; +import AutumnError, { Autumn } from "@/external/autumn/autumnCli.js"; +import { setupBefore } from "tests/before.js"; +import { + Customer, + ErrCode, + ReferralCode, + RewardRedemption, +} from "@autumn/shared"; +import { timeout } from "tests/utils/genUtils.js"; +import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js"; +import { Stripe } from "stripe"; +import { initCustomer } from "tests/utils/init.js"; +import { compareProductEntitlements } from "tests/utils/compare.js"; +import { addDays, addHours } from "date-fns"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; + +// UNCOMMENT FROM HERE +describe(`${chalk.yellowBright( + "referrals4: Testing free product referrals with trial" +)}`, () => { + let mainCustomerId = "main-referral-4"; + // let redeemers = ["referral4-r1", "referral4-r2"]; + let redeemerId = "referral4-r1"; + + let autumn: Autumn; + let stripeCli: Stripe; + let referralCode: ReferralCode; + + let redemptions: RewardRedemption[] = []; + let mainCustomer: Customer; + let redeemer: Customer; + + let testClockId: string; + before(async function () { + await setupBefore(this); + autumn = this.autumn; + stripeCli = this.stripeCli; + + await initCustomer({ + customerId: mainCustomerId, + sb: this.sb, + org: this.org, + env: this.env, + attachPm: true, + }); + + await autumn.attach({ + customerId: mainCustomerId, + productId: products.proWithTrial.id, + }); + + let { testClockId: testClockId1, customer } = + await initCustomerWithTestClock({ + customerId: redeemerId, + sb: this.sb, + org: this.org, + env: this.env, + }); + + testClockId = testClockId1; + redeemer = customer; + }); + + it("should create referral code", async function () { + referralCode = await autumn.referrals.createCode({ + customerId: mainCustomerId, + referralId: referralPrograms.freeProduct.id, + }); + + assert.exists(referralCode.code); + }); + + it("should create redemption for each redeemer and fail if redeemed again", async function () { + let redemption: RewardRedemption = await autumn.referrals.redeem({ + customerId: redeemerId, + code: referralCode.code, + }); + + redemptions.push(redemption); + }); + + it("should not be triggered because of trial", async function () { + await autumn.attach({ + customerId: redeemerId, + productId: products.proWithTrial.id, + }); + + await timeout(3000); + + // Get redemption object + let redemption = await autumn.redemptions.get(redemptions[0].id); + + assert.equal(redemption.triggered, false); + }); + + it("should be triggered after trial ends", async function () { + let advanceTo = addHours(addDays(new Date(), 7), 2).getTime(); + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo, + }); + + let redemption = await autumn.redemptions.get(redemptions[0].id); + + assert.equal(redemption.triggered, true); + + compareProductEntitlements({ + customerId: mainCustomerId, + product: products.freeAddOn, + features, + quantity: 1, + }); + + compareProductEntitlements({ + customerId: redeemerId, + product: products.freeAddOn, + features, + quantity: 1, + }); + }); +}); diff --git a/vite/src/views/products/reward-programs/RewardProgramsTable.tsx b/vite/src/views/products/reward-programs/RewardProgramsTable.tsx index d6f3d5a64..1f74b5d2c 100644 --- a/vite/src/views/products/reward-programs/RewardProgramsTable.tsx +++ b/vite/src/views/products/reward-programs/RewardProgramsTable.tsx @@ -43,7 +43,9 @@ export const RewardProgramsTable = () => { }} > - + {rewardProgram.id}