From 5bdfe3d586bf9b1b6defa687211f0e6d9ba5bf85 Mon Sep 17 00:00:00 2001 From: Lee Hansel Solevilla <13744167+sshanzel@users.noreply.github.com> Date: Mon, 10 Feb 2025 23:07:33 +0800 Subject: [PATCH] fix: paddle transaction management for gifting (#2648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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ć --- __tests__/paddle.ts | 66 +++++++++++++++---- src/paddle.ts | 2 +- src/routes/webhooks/paddle.ts | 117 +++++++++++++++++++++------------- 3 files changed, 126 insertions(+), 59 deletions(-) diff --git a/__tests__/paddle.ts b/__tests__/paddle.ts index ae7aea0f4..9994dfc99 100644 --- a/__tests__/paddle.ts +++ b/__tests__/paddle.ts @@ -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; @@ -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'; @@ -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) @@ -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) @@ -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) @@ -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 () => { @@ -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 }); @@ -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 }); diff --git a/src/paddle.ts b/src/paddle.ts index 971b86009..f3868af57 100644 --- a/src/paddle.ts +++ b/src/paddle.ts @@ -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; diff --git a/src/routes/webhooks/paddle.ts b/src/routes/webhooks/paddle.ts index 3770ce2af..6469b27ff 100644 --- a/src/routes/webhooks/paddle.ts +++ b/src/routes/webhooks/paddle.ts @@ -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: @@ -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, @@ -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, - }), }), }, ); @@ -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({ @@ -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 => { fastify.register(async (fastify: FastifyInstance): Promise => { fastify.post('/', { @@ -457,12 +486,12 @@ export const paddle = async (fastify: FastifyInstance): Promise => { } break; case EventName.TransactionCompleted: - Promise.all([ + await Promise.all([ logPaddleAnalyticsEvent( eventData, AnalyticsEventName.ReceivePayment, ), - notifyNewPaddleTransaction(eventData), + processTransactionCompleted({ event: eventData }), ]); break; default: