From 3fd6c43e7ec4156846804c46b15c8c263120ee09 Mon Sep 17 00:00:00 2001 From: capJavert Date: Mon, 15 Dec 2025 16:08:12 +0100 Subject: [PATCH 1/6] feat: recruiter organization seats --- seeds/ExperimentVariant.json | 2 +- src/common/paddle/recruiter/processing.ts | 126 ++++++++++---- src/common/schema/opportunities.ts | 20 ++- src/common/utils.ts | 10 ++ src/entity/Organization.ts | 4 + src/entity/opportunities/Opportunity.ts | 11 +- src/errors.ts | 9 + src/graphorm/index.ts | 15 +- ...-OrganizationRecruiterSubscriptionFlags.ts | 25 +++ src/schema/opportunity.ts | 162 +++++++++++++++++- 10 files changed, 320 insertions(+), 64 deletions(-) create mode 100644 src/migration/1765808739042-OrganizationRecruiterSubscriptionFlags.ts diff --git a/seeds/ExperimentVariant.json b/seeds/ExperimentVariant.json index 927348dfb1..ba1f3af1db 100644 --- a/seeds/ExperimentVariant.json +++ b/seeds/ExperimentVariant.json @@ -24,7 +24,7 @@ "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\":\"\"}}]", + "value": "[{\"title\":\"Starter\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0p5g7qw8zb1e8esf7qjw2\",\"ios\":\"\"}},{\"title\":\"Boost\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0pv4mxbar7qt1nzs694r1\",\"ios\":\"\"}}]", "type": "productPricing" } ] diff --git a/src/common/paddle/recruiter/processing.ts b/src/common/paddle/recruiter/processing.ts index 0a11e520f8..dbd1df94ab 100644 --- a/src/common/paddle/recruiter/processing.ts +++ b/src/common/paddle/recruiter/processing.ts @@ -17,7 +17,9 @@ import { ensureOpportunityPermissions, OpportunityPermissions, } from '../../opportunity/accessControl'; -import { updateSubscriptionFlags } from '../../utils'; +import { updateRecruiterSubscriptionFlags } from '../../utils'; +import { OpportunityState } from '@dailydotdev/schema'; +import { Organization } from '../../../entity/Organization'; export const createOpportunitySubscription = async ({ event, @@ -47,14 +49,33 @@ export const createOpportunitySubscription = async ({ return false; } - const opportunity: Pick = await con - .getRepository(OpportunityJob) - .findOneOrFail({ - select: ['id'], - where: { - id: opportunity_id, - }, - }); + const opportunity: Pick< + OpportunityJob, + 'id' | 'organizationId' | 'organization' + > = await con.getRepository(OpportunityJob).findOneOrFail({ + select: ['id', 'organizationId', 'organization'], + where: { + id: opportunity_id, + }, + relations: { + organization: true, + }, + }); + + const organization = await opportunity.organization; + + if (!organization) { + throw new Error( + 'Opportunity does not have organization during payment processing, can not assign subscription, manual fixup needed', + ); + } + + if ( + organization.recruiterSubscriptionFlags?.status === + SubscriptionStatus.Active + ) { + throw new Error('Organization already has active recruiter subscription'); + } await ensureOpportunityPermissions({ con: con.manager, @@ -63,22 +84,38 @@ export const createOpportunitySubscription = async ({ permission: OpportunityPermissions.Edit, }); - await con.getRepository(OpportunityJob).update( - { - id: opportunity.id, - }, - { - subscriptionFlags: updateSubscriptionFlags({ - cycle: subscriptionType, - createdAt: data.startedAt ?? new Date(), - updatedAt: new Date(), - subscriptionId: data.id, - priceId: data.items[0].price.id, - provider: SubscriptionProvider.Paddle, - status: SubscriptionStatus.Active, - }), - }, - ); + await con.transaction(async (entityManager) => { + await entityManager.getRepository(Organization).update( + { + id: opportunity.id, + }, + { + recruiterSubscriptionFlags: + updateRecruiterSubscriptionFlags({ + cycle: subscriptionType, + createdAt: data.startedAt ?? new Date(), + updatedAt: new Date(), + subscriptionId: data.id, + provider: SubscriptionProvider.Paddle, + status: SubscriptionStatus.Active, + items: data.items.map((item) => { + return { + priceId: item.price.id, + }; + }), + }), + }, + ); + + await entityManager.getRepository(OpportunityJob).update( + { + id: opportunity.id, + }, + { + state: OpportunityState.IN_REVIEW, + }, + ); + }); }; export const cancelRecruiterSubscription = async ({ @@ -91,14 +128,18 @@ export const cancelRecruiterSubscription = async ({ event.data.customData, ); - const opportunity: Pick = await con - .getRepository(OpportunityJob) - .findOneOrFail({ - select: ['id'], - where: { - id: opportunity_id, - }, - }); + const opportunity: Pick< + OpportunityJob, + 'id' | 'organizationId' | 'organization' + > = await con.getRepository(OpportunityJob).findOneOrFail({ + select: ['id', 'organizationId', 'organization'], + where: { + id: opportunity_id, + }, + relations: { + organization: true, + }, + }); await ensureOpportunityPermissions({ con: con.manager, @@ -107,19 +148,28 @@ export const cancelRecruiterSubscription = async ({ permission: OpportunityPermissions.Edit, }); - const subscriptionFlags: OpportunityJob['subscriptionFlags'] = { + const organization = await opportunity.organization; + + if (!organization) { + throw new Error( + 'Opportunity does not have organization during payment processing, can not cancel subscription, manual fixup needed', + ); + } + + const subscriptionFlags: Organization['recruiterSubscriptionFlags'] = { cycle: null, - status: SubscriptionStatus.Expired, + status: SubscriptionStatus.Cancelled, updatedAt: new Date(), + items: [], }; - con.getRepository(OpportunityJob).update( + con.getRepository(Organization).update( { id: opportunity.id, }, { - subscriptionFlags: - updateSubscriptionFlags(subscriptionFlags), + recruiterSubscriptionFlags: + updateRecruiterSubscriptionFlags(subscriptionFlags), }, ); }; diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index df05afd2c7..94566d0f1c 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -272,14 +272,11 @@ export const opportunityMatchesQuerySchema = z.object({ first: z.number().optional(), }); -export const opportunitySubscriptionFlagsSchema = z +export const recruiterSubscriptionFlagsSchema = 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', @@ -293,6 +290,16 @@ export const opportunitySubscriptionFlagsSchema = z status: z.enum(SubscriptionStatus, { error: 'Invalid subscription status', }), + items: z.array( + z.object({ + priceId: z.string({ + error: 'Price ID is required', + }), + }), + { + error: 'At least one subscription item is required', + }, + ), }) .partial(); @@ -300,3 +307,8 @@ export const gondulOpportunityPreviewResultSchema = z.object({ user_ids: z.array(z.string()), total_count: z.number().int().nonnegative(), }); + +export const opportunityUpdateSubscriptionSchema = z.object({ + id: z.uuid(), + priceId: z.string(), +}); diff --git a/src/common/utils.ts b/src/common/utils.ts index 15d872c761..fec9674d67 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -320,3 +320,13 @@ export const textToSlug = (text: string): string => locale: 'en', replacement: '-', }).substring(0, 100); + +export const updateRecruiterSubscriptionFlags = < + Entity extends { + recruiterSubscriptionFlags: object; + }, +>( + update: Partial, +): (() => string) => { + return () => `recruiterSubscriptionFlags || '${JSON.stringify(update)}'`; +}; diff --git a/src/entity/Organization.ts b/src/entity/Organization.ts index d34a55f661..7e251ce9d7 100644 --- a/src/entity/Organization.ts +++ b/src/entity/Organization.ts @@ -14,6 +14,7 @@ import type { organizationSubscriptionFlagsSchema, } from '../common/schema/organizations'; import type { CompanySize, CompanyStage } from '@dailydotdev/schema'; +import type { recruiterSubscriptionFlagsSchema } from '../common/schema/opportunities'; export type OrganizationLink = z.infer; @@ -87,4 +88,7 @@ export class Organization { { lazy: true }, ) members: Promise; + + @Column({ type: 'jsonb', default: {} }) + recruiterSubscriptionFlags: z.infer; } diff --git a/src/entity/opportunities/Opportunity.ts b/src/entity/opportunities/Opportunity.ts index b53abc335e..f5b43925fc 100644 --- a/src/entity/opportunities/Opportunity.ts +++ b/src/entity/opportunities/Opportunity.ts @@ -19,8 +19,6 @@ 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; @@ -29,9 +27,13 @@ export type OpportunityFlags = Partial<{ totalCount: number; }; batchSize: number; + plan: string; }>; -export type OpportunityFlagsPublic = Pick; +export type OpportunityFlagsPublic = Pick< + OpportunityFlags, + 'batchSize' | 'plan' +>; @Entity() @TableInheritance({ column: { type: 'text', name: 'type' } }) @@ -109,7 +111,4 @@ export class Opportunity { @Column({ type: 'jsonb', default: {} }) flags: OpportunityFlags; - - @Column({ type: 'jsonb', default: {} }) - subscriptionFlags: z.infer; } diff --git a/src/errors.ts b/src/errors.ts index fa92acd6a7..cbfa9958fd 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -218,3 +218,12 @@ export class ParseCVProfileError extends Error { } } } + +// Return 402 HTTP status code +export class PaymentRequiredError extends ApolloError { + constructor(message: string, extensions: Record = {}) { + super(message, 'PAYMENT_REQUIRED', extensions); + + Object.defineProperty(this, 'name', { value: 'PaymentRequiredError' }); + } +} diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index d84efbff94..1c1990e4da 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -71,11 +71,7 @@ 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, - OpportunityFlagsPublic, -} from '../entity/opportunities/Opportunity'; -import { SubscriptionStatus } from '../common/plus'; +import type { OpportunityFlagsPublic } from '../entity/opportunities/Opportunity'; const existsByUserAndPost = (entity: string, build?: (queryBuilder: QueryBuilder) => QueryBuilder) => @@ -1586,19 +1582,12 @@ const obj = new GraphORM({ .orderBy(`${childAlias}."questionOrder"`, 'ASC'), }, }, - subscriptionStatus: { - select: 'subscriptionFlags', - transform: ( - value: Opportunity['subscriptionFlags'], - ): SubscriptionStatus => { - return value?.status || SubscriptionStatus.None; - }, - }, flags: { jsonType: true, transform: (value: OpportunityFlagsPublic): OpportunityFlagsPublic => { return { batchSize: value?.batchSize ?? opportunityMatchBatchSize, + plan: value?.plan, }; }, }, diff --git a/src/migration/1765808739042-OrganizationRecruiterSubscriptionFlags.ts b/src/migration/1765808739042-OrganizationRecruiterSubscriptionFlags.ts new file mode 100644 index 0000000000..76614ec7a3 --- /dev/null +++ b/src/migration/1765808739042-OrganizationRecruiterSubscriptionFlags.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class OrganizationRecruiterSubscriptionFlags1765808739042 + implements MigrationInterface +{ + name = 'OrganizationRecruiterSubscriptionFlags1765808739042'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "opportunity" DROP COLUMN "subscriptionFlags"`, + ); + await queryRunner.query( + `ALTER TABLE "organization" ADD "recruiterSubscriptionFlags" jsonb NOT NULL DEFAULT '{}'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "organization" DROP COLUMN "recruiterSubscriptionFlags"`, + ); + await queryRunner.query( + `ALTER TABLE "opportunity" ADD "subscriptionFlags" jsonb NOT NULL DEFAULT '{}'`, + ); + } +} diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 35c5e480e1..60a6a0cd73 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -42,6 +42,7 @@ import { ForbiddenError, ValidationError } from 'apollo-server-errors'; import { ConflictError, NotFoundError, + PaymentRequiredError, TypeOrmError, type TypeORMQueryFailedError, } from '../errors'; @@ -68,6 +69,7 @@ import { parseOpportunitySchema, opportunityMatchesQuerySchema, gondulOpportunityPreviewResultSchema, + opportunityUpdateSubscriptionSchema, } from '../common/schema/opportunities'; import { OpportunityKeyword } from '../entity/OpportunityKeyword'; import { @@ -109,6 +111,9 @@ import { cursorToOffset, offsetToCursor } from 'graphql-relay/index'; import { getShowcaseCompanies } from '../common/opportunity/companies'; import { Opportunity } from '../entity/opportunities/Opportunity'; import type { GQLSource } from './sources'; +import { SubscriptionStatus } from '../common/plus'; +import { paddleInstance } from '../common/paddle'; +import type { ISubscriptionUpdateItem } from '@paddle/paddle-node-sdk'; export interface GQLOpportunity extends Pick< @@ -288,6 +293,7 @@ export const typeDefs = /* GraphQL */ ` """ type OpportunityFlagsPublic { batchSize: Int + plan: String } type Opportunity { @@ -304,7 +310,6 @@ export const typeDefs = /* GraphQL */ ` keywords: [OpportunityKeyword]! questions: [OpportunityScreeningQuestion]! feedbackQuestions: [OpportunityFeedbackQuestion]! - subscriptionStatus: SubscriptionStatus! flags: OpportunityFlagsPublic } @@ -908,6 +913,15 @@ export const typeDefs = /* GraphQL */ ` """ channelName: String! ): EmptyResponse @auth + + addOpportunitySeat( + """ + Id of the Opportunity + """ + id: ID! + + priceId: String! + ): EmptyResponse @auth } `; @@ -2172,9 +2186,11 @@ export const resolvers: IResolvers = traceResolvers< }, }); + const organization = await opportunity.organization; + switch (state) { case OpportunityState.LIVE: { - if (!opportunity.organizationId) { + if (!organization) { throw new ConflictError( `Opportunity must have an organization assigned`, ); @@ -2184,6 +2200,15 @@ export const resolvers: IResolvers = traceResolvers< throw new ConflictError(`Opportunity is closed`); } + if ( + organization.recruiterSubscriptionFlags.status !== + SubscriptionStatus.Active + ) { + throw new ConflictError( + `Opportunity subscription is not active yet, make sure your payment was processed in full. Contact support if the issue persists.`, + ); + } + opportunityStateLiveSchema.parse({ ...opportunity, organization: await opportunity.organization, @@ -2191,10 +2216,43 @@ export const resolvers: IResolvers = traceResolvers< questions: await opportunity.questions, }); + const availableSeats = + organization.recruiterSubscriptionFlags.items?.length || 0; + + const liveOpportunitiesCount = await ctx.con + .getRepository(OpportunityJob) + .count({ + where: { + organizationId: organization.id, + state: OpportunityState.LIVE, + }, + }); + + if (liveOpportunitiesCount >= availableSeats) { + throw new PaymentRequiredError( + `Your subscription allows for ${availableSeats} live opportunities. Please upgrade your subscription to add more or pause other live opportunities.`, + ); + } + await ctx.con.getRepository(OpportunityJob).update({ id }, { state }); break; } + case OpportunityState.CLOSED: + if (opportunity.state !== OpportunityState.LIVE) { + throw new ConflictError(`This opportunity is not live`); + } + + const subscriptionid = + organization.recruiterSubscriptionFlags.subscriptionId; + + if (!subscriptionid) { + throw new ConflictError(`Opportunity subscription not found`); + } + + await ctx.con.getRepository(OpportunityJob).update({ id }, { state }); + + break; default: throw new ConflictError('Invalid state transition'); } @@ -2203,6 +2261,106 @@ export const resolvers: IResolvers = traceResolvers< _: true, }; }, + addOpportunitySeat: async ( + _, + payload, + ctx: AuthContext, + ): Promise => { + const { id, priceId } = + opportunityUpdateSubscriptionSchema.parse(payload); + + await ensureOpportunityPermissions({ + con: ctx.con.manager, + userId: ctx.userId, + opportunityId: id, + permission: OpportunityPermissions.UpdateState, + isTeamMember: ctx.isTeamMember, + }); + + const opportunity = await ctx.con + .getRepository(OpportunityJob) + .findOneOrFail({ + where: { id }, + relations: { + organization: true, + }, + }); + + const organization = await opportunity.organization; + + if (!organization) { + throw new NotFoundError( + 'Opportunity must have organization to update subscription', + ); + } + + const subscriptionid = + organization.recruiterSubscriptionFlags.subscriptionId; + + if (!subscriptionid) { + throw new ConflictError(`Opportunity subscription not found`); + } + + const subscription = + await paddleInstance.subscriptions.get(subscriptionid); + + const liveOpportunitiesCount = await ctx.con + .getRepository(OpportunityJob) + .count({ + where: { + organizationId: organization.id, + state: OpportunityState.LIVE, + flags: JsonContains({ plan: priceId }), + }, + }); + + const availableSeatsForPlan = subscription.items.filter( + (item) => item.price.id === priceId, + ).length; + + if (liveOpportunitiesCount >= availableSeatsForPlan) { + throw new PaymentRequiredError( + `Your subscription allows for ${availableSeatsForPlan} live opportunities. Please upgrade your subscription to add more or pause other live opportunities.`, + ); + } + + const subscriptionItems = subscription.items.map((item) => { + return { + priceId: item.price.id, + quantity: item.quantity, + }; + }) as ISubscriptionUpdateItem[]; + + // find the existing price item + const priceItem = subscriptionItems.find( + (item) => item.priceId === priceId, + ); + + const quantityToAdd = 1; + + // if not found, add new item with quantity 1, else increment quantity + if (!priceItem) { + subscriptionItems.push({ priceId, quantity: quantityToAdd }); + } else { + priceItem.quantity += quantityToAdd; + } + + await paddleInstance.subscriptions.update(subscriptionid, { + prorationBillingMode: 'prorated_immediately', + items: subscriptionItems, + }); + + await ctx.con.getRepository(OpportunityJob).update( + { id }, + { + flags: updateFlagsStatement({ + plan: priceId, + }), + }, + ); + + return { _: true }; + }, createSharedSlackChannel: async ( _, payload: z.infer, From 3d8609bfd2299c4610f37976ccf53d020e6f3412 Mon Sep 17 00:00:00 2001 From: capJavert Date: Mon, 15 Dec 2025 16:10:24 +0100 Subject: [PATCH 2/6] feat: save quantity --- src/common/paddle/recruiter/processing.ts | 1 + src/common/schema/opportunities.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/common/paddle/recruiter/processing.ts b/src/common/paddle/recruiter/processing.ts index dbd1df94ab..1f165af9e8 100644 --- a/src/common/paddle/recruiter/processing.ts +++ b/src/common/paddle/recruiter/processing.ts @@ -101,6 +101,7 @@ export const createOpportunitySubscription = async ({ items: data.items.map((item) => { return { priceId: item.price.id, + quantity: item.quantity, }; }), }), diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index 94566d0f1c..1585ff5cc9 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -295,6 +295,9 @@ export const recruiterSubscriptionFlagsSchema = z priceId: z.string({ error: 'Price ID is required', }), + quantity: z.number().int().min(1, { + error: 'Quantity must be at least 1', + }), }), { error: 'At least one subscription item is required', From 2fd2f3263f3b7b368dc47f36572a540daf30539d Mon Sep 17 00:00:00 2001 From: capJavert Date: Mon, 15 Dec 2025 16:13:43 +0100 Subject: [PATCH 3/6] feat: check quantity when calculating seats --- src/schema/opportunity.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 60a6a0cd73..25e2c23029 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -2217,7 +2217,12 @@ export const resolvers: IResolvers = traceResolvers< }); const availableSeats = - organization.recruiterSubscriptionFlags.items?.length || 0; + organization.recruiterSubscriptionFlags.items?.reduce( + (total, item) => { + return total + item.quantity; + }, + 0, + ) || 0; const liveOpportunitiesCount = await ctx.con .getRepository(OpportunityJob) @@ -2314,9 +2319,9 @@ export const resolvers: IResolvers = traceResolvers< }, }); - const availableSeatsForPlan = subscription.items.filter( - (item) => item.price.id === priceId, - ).length; + const availableSeatsForPlan = subscription.items + .filter((item) => item.price.id === priceId) + .reduce((total, item) => total + item.quantity, 0); if (liveOpportunitiesCount >= availableSeatsForPlan) { throw new PaymentRequiredError( From 7e22f13c4c4ee5db5dfec67ba25745bbbc9ad777 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 16 Dec 2025 09:30:48 +0100 Subject: [PATCH 4/6] fix: build --- src/common/paddle/slack.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/common/paddle/slack.ts b/src/common/paddle/slack.ts index aaa6c33655..8ad4e2609c 100644 --- a/src/common/paddle/slack.ts +++ b/src/common/paddle/slack.ts @@ -14,6 +14,7 @@ import { logger } from '../../logger'; import { Organization } from '../../entity'; import { JsonContains } from 'typeorm'; import { OpportunityJob } from '../../entity/opportunities/OpportunityJob'; +import { recruiterPaddleCustomDataSchema } from './recruiter/types'; export const notifyNewPaddleCoresTransaction = async ({ data, @@ -433,24 +434,28 @@ export const notifyNewPaddleRecruiterTransaction = async ({ const con = await createOrGetConnection(); const { data } = event; - const { customData } = data ?? {}; - - const { user_id: purchasedById } = (customData ?? {}) as PaddleCustomData; + const { opportunity_id, user_id: purchasedById } = + recruiterPaddleCustomDataSchema.parse(event.data.customData); const opportunity: Pick< OpportunityJob, - 'subscriptionFlags' | 'organization' + 'id' | 'organization' | 'organizationId' > = await con.getRepository(OpportunityJob).findOneOrFail({ - select: ['id', 'subscriptionFlags', 'organization', 'organizationId'], + select: ['id', 'organization', 'organizationId'], where: { - subscriptionFlags: JsonContains({ - subscriptionId: data.subscriptionId, - }), + id: opportunity_id, }, relations: { organization: true, }, }); - const flags = opportunity?.subscriptionFlags; + + const organization = await opportunity.organization; + + if (!organization) { + return; + } + + const flags = organization?.recruiterSubscriptionFlags; const origin = data?.origin; const productId = data?.items?.[0].price?.productId; @@ -462,8 +467,6 @@ export const notifyNewPaddleRecruiterTransaction = async ({ const localTotal = data?.details?.totals?.total || '0'; const localCurrencyCode = data?.currencyCode || 'USD'; - const organization = await opportunity.organization; - if (origin === 'subscription_recurring') { return; } From d5db6a9438b433534d7770494e362ffe3dd57656 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 16 Dec 2025 11:20:27 +0100 Subject: [PATCH 5/6] feat: tests --- __tests__/schema/opportunity.ts | 202 ++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index 6a0c4313f5..b578c89981 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -77,6 +77,8 @@ import type { ServiceClient } from '../../src/types'; import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob'; import * as brokkrCommon from '../../src/common/brokkr'; import { randomUUID } from 'node:crypto'; +import { updateRecruiterSubscriptionFlags } from '../../src/common'; +import { SubscriptionStatus } from '../../src/common/plus'; // Mock Slack WebClient const mockConversationsCreate = jest.fn(); @@ -5037,6 +5039,25 @@ describe('mutation updateOpportunityState', () => { type: OpportunityUserType.Recruiter, }); + await con.getRepository(Organization).update( + { + id: organizationsFixture[0].id, + }, + { + recruiterSubscriptionFlags: + updateRecruiterSubscriptionFlags({ + subscriptionId: 'sub_test', + status: SubscriptionStatus.Active, + items: [ + { + priceId: 'test', + quantity: 1, + }, + ], + }), + }, + ); + await testMutationErrorCode( client, { @@ -5076,6 +5097,25 @@ describe('mutation updateOpportunityState', () => { const opportunityId = opportunitiesFixture[3].id; + await con.getRepository(Organization).update( + { + id: opportunitiesFixture[3].organizationId!, + }, + { + recruiterSubscriptionFlags: + updateRecruiterSubscriptionFlags({ + subscriptionId: 'sub_test', + status: SubscriptionStatus.Active, + items: [ + { + priceId: 'test', + quantity: 1, + }, + ], + }), + }, + ); + await con.getRepository(OpportunityUser).save({ opportunityId, userId: '1', @@ -5173,6 +5213,168 @@ describe('mutation updateOpportunityState', () => { 'Opportunity must have an organization assigned', ); }); + + it('should update state to CLOSED state', async () => { + loggedUser = '1'; + + const opportunity = await con.getRepository(OpportunityJob).save({ + title: 'Test', + tldr: 'Test', + state: OpportunityState.LIVE, + organizationId: organizationsFixture[0].id, + }); + + await con.getRepository(OpportunityUser).save({ + opportunityId: opportunity.id, + userId: '1', + type: OpportunityUserType.Recruiter, + }); + + await con.getRepository(Organization).update( + { + id: organizationsFixture[0].id, + }, + { + recruiterSubscriptionFlags: + updateRecruiterSubscriptionFlags({ + subscriptionId: 'sub_test', + status: SubscriptionStatus.Active, + items: [ + { + priceId: 'test', + quantity: 1, + }, + ], + }), + }, + ); + + const res = await client.mutate(MUTATION, { + variables: { id: opportunity.id, state: OpportunityState.CLOSED }, + }); + + expect(res.errors).toBeFalsy(); + + const after = await con + .getRepository(Opportunity) + .findOneByOrFail({ id: opportunity.id }); + expect(after.state).toBe(OpportunityState.CLOSED); + }); + + it('should throw conflict on CLOSED transition when subscription is missing', async () => { + loggedUser = '1'; + + const opportunity = await con.getRepository(OpportunityJob).save({ + title: 'Test', + tldr: 'Test', + state: OpportunityState.LIVE, + organizationId: organizationsFixture[0].id, + }); + + await con.getRepository(OpportunityUser).save({ + opportunityId: opportunity.id, + userId: '1', + type: OpportunityUserType.Recruiter, + }); + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { id: opportunity.id, state: OpportunityState.CLOSED }, + }, + 'CONFLICT', + 'Opportunity subscription not found', + ); + }); + + it('should throw conflict on LIVE transition when subscription is not active yet', async () => { + loggedUser = '1'; + + const opportunity = await con.getRepository(OpportunityJob).save({ + title: 'Test', + tldr: 'Test', + state: OpportunityState.DRAFT, + organizationId: organizationsFixture[0].id, + }); + + await con.getRepository(OpportunityUser).save({ + opportunityId: opportunity.id, + userId: '1', + type: OpportunityUserType.Recruiter, + }); + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { id: opportunity.id, state: OpportunityState.LIVE }, + }, + 'CONFLICT', + 'Opportunity subscription is not active yet, make sure your payment was processed in full. Contact support if the issue persists.', + ); + }); + + it('should throw payment required on LIVE transition when no more allowed seats', async () => { + loggedUser = '1'; + + const opportunityId = opportunitiesFixture[3].id; + + await con.getRepository(Organization).update( + { + id: opportunitiesFixture[3].organizationId!, + }, + { + recruiterSubscriptionFlags: + updateRecruiterSubscriptionFlags({ + subscriptionId: 'sub_test', + status: SubscriptionStatus.Active, + items: [], + }), + }, + ); + + await con.getRepository(OpportunityUser).save({ + opportunityId, + userId: '1', + type: OpportunityUserType.Recruiter, + }); + + await con.getRepository(OpportunityKeyword).save({ + opportunityId, + keyword: 'typescript', + }); + await con.getRepository(QuestionScreening).save({ + opportunityId, + title: 'Tell us about a recent project', + questionOrder: 0, + }); + await con.getRepository(Opportunity).update( + { id: opportunityId }, + { + content: { + overview: { content: 'Overview content', html: '' }, + responsibilities: { content: 'Responsibilities content', html: '' }, + requirements: { content: 'Requirements content', html: '' }, + }, + }, + ); + + const before = await con + .getRepository(Opportunity) + .findOneByOrFail({ id: opportunityId }); + expect(before.state).toBe(OpportunityState.DRAFT); + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { id: opportunityId, state: OpportunityState.LIVE }, + }, + 'PAYMENT_REQUIRED', + 'Your subscription allows for 0 live opportunities. Please upgrade your subscription to add more or pause other live opportunities.', + ); + }); }); describe('mutation parseOpportunity', () => { From 16de0d7224b48f6b056612fab85db569c9e86438 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 16 Dec 2025 13:56:50 +0100 Subject: [PATCH 6/6] fix: correct id --- src/common/paddle/recruiter/processing.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/paddle/recruiter/processing.ts b/src/common/paddle/recruiter/processing.ts index 1f165af9e8..bb46e51399 100644 --- a/src/common/paddle/recruiter/processing.ts +++ b/src/common/paddle/recruiter/processing.ts @@ -87,7 +87,7 @@ export const createOpportunitySubscription = async ({ await con.transaction(async (entityManager) => { await entityManager.getRepository(Organization).update( { - id: opportunity.id, + id: organization.id, }, { recruiterSubscriptionFlags: @@ -166,7 +166,7 @@ export const cancelRecruiterSubscription = async ({ con.getRepository(Organization).update( { - id: opportunity.id, + id: organization.id, }, { recruiterSubscriptionFlags: