Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions src/services/bit2me.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Axios, AxiosError, AxiosRequestConfig } from 'axios';
import { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios';
import jwt from 'jsonwebtoken';
import { AppConfig } from '../config';
import { createHmac } from 'crypto';
Expand All @@ -20,7 +20,7 @@ import {
export class Bit2MeService {
constructor(
private readonly config: AppConfig,
private readonly axios: Axios,
private readonly axios: AxiosInstance,
private readonly secretKey = config.CRYPTO_PAYMENTS_PROCESSOR_SECRET_KEY,
private readonly apiKey = config.CRYPTO_PAYMENTS_PROCESSOR_API_KEY,
private readonly apiUrl = config.CRYPTO_PAYMENTS_PROCESSOR_API_URL,
Expand Down
11 changes: 5 additions & 6 deletions src/services/klaviyo.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import axios from 'axios';
import Logger from '../Logger';
import { BadRequestError } from '../errors/Errors';
import config from '../config';

export enum KlaviyoEvent {
Expand All @@ -12,14 +11,14 @@ interface KlaviyoEventOptions {
eventName: KlaviyoEvent;
}


export class KlaviyoTrackingService {
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly apiKey?: string;
private readonly baseUrl?: string;

constructor() {
if (!config.KLAVIYO_API_KEY) {
throw new BadRequestError("Klaviyo API Key is required.");
Logger.warn('Klaviyo API key is not set');
return;
}

this.apiKey = config.KLAVIYO_API_KEY;
Expand Down Expand Up @@ -74,4 +73,4 @@ export class KlaviyoTrackingService {
}
}

export const klaviyoService = new KlaviyoTrackingService();
export const klaviyoService = new KlaviyoTrackingService();
4 changes: 2 additions & 2 deletions src/services/objectStorage.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { PaymentService } from './payment.service';
import { sign } from 'jsonwebtoken';
import { Axios, AxiosRequestConfig } from 'axios';
import { AxiosInstance, AxiosRequestConfig } from 'axios';
import { type AppConfig } from '../config';

function signToken(duration: string, secret: string) {
Expand All @@ -14,7 +14,7 @@ export class ObjectStorageService {
constructor(
private readonly paymentService: PaymentService,
private readonly config: AppConfig,
private readonly axios: Axios,
private readonly axios: AxiosInstance,
) {}

async initObjectStorageUser(payload: { email: string; customerId: string }) {
Expand Down
66 changes: 63 additions & 3 deletions src/services/payment.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Stripe from 'stripe';
import dayjs from 'dayjs';

import { DisplayPrice } from '../core/users/DisplayPrice';
import { ProductsRepository } from '../core/users/ProductsRepository';
Expand Down Expand Up @@ -471,7 +472,47 @@ export class PaymentService {
return { maxSpaceBytes: parseInt(price.metadata.maxSpaceBytes), recurring: isRecurring };
}

/**
* Gets the annual commitment cancellation info
* @param subscription - The subscription we want to get info for
* @returns - The annual commitment cancellation info (remaining payments, amount per month, currency, cancel at)
*/
getAnnualCommitmentCancellationInfo(subscription: Stripe.Subscription): {
remainingPayments: number;
cancelAt: number;
cancellationDate: string;
isFirstMonth: boolean;
} {
const createdAt = dayjs.unix(subscription.created);
const now = dayjs();

const monthsElapsed = now.diff(createdAt, 'month');
const monthsIntoPeriod = monthsElapsed % 12;
const periodsElapsed = Math.floor(monthsElapsed / 12);

const cancelAtDate = createdAt.add(periodsElapsed + 1, 'year');
const cancelAt = cancelAtDate.unix();

const isFirstMonth = monthsElapsed === 0 && now.diff(createdAt, 'day') <= 30;
const remainingPayments = monthsIntoPeriod === 0 ? 12 : 12 - monthsIntoPeriod;
const cancellationDate = cancelAtDate.toISOString();

return { remainingPayments, cancelAt, cancellationDate, isFirstMonth };
}

async cancelSubscription(subscriptionId: SubscriptionId): Promise<void> {
const subscription = await this.getSubscriptionById(subscriptionId);
const item = subscription.items.data[0];
const hasAnnualCommitment = this.hasAnnualCommitment(item.price);
const { isFirstMonth, cancelAt } = this.getAnnualCommitmentCancellationInfo(subscription);

if (hasAnnualCommitment && !isFirstMonth) {
await this.provider.subscriptions.update(subscriptionId, {
cancel_at: cancelAt,
});
return;
}

await this.provider.subscriptions.cancel(subscriptionId, {});
}

Expand Down Expand Up @@ -924,6 +965,8 @@ export class PaymentService {
subscription.plan.metadata.maxSpaceBytes,
) || 0;
const item = subscription.items.data[0] as Stripe.SubscriptionItem;
const hasAnnualCommitment = this.hasAnnualCommitment(item.price);
const commitment = hasAnnualCommitment ? this.getAnnualCommitmentCancellationInfo(subscription) : null;

const plan: PlanSubscription = {
status: subscription.status,
Expand All @@ -942,7 +985,16 @@ export class PaymentService {
isTeam: !!subscription.plan.product.metadata.is_teams,
paymentInterval: subscription.plan.nickname,
isLifetime: false,
renewalPeriod: this.getRenewalPeriod(subscription.plan.intervalCount, subscription.plan.interval),
renewalPeriod: this.getRenewalPeriod(
subscription.plan.intervalCount,
hasAnnualCommitment ? 'year' : subscription.plan.interval,
),
commitment: {
enabled: hasAnnualCommitment,
remainingMonths: commitment?.remainingPayments,
cancellationDate: commitment?.cancellationDate,
isFirstMonth: commitment?.isFirstMonth,
},
storageLimit: storageLimit,
amountOfSeats: item.quantity || 1,
seats: {
Expand All @@ -952,13 +1004,14 @@ export class PaymentService {
};

const { price } = subscription.items.data[0];
const itemInterval = hasAnnualCommitment ? 'year' : price.recurring?.interval;

return {
type: 'subscription',
subscriptionId: subscription.id,
amount: price.unit_amount!,
currency: price.currency,
interval: price.recurring!.interval as 'year' | 'month',
interval: itemInterval,
nextPayment: subscription.current_period_end,
amountAfterCoupon: upcomingInvoice.total,
priceId: price.id,
Expand Down Expand Up @@ -988,13 +1041,16 @@ export class PaymentService {
);
})
.map((price) => {
const hasAnnualCommitment = this.hasAnnualCommitment(price);
const recurringInterval = hasAnnualCommitment ? 'year' : (price.recurring?.interval as 'year' | 'month');

return {
id: price.id,
productId: (price.product as Stripe.Product).id,
currency: currencyValue,
amount: price.currency_options![currencyValue].unit_amount as number,
bytes: parseInt(price.metadata.maxSpaceBytes),
interval: price.type === 'one_time' ? 'lifetime' : (price.recurring!.interval as 'year' | 'month'),
interval: price.type === 'one_time' ? 'lifetime' : recurringInterval,
};
});
}
Expand Down Expand Up @@ -1555,4 +1611,8 @@ export class PaymentService {

return invoice.status === 'paid';
}

private hasAnnualCommitment(price: Stripe.Price): boolean {
return price?.metadata.annualCommitment === 'true';
}
}
4 changes: 2 additions & 2 deletions src/services/storage.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import axios, { Axios, AxiosRequestConfig, isAxiosError } from 'axios';
import axios, { AxiosInstance, AxiosRequestConfig, isAxiosError } from 'axios';
import { sign } from 'jsonwebtoken';
import { isProduction, type AppConfig } from '../config';
import { User } from '../core/users/User';
Expand All @@ -15,7 +15,7 @@ function signToken(duration: string, secret: string) {
export class StorageService {
constructor(
private readonly config: AppConfig,
private readonly axios: Axios,
private readonly axios: AxiosInstance,
) {}

async updateUserStorageAndTier(uuid: string, newStorageBytes: number, foreignTierId: string): Promise<void> {
Expand Down
4 changes: 2 additions & 2 deletions src/services/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Coupon } from '../core/coupons/Coupon';
import { CouponsRepository } from '../core/coupons/CouponsRepository';
import { UsersCouponsRepository } from '../core/coupons/UsersCouponsRepository';
import { sign } from 'jsonwebtoken';
import { Axios, AxiosRequestConfig } from 'axios';
import { AxiosInstance, AxiosRequestConfig } from 'axios';
import { isProduction, type AppConfig } from '../config';
import { Service, VpnFeatures } from '../core/users/Tier';
import { UserNotFoundError } from '../errors/PaymentErrors';
Expand All @@ -31,7 +31,7 @@ export class UsersService {
private readonly couponsRepository: CouponsRepository,
private readonly usersCouponsRepository: UsersCouponsRepository,
private readonly config: AppConfig,
private readonly axios: Axios,
private readonly axios: AxiosInstance,
) {}

async updateUser(customerId: User['customerId'], body: Partial<User>): Promise<void> {
Expand Down
6 changes: 6 additions & 0 deletions src/types/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface PlanSubscription {
paymentInterval: string;
isLifetime: boolean;
renewalPeriod: RenewalPeriod;
commitment: {
enabled: boolean;
remainingMonths?: number;
cancellationDate?: string;
isFirstMonth?: boolean;
};
storageLimit: number;
amountOfSeats: number;
seats?: {
Expand Down
3 changes: 3 additions & 0 deletions tests/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,9 @@ export function getSubscription({
type: userType,
price: 119.88,
monthlyPrice: 9.99,
commitment: {
enabled: false,
},
currency: 'eur',
isTeam: false,
paymentInterval: '',
Expand Down
18 changes: 9 additions & 9 deletions tests/src/services/klaviyo.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import axios from 'axios';
import { KlaviyoTrackingService, KlaviyoEvent } from '../../../src/services/klaviyo.service';
import Logger from '../../../src/Logger';
import { BadRequestError } from '../../../src/errors/Errors';
import config from '../../../src/config';
import { createTestServices } from '../helpers/services-factory';

Expand All @@ -17,7 +16,6 @@ jest.mock('../../../src/config', () => ({

const mockedAxios = axios as jest.Mocked<typeof axios>;


describe('KlaviyoTrackingService', () => {
let service: KlaviyoTrackingService;
let loggerInfoSpy: jest.SpyInstance;
Expand All @@ -37,9 +35,11 @@ describe('KlaviyoTrackingService', () => {
});

describe('Initialization', () => {
test('When instantiated without an API Key in config, then it throws an error', () => {
Comment thread
sg-gs marked this conversation as resolved.
test('When instantiated without an API Key in config, then it logs a warning', () => {
const loggerWarnSpy = jest.spyOn(Logger, 'warn').mockImplementation();
(config as any).KLAVIYO_API_KEY = undefined;
expect(() => createTestServices({ stripe: {} as any }).klaviyoTrackingService).toThrow(BadRequestError);
createTestServices({ stripe: {} as any });
expect(loggerWarnSpy).toHaveBeenCalled();
});

test('When instantiated with valid config, then it initializes correctly', () => {
Expand All @@ -51,7 +51,7 @@ describe('KlaviyoTrackingService', () => {
test('When tracking a cancellation, then it sends the correct payload to Klaviyo', async () => {
const email = 'user@example.com';
const expectedUrl = `${mockBaseUrl}/events/`;

const expectedPayload = {
data: {
type: 'event',
Expand Down Expand Up @@ -87,7 +87,7 @@ describe('KlaviyoTrackingService', () => {
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
expect(mockedAxios.post).toHaveBeenCalledWith(expectedUrl, expectedPayload, expectedHeaders);
expect(loggerInfoSpy).toHaveBeenCalledWith(
expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} tracked for ${email}`)
expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} tracked for ${email}`),
);
});

Expand All @@ -99,10 +99,10 @@ describe('KlaviyoTrackingService', () => {
mockedAxios.post.mockRejectedValue(error);

await expect(service.trackSubscriptionCancelled(email)).rejects.toThrow(errorMessage);

expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} failed for ${email}: ${errorMessage}`)
expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} failed for ${email}: ${errorMessage}`),
);
});
});
});
});
Loading
Loading