[web] purchase/analytics: log correct amount only once

This commit is contained in:
Yangshun 2025-04-25 19:18:03 +08:00
parent 5889a207ca
commit e36ee4b699
11 changed files with 199 additions and 118 deletions

View File

@ -1,7 +1,5 @@
import { cookies } from 'next/headers';
import type { Metadata } from 'next/types';
import fetchInterviewsPricingPlanPaymentConfigLocalizedRecord from '~/components/interviews/purchase/fetchInterviewsPricingPlanPaymentConfigLocalizedRecord';
import InterviewsPaymentSuccessPage from '~/components/interviews/purchase/InterviewsPaymentSuccessPage';
import emailsClearCheckoutRedis from '~/emails/items/checkout/EmailsCheckoutUtils';
@ -32,19 +30,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
export default async function Page() {
const cookieStore = cookies();
const countryCode: string = cookieStore.get('country')?.value ?? 'US';
const [plansPaymentConfig, viewer] = await Promise.all([
fetchInterviewsPricingPlanPaymentConfigLocalizedRecord(countryCode),
readViewerFromToken(),
]);
const viewer = await readViewerFromToken();
if (viewer) {
// Clear checkout email redis data on payment success
emailsClearCheckoutRedis({ userId: viewer.id });
await emailsClearCheckoutRedis({ userId: viewer.id });
}
return (
<InterviewsPaymentSuccessPage plansPaymentConfig={plansPaymentConfig} />
);
return <InterviewsPaymentSuccessPage />;
}

View File

@ -5,18 +5,11 @@ import { FormattedMessage } from 'react-intl';
import { SocialLinks } from '~/data/SocialLinks';
import type { ProjectsPricingPlanPaymentConfigLocalizedRecord } from '~/components/projects/purchase/ProjectsPricingPlans';
import ProjectsPurchaseSuccessLogging from '~/components/projects/purchase/ProjectsPurchaseSuccessLogging';
import PurchasePaymentSuccessSection from '~/components/purchase/PurchasePaymentSuccessSection';
import Container from '~/components/ui/Container';
type Props = Readonly<{
plansPaymentConfig: ProjectsPricingPlanPaymentConfigLocalizedRecord;
}>;
export default function ProjectsPaymentSuccessPage({
plansPaymentConfig,
}: Props): JSX.Element {
export default function ProjectsPaymentSuccessPage() {
const actions = [
{
description: (
@ -77,7 +70,7 @@ export default function ProjectsPaymentSuccessPage({
return (
<Container className="py-16" width="2xl">
<ProjectsPurchaseSuccessLogging plansPaymentConfig={plansPaymentConfig} />
<ProjectsPurchaseSuccessLogging />
<PurchasePaymentSuccessSection
actions={actions}
title={

View File

@ -1,8 +1,5 @@
import { cookies } from 'next/headers';
import type { Metadata } from 'next/types';
import fetchProjectsPricingPlanPaymentConfigLocalizedRecord from '~/components/projects/purchase/fetchProjectsPricingPlanPaymentConfigLocalizedRecord';
import { getIntlServerOnly } from '~/i18n';
import defaultProjectsMetadata from '~/seo/defaultProjectsMetadata';
@ -37,10 +34,5 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
export default async function Page() {
const cookieStore = cookies();
const countryCode: string = cookieStore.get('country')?.value ?? 'US';
const plansPaymentConfig =
await fetchProjectsPricingPlanPaymentConfigLocalizedRecord(countryCode);
return <ProjectsPaymentSuccessPage plansPaymentConfig={plansPaymentConfig} />;
return <ProjectsPaymentSuccessPage />;
}

View File

@ -5,20 +5,13 @@ import { FormattedMessage } from 'react-intl';
import { SocialLinks } from '~/data/SocialLinks';
import type { InterviewsPricingPlanPaymentConfigLocalizedRecord } from '~/components/interviews/purchase/InterviewsPricingPlans';
import PromotionsInterviewsPremiumPerksProjectDiscountSection from '~/components/promotions/perks/PromotionsInterviewsPremiumPerksProjectDiscountSection';
import PurchasePaymentSuccessSection from '~/components/purchase/PurchasePaymentSuccessSection';
import Container from '~/components/ui/Container';
import InterviewsPurchaseSuccessLogging from './InterviewsPurchaseSuccessLogging';
type Props = Readonly<{
plansPaymentConfig: InterviewsPricingPlanPaymentConfigLocalizedRecord;
}>;
export default function InterviewsPaymentSuccessPage({
plansPaymentConfig,
}: Props): JSX.Element {
export default function InterviewsPaymentSuccessPage() {
const actions = [
{
description: (
@ -84,9 +77,7 @@ export default function InterviewsPaymentSuccessPage({
return (
<Container className="py-16" width="2xl">
<InterviewsPurchaseSuccessLogging
plansPaymentConfig={plansPaymentConfig}
/>
<InterviewsPurchaseSuccessLogging />
<PurchasePaymentSuccessSection
actions={actions}
crossSellSection={

View File

@ -1,52 +1,64 @@
import { useSearchParams } from 'next/navigation';
import { Suspense, useEffect } from 'react';
import { trpc } from '~/hooks/trpc';
import { purchaseSuccessLogging } from '~/components/purchase/PurchaseLogging';
import type {
InterviewsPricingPlanPaymentConfigLocalizedRecord,
InterviewsPricingPlanType,
} from './InterviewsPricingPlans';
import { useI18nRouter } from '~/next-i18nostic/src';
export function useInterviewsPurchaseSuccessLogging(
plansPaymentConfig: InterviewsPricingPlanPaymentConfigLocalizedRecord,
) {
import type { InterviewsPricingPlanType } from './InterviewsPricingPlans';
export function useInterviewsPurchaseSuccessLogging() {
const searchParams = useSearchParams();
const planSearchParam = searchParams?.get(
'plan',
) as InterviewsPricingPlanType | null;
useEffect(() => {
if (planSearchParam != null) {
const paymentConfig = plansPaymentConfig[planSearchParam];
trpc.purchases.lastSuccessfulPaymentThatHasntBeenLogged.useQuery(undefined, {
onSuccess: (data) => {
if (data == null) {
return;
}
const planSearchParam = searchParams?.get(
'plan',
) as InterviewsPricingPlanType;
purchaseSuccessLogging({
amount: data.amount,
currency: data.currency,
plan: planSearchParam,
product: 'interviews',
purchasePrice: paymentConfig,
});
}
}, [planSearchParam, plansPaymentConfig]);
},
});
}
type Props = Readonly<{
plansPaymentConfig: InterviewsPricingPlanPaymentConfigLocalizedRecord;
}>;
export function InterviewsPurchaseSuccessLoggingImpl() {
useInterviewsPurchaseSuccessLogging();
export function InterviewsPurchaseSuccessLoggingImpl({
plansPaymentConfig,
}: Props) {
useInterviewsPurchaseSuccessLogging(plansPaymentConfig);
// Redirect to interviews dashboard after 24 hours,
// we don't want users to stay on the success page for too long
const router = useI18nRouter();
useEffect(() => {
const timer = setTimeout(
() => {
router.push('/interviews/dashboard');
},
24 * 3600 * 1000,
);
return () => clearTimeout(timer);
}, [router]);
return null;
}
// UseSearchParams should be within a suspense boundary according to
// https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
export default function InterviewsPurchaseSuccessLogging(props: Props) {
export default function InterviewsPurchaseSuccessLogging() {
return (
<Suspense>
<InterviewsPurchaseSuccessLoggingImpl {...props} />
<InterviewsPurchaseSuccessLoggingImpl />
</Suspense>
);
}

View File

@ -1,51 +1,64 @@
import { useSearchParams } from 'next/navigation';
import { Suspense, useEffect } from 'react';
import { trpc } from '~/hooks/trpc';
import { purchaseSuccessLogging } from '~/components/purchase/PurchaseLogging';
import type { ProjectsPricingPlanPaymentConfigLocalizedRecord } from './ProjectsPricingPlans';
import { useI18nRouter } from '~/next-i18nostic/src';
import type { ProjectsSubscriptionPlan } from '@prisma/client';
export function useProjectsPurchaseSuccessLogging(
plansPaymentConfig: ProjectsPricingPlanPaymentConfigLocalizedRecord,
) {
export function useProjectsPurchaseSuccessLogging() {
const searchParams = useSearchParams();
const planSearchParam = searchParams?.get(
'plan',
) as ProjectsSubscriptionPlan | null;
useEffect(() => {
if (planSearchParam != null) {
const paymentConfig = plansPaymentConfig[planSearchParam];
trpc.purchases.lastSuccessfulPaymentThatHasntBeenLogged.useQuery(undefined, {
onSuccess: (data) => {
if (data == null) {
return;
}
const planSearchParam = searchParams?.get(
'plan',
) as ProjectsSubscriptionPlan;
purchaseSuccessLogging({
amount: data.amount,
currency: data.currency,
plan: planSearchParam,
product: 'projects',
purchasePrice: paymentConfig,
});
}
}, [planSearchParam, plansPaymentConfig]);
},
});
}
type Props = Readonly<{
plansPaymentConfig: ProjectsPricingPlanPaymentConfigLocalizedRecord;
}>;
export function ProjectsPurchaseSuccessLoggingImpl() {
useProjectsPurchaseSuccessLogging();
export function ProjectsPurchaseSuccessLoggingImpl({
plansPaymentConfig,
}: Props) {
useProjectsPurchaseSuccessLogging(plansPaymentConfig);
// Redirect to projects dashboard after 24 hours,
// we don't want users to stay on the success page for too long
const router = useI18nRouter();
useEffect(() => {
const timer = setTimeout(
() => {
router.push('/projects/challenges');
},
24 * 3600 * 1000,
);
return () => clearTimeout(timer);
}, [router]);
return null;
}
// UseSearchParams should be within a suspense boundary according to
// https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
export default function ProjectsPurchaseSuccessLogging(props: Props) {
export default function ProjectsPurchaseSuccessLogging() {
return (
<Suspense>
<ProjectsPurchaseSuccessLoggingImpl {...props} />
<ProjectsPurchaseSuccessLoggingImpl />
</Suspense>
);
}

View File

@ -131,14 +131,22 @@ export function purchaseCancelLogging({ product, plan, purchasePrice }: Props) {
export function purchaseSuccessLogging({
product,
plan,
purchasePrice,
}: Props) {
// Special conversion event expected by GA.
amount,
currency: currencyLowerCase,
}: Readonly<{
amount: number;
currency: string;
plan: string;
product: 'interviews' | 'projects';
}>) {
const currency = currencyLowerCase.toLocaleUpperCase();
// Special conversion event expected by GA
gtag.event({
action: 'purchase',
category: 'ecommerce',
extra: {
currency: purchasePrice.currency.toLocaleUpperCase(),
currency,
ignore_referrer: 'true',
items: [
{
@ -148,48 +156,49 @@ export function purchaseSuccessLogging({
],
},
label: `${product}.${plan}`,
value: purchasePrice.unitCostCurrency.withPPP.after,
value: amount,
});
// Custom event logging.
// Custom event logging
gtag.event({
action: 'checkout.success',
category: 'ecommerce',
extra: {
currency,
ignore_referrer: 'true',
},
label: `${product}.${plan}`,
value: amount,
});
gtag.event({
action: 'conversion',
extra: {
currency: purchasePrice.currency.toLocaleUpperCase(),
currency,
ignore_referrer: 'true',
send_to: 'AW-11039716901/SrTfCIrox5UYEKXskpAp',
transaction_id: '',
value: purchasePrice.unitCostCurrency.withPPP.after,
},
value: amount,
});
fbqGFE('track', 'Purchase', {
content_name: `${product}.${plan}`,
currency: purchasePrice.currency.toLocaleUpperCase(),
value: purchasePrice.unitCostCurrency.withPPP.after,
currency,
value: amount,
});
logMessage({
level: 'success',
message: `[${product}] Purchased ${plan} plan for ${purchasePrice.currency.toLocaleUpperCase()} ${
purchasePrice.unitCostCurrency.withPPP.after
}`,
message: `[${product}] Purchased ${plan} plan for ${currency} ${amount}`,
namespace: product,
title: 'Purchase',
});
logEvent('checkout.success', {
currency: purchasePrice.currency.toLocaleUpperCase(),
currency,
namespace: product,
plan: `${product}.${plan}`,
value: purchasePrice.unitCostCurrency.withPPP.after,
value: amount,
});
}

View File

@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
convertCurrencyValueToStripeValue,
isHugeAmountCurrency,
isSupportedCurrency,
isZeroDecimalCurrency,
normalizeCurrencyValue,
shouldUseCountryCurrency,
withinStripeAmountLimit,
} from './stripeUtils';
@ -19,13 +19,13 @@ describe('isZeroDecimalCurrency', () => {
});
});
describe('normalizeCurrencyValue', () => {
describe('convertCurrencyValueToStripeValue', () => {
it('value multiplied by 100 for non-zero decimal currency', () => {
expect(normalizeCurrencyValue(5, 'usd')).toBe(500);
expect(convertCurrencyValueToStripeValue(5, 'usd')).toBe(500);
});
it('same value for zero decimal currency', () => {
expect(normalizeCurrencyValue(5, 'bif')).toBe(5);
expect(convertCurrencyValueToStripeValue(5, 'bif')).toBe(5);
});
});

View File

@ -33,10 +33,27 @@ export function isZeroDecimalCurrency(currency: string) {
* @param currency
* @param value
*/
export function normalizeCurrencyValue(value: number, currency: string) {
export function convertCurrencyValueToStripeValue(
value: number,
currency: string,
) {
return isZeroDecimalCurrency(currency) ? value : value * 100;
}
/**
* Converts a Stripe amount into a numerical dollar currency value.
* Basically divide by 100 if not a zero decimal currency.
*
* @param currency
* @param value
*/
export function convertStripeValueToCurrencyValue(
value: number,
currency: string,
) {
return isZeroDecimalCurrency(currency) ? value : value / 100;
}
// List of currencies where conversion from USD will be a huge value.
const hugeAmountCurrencies = new Set([
'bif',

View File

@ -3,7 +3,7 @@ import Stripe from 'stripe';
import url from 'url';
import absoluteUrl from '~/lib/absoluteUrl';
import { normalizeCurrencyValue } from '~/lib/stripeUtils';
import { convertCurrencyValueToStripeValue } from '~/lib/stripeUtils';
import { PROMO_FAANG_TECH_LEADS_MAX_PPP_ELIGIBLE } from '~/data/PromotionConfig';
@ -109,7 +109,7 @@ export default async function handler(
}
const { currency, unitCostCurrency } = planPaymentConfig;
const unitAmountInStripeFormat = normalizeCurrencyValue(
const unitAmountInStripeFormat = convertCurrencyValueToStripeValue(
unitCostCurrency.withPPP.after,
currency,
);

View File

@ -1,6 +1,8 @@
import Stripe from 'stripe';
import { z } from 'zod';
import { convertStripeValueToCurrencyValue } from '~/lib/stripeUtils';
import countryNames from '~/data/countryCodesToNames.json';
import fetchInterviewsPricingPlanPaymentConfigLocalizedRecord from '~/components/interviews/purchase/fetchInterviewsPricingPlanPaymentConfigLocalizedRecord';
@ -10,6 +12,8 @@ import prisma from '~/server/prisma';
import { publicProcedure, router, userProcedure } from '../trpc';
import { Redis } from '@upstash/redis';
type CountryCode = keyof typeof countryNames;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
@ -139,17 +143,76 @@ export const purchasesRouter = router({
const lastPaymentIntent = paymentIntents.data[0];
if (lastPaymentIntent.last_payment_error != null) {
return {
code: lastPaymentIntent.last_payment_error.code,
declineCode_DO_NOT_DISPLAY_TO_USER:
lastPaymentIntent.last_payment_error.decline_code,
message: lastPaymentIntent.last_payment_error.message,
};
if (lastPaymentIntent.last_payment_error == null) {
return null;
}
return null;
return {
code: lastPaymentIntent.last_payment_error.code,
declineCode_DO_NOT_DISPLAY_TO_USER:
lastPaymentIntent.last_payment_error.decline_code,
message: lastPaymentIntent.last_payment_error.message,
};
}),
lastSuccessfulPaymentThatHasntBeenLogged: userProcedure.query(
async ({ ctx }) => {
const userProfile = await prisma.profile.findFirst({
select: {
stripeCustomer: true,
},
where: {
id: ctx.viewer.id,
},
});
// No Stripe customer or non-existent user
if (userProfile?.stripeCustomer == null) {
return null;
}
const { stripeCustomer: stripeCustomerId } = userProfile;
const oneDayInSeconds = 24 * 3600;
const oneDayAgo = Math.floor(Date.now() / 1000) - oneDayInSeconds;
const paymentIntents = await stripe.paymentIntents.list({
created: {
gte: oneDayAgo,
},
customer: stripeCustomerId,
});
const successfulPaymentIntents = paymentIntents.data.filter(
(paymentIntent) => paymentIntent.status === 'succeeded',
);
if (successfulPaymentIntents.length === 0) {
return null;
}
const lastSuccessfulPaymentIntent = successfulPaymentIntents[0];
const redis = Redis.fromEnv();
const paymentKey = `purchases:${lastSuccessfulPaymentIntent.id}`;
const paymentAlreadyLogged = await redis.get(paymentKey);
if (paymentAlreadyLogged) {
return null;
}
const { amount, currency } = lastSuccessfulPaymentIntent;
// Prematurely setting the redis key to true to prevent duplicate logging
// Will be inaccurate it the client doesn't log, but should be rare
await redis.set(paymentKey, true, {
ex: oneDayInSeconds,
});
return {
amount: convertStripeValueToCurrencyValue(amount, currency),
currency,
};
},
),
latestCheckoutSessionMetadata: userProcedure.query(async ({ ctx }) => {
const userProfile = await prisma.profile.findFirst({
select: {