Skip to content

Commit

Permalink
fix: paddle transaction management for gifting (#2648)
Browse files Browse the repository at this point in the history
One-time payments are not considered subscriptions in Paddle's
dictionary, so we must handle the event for gifting within the
TransactionCompleted.

---------

Co-authored-by: Ante Barić <[email protected]>
  • Loading branch information
sshanzel and capJavert authored Feb 10, 2025
1 parent 44c151d commit 5bdfe3d
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 59 deletions.
66 changes: 52 additions & 14 deletions __tests__/paddle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import createOrGetConnection from '../src/db';
import { saveFixtures } from './helpers';
import { User } from '../src/entity';
import { plusUsersFixture, usersFixture } from './fixture';
import { EventName, SubscriptionCreatedEvent } from '@paddle/paddle-node-sdk';
import {
EventName,
SubscriptionCreatedEvent,
TransactionCompletedEvent,
} from '@paddle/paddle-node-sdk';
import {
PaddleCustomData,
processGiftedPayment,
updateUserSubscription,
} from '../src/routes/webhooks/paddle';
import { isPlusMember, SubscriptionCycles } from '../src/paddle';
import { FastifyInstance } from 'fastify';
import appFunc from '../src';
import { logger } from '../src/logger';

let app: FastifyInstance;
let con: DataSource;
Expand Down Expand Up @@ -76,6 +82,29 @@ const getSubscriptionData = (customData: PaddleCustomData) =>
},
});

const getTransactionData = (customData: PaddleCustomData) =>
new TransactionCompletedEvent({
event_id: '1',
notification_id: '1',
event_type: EventName.SubscriptionCreated,
occurred_at: new Date().toISOString(),
data: {
id: '1',
customer_id: '1',
address_id: '1',
business_id: null,
custom_data: customData,
currency_code: 'USD',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
collection_mode: 'automatic',
status: 'completed',
origin: 'web',
payments: [],
items: [],
},
});

describe('plus subscription', () => {
it('should add a plus subscription to a user', async () => {
const userId = 'whp-1';
Expand All @@ -97,14 +126,21 @@ describe('plus subscription', () => {
});

describe('gift', () => {
const logError = jest.fn();

beforeEach(() => {
jest.resetAllMocks();
});

it('should ignore if gifter user is not valid', async () => {
jest.spyOn(logger, 'error').mockImplementation(logError);
const userId = 'whp-2';
const data = getSubscriptionData({
const result = getTransactionData({
user_id: userId,
gifter_id: 'whp-10',
});
const res = await updateUserSubscription({ data, state: true });
expect(res).toBe(false);
await processGiftedPayment({ event: result });
expect(logError).toHaveBeenCalled();

const updatedUser = await con
.getRepository(User)
Expand All @@ -114,17 +150,18 @@ describe('gift', () => {
});

it('should ignore if gifter and user is the same', async () => {
jest.spyOn(logger, 'error').mockImplementation(logError);
const userId = 'whp-2';
const user = await con.getRepository(User).findOneByOrFail({ id: userId });
const isInitiallyPlus = isPlusMember(user.subscriptionFlags?.cycle);
expect(isInitiallyPlus).toBe(false);

const data = getSubscriptionData({
const result = getTransactionData({
user_id: userId,
gifter_id: userId,
});
const res = await updateUserSubscription({ data, state: true });
expect(res).toBe(false);
await processGiftedPayment({ event: result });
expect(logError).toHaveBeenCalled();

const updatedUser = await con
.getRepository(User)
Expand All @@ -134,6 +171,7 @@ describe('gift', () => {
});

it('should ignore if user is already plus', async () => {
jest.spyOn(logger, 'error').mockImplementation(logError);
const userId = 'whp-1';
await con
.getRepository(User)
Expand All @@ -145,12 +183,12 @@ describe('gift', () => {
const isInitiallyPlus = isPlusMember(user.subscriptionFlags?.cycle);
expect(isInitiallyPlus).toBe(true);

const data = getSubscriptionData({
const result = getTransactionData({
user_id: user.id,
gifter_id: 'whp-2',
});
const res = await updateUserSubscription({ data, state: true });
expect(res).toBe(false);
await processGiftedPayment({ event: result });
expect(logError).toHaveBeenCalled();
});

it('should gift a subscription to a user', async () => {
Expand All @@ -160,12 +198,12 @@ describe('gift', () => {

expect(isInitiallyPlus).toBe(false);

const data = getSubscriptionData({
const result = getTransactionData({
user_id: userId,
gifter_id: 'whp-2',
});
await updateUserSubscription({ data, state: true });

await processGiftedPayment({ event: result });
const updatedUser = await con
.getRepository(User)
.findOneByOrFail({ id: userId });
Expand All @@ -189,12 +227,12 @@ describe('gift', () => {
trustScore: 1,
});

const data = getSubscriptionData({
const result = getTransactionData({
user_id: userId,
gifter_id: 'whp-2',
});
await updateUserSubscription({ data, state: true });

await processGiftedPayment({ event: result });
const updatedUser = await con
.getRepository(User)
.findOneByOrFail({ id: userId });
Expand Down
2 changes: 1 addition & 1 deletion src/paddle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export enum SubscriptionCycles {
}

// one year
export const subscriptionGiftDuration = 31557600000;
export const plusGiftDuration = 31557600000;

export const isPlusMember = (cycle: SubscriptionCycles | undefined): boolean =>
!!cycle?.length || false;
Expand Down
117 changes: 73 additions & 44 deletions src/routes/webhooks/paddle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import {
import { JsonContains } from 'typeorm';
import { paddleInstance } from '../../common/paddle';
import { addMilliseconds } from 'date-fns';
import { isPlusMember, subscriptionGiftDuration } from '../../paddle';
import {
isPlusMember,
plusGiftDuration,
SubscriptionCycles,
} from '../../paddle';

const extractSubscriptionType = (
items:
Expand Down Expand Up @@ -86,33 +90,6 @@ export const updateUserSubscription = async ({
return false;
}

const { gifter_id: gifterId } = customData;
const duration = subscriptionGiftDuration;
const isGift = !!gifterId;
if (isGift) {
if (userId === gifterId) {
logger.error({ type: 'paddle', data }, 'User and gifter are the same');
return false;
}

const gifterUser = await con
.getRepository(User)
.findOneBy({ id: gifterId });
if (!gifterUser) {
logger.error({ type: 'paddle', data }, 'Gifter user not found');
return false;
}

const targetUser = await con.getRepository(User).findOne({
select: ['subscriptionFlags'],
where: { id: userId },
});
if (isPlusMember(targetUser?.subscriptionFlags?.cycle)) {
logger.error({ type: 'paddle', data }, 'User is already a Plus member');
return false;
}
}

await con.getRepository(User).update(
{
id: userId,
Expand All @@ -122,18 +99,6 @@ export const updateUserSubscription = async ({
cycle: state ? subscriptionType : null,
createdAt: state ? data.data?.startedAt : null,
subscriptionId: state ? data.data?.id : null,
...(isGift && {
gifterId,
giftExpirationDate: addMilliseconds(
new Date(),
duration,
).toISOString(),
}),
}),
...(isGift && {
flags: updateFlagsStatement({
showPlusGift: isGift,
}),
}),
},
);
Expand Down Expand Up @@ -246,8 +211,10 @@ const logPaddleAnalyticsEvent = async (

const concatText = (a: string, b: string) => [a, b].filter(Boolean).join(`\n`);
const notifyNewPaddleTransaction = async ({
data,
}: TransactionCompletedEvent) => {
event: { data },
}: {
event: TransactionCompletedEvent;
}) => {
const { user_id, gifter_id } = (data?.customData ?? {}) as PaddleCustomData;
const purchasedById = gifter_id ?? user_id;
const subscriptionForId = await getUserId({
Expand Down Expand Up @@ -405,6 +372,68 @@ const notifyNewPaddleTransaction = async ({
await webhooks.transactions.send({ blocks });
};

export const processGiftedPayment = async ({
event: { data },
}: {
event: TransactionCompletedEvent;
}) => {
const con = await createOrGetConnection();
const { gifter_id, user_id } = data.customData as PaddleCustomData;

if (user_id === gifter_id) {
logger.error({ type: 'paddle', data }, 'User and gifter are the same');
return;
}

const gifterUser = await con.getRepository(User).findOneBy({ id: gifter_id });

if (!gifterUser) {
logger.error({ type: 'paddle', data }, 'Gifter user not found');
return;
}

const targetUser = await con.getRepository(User).findOne({
select: ['subscriptionFlags'],
where: { id: user_id },
});

if (isPlusMember(targetUser?.subscriptionFlags?.cycle)) {
logger.error({ type: 'paddle', data }, 'User is already a Plus member');
return;
}

await con.getRepository(User).update(
{ id: user_id },
{
subscriptionFlags: updateSubscriptionFlags({
cycle: SubscriptionCycles.Yearly,
createdAt: data?.createdAt,
subscriptionId: data?.id,
gifterId: gifter_id,
giftExpirationDate: addMilliseconds(
new Date(),
plusGiftDuration,
).toISOString(),
}),
flags: updateFlagsStatement({ showPlusGift: true }),
},
);
};

export const processTransactionCompleted = async ({
event,
}: {
event: TransactionCompletedEvent;
}) => {
const { gifter_id } = (event?.data?.customData ?? {}) as PaddleCustomData;

if (gifter_id) {
await processGiftedPayment({ event });
}

await notifyNewPaddleTransaction({ event });
};

export const paddle = async (fastify: FastifyInstance): Promise<void> => {
fastify.register(async (fastify: FastifyInstance): Promise<void> => {
fastify.post('/', {
Expand Down Expand Up @@ -457,12 +486,12 @@ export const paddle = async (fastify: FastifyInstance): Promise<void> => {
}
break;
case EventName.TransactionCompleted:
Promise.all([
await Promise.all([
logPaddleAnalyticsEvent(
eventData,
AnalyticsEventName.ReceivePayment,
),
notifyNewPaddleTransaction(eventData),
processTransactionCompleted({ event: eventData }),
]);
break;
default:
Expand Down

0 comments on commit 5bdfe3d

Please sign in to comment.