Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 2 additions & 28 deletions src/app/analytics/ga.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ describe('Testing GA Service', () => {

vi.mocked(localStorageService.get).mockImplementation((key) => {
const store: Record<string, string> = {
subscriptionId: 'sub_12345',
paymentIntentId: '',
priceId: 'price_yearly_2tb',
currency: 'EUR',
Expand All @@ -236,7 +235,7 @@ describe('Testing GA Service', () => {
expect(event).toMatchObject({
event: 'purchase',
ecommerce: {
transaction_id: 'sub_12345',
transaction_id: 'user_uuid_123',
currency: 'EUR',
value: 95.9,
items: [
Expand All @@ -260,7 +259,6 @@ describe('Testing GA Service', () => {
vi.mocked(localStorageService.getUser).mockReturnValue({ uuid: 'user_uuid' } as any);
vi.mocked(localStorageService.get).mockImplementation((key) => {
if (key === 'paymentIntentId') return 'pi_999';
if (key === 'subscriptionId') return 'sub_888';
if (key === 'amountPaid') return '100';
if (key === 'itemOriginalPrice') return '119.88';
if (key === 'checkout_item_data')
Expand All @@ -279,34 +277,10 @@ describe('Testing GA Service', () => {
expect(event.ecommerce.transaction_id).toBe('pi_999');
});

it('should use subscription ID when payment intent is not available', () => {
vi.mocked(localStorageService.getUser).mockReturnValue({ uuid: 'user_uuid' } as any);
vi.mocked(localStorageService.get).mockImplementation((key) => {
if (key === 'paymentIntentId') return null;
if (key === 'subscriptionId') return 'sub_888';
if (key === 'amountPaid') return '100';
if (key === 'itemOriginalPrice') return '119.88';
if (key === 'checkout_item_data')
return JSON.stringify({
item_name: '2TB Year Plan',
item_category: 'Individual',
item_variant: 'year',
discount: 0,
});
return '';
});

gaService.trackPurchase();

const event = globalThis.window.dataLayer[0] as any;
expect(event.ecommerce.transaction_id).toBe('sub_888');
});

it('should fallback to user UUID when neither payment intent nor subscription ID are available', () => {
it('should fallback to user UUID when payment intent is not available', () => {
vi.mocked(localStorageService.getUser).mockReturnValue({ uuid: 'user_fallback_uuid' } as any);
vi.mocked(localStorageService.get).mockImplementation((key) => {
if (key === 'paymentIntentId') return null;
if (key === 'subscriptionId') return null;
if (key === 'amountPaid') return '100';
if (key === 'itemOriginalPrice') return '119.88';
if (key === 'checkout_item_data')
Expand Down
3 changes: 1 addition & 2 deletions src/app/analytics/ga.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ function trackPurchase(): void {
return;
}

const subscriptionId = localStorageService.get('subscriptionId');
const paymentIntentId = localStorageService.get('paymentIntentId');
const priceId = localStorageService.get('priceId');
const currency = localStorageService.get('currency');
Expand All @@ -173,7 +172,7 @@ function trackPurchase(): void {
console.error('[GA Service] Error parsing checkout_item_data:', parseError);
}

const transactionId = paymentIntentId || subscriptionId || uuid;
const transactionId = paymentIntentId || uuid;
const currencyCode = currency ?? 'EUR';

const itemName = checkoutItemData?.item_name || 'Unknown Plan';
Expand Down
93 changes: 44 additions & 49 deletions src/app/analytics/impact.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ vi.mock('services/error.service', () => ({
},
}));

const subId = 'sub_123';
const paymentIntentId = 'py_123';
const mockedUserUuid = '00000000-0000-0000-0000-0000000000';
const mockImpactApiUrl = 'mock-impact-api-url';
Expand Down Expand Up @@ -93,7 +92,6 @@ beforeEach(() => {

vi.spyOn(localStorageService, 'get').mockImplementation((key) => {
if (key === 'paymentIntentId') return paymentIntentId;
if (key === 'subscriptionId') return subId;
if (key === 'productName') return planName;
if (key === 'priceId') return product.price.id;
if (key === 'currency') return product.price.currency;
Expand All @@ -106,11 +104,10 @@ beforeEach(() => {

describe('Testing Impact Service', () => {
describe('savePaymentDataInLocalStorage', () => {
it('should save the correct amount to localStorage after applying coupon', () => {
it('When a coupon is applied, then the discounted amount is stored for conversion tracking', () => {
const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set');

savePaymentDataInLocalStorage({
subscriptionId: subId,
paymentIntentId,
selectedPlan: product as PriceWithTax,
users: 1,
Expand All @@ -121,32 +118,12 @@ describe('Testing Impact Service', () => {
expect(setToLocalStorageSpy).toHaveBeenCalledWith('amountPaid', expectedAmount);
});

it('should save subscription ID when plan is not lifetime', () => {
it('When a payment intent is provided, then the payment reference is stored for tracking', () => {
const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set');

savePaymentDataInLocalStorage({
subscriptionId: subId,
paymentIntentId: undefined,
selectedPlan: product as PriceWithTax,
users: 1,
couponCodeData: promoCode,
isFirstPurchase: true,
});

expect(setToLocalStorageSpy).toHaveBeenCalledWith('subscriptionId', subId);
});

it('should save payment intent ID when plan is lifetime', () => {
const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set');
const lifetimeProduct = {
...product,
price: { ...product.price, interval: 'lifetime' },
};

savePaymentDataInLocalStorage({
subscriptionId: undefined,
paymentIntentId,
selectedPlan: lifetimeProduct as PriceWithTax,
selectedPlan: product as PriceWithTax,
users: 1,
couponCodeData: promoCode,
isFirstPurchase: true,
Expand All @@ -155,11 +132,10 @@ describe('Testing Impact Service', () => {
expect(setToLocalStorageSpy).toHaveBeenCalledWith('paymentIntentId', paymentIntentId);
});

it('should save product metadata including name, price ID, and currency', () => {
it('When payment data is saved, then the plan name, price, and currency are all stored', () => {
const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set');

savePaymentDataInLocalStorage({
subscriptionId: subId,
paymentIntentId,
selectedPlan: product as PriceWithTax,
users: 1,
Expand All @@ -172,11 +148,10 @@ describe('Testing Impact Service', () => {
expect(setToLocalStorageSpy).toHaveBeenCalledWith('currency', product.price.currency);
});

it('should save coupon code when provided', () => {
it('When a coupon code is used, then it is stored alongside the payment data', () => {
const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set');

savePaymentDataInLocalStorage({
subscriptionId: subId,
paymentIntentId,
selectedPlan: product as PriceWithTax,
users: 1,
Expand All @@ -187,11 +162,10 @@ describe('Testing Impact Service', () => {
expect(setToLocalStorageSpy).toHaveBeenCalledWith('couponCode', promoCode.codeName);
});

it('should save isFirstPurchase flag to localStorage', () => {
it('When payment data is saved, then whether it is the first purchase is also stored', () => {
const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set');

savePaymentDataInLocalStorage({
subscriptionId: subId,
paymentIntentId,
selectedPlan: product as PriceWithTax,
users: 1,
Expand All @@ -205,15 +179,15 @@ describe('Testing Impact Service', () => {

describe('trackSignUp', () => {
describe('gtag tracking', () => {
it('should send User Signup event to gtag', async () => {
it('When a user signs up, then the registration is reported to Google Analytics', async () => {
const gTagSpy = vi.spyOn(globalThis.window, 'gtag');

await trackSignUp(mockedUserUuid);

expect(gTagSpy).toHaveBeenCalledWith('event', 'User Signup');
});

it('should report error when gtag fails but continue execution', async () => {
it('When Google Analytics fails, then the error is logged and sign-up tracking continues', async () => {
const unknownError = new Error('gtag Error');
const gTagSpy = vi.spyOn(globalThis.window, 'gtag').mockImplementation(() => {
throw unknownError;
Expand All @@ -228,7 +202,7 @@ describe('Testing Impact Service', () => {
});

describe('Impact API tracking', () => {
it('should send signup event to Impact API with correct payload', async () => {
it('When trackSignUp is called, then it sends signup event to Impact API with correct payload', async () => {
const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({});

await trackSignUp(mockedUserUuid);
Expand All @@ -246,7 +220,7 @@ describe('Testing Impact Service', () => {
);
});

it('should include message ID in Impact API payload', async () => {
it('When trackSignUp is called, then it includes message ID in Impact API payload', async () => {
const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({});

await trackSignUp(mockedUserUuid);
Expand All @@ -256,7 +230,7 @@ describe('Testing Impact Service', () => {
expect(callArgs.messageId).toBe(mockedUserUuid);
});

it('should not send to Impact API when source is direct', async () => {
it('When source is direct, then it does not send to Impact API', async () => {
const getCookieMock = await import('./utils');
vi.mocked(getCookieMock.getCookie).mockImplementation((key) => {
if (key === 'impactSource') return 'direct';
Expand All @@ -274,7 +248,7 @@ describe('Testing Impact Service', () => {

describe('trackPaymentConversion', () => {
describe('Impact API tracking', () => {
it('should send payment conversion to Impact API with correct data', async () => {
it('When a payment is completed, then the full order details are reported to Impact', async () => {
const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({});

await trackPaymentConversion();
Expand All @@ -287,7 +261,6 @@ describe('Testing Impact Service', () => {
timestamp: expect.any(String),
properties: expect.objectContaining({
impact_value: parseFloat(expectedAmount),
subscription_id: subId,
payment_intent: paymentIntentId,
order_promo_code: promoCode.codeName,
}),
Expand All @@ -298,10 +271,9 @@ describe('Testing Impact Service', () => {
);
});

it('should use minimum value of 0.01 when amount is 0 (free purchase)', async () => {
it('When the purchase is free, then the minimum trackable amount is used in the conversion report', async () => {
vi.spyOn(localStorageService, 'get').mockImplementation((key) => {
if (key === 'amountPaid') return '0';
if (key === 'subscriptionId') return subId;
if (key === 'couponCode') return promoCode.codeName;
if (key === 'isFirstPurchase') return 'true';
return null;
Expand All @@ -314,7 +286,7 @@ describe('Testing Impact Service', () => {
expect(callArgs.properties.impact_value).toBe(0.01);
});

it('should include coupon code in properties when available', async () => {
it('When a promo code was used, then it appears in the conversion data reported to Impact', async () => {
const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({});

await trackPaymentConversion();
Expand All @@ -323,7 +295,7 @@ describe('Testing Impact Service', () => {
expect(callArgs.properties).toHaveProperty('order_promo_code', promoCode.codeName);
});

it('should report error when Impact API call fails', async () => {
it('When reporting to Impact fails, then the error is logged', async () => {
const unknownError = new Error('API Error');
const axiosSpy = vi.spyOn(axios, 'post').mockRejectedValue(unknownError);
const errorServiceSpy = vi.spyOn(errorService, 'reportError');
Expand All @@ -334,7 +306,7 @@ describe('Testing Impact Service', () => {
expect(errorServiceSpy).toHaveBeenCalledWith(unknownError);
});

it('should not send to Impact when source is direct and no coupon code', async () => {
it('When the traffic source is direct and no promo code was used, then the conversion is not sent to Impact', async () => {
const getCookieMock = await import('./utils');
vi.mocked(getCookieMock.getCookie).mockImplementation((key) => {
if (key === 'impactSource') return 'direct';
Expand All @@ -353,7 +325,30 @@ describe('Testing Impact Service', () => {
expect(axiosSpy).not.toHaveBeenCalled();
});

it('should not send to Impact when isFirstPurchase is false', async () => {
it('When the traffic source is direct but a promo code was used, then the conversion is still sent to Impact', async () => {
const getCookieMock = await import('./utils');
vi.mocked(getCookieMock.getCookie).mockImplementation((key) => {
if (key === 'impactSource') return 'direct';
if (key === 'impactAnonymousId') return '';
return '';
});
vi.spyOn(localStorageService, 'get').mockImplementation((key) => {
if (key === 'couponCode') return 'CNINTERNXT';
if (key === 'amountPaid') return expectedAmount;
if (key === 'isFirstPurchase') return 'true';
return null;
});
const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({});

await trackPaymentConversion();

expect(axiosSpy).toHaveBeenCalledTimes(1);
const callArgs = axiosSpy.mock.calls[0][1] as { properties: Record<string, unknown>; anonymousId: string };
expect(callArgs.properties).toHaveProperty('order_promo_code', 'CNINTERNXT');
expect(callArgs.anonymousId).toBe('');
});

it('When the purchase is not the first, then it is not reported as a conversion to Impact', async () => {
vi.spyOn(localStorageService, 'get').mockImplementation((key) => {
if (key === 'isFirstPurchase') return 'false';
if (key === 'amountPaid') return expectedAmount;
Expand All @@ -369,7 +364,7 @@ describe('Testing Impact Service', () => {
});

describe('Error handling', () => {
it('should handle missing user settings gracefully', async () => {
it('When the user profile is not available, then conversion tracking completes without crashing', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(localStorageService, 'getUser').mockReturnValue(null);

Expand All @@ -378,13 +373,13 @@ describe('Testing Impact Service', () => {
consoleWarnSpy.mockRestore();
});

it('should continue execution when gtag is not available', async () => {
it('When Google Analytics is not loaded, then the rest of conversion tracking still runs', async () => {
globalThis.window.gtag = undefined as any;

await expect(trackPaymentConversion()).resolves.not.toThrow();
});

it('should handle errors in entire function gracefully', async () => {
it('When an unexpected error occurs, then conversion tracking fails silently without crashing', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(localStorageService, 'getUser').mockImplementation(() => {
throw new Error('Storage Error');
Expand All @@ -399,7 +394,7 @@ describe('Testing Impact Service', () => {
});

describe('uuid library', () => {
it('v4 generates a valid UUID', async () => {
it('When a UUID is generated, then it follows the expected UUID v4 format', async () => {
const { v4 } = await vi.importActual<typeof import('uuid')>('uuid');
const id = v4();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
Expand Down
10 changes: 1 addition & 9 deletions src/app/analytics/impact.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { sendAddShoppersConversion } from './addShoppers.services';
*
*/
export interface SavePaymentDataParams {
subscriptionId: string | undefined;
paymentIntentId: string | undefined;
selectedPlan: PriceWithTax | undefined;
users: number;
Expand All @@ -38,18 +37,13 @@ export interface SavePaymentDataParams {
}

export function savePaymentDataInLocalStorage({
subscriptionId,
paymentIntentId,
selectedPlan,
users,
couponCodeData,
isFirstPurchase,
}: SavePaymentDataParams) {
if (subscriptionId && selectedPlan?.price.interval !== 'lifetime') {
localStorageService.set('subscriptionId', subscriptionId);
}

if (paymentIntentId && selectedPlan?.price.interval === 'lifetime') {
if (paymentIntentId) {
localStorageService.set('paymentIntentId', paymentIntentId);
}

Expand Down Expand Up @@ -107,7 +101,6 @@ export async function trackPaymentConversion(): Promise<void> {
}

const { uuid, email: userEmail } = userSettings;
const subscription = localStorageService.get('subscriptionId');
const paymentIntent = localStorageService.get('paymentIntentId');
const currency = localStorageService.get('currency');
const amountPaidStr = localStorageService.get('amountPaid');
Expand Down Expand Up @@ -138,7 +131,6 @@ export async function trackPaymentConversion(): Promise<void> {
timestamp: dayjs().format('YYYY-MM-DDTHH:mm:ss.sssZ'),
properties: {
impact_value: amount === 0 ? 0.01 : amount,
subscription_id: subscription,
payment_intent: paymentIntent,
...(couponCode && { order_promo_code: couponCode }),
},
Expand Down
Loading
Loading