Repository: dailydotdev/daily-api
Pull request #2810: feat: add claimable item to user
Author: login: AmarTrebinjac
Created: 2025-05-14T03:27:52Z | Merged: 2025-05-16T09:29:02Z

Add claimable item to user upon creation, as well as identifies a cio customer by email.



Diff:
diff --git a/__tests__/paddle.ts b/__tests__/paddle.ts
index 16454f4763..9687a02d11 100644
--- a/__tests__/paddle.ts
+++ b/__tests__/paddle.ts
@@ -17,7 +17,8 @@ import {
   EventName,
   SubscriptionCreatedEvent,
   TransactionCompletedEvent,
-  Customer,
+  type Customer,
+  type SubscriptionStatus,
 } from '@paddle/paddle-node-sdk';
 import {
   PaddleCustomData,
@@ -123,7 +124,10 @@ const getPricingPreviewData = () => ({
   currencyCode: 'USD',
 });
 
-const getSubscriptionData = (customData: PaddleCustomData) =>
+const getSubscriptionData = (
+  customData: PaddleCustomData,
+  status: SubscriptionStatus = 'active',
+) =>
   new SubscriptionCreatedEvent({
     event_id: '1',
     notification_id: '1',
@@ -131,7 +135,7 @@ const getSubscriptionData = (customData: PaddleCustomData) =>
     occurred_at: new Date().toISOString(),
     data: {
       id: '1',
-      status: 'active',
+      status,
       transaction_id: '1',
       customer_id: '1',
       address_id: '1',
@@ -819,6 +823,138 @@ it('should not throw an error if the email has claimed a previously claimable su
   ).resolves.not.toThrow();
 });
 
+describe('anonymous subscription', () => {
+  it('should add an anonymous subscription to the claimable_items table', async () => {
+    const mockCustomer = { email: 'test@example.com' };
+
+    jest
+      .spyOn(paddleInstance.customers, 'get')
+      .mockResolvedValue(mockCustomer as Customer);
+
+    const data = getSubscriptionData({
+      user_id: undefined,
+    });
+
+    await updateUserSubscription({ event: data, state: true });
+
+    const claimableItem = await con
+      .getRepository(ClaimableItem)
+      .findOneByOrFail({ email: mockCustomer.email });
+
+    expect(claimableItem).toBeTruthy();
+    expect(claimableItem.email).toBe('test@example.com');
+    expect(claimableItem.type).toBe(ClaimableItemTypes.Plus);
+    expect(claimableItem.flags).toHaveProperty(
+      'cycle',
+      SubscriptionCycles.Yearly,
+    );
+    expect(claimableItem.flags).toHaveProperty(
+      'createdAt',
+      data.data.startedAt,
+    );
+    expect(claimableItem.flags).toHaveProperty('subscriptionId', data.data.id);
+    expect(claimableItem.flags).toHaveProperty(
+      'provider',
+      SubscriptionProvider.Paddle,
+    );
+    expect(claimableItem.flags).toHaveProperty(
+      'status',
+      UserSubscriptionStatus.Active,
+    );
+  });
+
+  it('should throw an error if the email already has a claimable subscription', async () => {
+    const mockCustomer = { email: 'test@example.com' };
+
+    jest
+      .spyOn(paddleInstance.customers, 'get')
+      .mockResolvedValue(mockCustomer as Customer);
+
+    const data = getSubscriptionData({
+      user_id: undefined,
+    });
+
+    await con.getRepository(ClaimableItem).save({
+      email: 'test@example.com',
+      type: ClaimableItemTypes.Plus,
+      flags: {
+        status: UserSubscriptionStatus.Active,
+        provider: SubscriptionProvider.Paddle,
+        cycle: SubscriptionCycles.Yearly,
+        subscriptionId: '1',
+      },
+    });
+
+    await expect(
+      updateUserSubscription({ event: data, state: true }),
+    ).rejects.toThrow(
+      `User test@example.com already has a claimable subscription`,
+    );
+  });
+
+  it('should not throw an error if the email has claimed a previously claimable subscription', async () => {
+    const mockCustomer = { email: 'test@example.com' };
+
+    jest
+      .spyOn(paddleInstance.customers, 'get')
+      .mockResolvedValue(mockCustomer as Customer);
+
+    await con.getRepository(ClaimableItem).save({
+      email: 'test@example.com',
+      type: ClaimableItemTypes.Plus,
+      flags: {
+        status: UserSubscriptionStatus.Active,
+        provider: SubscriptionProvider.Paddle,
+        cycle: SubscriptionCycles.Yearly,
+        subscriptionId: '1',
+      },
+      claimedAt: new Date(),
+    });
+
+    const data = getSubscriptionData({
+      user_id: undefined,
+    });
+
+    await expect(
+      updateUserSubscription({ event: data, state: true }),
+    ).resolves.not.toThrow();
+  });
+
+  it('should drop a claimable item if the subscription is canceled', async () => {
+    const mockCustomer = { email: 'test@example.com' };
+
+    await con.getRepository(ClaimableItem).save({
+      email: 'test@example.com',
+      type: ClaimableItemTypes.Plus,
+      flags: {
+        status: UserSubscriptionStatus.Active,
+        provider: SubscriptionProvider.Paddle,
+        cycle: SubscriptionCycles.Yearly,
+        subscriptionId: '1',
+      },
+    });
+
+    jest
+      .spyOn(paddleInstance.customers, 'get')
+      .mockResolvedValue(mockCustomer as Customer);
+
+    const data = getSubscriptionData(
+      {
+        user_id: undefined,
+      },
+      'canceled',
+    );
+
+    await updateUserSubscription({ event: data, state: false });
+
+    const claimableItem = await con
+      .getRepository(ClaimableItem)
+      .findOneBy({ email: mockCustomer.email });
+
+    expect(claimableItem).toBeNull();
+  });
+});
+
 describe('gift', () => {
   const logError = jest.fn();
 
diff --git a/__tests__/users.ts b/__tests__/users.ts
index 5301f03ffa..f49dc91381 100644
--- a/__tests__/users.ts
+++ b/__tests__/users.ts
@@ -90,7 +90,7 @@ import {
 import { DataSource, In, IsNull } from 'typeorm';
 import createOrGetConnection from '../src/db';
 import request from 'supertest';
-import { FastifyInstance } from 'fastify';
+import { FastifyInstance, type FastifyRequest } from 'fastify';
 import setCookieParser from 'set-cookie-parser';
 import { DisallowHandle } from '../src/entity/DisallowHandle';
 import { CampaignType, Invite } from '../src/entity/Invite';
@@ -127,6 +127,9 @@ import {
   UserTransactionStatus,
 } from '../src/entity/user/UserTransaction';
 import { DeletedUser } from '../src/entity/user/DeletedUser';
+import { randomUUID } from 'crypto';
+import { ClaimableItem, ClaimableItemTypes } from '../src/entity/ClaimableItem';
+import { addClaimableItemsToUser } from '../src/entity/user/utils';
 
 let con: DataSource;
 let app: FastifyInstance;
@@ -6377,3 +6380,102 @@ describe('query checkCoresRole', () => {
     expect(userAction).not.toBeNull();
   });
 });
+
+describe('add claimable items to user', () => {
+  const mockRequest = {
+    headers: { 'user-agent': 'test-agent' },
+    query: { param: 'value' },
+    params: { id: '123' },
+    body: { data: 'test' },
+    user: { id: '1' },
+    log: { info: jest.fn(), error: jest.fn() },
+    ip: '127.0.0.1',
+    hostname: 'localhost',
+    protocol: 'http',
+    method: 'GET',
+    url: '/test-url',
+  } as unknown as FastifyRequest;
+
+  it('should add the claimable item to the user', async () => {
+    const userId = randomUUID();
+    const claimableItemUuid = randomUUID();
+
+    const flags = {
+      cycle: 'yearly',
+      status: 'active',
+      provider: 'paddle',
+      createdAt: new Date(),
+      customerId: 'ctm_01jktawy94f7ypbn7x8wdvtv86',
+      subscriptionId: 'sub_01jv95ymhjr71a700kpx8txt2j',
+    };
+
+    await con.getRepository(ClaimableItem).save({
+      id: claimableItemUuid,
+      type: ClaimableItemTypes.Plus,
+      email: 'john.doe@example.com',
+      flags,
+    });
+
+    await con.getRepository(User).save({
+      id: userId,
+      name: 'John Doe',
+      email: 'john.doe@example.com',
+    });
+
+    await addClaimableItemsToUser(
+      con,
+      {
+        id: userId,
+        email: 'john.doe@example.com',
+        createdAt: new Date(),
+        name: '',
+        infoConfirmed: false,
+        acceptedMarketing: false,
+        experienceLevel: null,
+        language: null,
+      },
+      mockRequest as FastifyRequest,
+    );
+
+    const user = await con.getRepository(User).findOneBy({ id: userId });
+    expect(user).not.toBeNull();
+    expect(user?.subscriptionFlags).toBeDefined();
+    expect(user?.subscriptionFlags?.cycle).toEqual(flags.cycle);
+    expect(user?.subscriptionFlags?.status).toEqual(flags.status);
+    expect(user?.subscriptionFlags?.provider).toEqual(flags.provider);
+
+    const claimableItem = await con.getRepository(ClaimableItem).findOneBy({
+      id: claimableItemUuid,
+    });
+    expect(claimableItem).not.toBeNull();
+    expect(claimableItem?.claimedAt).not.toBeNull();
+  });
+
+  it('should create user but not add any claimable items if there were none found', async () => {
+    const userId = randomUUID();
+    await con.getRepository(User).save({
+      id: userId,
+      name: 'Clark Kent',
+      email: 'clark.kent@example.com',
+    });
+
+    await addClaimableItemsToUser(
+      con,
+      {
+        id: userId,
+        email: 'clark.kent@example.com',
+        createdAt: new Date(),
+        name: 'Clark Kent',
+        infoConfirmed: false,
+        acceptedMarketing: false,
+        experienceLevel: null,
+        language: null,
+      },
+      mockRequest as FastifyRequest,
+    );
+
+    const user = await con.getRepository(User).findOneBy({ id: userId });
+    expect(user).not.toBeNull();
+    expect(user?.subscriptionFlags).toMatchObject({});
+  });
+});
diff --git a/src/cio.ts b/src/cio.ts
index 983080e7c5..92ee6dde80 100644
--- a/src/cio.ts
+++ b/src/cio.ts
@@ -240,3 +240,36 @@ export async function identifyUserPersonalizedDigest({
     throw err;
   }
 }
+
+export async function identifyAnonymousFunnelSubscription({
+  cio,
+  email,
+  claimedSub,
+}: {
+  cio: TrackClient;
+  email: string;
+  claimedSub: boolean;
+}): Promise<void> {
+  try {
+    await cio.identify(email, {
+      funnel: true,
+      claimed_sub: claimedSub,
+    });
+  } catch (err) {
+    logger.warn({ err }, 'failed to identify anonymous funnel subscription');
+  }
+}
+
+export async function destroyAnonymousFunnelSubscription({
+  cio,
+  email,
+}: {
+  cio: TrackClient;
+  email: string;
+}): Promise<void> {
+  try {
+    await cio.destroy(email);
+  } catch (err) {
+    logger.warn({ err }, 'failed to destroy anonymous funnel subscription');
+  }
+}
diff --git a/src/common/paddle/index.ts b/src/common/paddle/index.ts
index 76dcb3e81f..4678d49d49 100644
--- a/src/common/paddle/index.ts
+++ b/src/common/paddle/index.ts
@@ -18,12 +18,25 @@ import { PricingPreviewLineItem } from '@paddle/paddle-node-sdk/dist/types/entit
 import { remoteConfig } from '../../remoteConfig';
 import { z } from 'zod';
 import { UserTransaction } from '../../entity/user/UserTransaction';
-import { IsNull, type DataSource, type EntityManager } from 'typeorm';
-import { SubscriptionProvider, UserSubscriptionStatus } from '../../entity';
+import {
+  IsNull,
+  JsonContains,
+  type DataSource,
+  type EntityManager,
+} from 'typeorm';
+import {
+  SubscriptionProvider,
+  UserSubscriptionStatus,
+  type ConnectionManager,
+} from '../../entity';
 import { isProd } from '../utils';
-import { ClaimableItemTypes } from '../../entity/ClaimableItem';
-import { ClaimableItem } from '../../entity/ClaimableItem';
+import { ClaimableItem, ClaimableItemTypes } from '../../entity/ClaimableItem';
 import { SubscriptionCycles, type PaddleSubscriptionEvent } from '../../paddle';
+import {
+  cio,
+  destroyAnonymousFunnelSubscription,
+  identifyAnonymousFunnelSubscription,
+} from '../../cio';
 
 export const paddleInstance = new Paddle(process.env.PADDLE_API_KEY, {
   environment: process.env.PADDLE_ENVIRONMENT as Environment,
@@ -210,10 +223,12 @@ export const extractSubscriptionCycle = (
     : SubscriptionCycles.Monthly;
 };
 
-export const insertClaimableItem = async (
-  con: DataSource | EntityManager,
+export const updateClaimableItem = async (
+  con: ConnectionManager,
   data: SubscriptionNotification | SubscriptionCreatedNotification,
 ) => {
+  if (data?.status === 'canceled' || data?.scheduledChange?.action === 'cancel')
+    return;
   const customer = await paddleInstance.customers.get(data.customerId);
 
   const existingEntries = await con.getRepository(ClaimableItem).find({
@@ -223,6 +238,12 @@ export const insertClaimableItem = async (
     },
   });
 
+  if (existingEntries.length > 0) {
+    throw new Error(
+      `User ${customer.email} already has a claimable subscription`,
+    );
+  }
+
   await con.getRepository(ClaimableItem).insert({
     type: ClaimableItemTypes.Plus,
     email: customer.email,
@@ -235,9 +256,29 @@ export const insertClaimableItem = async (
     },
   });
 
-  if (existingEntries.length > 0) {
-    throw new Error(
-      `User ${customer.email} already has a claimable subscription`,
-    );
-  }
+  await identifyAnonymousFunnelSubscription({
+    cio,
+    email: customer.email,
+    claimedSub: false,
+  });
+};
+
+export const dropClaimableItem = async (
+  con: ConnectionManager,
+  data: SubscriptionNotification,
+) => {
+  const customer = await paddleInstance.customers.get(data.customerId);
+
+  await con.getRepository(ClaimableItem).delete({
+    email: customer.email,
+    claimedAt: IsNull(),
+    flags: JsonContains({
+      subscriptionId: data.id,
+    }),
+  });
+
+  await destroyAnonymousFunnelSubscription({
+    cio,
+    email: customer.email,
+  });
 };
diff --git a/src/entity/ClaimableItem.ts b/src/entity/ClaimableItem.ts
index 739a29120d..0c4678875d 100644
--- a/src/entity/ClaimableItem.ts
+++ b/src/entity/ClaimableItem.ts
@@ -5,12 +5,25 @@ import {
   ManyToOne,
   PrimaryGeneratedColumn,
 } from 'typeorm';
-import type { User } from './user';
+import type {
+  SubscriptionProvider,
+  User,
+  UserSubscriptionStatus,
+} from './user';
+import type { SubscriptionCycles } from '../paddle';
 
 export enum ClaimableItemTypes {
   Plus = 'plus',
 }
 
+export type ClaimableItemFlags = {
+  cycle: SubscriptionCycles | null;
+  createdAt: Date | null;
+  subscriptionId: string | null;
+  provider: SubscriptionProvider | null;
+  status: UserSubscriptionStatus | null;
+};
+
 @Entity()
 export class ClaimableItem {
   @PrimaryGeneratedColumn('uuid')
@@ -30,11 +43,11 @@ export class ClaimableItem {
   type: ClaimableItemTypes;
 
   @Column({ type: 'jsonb', default: {} })
-  flags = {};
+  flags: ClaimableItemFlags;
 
   @Column({ nullable: true })
   claimedById?: string;
 
-  @ManyToOne('User', { lazy: true })
+  @ManyToOne('User', { lazy: true, onDelete: 'CASCADE' })
   claimedBy?: Promise<User>;
 }
diff --git a/src/entity/user/utils.ts b/src/entity/user/utils.ts
index 35ecf7a082..f10638a889 100644
--- a/src/entity/user/utils.ts
+++ b/src/entity/user/utils.ts
@@ -5,7 +5,7 @@ import {
   UserFailErrorKeys,
 } from '../../errors';
 import { ContentLanguage } from '../../types';
-import { DataSource, DeepPartial, EntityManager } from 'typeorm';
+import { DataSource, DeepPartial, EntityManager, IsNull } from 'typeorm';
 import { FastifyBaseLogger, FastifyRequest } from 'fastify';
 import { counters } from '../../telemetry';
 import { generateTrackingId } from '../../ids';
@@ -34,13 +34,15 @@ import { GQLUpdateUserInput } from '../../schema/users';
 import { validateValidTimeZone } from '../../common/timezone';
 import { nameRegex, validateRegex, ValidateRegex } from '../../common/object';
 import { logger } from '../../logger';
-import { User } from './User';
+import { User, type UserSubscriptionFlags } from './User';
 import { Feed } from '../Feed';
 import { remoteConfig } from '../../remoteConfig';
 import { getUserCoresRole } from '../../common/user';
 import { insertOrIgnoreAction } from '../../schema/actions';
 import { UserActionType } from './UserAction';
 import { DeletedUser } from './DeletedUser';
+import { ClaimableItem } from '../ClaimableItem';
+import { cio, identifyAnonymousFunnelSubscription } from '../../cio';
 
 export type AddUserData = Pick<
   User,
@@ -368,6 +370,41 @@ export const addNewUser = async (
   }
 };
 
+export const addClaimableItemsToUser = async (
+  con: DataSource,
+  body: AddUserData,
+  req: FastifyRequest,
+) => {
+  try {
+    const subscription = await con
+      .getRepository(ClaimableItem)
+      .findOneBy({ email: body.email, claimedById: IsNull() });
+
+    if (subscription) {
+      await con.transaction(async (em) => {
+        await em.getRepository(ClaimableItem).update(subscription.id, {
+          claimedById: body.id,
+          claimedAt: new Date(),
+        });
+        await em.getRepository(User).update(body.id, {
+          subscriptionFlags: subscription.flags as UserSubscriptionFlags,
+        });
+      });
+
+      await identifyAnonymousFunnelSubscription({
+        cio,
+        email: body.email,
+        claimedSub: true,
+      });
+    }
+  } catch (err) {
+    req.log.error(
+      { err, userId: body.id },
+      'Error adding claimable items to user',
+    );
+  }
+};
+
 export const validateUserUpdate = async (
   user: User,
   data: GQLUpdateUserInput,
diff --git a/src/migration/1747238500907-ClaimableItem.ts b/src/migration/1747238500907-ClaimableItem.ts
index 0baa2ce03b..18c4c33d72 100644
--- a/src/migration/1747238500907-ClaimableItem.ts
+++ b/src/migration/1747238500907-ClaimableItem.ts
@@ -6,7 +6,7 @@ export class ClaimableItem1747238500907 implements MigrationInterface {
     public async up(queryRunner: QueryRunner): Promise<void> {
         await queryRunner.query(`CREATE TABLE "claimable_item" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "claimedAt" TIMESTAMP, "type" character varying NOT NULL, "flags" jsonb NOT NULL DEFAULT '{}', "claimedById" character varying, CONSTRAINT "PK_29e58275287423cbbd1dc64469b" PRIMARY KEY ("id"))`);
         await queryRunner.query(`CREATE INDEX "IDX_claimable_item_email" ON "claimable_item" ("email") `);
-        await queryRunner.query(`ALTER TABLE "claimable_item" ADD CONSTRAINT "FK_a91f7947d5dd4d5567d6a473bf4" FOREIGN KEY ("claimedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "claimable_item" ADD CONSTRAINT "FK_a91f7947d5dd4d5567d6a473bf4" FOREIGN KEY ("claimedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
     }
 
     public async down(queryRunner: QueryRunner): Promise<void> {
diff --git a/src/routes/private.ts b/src/routes/private.ts
index 5ba210619b..e1cdf132f8 100644
--- a/src/routes/private.ts
+++ b/src/routes/private.ts
@@ -8,6 +8,7 @@ import type {
   UpdateUserEmailData,
 } from '../entity/user/utils';
 import {
+  addClaimableItemsToUser,
   addNewUser,
   confirmUserEmail,
   updateUserEmail,
@@ -41,6 +42,9 @@ export default async function (fastify: FastifyInstance): Promise<void> {
 
     const body = { ...rest, referralId, referralOrigin };
     const operationResult = await addNewUser(con, body, req);
+
+    await addClaimableItemsToUser(con, body, req);
+
     return res.status(200).send(operationResult);
   });
   fastify.post<{ Body: UpdateUserEmailData }>(
diff --git a/src/routes/webhooks/paddle.ts b/src/routes/webhooks/paddle.ts
index 74d9c69966..749315035e 100644
--- a/src/routes/webhooks/paddle.ts
+++ b/src/routes/webhooks/paddle.ts
@@ -32,10 +32,11 @@ import {
 } from '../../integrations/analytics';
 import { JsonContains, type DataSource, type EntityManager } from 'typeorm';
 import {
+  dropClaimableItem,
   extractSubscriptionCycle,
   getPaddleTransactionData,
   getTransactionForProviderId,
-  insertClaimableItem,
+  updateClaimableItem,
   isCoreTransaction,
   paddleInstance,
 } from '../../common/paddle';
@@ -91,9 +92,12 @@ export const updateUserSubscription = async ({
     );
     return false;
   }
-
   if (!userId) {
-    await insertClaimableItem(con, data);
+    if (state) {
+      await updateClaimableItem(con, data);
+    } else {
+      await dropClaimableItem(con, data);
+    }
   } else {
     const user = await con.getRepository(User).findOneBy({ id: userId });
     if (!user) {


Reviews:
user: login: AmarTrebinjac
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T03:29:33Z
user: login: sshanzel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T04:26:57Z
user: login: sshanzel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T04:33:05Z
user: login: sshanzel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T04:33:41Z
user: login: sshanzel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T04:35:40Z
user: login: sshanzel
body: Few things but nothing major.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T04:47:42Z
user: login: idoshamun
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T04:50:53Z
user: login: AmarTrebinjac
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T04:52:35Z
user: login: idoshamun
body: Just fix @sshanzel's comment and make cascade deletion
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-16T04:54:51Z
user: login: sshanzel
body: 🚀 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T05:14:43Z
user: login: sshanzel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:29:54Z
user: login: sshanzel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:31:58Z
user: login: sshanzel
body: I have nothing against updating the flags from the claimable entity, although I think it is something we can ignore doing if not necessary.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:32:34Z
user: login: capJavert
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:37:13Z
user: login: capJavert
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:39:46Z
user: login: capJavert
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:44:26Z
user: login: AmarTrebinjac
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:45:25Z
user: login: sshanzel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:51:40Z
user: login: capJavert
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:57:40Z
user: login: capJavert
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:59:48Z

Review comments:
diff_hunk: @@ -368,6 +369,39 @@ export const addNewUser = async (
   }
 };
 
+export const addClaimableItemsToUser = async (
+  con: DataSource,
+  body: AddUserData,
+  req: FastifyRequest,
+) => {
+  try {
+    const claimableItems = await con
+      .getRepository(ClaimableItem)
+      .findBy({ email: body.email });
+    if (claimableItems.length > 0) {
+      const subscription = claimableItems.find(
path: src/entity/user/utils.ts
user: login: AmarTrebinjac
body: Right now the only thing we're using this for is adding a sub bought from funnels, so this should be okay. We will need to change this to a loop when we add more options, though.
created_at: 2025-05-14T03:29:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,6 +35,6 @@ export class ClaimableItem {
   @Column({ nullable: true })
   claimedById?: string;
 
-  @ManyToOne('User', { lazy: true })
+  @ManyToOne('User', { lazy: true, onDelete: 'SET NULL' })
path: src/entity/ClaimableItem.ts
user: login: sshanzel
body: Should we delete the entity here too (Cascade), when the user deletes their account?
created_at: 2025-05-16T04:26:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -240,4 +250,30 @@ export const insertClaimableItem = async (
       `User ${customer.email} already has a claimable subscription`,
     );
   }
+
+  await identifyAnonymousFunnelSubscription({
+    cio,
+    email: customer.email,
+    claimedSub: false,
+  });
+};
+
+export const dropClaimableItem = async (
+  con: DataSource | EntityManager,
path: src/common/paddle/index.ts
user: login: sshanzel
body: I think we have a common type for this.
```suggestion
  con: ConnectionManager,
```
created_at: 2025-05-16T04:33:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -240,4 +250,30 @@ export const insertClaimableItem = async (
       `User ${customer.email} already has a claimable subscription`,
     );
   }
+
+  await identifyAnonymousFunnelSubscription({
+    cio,
+    email: customer.email,
+    claimedSub: false,
+  });
+};
+
+export const dropClaimableItem = async (
+  con: DataSource | EntityManager,
+  data: SubscriptionNotification | SubscriptionCreatedNotification,
path: src/common/paddle/index.ts
user: login: sshanzel
body: ```suggestion
  data: SubscriptionNotification,
```
created_at: 2025-05-16T04:33:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -368,6 +369,39 @@ export const addNewUser = async (
   }
 };
 
+export const addClaimableItemsToUser = async (
+  con: DataSource,
+  body: AddUserData,
+  req: FastifyRequest,
+) => {
+  try {
+    const claimableItems = await con
+      .getRepository(ClaimableItem)
+      .findBy({ email: body.email });
+    if (claimableItems.length > 0) {
+      const subscription = claimableItems.find(
path: src/entity/user/utils.ts
user: login: sshanzel
body: Why not make this part of the query conditions?
created_at: 2025-05-16T04:35:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,6 +35,6 @@ export class ClaimableItem {
   @Column({ nullable: true })
   claimedById?: string;
 
-  @ManyToOne('User', { lazy: true })
+  @ManyToOne('User', { lazy: true, onDelete: 'SET NULL' })
path: src/entity/ClaimableItem.ts
user: login: idoshamun
body: yes we should, otherwise it may be seen like unclaimed
created_at: 2025-05-16T04:50:53Z
author_association: MEMBER
diff_hunk: @@ -368,6 +369,39 @@ export const addNewUser = async (
   }
 };
 
+export const addClaimableItemsToUser = async (
+  con: DataSource,
+  body: AddUserData,
+  req: FastifyRequest,
+) => {
+  try {
+    const claimableItems = await con
+      .getRepository(ClaimableItem)
+      .findBy({ email: body.email });
+    if (claimableItems.length > 0) {
+      const subscription = claimableItems.find(
path: src/entity/user/utils.ts
user: login: AmarTrebinjac
body: Youre right. Updated!
created_at: 2025-05-16T04:52:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -237,17 +239,29 @@ export const insertClaimableItem = async (
     },
   });
 
-  await con.getRepository(ClaimableItem).insert({
-    type: ClaimableItemTypes.Plus,
-    email: customer.email,
-    flags: {
-      cycle: extractSubscriptionCycle(data.items),
-      createdAt: data.startedAt,
-      subscriptionId: data.id,
-      provider: SubscriptionProvider.Paddle,
-      status: UserSubscriptionStatus.Active,
-    },
-  });
+  const currentSubscription = existingEntries.find(
+    (entry) => entry.flags.subscriptionId === data.id,
+  );
+
+  const flags = {
+    cycle: state ? extractSubscriptionCycle(data.items) : null,
+    createdAt: state ? data.startedAt : null,
+    subscriptionId: state ? data.id : null,
+    provider: state ? SubscriptionProvider.Paddle : null,
+    status: state ? UserSubscriptionStatus.Active : null,
path: src/common/paddle/index.ts
user: login: sshanzel
body: Just a random thought. I think we don't even have to run updates on this entity. This table is more of a record for when user is trying to claim their subscription. Cancelling it shouldn't matter here since the entity has served its purpose already.

We should only:
- Create
- Claim
- Delete
created_at: 2025-05-16T08:29:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,11 +43,11 @@ export class ClaimableItem {
   type: ClaimableItemTypes;
 
   @Column({ type: 'jsonb', default: {} })
-  flags = {};
+  flags: ClaimableItemFlags;
 
   @Column({ nullable: true })
   claimedById?: string;
 
-  @ManyToOne('User', { lazy: true })
+  @ManyToOne('User', { lazy: true, onDelete: 'SET NULL' })
path: src/entity/ClaimableItem.ts
user: login: sshanzel
body: ```suggestion
  @ManyToOne('User', { lazy: true, onDelete: 'CASCADE' })
```
created_at: 2025-05-16T08:31:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -237,17 +239,29 @@ export const insertClaimableItem = async (
     },
   });
 
-  await con.getRepository(ClaimableItem).insert({
-    type: ClaimableItemTypes.Plus,
-    email: customer.email,
-    flags: {
-      cycle: extractSubscriptionCycle(data.items),
-      createdAt: data.startedAt,
-      subscriptionId: data.id,
-      provider: SubscriptionProvider.Paddle,
-      status: UserSubscriptionStatus.Active,
-    },
-  });
+  const currentSubscription = existingEntries.find(
+    (entry) => entry.flags.subscriptionId === data.id,
+  );
+
+  const flags = {
+    cycle: state ? extractSubscriptionCycle(data.items) : null,
+    createdAt: state ? data.startedAt : null,
+    subscriptionId: state ? data.id : null,
+    provider: state ? SubscriptionProvider.Paddle : null,
+    status: state ? UserSubscriptionStatus.Active : null,
path: src/common/paddle/index.ts
user: login: capJavert
body: Technically user could change subscription type in paddle while still not registered, and then it would claim different sub?
created_at: 2025-05-16T08:37:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -368,6 +370,39 @@ export const addNewUser = async (
   }
 };
 
+export const addClaimableItemsToUser = async (
+  con: DataSource,
+  body: AddUserData,
+  req: FastifyRequest,
+) => {
+  try {
+    const subscription = await con
+      .getRepository(ClaimableItem)
+      .findOneBy({ email: body.email, claimedById: IsNull() });
+
+    if (subscription) {
+      await con.getRepository(ClaimableItem).update(subscription.id, {
+        claimedById: body.id,
+        claimedAt: new Date(),
+      });
+      await con.getRepository(User).update(body.id, {
+        subscriptionFlags: subscription.flags,
+      });
path: src/entity/user/utils.ts
user: login: capJavert
body: Let's do it in a transaaction
created_at: 2025-05-16T08:39:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,21 +239,59 @@ export const insertClaimableItem = async (
     },
   });
 
-  await con.getRepository(ClaimableItem).insert({
-    type: ClaimableItemTypes.Plus,
-    email: customer.email,
-    flags: {
-      cycle: extractSubscriptionCycle(data.items),
-      createdAt: data.startedAt,
-      subscriptionId: data.id,
-      provider: SubscriptionProvider.Paddle,
-      status: UserSubscriptionStatus.Active,
-    },
-  });
+  const currentSubscription = existingEntries.find(
+    (entry) => entry.flags.subscriptionId === data.id,
+  );
+
+  const flags = {
+    cycle: state ? extractSubscriptionCycle(data.items) : null,
+    createdAt: state ? data.startedAt : null,
+    subscriptionId: state ? data.id : null,
+    provider: state ? SubscriptionProvider.Paddle : null,
+    status: state ? UserSubscriptionStatus.Active : null,
+  };
+
+  if (currentSubscription) {
+    await con.getRepository(ClaimableItem).update(currentSubscription.id, {
+      flags,
+    });
+  } else {
+    await con.getRepository(ClaimableItem).insert({
+      type: ClaimableItemTypes.Plus,
+      email: customer.email,
+      flags,
+    });
+  }
 
   if (existingEntries.length > 0) {
     throw new Error(
       `User ${customer.email} already has a claimable subscription`,
     );
   }
+
+  await identifyAnonymousFunnelSubscription({
+    cio,
+    email: customer.email,
+    claimedSub: false,
+  });
+};
+
+export const dropClaimableItem = async (
+  con: ConnectionManager,
+  data: SubscriptionNotification,
+) => {
+  const customer = await paddleInstance.customers.get(data.customerId);
+
+  await con.getRepository(ClaimableItem).delete({
+    email: customer.email,
+    claimedAt: IsNull(),
+    flags: JsonContains({
+      subscriptionId: data.id,
+    }),
path: src/common/paddle/index.ts
user: login: capJavert
body: Better to use createQueryBuilder and match by `flags->>'subscriptionId' = :dataId`, then we can also add an index for it.
created_at: 2025-05-16T08:44:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -368,6 +370,39 @@ export const addNewUser = async (
   }
 };
 
+export const addClaimableItemsToUser = async (
+  con: DataSource,
+  body: AddUserData,
+  req: FastifyRequest,
+) => {
+  try {
+    const subscription = await con
+      .getRepository(ClaimableItem)
+      .findOneBy({ email: body.email, claimedById: IsNull() });
+
+    if (subscription) {
+      await con.getRepository(ClaimableItem).update(subscription.id, {
+        claimedById: body.id,
+        claimedAt: new Date(),
+      });
+      await con.getRepository(User).update(body.id, {
+        subscriptionFlags: subscription.flags,
+      });
path: src/entity/user/utils.ts
user: login: AmarTrebinjac
body: @capJavert added
created_at: 2025-05-16T08:45:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -237,17 +239,29 @@ export const insertClaimableItem = async (
     },
   });
 
-  await con.getRepository(ClaimableItem).insert({
-    type: ClaimableItemTypes.Plus,
-    email: customer.email,
-    flags: {
-      cycle: extractSubscriptionCycle(data.items),
-      createdAt: data.startedAt,
-      subscriptionId: data.id,
-      provider: SubscriptionProvider.Paddle,
-      status: UserSubscriptionStatus.Active,
-    },
-  });
+  const currentSubscription = existingEntries.find(
+    (entry) => entry.flags.subscriptionId === data.id,
+  );
+
+  const flags = {
+    cycle: state ? extractSubscriptionCycle(data.items) : null,
+    createdAt: state ? data.startedAt : null,
+    subscriptionId: state ? data.id : null,
+    provider: state ? SubscriptionProvider.Paddle : null,
+    status: state ? UserSubscriptionStatus.Active : null,
path: src/common/paddle/index.ts
user: login: sshanzel
body: Makes sense. Then yeah, let's keep the update all throughout.
created_at: 2025-05-16T08:51:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,21 +239,59 @@ export const insertClaimableItem = async (
     },
   });
 
-  await con.getRepository(ClaimableItem).insert({
-    type: ClaimableItemTypes.Plus,
-    email: customer.email,
-    flags: {
-      cycle: extractSubscriptionCycle(data.items),
-      createdAt: data.startedAt,
-      subscriptionId: data.id,
-      provider: SubscriptionProvider.Paddle,
-      status: UserSubscriptionStatus.Active,
-    },
-  });
+  const currentSubscription = existingEntries.find(
+    (entry) => entry.flags.subscriptionId === data.id,
+  );
+
+  const flags = {
+    cycle: state ? extractSubscriptionCycle(data.items) : null,
+    createdAt: state ? data.startedAt : null,
+    subscriptionId: state ? data.id : null,
+    provider: state ? SubscriptionProvider.Paddle : null,
+    status: state ? UserSubscriptionStatus.Active : null,
+  };
+
+  if (currentSubscription) {
+    await con.getRepository(ClaimableItem).update(currentSubscription.id, {
+      flags,
+    });
+  } else {
+    await con.getRepository(ClaimableItem).insert({
+      type: ClaimableItemTypes.Plus,
+      email: customer.email,
+      flags,
+    });
+  }
 
   if (existingEntries.length > 0) {
     throw new Error(
       `User ${customer.email} already has a claimable subscription`,
     );
   }
+
+  await identifyAnonymousFunnelSubscription({
+    cio,
+    email: customer.email,
+    claimedSub: false,
+  });
+};
+
+export const dropClaimableItem = async (
+  con: ConnectionManager,
+  data: SubscriptionNotification,
+) => {
+  const customer = await paddleInstance.customers.get(data.customerId);
+
+  await con.getRepository(ClaimableItem).delete({
+    email: customer.email,
+    claimedAt: IsNull(),
+    flags: JsonContains({
+      subscriptionId: data.id,
+    }),
path: src/common/paddle/index.ts
user: login: capJavert
body: non blocking
created_at: 2025-05-16T08:57:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: sshanzel
created_at: 2025-05-16T04:26:02Z
body: > canceling through the dashboard

Did you have the proper setup for the webhook so that your system will receive the message from cancelling in the dashboard?
author_association: CONTRIBUTOR
user: login: AmarTrebinjac
created_at: 2025-05-16T05:05:42Z
body: > > canceling through the dashboard
> 
> Did you have the proper setup for the webhook so that your system will receive the message from cancelling in the dashboard?

@sshanzel omg, you are absolutely right! I hadn't hooked off that event in the paddle dashboard when I made this 🤦🏼 
author_association: CONTRIBUTOR