From e8583c9be43e8d2e56aba6abf9cec55eeff2aa2b Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 10 Dec 2025 16:01:52 +0100 Subject: [PATCH 1/5] feat: recruiter subscription --- seeds/ExperimentVariant.json | 9 +- src/common/paddle/pricing.ts | 14 +- src/common/paddle/recruiter/eventHandler.ts | 52 +++++++ src/common/paddle/recruiter/processing.ts | 118 +++++++++++++++ src/common/paddle/recruiter/types.ts | 6 + src/common/paddle/slack.ts | 142 ++++++++++++++++++ src/common/schema/opportunities.ts | 28 ++++ src/entity/opportunities/Opportunity.ts | 5 + src/graphorm/index.ts | 8 + ...5378734870-OpportunitySubscriptionFlags.ts | 19 +++ src/routes/webhooks/paddle/index.ts | 2 + src/schema/opportunity.ts | 1 + 12 files changed, 401 insertions(+), 3 deletions(-) create mode 100644 src/common/paddle/recruiter/eventHandler.ts create mode 100644 src/common/paddle/recruiter/processing.ts create mode 100644 src/common/paddle/recruiter/types.ts create mode 100644 src/migration/1765378734870-OpportunitySubscriptionFlags.ts diff --git a/seeds/ExperimentVariant.json b/seeds/ExperimentVariant.json index 099d226507..927348dfb1 100644 --- a/seeds/ExperimentVariant.json +++ b/seeds/ExperimentVariant.json @@ -10,7 +10,7 @@ "feature": "cores_pricing_ids", "variant": "cores_default", "createdAt": "2025-04-23T11:45:36.548Z", - "value": "[{\"title\":\"100 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q5nezsvh8nz900e0amxzz\",\"ios\":\"cores_100\"},\"coresValue\":100},{\"title\":\"300 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q6er5mnsz8sw29bb2j4n4\",\"ios\":\"cores_300\"},\"coresValue\":300},{\"title\":\"600 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q71dgtxzsv89ht75zhffj\",\"ios\":\"cores_600\"},\"coresValue\":600},{\"title\":\"1,200 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q7v1ndyc5j01q6q2vq0e2\",\"ios\":\"cores_1200\"},\"coresValue\":1200},{\"title\":\"2,400 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q864c61s876jdvhpcav7c\",\"ios\":\"cores_2400\"},\"coresValue\":2400},{\"title\":\"5,000 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q8h90cnr475ebb2bppqsd\",\"ios\":\"cores_5000\"},\"coresValue\":5000},{\"title\":\"8,000 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q8vn7mhxcztxcbrksqq97\",\"ios\":\"cores_8000\"},\"coresValue\":8000},{\"title\":\"12,000 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q9dq16epxasg025crpqt1\",\"ios\":\"cores_12000\"},\"coresValue\":12000},{\"title\":\"25,000 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q9xyg7apk8wn7jh5a7tye\",\"ios\":\"cores_25000\"},\"coresValue\":25000}]", + "value": "[{\"title\":\"100 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q5nezsvh8nz900e0amxzz\",\"ios\":\"cores_100\"},\"coresValue\":100},{\"title\":\"300 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q6er5mnsz8sw29bb2j4n4\",\"ios\":\"cores_300\"},\"coresValue\":300},{\"title\":\"600 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q71dgtxzsv89ht75zhffj\",\"ios\":\"cores_600\"},\"coresValue\":600},{\"title\":\"1,200 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q7v1ndyc5j01q6q2vq0e2\",\"ios\":\"cores_1200\"},\"coresValue\":1200},{\"title\":\"2,400 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q864c61s876jdvhpcav7c\",\"ios\":\"cores_2400\"},\"coresValue\":2400},{\"title\":\"5,000 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q8h90cnr475ebb2bppqsd\",\"ios\":\"cores_5000\"},\"coresValue\":5000},{\"title\":\"8,000 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q8vn7mhxcztxcbrksqq97\",\"ios\":\"cores_8000\"},\"coresValue\":8000},{\"title\":\"12,000 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q9dq16epxasg025crpqt1\",\"ios\":\"cores_12000\"},\"coresValue\":12000},{\"title\":\"25,000 Cores\",\"idMap\":{\"paddle\":\"pri_01jp2q9xyg7apk8wn7jh5a7tye\",\"ios\":\"cores_25000\"},\"coresValue\":25000}]", "type": "productPricing" }, { @@ -19,5 +19,12 @@ "createdAt": "2025-04-08T10:41:57.489Z", "value": "[{\"appsId\":\"annual\",\"title\":\"Annual\",\"caption\":{\"copy\":\"Save 25%\",\"color\":\"success\"},\"idMap\":{\"paddle\":\"pri_01jvm26zgvd5djwd0wjzz9np4s\"}},{\"appsId\":\"monthly\",\"title\":\"Monthly\",\"idMap\":{\"paddle\":\"pri_01jvm24pryx74g1pq0tn6bvamb\"}}]", "type": "productPricing" + }, + { + "feature": "recruiter_pricing_ids", + "variant": "recruiter_default", + "createdAt": "2025-12-10T13:45:08.395Z", + "value": "[{\"title\":\"Job Slot (Starter Tier)\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0p5g7qw8zb1e8esf7qjw2\",\"ios\":\"\"}},{\"title\":\"Job Slot (Boost Tier)\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0pv4mxbar7qt1nzs694r1\",\"ios\":\"\"}}]", + "type": "productPricing" } ] diff --git a/src/common/paddle/pricing.ts b/src/common/paddle/pricing.ts index 04af0d7ec1..378d451be4 100644 --- a/src/common/paddle/pricing.ts +++ b/src/common/paddle/pricing.ts @@ -37,6 +37,8 @@ export const CORES_FEATURE_KEY = 'cores_pricing_ids'; export const DEFAULT_CORES_METADATA = 'cores_default'; export const ORGANIZATION_FEATURE_KEY = 'organization_pricing_ids'; export const DEFAULT_ORGANIZATION_METADATA = 'organization_default'; +export const RECRUITER_FEATURE_KEY = 'recruiter_pricing_ids'; +export const DEFAULT_RECRUITER_METADATA = 'recruiter_default'; export interface BasePricingMetadata { appsId: string; @@ -119,18 +121,24 @@ export const getCoresPricingMetadata = async ({ }: Omit): Promise => getPaddleMetadata({ con, feature: CORES_FEATURE_KEY, variant }); +export const getRecruiterPricingMetadata = async ({ + con, + variant, +}: Omit): Promise => + getPaddleMetadata({ con, feature: RECRUITER_FEATURE_KEY, variant }); + const featureKey: Record = { [PurchaseType.Plus]: PLUS_FEATURE_KEY, [PurchaseType.Organization]: ORGANIZATION_FEATURE_KEY, [PurchaseType.Cores]: CORES_FEATURE_KEY, - [PurchaseType.Recruiter]: '', // Not used, but added for completeness + [PurchaseType.Recruiter]: RECRUITER_FEATURE_KEY, }; const defaultVariant: Record = { [PurchaseType.Plus]: DEFAULT_PLUS_METADATA, [PurchaseType.Organization]: DEFAULT_ORGANIZATION_METADATA, [PurchaseType.Cores]: DEFAULT_CORES_METADATA, - [PurchaseType.Recruiter]: '', // Not used, but added for completeness + [PurchaseType.Recruiter]: DEFAULT_RECRUITER_METADATA, }; export const getPricingDuration = ( @@ -170,6 +178,8 @@ export const getPricingMetadata = async ( return getPlusOrganizationPricingMetadata({ con, variant }); case PurchaseType.Cores: return getCoresPricingMetadata({ con, variant }); + case PurchaseType.Recruiter: + return getRecruiterPricingMetadata({ con, variant }); default: throw new Error('Invalid pricing type'); } diff --git a/src/common/paddle/recruiter/eventHandler.ts b/src/common/paddle/recruiter/eventHandler.ts new file mode 100644 index 0000000000..d2d5c9372c --- /dev/null +++ b/src/common/paddle/recruiter/eventHandler.ts @@ -0,0 +1,52 @@ +import { EventName, type EventEntity } from '@paddle/paddle-node-sdk'; +import { PurchaseType, SubscriptionProvider } from '../../plus'; +import { logger } from '../../../logger'; +import { logPaddleAnalyticsEvent } from '../index'; +import { AnalyticsEventName } from '../../../integrations/analytics'; +import { + cancelRecruiterSubscription, + createOpportunitySubscription, +} from './processing'; +import { notifyNewPaddleRecruiterTransaction } from '../slack'; + +export const processRecruiterPaddleEvent = async (event: EventEntity) => { + switch (event?.eventType) { + case EventName.SubscriptionCreated: + await createOpportunitySubscription({ event }); + + break; + case EventName.SubscriptionCanceled: + await Promise.all([ + await cancelRecruiterSubscription({ event }), + logPaddleAnalyticsEvent(event, AnalyticsEventName.CancelSubscription), + ]); + + break; + case EventName.SubscriptionUpdated: + logger.info( + { + provider: SubscriptionProvider.Paddle, + purchaseType: PurchaseType.Recruiter, + event, + }, + 'Subscription updated', + ); + + break; + case EventName.TransactionCompleted: + await Promise.all([ + logPaddleAnalyticsEvent(event, AnalyticsEventName.ReceivePayment), + notifyNewPaddleRecruiterTransaction({ event }), + ]); + + break; + default: + logger.info( + { + provider: SubscriptionProvider.Paddle, + purchaseType: PurchaseType.Recruiter, + }, + event?.eventType, + ); + } +}; diff --git a/src/common/paddle/recruiter/processing.ts b/src/common/paddle/recruiter/processing.ts new file mode 100644 index 0000000000..170e9969e8 --- /dev/null +++ b/src/common/paddle/recruiter/processing.ts @@ -0,0 +1,118 @@ +import type { + SubscriptionCanceledEvent, + SubscriptionCreatedEvent, +} from '@paddle/paddle-node-sdk'; +import { extractSubscriptionCycle, getPaddleSubscriptionData } from '../index'; +import type { PaddleSubscriptionEvent } from '../../../paddle'; +import createOrGetConnection from '../../../db'; +import { + PurchaseType, + SubscriptionProvider, + SubscriptionStatus, +} from '../../plus/subscription'; +import { logger } from '../../../logger'; +import { OpportunityJob } from '../../../entity/opportunities/OpportunityJob'; +import { recruiterPaddleCustomDataSchema } from './types'; +import { + ensureOpportunityPermissions, + OpportunityPermissions, +} from '../../opportunity/accessControl'; +import { updateSubscriptionFlags } from '../../utils'; + +export const createOpportunitySubscription = async ({ + event, +}: { + event: SubscriptionCreatedEvent; +}) => { + const data = getPaddleSubscriptionData({ event }); + const con = await createOrGetConnection(); + const { opportunity_id, user_id } = recruiterPaddleCustomDataSchema.parse( + data.customData, + ); + + const subscriptionType = extractSubscriptionCycle( + data.items as PaddleSubscriptionEvent['data']['items'], + ); + + if (!subscriptionType) { + logger.error( + { + provider: SubscriptionProvider.Paddle, + purchaseType: PurchaseType.Recruiter, + data: event, + }, + 'Subscription type missing in payload', + ); + + return false; + } + + const opportunity = await con.getRepository(OpportunityJob).findOneOrFail({ + where: { + id: opportunity_id, + }, + }); + + await ensureOpportunityPermissions({ + con: con.manager, + userId: user_id, + opportunityId: opportunity_id, + permission: OpportunityPermissions.Edit, + }); + + await con.getRepository(OpportunityJob).update( + { + id: opportunity.id, + }, + { + subscriptionFlags: updateSubscriptionFlags({ + cycle: subscriptionType, + createdAt: data.startedAt ?? undefined, + subscriptionId: data.id, + priceId: data.items[0].price.id, + provider: SubscriptionProvider.Paddle, + status: SubscriptionStatus.Active, + }), + }, + ); +}; + +export const cancelRecruiterSubscription = async ({ + event, +}: { + event: SubscriptionCanceledEvent; +}) => { + const data = getPaddleSubscriptionData({ event }); + const con = await createOrGetConnection(); + const { opportunity_id, user_id } = recruiterPaddleCustomDataSchema.parse( + data.customData, + ); + + const opportunity = await con.getRepository(OpportunityJob).findOneOrFail({ + where: { + id: opportunity_id, + }, + }); + + await ensureOpportunityPermissions({ + con: con.manager, + userId: user_id, + opportunityId: opportunity_id, + permission: OpportunityPermissions.Edit, + }); + + const subscriptionFlags = { + cycle: null, + status: SubscriptionStatus.Expired, + }; + + con.getRepository(OpportunityJob).update( + { + id: opportunity.id, + }, + { + subscriptionFlags: + updateSubscriptionFlags(subscriptionFlags), + }, + ); +}; diff --git a/src/common/paddle/recruiter/types.ts b/src/common/paddle/recruiter/types.ts new file mode 100644 index 0000000000..0ce05c792e --- /dev/null +++ b/src/common/paddle/recruiter/types.ts @@ -0,0 +1,6 @@ +import z from 'zod'; + +export const recruiterPaddleCustomDataSchema = z.object({ + user_id: z.string(), + opportunity_id: z.uuid(), +}); diff --git a/src/common/paddle/slack.ts b/src/common/paddle/slack.ts index 1a25939380..3a8246f72a 100644 --- a/src/common/paddle/slack.ts +++ b/src/common/paddle/slack.ts @@ -13,6 +13,7 @@ import { SubscriptionProvider } from '../plus'; import { logger } from '../../logger'; import { Organization } from '../../entity'; import { JsonContains } from 'typeorm'; +import { OpportunityJob } from '../../entity/opportunities/OpportunityJob'; export const notifyNewPaddleCoresTransaction = async ({ data, @@ -423,3 +424,144 @@ export const notifyNewPaddleOrganizationTransaction = async ({ await webhooks.transactions.send({ blocks }); }; + +export const notifyNewPaddleRecruiterTransaction = async ({ + event, +}: { + event: TransactionCompletedEvent; +}) => { + const con = await createOrGetConnection(); + + const { data } = event; + const { customData } = data ?? {}; + + const { user_id: purchasedById } = (customData ?? {}) as PaddleCustomData; + const opportunity = await con.getRepository(OpportunityJob).findOneOrFail({ + where: { + subscriptionFlags: JsonContains({ + subscriptionId: data.subscriptionId, + }), + }, + relations: { + organization: true, + }, + }); + const flags = opportunity?.subscriptionFlags; + + const origin = data?.origin; + const productId = data?.items?.[0].price?.productId; + + const total = data?.items?.[0]?.price?.unitPrice?.amount || '0'; + const currencyCode = + data?.items?.[0]?.price?.unitPrice?.currencyCode || 'USD'; + + const localTotal = data?.details?.totals?.total || '0'; + const localCurrencyCode = data?.currencyCode || 'USD'; + + const organization = await opportunity.organization; + + if (origin === 'subscription_recurring') { + return; + } + + const subscriptionCycle = + flags?.cycle || data.billingDetails?.paymentTerms.interval; + + const blocks = [ + { + type: 'header', + text: { + type: 'plain_text', + text: 'New job subscription :tears-of-joy-office: :paddle:', + emoji: true, + }, + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: concatTextToNewline( + '*Transaction ID:*', + ``, + ), + }, + { + type: 'mrkdwn', + text: concatTextToNewline( + '*Customer ID:*', + ``, + ), + }, + ], + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: concatTextToNewline( + '*Type:*', + ``, + ), + }, + { + type: 'mrkdwn', + text: concatTextToNewline( + '*Purchased by:*', + ``, + ), + }, + ], + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: concatTextToNewline('*Organization:*', organization.name), + }, + ], + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: concatTextToNewline( + '*Cost:*', + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + }).format((parseFloat(total) || 0) / 100), + ), + }, + { + type: 'mrkdwn', + text: concatTextToNewline('*Currency:*', currencyCode), + }, + ], + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: concatTextToNewline( + '*Cost (local):*', + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: localCurrencyCode, + }).format((parseFloat(localTotal) || 0) / 100), + ), + }, + { + type: 'mrkdwn', + text: concatTextToNewline('*Currency (local):*', localCurrencyCode), + }, + ], + }, + ]; + + await webhooks.transactions.send({ blocks }); +}; diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index 84dbf45dd9..49fecebd15 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -4,6 +4,8 @@ import { organizationLinksSchema } from './organizations'; import { fileUploadSchema, urlParseSchema } from './common'; import { parseBigInt } from '../utils'; import { OpportunityMatchStatus } from '../../entity/opportunities/types'; +import { SubscriptionCycles } from '../../paddle'; +import { SubscriptionProvider, SubscriptionStatus } from '../plus/subscription'; export const opportunityMatchDescriptionSchema = z.object({ reasoning: z.string(), @@ -269,3 +271,29 @@ export const opportunityMatchesQuerySchema = z.object({ after: z.string().optional(), first: z.number().optional(), }); + +export const opportunitySubscriptionFlagsSchema = z + .object({ + subscriptionId: z.string({ + error: 'Subscription ID is required', + }), + priceId: z.string({ + error: 'Price ID is required', + }), + cycle: z + .enum(SubscriptionCycles, { + error: 'Invalid subscription cycle', + }) + .nullish(), + createdAt: z.preprocess( + (value) => new Date(value as string), + z.date().optional(), + ), + provider: z.enum(SubscriptionProvider, { + error: 'Invalid subscription provider', + }), + status: z.enum(SubscriptionStatus, { + error: 'Invalid subscription status', + }), + }) + .partial(); diff --git a/src/entity/opportunities/Opportunity.ts b/src/entity/opportunities/Opportunity.ts index 27959351d4..7f2ee88398 100644 --- a/src/entity/opportunities/Opportunity.ts +++ b/src/entity/opportunities/Opportunity.ts @@ -19,6 +19,8 @@ import type { OpportunityKeyword } from '../OpportunityKeyword'; import type { OpportunityMatch } from '../OpportunityMatch'; import type { QuestionScreening } from '../questions/QuestionScreening'; import type { QuestionFeedback } from '../questions/QuestionFeedback'; +import type { opportunitySubscriptionFlagsSchema } from '../../common/schema/opportunities'; +import type z from 'zod'; export type OpportunityFlags = Partial<{ anonUserId: string | null; @@ -104,4 +106,7 @@ export class Opportunity { @Column({ type: 'jsonb', default: {} }) flags: OpportunityFlags; + + @Column({ type: 'jsonb', default: {} }) + subscriptionFlags: z.infer; } diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 7b48e8f0e6..d290e05c20 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -66,6 +66,8 @@ import { OrganizationLinkType } from '../common/schema/organizations'; import type { GCSBlob } from '../common/schema/userCandidate'; import { QuestionType } from '../entity/questions/types'; import { snotraClient } from '../integrations/snotra'; +import type { Opportunity } from '../entity/opportunities/Opportunity'; +import { SubscriptionStatus } from '../common/plus'; const existsByUserAndPost = (entity: string, build?: (queryBuilder: QueryBuilder) => QueryBuilder) => @@ -1576,6 +1578,12 @@ const obj = new GraphORM({ .orderBy(`${childAlias}."questionOrder"`, 'ASC'), }, }, + paid: { + select: 'subscriptionFlags', + transform: (value: Opportunity['subscriptionFlags']): boolean => { + return value?.status === SubscriptionStatus.Active; + }, + }, }, }, OpportunityScreeningQuestion: { diff --git a/src/migration/1765378734870-OpportunitySubscriptionFlags.ts b/src/migration/1765378734870-OpportunitySubscriptionFlags.ts new file mode 100644 index 0000000000..185e5efbf6 --- /dev/null +++ b/src/migration/1765378734870-OpportunitySubscriptionFlags.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class OpportunitySubscriptionFlags1765378734870 + implements MigrationInterface +{ + name = 'OpportunitySubscriptionFlags1765378734870'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "opportunity" ADD "subscriptionFlags" jsonb NOT NULL DEFAULT '{}'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "opportunity" DROP COLUMN "subscriptionFlags"`, + ); + } +} diff --git a/src/routes/webhooks/paddle/index.ts b/src/routes/webhooks/paddle/index.ts index a26f7f9da8..2b71ebc759 100644 --- a/src/routes/webhooks/paddle/index.ts +++ b/src/routes/webhooks/paddle/index.ts @@ -9,6 +9,7 @@ import { remoteConfig } from '../../../remoteConfig'; import { processCorePaddleEvent } from '../../../common/paddle/cores/eventHandler'; import { processPlusPaddleEvent } from '../../../common/paddle/plus/eventHandler'; import { processOrganizationPaddleEvent } from '../../../common/paddle/organization/eventHandler'; +import { processRecruiterPaddleEvent } from '../../../common/paddle/recruiter/eventHandler'; export const paddle = async (fastify: FastifyInstance): Promise => { fastify.register(async (fastify: FastifyInstance): Promise => { @@ -50,6 +51,7 @@ export const paddle = async (fastify: FastifyInstance): Promise => { await processPlusPaddleEvent(event); break; case isPurchaseType(PurchaseType.Recruiter, event): + await processRecruiterPaddleEvent(event); break; default: logger.info( diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 62f57a6a89..097d567496 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -277,6 +277,7 @@ export const typeDefs = /* GraphQL */ ` keywords: [OpportunityKeyword]! questions: [OpportunityScreeningQuestion]! feedbackQuestions: [OpportunityFeedbackQuestion]! + paid: Boolean! } type OpportunityMatchDescription { From ee93db6d4ede5712e5d60d3de7ec7e6f2b02c253 Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 10 Dec 2025 16:24:07 +0100 Subject: [PATCH 2/5] feat: parse custom data directly --- src/common/paddle/recruiter/processing.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/common/paddle/recruiter/processing.ts b/src/common/paddle/recruiter/processing.ts index 170e9969e8..02c04fa13e 100644 --- a/src/common/paddle/recruiter/processing.ts +++ b/src/common/paddle/recruiter/processing.ts @@ -27,7 +27,7 @@ export const createOpportunitySubscription = async ({ const data = getPaddleSubscriptionData({ event }); const con = await createOrGetConnection(); const { opportunity_id, user_id } = recruiterPaddleCustomDataSchema.parse( - data.customData, + event.data.customData, ); const subscriptionType = extractSubscriptionCycle( @@ -82,10 +82,9 @@ export const cancelRecruiterSubscription = async ({ }: { event: SubscriptionCanceledEvent; }) => { - const data = getPaddleSubscriptionData({ event }); const con = await createOrGetConnection(); const { opportunity_id, user_id } = recruiterPaddleCustomDataSchema.parse( - data.customData, + event.data.customData, ); const opportunity = await con.getRepository(OpportunityJob).findOneOrFail({ From 749a082bb5d10aae260101f1ce03e156a4625ab9 Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 10 Dec 2025 16:27:50 +0100 Subject: [PATCH 3/5] fix: org can be optional --- src/common/paddle/slack.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/common/paddle/slack.ts b/src/common/paddle/slack.ts index 3a8246f72a..c2f1e0696d 100644 --- a/src/common/paddle/slack.ts +++ b/src/common/paddle/slack.ts @@ -519,7 +519,10 @@ export const notifyNewPaddleRecruiterTransaction = async ({ fields: [ { type: 'mrkdwn', - text: concatTextToNewline('*Organization:*', organization.name), + text: concatTextToNewline( + '*Organization:*', + organization?.name || 'Unknown', + ), }, ], }, From dee78a828e485bec2f133563cee4a38c92b8ecc3 Mon Sep 17 00:00:00 2001 From: capJavert Date: Thu, 11 Dec 2025 10:17:41 +0100 Subject: [PATCH 4/5] refactor: paid to subscriptionStatus --- src/common/plus/subscription.ts | 1 + src/graphorm/index.ts | 8 +++++--- src/schema/opportunity.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/common/plus/subscription.ts b/src/common/plus/subscription.ts index 7efab93a55..e97e8a0c2f 100644 --- a/src/common/plus/subscription.ts +++ b/src/common/plus/subscription.ts @@ -19,4 +19,5 @@ export enum SubscriptionStatus { Active = 'active', Expired = 'expired', Cancelled = 'cancelled', + None = 'none', } diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index d290e05c20..cafde0f0ff 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1578,10 +1578,12 @@ const obj = new GraphORM({ .orderBy(`${childAlias}."questionOrder"`, 'ASC'), }, }, - paid: { + subscriptionStatus: { select: 'subscriptionFlags', - transform: (value: Opportunity['subscriptionFlags']): boolean => { - return value?.status === SubscriptionStatus.Active; + transform: ( + value: Opportunity['subscriptionFlags'], + ): SubscriptionStatus => { + return value?.status || SubscriptionStatus.None; }, }, }, diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 097d567496..0f96f8d8cc 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -277,7 +277,7 @@ export const typeDefs = /* GraphQL */ ` keywords: [OpportunityKeyword]! questions: [OpportunityScreeningQuestion]! feedbackQuestions: [OpportunityFeedbackQuestion]! - paid: Boolean! + subscriptionStatus: SubscriptionStatus! } type OpportunityMatchDescription { From 272a0f4e6bd4710eea18e45f64f01298b236280d Mon Sep 17 00:00:00 2001 From: capJavert Date: Thu, 11 Dec 2025 10:25:25 +0100 Subject: [PATCH 5/5] feat: updatedAt for sub flags --- src/common/paddle/recruiter/processing.ts | 32 ++++++++++++++--------- src/common/paddle/slack.ts | 6 ++++- src/common/schema/opportunities.ts | 6 ++--- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/common/paddle/recruiter/processing.ts b/src/common/paddle/recruiter/processing.ts index 02c04fa13e..0a11e520f8 100644 --- a/src/common/paddle/recruiter/processing.ts +++ b/src/common/paddle/recruiter/processing.ts @@ -47,11 +47,14 @@ export const createOpportunitySubscription = async ({ return false; } - const opportunity = await con.getRepository(OpportunityJob).findOneOrFail({ - where: { - id: opportunity_id, - }, - }); + const opportunity: Pick = await con + .getRepository(OpportunityJob) + .findOneOrFail({ + select: ['id'], + where: { + id: opportunity_id, + }, + }); await ensureOpportunityPermissions({ con: con.manager, @@ -67,7 +70,8 @@ export const createOpportunitySubscription = async ({ { subscriptionFlags: updateSubscriptionFlags({ cycle: subscriptionType, - createdAt: data.startedAt ?? undefined, + createdAt: data.startedAt ?? new Date(), + updatedAt: new Date(), subscriptionId: data.id, priceId: data.items[0].price.id, provider: SubscriptionProvider.Paddle, @@ -87,11 +91,14 @@ export const cancelRecruiterSubscription = async ({ event.data.customData, ); - const opportunity = await con.getRepository(OpportunityJob).findOneOrFail({ - where: { - id: opportunity_id, - }, - }); + const opportunity: Pick = await con + .getRepository(OpportunityJob) + .findOneOrFail({ + select: ['id'], + where: { + id: opportunity_id, + }, + }); await ensureOpportunityPermissions({ con: con.manager, @@ -100,9 +107,10 @@ export const cancelRecruiterSubscription = async ({ permission: OpportunityPermissions.Edit, }); - const subscriptionFlags = { + const subscriptionFlags: OpportunityJob['subscriptionFlags'] = { cycle: null, status: SubscriptionStatus.Expired, + updatedAt: new Date(), }; con.getRepository(OpportunityJob).update( diff --git a/src/common/paddle/slack.ts b/src/common/paddle/slack.ts index c2f1e0696d..aaa6c33655 100644 --- a/src/common/paddle/slack.ts +++ b/src/common/paddle/slack.ts @@ -436,7 +436,11 @@ export const notifyNewPaddleRecruiterTransaction = async ({ const { customData } = data ?? {}; const { user_id: purchasedById } = (customData ?? {}) as PaddleCustomData; - const opportunity = await con.getRepository(OpportunityJob).findOneOrFail({ + const opportunity: Pick< + OpportunityJob, + 'subscriptionFlags' | 'organization' + > = await con.getRepository(OpportunityJob).findOneOrFail({ + select: ['id', 'subscriptionFlags', 'organization', 'organizationId'], where: { subscriptionFlags: JsonContains({ subscriptionId: data.subscriptionId, diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index 49fecebd15..e23ca0abda 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -285,10 +285,8 @@ export const opportunitySubscriptionFlagsSchema = z error: 'Invalid subscription cycle', }) .nullish(), - createdAt: z.preprocess( - (value) => new Date(value as string), - z.date().optional(), - ), + createdAt: z.preprocess((value) => new Date(value as string), z.date()), + updatedAt: z.preprocess((value) => new Date(value as string), z.date()), provider: z.enum(SubscriptionProvider, { error: 'Invalid subscription provider', }),