From 444f9e0cb478b6f085863a4d724a5b2af19bbcbc Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Tue, 21 Apr 2026 10:33:16 +0200 Subject: [PATCH 1/2] feat: add isFirstPruchaseCheck --- src/app/analytics/impact.service.test.ts | 35 ++++++++++++-- src/app/analytics/impact.service.ts | 6 ++- .../Checkout/hooks/useUserPayment.test.ts | 47 ++++++++++++++++--- src/views/Checkout/hooks/useUserPayment.ts | 29 +++++++++++- src/views/Checkout/types/index.ts | 1 + 5 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/app/analytics/impact.service.test.ts b/src/app/analytics/impact.service.test.ts index 8c991a47e..4e460b32b 100644 --- a/src/app/analytics/impact.service.test.ts +++ b/src/app/analytics/impact.service.test.ts @@ -99,6 +99,7 @@ beforeEach(() => { if (key === 'currency') return product.price.currency; if (key === 'amountPaid') return expectedAmount; if (key === 'couponCode') return promoCode.codeName; + if (key === 'isFirstPurchase') return 'true'; return null; }); }); @@ -108,7 +109,7 @@ describe('Testing Impact Service', () => { it('should save the correct amount to localStorage after applying coupon', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); - savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode); + savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode, true); expect(setToLocalStorageSpy).toHaveBeenCalledWith('amountPaid', expectedAmount); }); @@ -116,7 +117,7 @@ describe('Testing Impact Service', () => { it('should save subscription ID when plan is not lifetime', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); - savePaymentDataInLocalStorage(subId, undefined, product as PriceWithTax, 1, promoCode); + savePaymentDataInLocalStorage(subId, undefined, product as PriceWithTax, 1, promoCode, true); expect(setToLocalStorageSpy).toHaveBeenCalledWith('subscriptionId', subId); }); @@ -128,7 +129,7 @@ describe('Testing Impact Service', () => { price: { ...product.price, interval: 'lifetime' }, }; - savePaymentDataInLocalStorage(undefined, paymentIntentId, lifetimeProduct as PriceWithTax, 1, promoCode); + savePaymentDataInLocalStorage(undefined, paymentIntentId, lifetimeProduct as PriceWithTax, 1, promoCode, true); expect(setToLocalStorageSpy).toHaveBeenCalledWith('paymentIntentId', paymentIntentId); }); @@ -136,7 +137,7 @@ describe('Testing Impact Service', () => { it('should save product metadata including name, price ID, and currency', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); - savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode); + savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode, true); expect(setToLocalStorageSpy).toHaveBeenCalledWith('productName', planName); expect(setToLocalStorageSpy).toHaveBeenCalledWith('priceId', product.price.id); @@ -146,10 +147,18 @@ describe('Testing Impact Service', () => { it('should save coupon code when provided', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); - savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode); + savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode, true); expect(setToLocalStorageSpy).toHaveBeenCalledWith('couponCode', promoCode.codeName); }); + + it('should save isFirstPurchase flag to localStorage', () => { + const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); + + savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode, true); + + expect(setToLocalStorageSpy).toHaveBeenCalledWith('isFirstPurchase', 'true'); + }); }); describe('trackSignUp', () => { @@ -252,6 +261,7 @@ describe('Testing Impact Service', () => { if (key === 'amountPaid') return '0'; if (key === 'subscriptionId') return subId; if (key === 'couponCode') return promoCode.codeName; + if (key === 'isFirstPurchase') return 'true'; return null; }); const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({}); @@ -291,6 +301,21 @@ describe('Testing Impact Service', () => { vi.spyOn(localStorageService, 'get').mockImplementation((key) => { if (key === 'couponCode') return null; if (key === 'amountPaid') return expectedAmount; + if (key === 'isFirstPurchase') return 'true'; + return null; + }); + const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({}); + + await trackPaymentConversion(); + + expect(axiosSpy).not.toHaveBeenCalled(); + }); + + it('should not send to Impact when isFirstPurchase is false', async () => { + vi.spyOn(localStorageService, 'get').mockImplementation((key) => { + if (key === 'isFirstPurchase') return 'false'; + if (key === 'amountPaid') return expectedAmount; + if (key === 'couponCode') return promoCode.codeName; return null; }); const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({}); diff --git a/src/app/analytics/impact.service.ts b/src/app/analytics/impact.service.ts index e94283f87..c9d283a79 100644 --- a/src/app/analytics/impact.service.ts +++ b/src/app/analytics/impact.service.ts @@ -39,6 +39,7 @@ export function savePaymentDataInLocalStorage( selectedPlan: PriceWithTax | undefined, users: number, couponCodeData: CouponCodeData | undefined, + isFirstPurchase: boolean, ) { if (subscriptionId && selectedPlan?.price.interval !== 'lifetime') { localStorageService.set('subscriptionId', subscriptionId); @@ -61,6 +62,8 @@ export function savePaymentDataInLocalStorage( if (couponCodeData?.codeName) { localStorageService.set('couponCode', couponCodeData.codeName); } + + localStorageService.set('isFirstPurchase', String(isFirstPurchase)); } export async function trackSignUp(uuid: string): Promise { @@ -106,6 +109,7 @@ export async function trackPaymentConversion(): Promise { const amountPaidStr = localStorageService.get('amountPaid'); const amount = Number.parseFloat(amountPaidStr ?? '0'); const couponCode = localStorageService.get('couponCode'); + const isFirstPurchase = localStorageService.get('isFirstPurchase') === 'true'; try { sendAddShoppersConversion({ @@ -123,7 +127,7 @@ export async function trackPaymentConversion(): Promise { const anonymousID = getCookie('impactAnonymousId'); const source = getCookie('impactSource'); - if ((source && source !== 'direct') || couponCode) { + if (isFirstPurchase && ((source && source !== 'direct') || couponCode)) { try { await axios.post(IMPACT_API, { anonymousId: anonymousID, diff --git a/src/views/Checkout/hooks/useUserPayment.test.ts b/src/views/Checkout/hooks/useUserPayment.test.ts index 9132ccda2..e94c7fe6b 100644 --- a/src/views/Checkout/hooks/useUserPayment.test.ts +++ b/src/views/Checkout/hooks/useUserPayment.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { useUserPayment } from './useUserPayment'; +import { checkIsFirstPurchase, useUserPayment } from './useUserPayment'; import checkoutService from '../services/checkout.service'; import localStorageService from 'services/local-storage.service'; import envService from 'services/env.service'; @@ -9,6 +9,8 @@ import { AppView } from 'app/core/types'; import { PaymentType, ProcessPurchasePayload, UseUserPaymentPayload } from '../types'; import { CreateSubscriptionPayload } from '@internxt/sdk/dist/payments/types'; import notificationsService from 'app/notifications/services/notifications.service'; +import { paymentService } from '../services'; +import errorService from 'services/error.service'; const mockHostname = 'https://hostname.com'; @@ -24,6 +26,8 @@ describe('Custom hook to handle payments', () => { if (key === 'hostname') return mockHostname; else return 'no mock implementation'; }); + + vi.spyOn(paymentService, 'getInvoices').mockResolvedValue([]); }); describe('Get subscription data to do the payment', () => { @@ -190,7 +194,7 @@ describe('Custom hook to handle payments', () => { captchaToken: subscriptionPaymentPayload.captchaToken, }); - expect(localStorageServiceSpy).toHaveBeenCalledTimes(5); + expect(localStorageServiceSpy).toHaveBeenCalledTimes(6); expect(setupIntent).toHaveBeenCalledWith({ elements: subscriptionPaymentPayload.elements, @@ -248,7 +252,7 @@ describe('Custom hook to handle payments', () => { captchaToken: subscriptionPaymentPayload.captchaToken, }); - expect(localStorageServiceSpy).toHaveBeenCalledTimes(5); + expect(localStorageServiceSpy).toHaveBeenCalledTimes(6); expect(confirmPayment).toHaveBeenCalledWith({ elements: subscriptionPaymentPayload.elements, @@ -307,7 +311,7 @@ describe('Custom hook to handle payments', () => { captchaToken: subscriptionPaymentPayload.captchaToken, }); - expect(localStorageServiceSpy).toHaveBeenCalledTimes(5); + expect(localStorageServiceSpy).toHaveBeenCalledTimes(6); expect(notificationsServiceSpy).toHaveBeenCalled(); expect(confirmPayment).not.toHaveBeenCalled(); @@ -356,7 +360,7 @@ describe('Custom hook to handle payments', () => { promoCodeId: undefined, }); - expect(localStorageServiceSpy).toHaveBeenCalledTimes(5); + expect(localStorageServiceSpy).toHaveBeenCalledTimes(6); expect(confirmPayment).toHaveBeenCalledWith({ elements: lifetimePaymentPayload.elements, @@ -408,13 +412,44 @@ describe('Custom hook to handle payments', () => { promoCodeId: undefined, }); - expect(localStorageServiceSpy).toHaveBeenCalledTimes(5); + expect(localStorageServiceSpy).toHaveBeenCalledTimes(6); expect(navigationServiceSpy).toHaveBeenCalledWith(AppView.CheckoutSuccess); expect(confirmPayment).not.toHaveBeenCalled(); }); }); + describe('checkIsFirstPurchase', () => { + test('When the user has no prior invoices, then it is a first purchase', async () => { + vi.spyOn(paymentService, 'getInvoices').mockResolvedValue([]); + + const result = await checkIsFirstPurchase(); + + expect(result).toBe(true); + expect(paymentService.getInvoices).toHaveBeenCalledWith({ userType: UserType.Individual, limit: 1 }); + }); + + test('When the user has prior invoices, then it is not a first purchase', async () => { + vi.spyOn(paymentService, 'getInvoices').mockResolvedValue([{ id: 'inv_123' } as any]); + + const result = await checkIsFirstPurchase(); + + expect(result).toBe(false); + }); + + test('When getInvoices fails, then it returns false and reports the error', async () => { + const error = new Error('Network error'); + vi.spyOn(paymentService, 'getInvoices').mockRejectedValue(error); + const reportErrorSpy = vi.spyOn(errorService, 'reportError').mockImplementation(() => {}); + vi.spyOn(errorService, 'castError').mockReturnValue(error as any); + + const result = await checkIsFirstPurchase(); + + expect(result).toBe(false); + expect(reportErrorSpy).toHaveBeenCalledWith(error); + }); + }); + describe('Handling user payment', () => { test('When the plan is a subscription, then the subscription handler is called', async () => { const payment = useUserPayment(); diff --git a/src/views/Checkout/hooks/useUserPayment.ts b/src/views/Checkout/hooks/useUserPayment.ts index ad5f98ce2..22bc67dc7 100644 --- a/src/views/Checkout/hooks/useUserPayment.ts +++ b/src/views/Checkout/hooks/useUserPayment.ts @@ -5,6 +5,7 @@ import checkoutService from '../services/checkout.service'; import envService from 'services/env.service'; import { sendConversionToAPI } from 'app/analytics/googleSheet.service'; import navigationService from 'services/navigation.service'; +import errorService from 'services/error.service'; import { AppView } from 'app/core/types'; import { CreatePaymentIntentPayload, @@ -17,6 +18,19 @@ import { import { ActionDialog } from 'app/contexts/dialog-manager/ActionDialogManager.context'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import { CreateSubscriptionPayload } from '@internxt/sdk/dist/payments/types'; +import { UserType } from '@internxt/sdk/dist/drive/payments/types/types'; +import { paymentService } from '../services'; + +export const checkIsFirstPurchase = async (): Promise => { + try { + const invoices = await paymentService.getInvoices({ userType: UserType.Individual, limit: 1 }); + return invoices.length === 0; + } catch (error) { + const castedError = errorService.castError(error); + errorService.reportError(castedError); + return false; + } +}; export const useUserPayment = () => { const getSubscriptionPaymentIntent = async ({ @@ -140,6 +154,7 @@ export const useUserPayment = () => { translate, confirmPayment, confirmSetupIntent, + isFirstPurchase, }: ProcessPurchasePayload) => { const subscription = await getSubscriptionPaymentIntent({ customerId, @@ -157,6 +172,7 @@ export const useUserPayment = () => { currentSelectedPlan, seatsForBusinessSubscription, couponCodeData, + isFirstPurchase ?? false, ); switch (subscription.type) { @@ -189,6 +205,7 @@ export const useUserPayment = () => { userAddress, confirmPayment, openCryptoPaymentDialog, + isFirstPurchase, }: ProcessPurchasePayload) => { const { id: paymentIntentId, @@ -207,7 +224,14 @@ export const useUserPayment = () => { currency, }); - savePaymentDataInLocalStorage(undefined, paymentIntentId, currentSelectedPlan, 1, couponCodeData); + savePaymentDataInLocalStorage( + undefined, + paymentIntentId, + currentSelectedPlan, + 1, + couponCodeData, + isFirstPurchase ?? false, + ); // !DO NOT REMOVE THIS // If there is a one time payment with a 100% OFF coupon code, the invoice will be marked as 'paid' by Stripe and @@ -257,6 +281,7 @@ export const useUserPayment = () => { confirmSetupIntent, }: UseUserPaymentPayload) => { const planInterval = selectedPlan.price.interval; + const isFirstPurchase = await checkIsFirstPurchase(); if (gclidStored) { await sendConversionToAPI({ @@ -287,6 +312,7 @@ export const useUserPayment = () => { translate, confirmPayment, confirmSetupIntent, + isFirstPurchase, }); break; @@ -305,6 +331,7 @@ export const useUserPayment = () => { confirmPayment, openCryptoPaymentDialog, confirmSetupIntent, + isFirstPurchase, }); break; diff --git a/src/views/Checkout/types/index.ts b/src/views/Checkout/types/index.ts index fdf027f4e..d572fbb7d 100644 --- a/src/views/Checkout/types/index.ts +++ b/src/views/Checkout/types/index.ts @@ -118,6 +118,7 @@ export interface ProcessPurchasePayload { currentSelectedPlan: PriceWithTax; seatsForBusinessSubscription?: number; couponCodeData?: CouponCodeData; + isFirstPurchase?: boolean; } export interface UseUserPaymentPayload { From a6ad508a3d8f8d55b60e9e54a600fa191e577cca Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Wed, 22 Apr 2026 10:09:47 +0200 Subject: [PATCH 2/2] refactor: use object param in savePaymentDataInLocalStorage --- src/app/analytics/impact.service.test.ts | 54 +++++++++++++++++++--- src/app/analytics/impact.service.ts | 30 ++++++------ src/views/Checkout/hooks/useUserPayment.ts | 26 +++++------ 3 files changed, 78 insertions(+), 32 deletions(-) diff --git a/src/app/analytics/impact.service.test.ts b/src/app/analytics/impact.service.test.ts index 4e460b32b..d7b7cd352 100644 --- a/src/app/analytics/impact.service.test.ts +++ b/src/app/analytics/impact.service.test.ts @@ -109,7 +109,14 @@ describe('Testing Impact Service', () => { it('should save the correct amount to localStorage after applying coupon', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); - savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode, true); + savePaymentDataInLocalStorage({ + subscriptionId: subId, + paymentIntentId, + selectedPlan: product as PriceWithTax, + users: 1, + couponCodeData: promoCode, + isFirstPurchase: true, + }); expect(setToLocalStorageSpy).toHaveBeenCalledWith('amountPaid', expectedAmount); }); @@ -117,7 +124,14 @@ describe('Testing Impact Service', () => { it('should save subscription ID when plan is not lifetime', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); - savePaymentDataInLocalStorage(subId, undefined, product as PriceWithTax, 1, promoCode, true); + savePaymentDataInLocalStorage({ + subscriptionId: subId, + paymentIntentId: undefined, + selectedPlan: product as PriceWithTax, + users: 1, + couponCodeData: promoCode, + isFirstPurchase: true, + }); expect(setToLocalStorageSpy).toHaveBeenCalledWith('subscriptionId', subId); }); @@ -129,7 +143,14 @@ describe('Testing Impact Service', () => { price: { ...product.price, interval: 'lifetime' }, }; - savePaymentDataInLocalStorage(undefined, paymentIntentId, lifetimeProduct as PriceWithTax, 1, promoCode, true); + savePaymentDataInLocalStorage({ + subscriptionId: undefined, + paymentIntentId, + selectedPlan: lifetimeProduct as PriceWithTax, + users: 1, + couponCodeData: promoCode, + isFirstPurchase: true, + }); expect(setToLocalStorageSpy).toHaveBeenCalledWith('paymentIntentId', paymentIntentId); }); @@ -137,7 +158,14 @@ describe('Testing Impact Service', () => { it('should save product metadata including name, price ID, and currency', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); - savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode, true); + savePaymentDataInLocalStorage({ + subscriptionId: subId, + paymentIntentId, + selectedPlan: product as PriceWithTax, + users: 1, + couponCodeData: promoCode, + isFirstPurchase: true, + }); expect(setToLocalStorageSpy).toHaveBeenCalledWith('productName', planName); expect(setToLocalStorageSpy).toHaveBeenCalledWith('priceId', product.price.id); @@ -147,7 +175,14 @@ describe('Testing Impact Service', () => { it('should save coupon code when provided', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); - savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode, true); + savePaymentDataInLocalStorage({ + subscriptionId: subId, + paymentIntentId, + selectedPlan: product as PriceWithTax, + users: 1, + couponCodeData: promoCode, + isFirstPurchase: true, + }); expect(setToLocalStorageSpy).toHaveBeenCalledWith('couponCode', promoCode.codeName); }); @@ -155,7 +190,14 @@ describe('Testing Impact Service', () => { it('should save isFirstPurchase flag to localStorage', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); - savePaymentDataInLocalStorage(subId, paymentIntentId, product as PriceWithTax, 1, promoCode, true); + savePaymentDataInLocalStorage({ + subscriptionId: subId, + paymentIntentId, + selectedPlan: product as PriceWithTax, + users: 1, + couponCodeData: promoCode, + isFirstPurchase: true, + }); expect(setToLocalStorageSpy).toHaveBeenCalledWith('isFirstPurchase', 'true'); }); diff --git a/src/app/analytics/impact.service.ts b/src/app/analytics/impact.service.ts index c9d283a79..a0ff1268a 100644 --- a/src/app/analytics/impact.service.ts +++ b/src/app/analytics/impact.service.ts @@ -27,20 +27,24 @@ import { sendAddShoppersConversion } from './addShoppers.services'; * - Number of users * - Coupon code data (if any) * - * @param subscriptionId - Stripe subscription ID (only for recurring plans) - * @param paymentIntentId - Stripe payment intent ID (only for lifetime plans) - * @param selectedPlan - The pricing plan selected by the user - * @param users - Number of users for the purchase (1 for individual, >1 for B2B) - * @param couponCodeData - Optional coupon code information applied to the purchase */ -export function savePaymentDataInLocalStorage( - subscriptionId: string | undefined, - paymentIntentId: string | undefined, - selectedPlan: PriceWithTax | undefined, - users: number, - couponCodeData: CouponCodeData | undefined, - isFirstPurchase: boolean, -) { +export interface SavePaymentDataParams { + subscriptionId: string | undefined; + paymentIntentId: string | undefined; + selectedPlan: PriceWithTax | undefined; + users: number; + couponCodeData: CouponCodeData | undefined; + isFirstPurchase: boolean; +} + +export function savePaymentDataInLocalStorage({ + subscriptionId, + paymentIntentId, + selectedPlan, + users, + couponCodeData, + isFirstPurchase, +}: SavePaymentDataParams) { if (subscriptionId && selectedPlan?.price.interval !== 'lifetime') { localStorageService.set('subscriptionId', subscriptionId); } diff --git a/src/views/Checkout/hooks/useUserPayment.ts b/src/views/Checkout/hooks/useUserPayment.ts index 22bc67dc7..8fbf82274 100644 --- a/src/views/Checkout/hooks/useUserPayment.ts +++ b/src/views/Checkout/hooks/useUserPayment.ts @@ -166,14 +166,14 @@ export const useUserPayment = () => { currency, }); - savePaymentDataInLocalStorage( - subscription.subscriptionId, - subscription.paymentIntentId, - currentSelectedPlan, - seatsForBusinessSubscription, + savePaymentDataInLocalStorage({ + subscriptionId: subscription.subscriptionId, + paymentIntentId: subscription.paymentIntentId, + selectedPlan: currentSelectedPlan, + users: seatsForBusinessSubscription, couponCodeData, - isFirstPurchase ?? false, - ); + isFirstPurchase: isFirstPurchase ?? false, + }); switch (subscription.type) { case 'payment': @@ -224,14 +224,14 @@ export const useUserPayment = () => { currency, }); - savePaymentDataInLocalStorage( - undefined, + savePaymentDataInLocalStorage({ + subscriptionId: undefined, paymentIntentId, - currentSelectedPlan, - 1, + selectedPlan: currentSelectedPlan, + users: 1, couponCodeData, - isFirstPurchase ?? false, - ); + isFirstPurchase: isFirstPurchase ?? false, + }); // !DO NOT REMOVE THIS // If there is a one time payment with a 100% OFF coupon code, the invoice will be marked as 'paid' by Stripe and