[web] purchase/analytics: log correct amount only once
This commit is contained in:
parent
5889a207ca
commit
e36ee4b699
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue