Skip to content

Commit f471cda

Browse files
authored
feat: recruiter subscription (#3341)
1 parent 24b84c6 commit f471cda

13 files changed

Lines changed: 416 additions & 3 deletions

File tree

seeds/ExperimentVariant.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"feature": "cores_pricing_ids",
1111
"variant": "cores_default",
1212
"createdAt": "2025-04-23T11:45:36.548Z",
13-
"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}]",
13+
"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}]",
1414
"type": "productPricing"
1515
},
1616
{
@@ -19,5 +19,12 @@
1919
"createdAt": "2025-04-08T10:41:57.489Z",
2020
"value": "[{\"appsId\":\"annual\",\"title\":\"Annual\",\"caption\":{\"copy\":\"Save 25%\",\"color\":\"success\"},\"idMap\":{\"paddle\":\"pri_01jvm26zgvd5djwd0wjzz9np4s\"}},{\"appsId\":\"monthly\",\"title\":\"Monthly\",\"idMap\":{\"paddle\":\"pri_01jvm24pryx74g1pq0tn6bvamb\"}}]",
2121
"type": "productPricing"
22+
},
23+
{
24+
"feature": "recruiter_pricing_ids",
25+
"variant": "recruiter_default",
26+
"createdAt": "2025-12-10T13:45:08.395Z",
27+
"value": "[{\"title\":\"Job Slot (Starter Tier)\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0p5g7qw8zb1e8esf7qjw2\",\"ios\":\"\"}},{\"title\":\"Job Slot (Boost Tier)\",\"appsId\":\"\",\"idMap\":{\"paddle\":\"pri_01kbq0pv4mxbar7qt1nzs694r1\",\"ios\":\"\"}}]",
28+
"type": "productPricing"
2229
}
2330
]

src/common/paddle/pricing.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export const CORES_FEATURE_KEY = 'cores_pricing_ids';
3737
export const DEFAULT_CORES_METADATA = 'cores_default';
3838
export const ORGANIZATION_FEATURE_KEY = 'organization_pricing_ids';
3939
export const DEFAULT_ORGANIZATION_METADATA = 'organization_default';
40+
export const RECRUITER_FEATURE_KEY = 'recruiter_pricing_ids';
41+
export const DEFAULT_RECRUITER_METADATA = 'recruiter_default';
4042

4143
export interface BasePricingMetadata {
4244
appsId: string;
@@ -119,18 +121,24 @@ export const getCoresPricingMetadata = async ({
119121
}: Omit<GetMetadataProps, 'feature'>): Promise<BasePricingMetadata[]> =>
120122
getPaddleMetadata({ con, feature: CORES_FEATURE_KEY, variant });
121123

124+
export const getRecruiterPricingMetadata = async ({
125+
con,
126+
variant,
127+
}: Omit<GetMetadataProps, 'feature'>): Promise<BasePricingMetadata[]> =>
128+
getPaddleMetadata({ con, feature: RECRUITER_FEATURE_KEY, variant });
129+
122130
const featureKey: Record<PurchaseType, string> = {
123131
[PurchaseType.Plus]: PLUS_FEATURE_KEY,
124132
[PurchaseType.Organization]: ORGANIZATION_FEATURE_KEY,
125133
[PurchaseType.Cores]: CORES_FEATURE_KEY,
126-
[PurchaseType.Recruiter]: '', // Not used, but added for completeness
134+
[PurchaseType.Recruiter]: RECRUITER_FEATURE_KEY,
127135
};
128136

129137
const defaultVariant: Record<PurchaseType, string> = {
130138
[PurchaseType.Plus]: DEFAULT_PLUS_METADATA,
131139
[PurchaseType.Organization]: DEFAULT_ORGANIZATION_METADATA,
132140
[PurchaseType.Cores]: DEFAULT_CORES_METADATA,
133-
[PurchaseType.Recruiter]: '', // Not used, but added for completeness
141+
[PurchaseType.Recruiter]: DEFAULT_RECRUITER_METADATA,
134142
};
135143

136144
export const getPricingDuration = (
@@ -170,6 +178,8 @@ export const getPricingMetadata = async (
170178
return getPlusOrganizationPricingMetadata({ con, variant });
171179
case PurchaseType.Cores:
172180
return getCoresPricingMetadata({ con, variant });
181+
case PurchaseType.Recruiter:
182+
return getRecruiterPricingMetadata({ con, variant });
173183
default:
174184
throw new Error('Invalid pricing type');
175185
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { EventName, type EventEntity } from '@paddle/paddle-node-sdk';
2+
import { PurchaseType, SubscriptionProvider } from '../../plus';
3+
import { logger } from '../../../logger';
4+
import { logPaddleAnalyticsEvent } from '../index';
5+
import { AnalyticsEventName } from '../../../integrations/analytics';
6+
import {
7+
cancelRecruiterSubscription,
8+
createOpportunitySubscription,
9+
} from './processing';
10+
import { notifyNewPaddleRecruiterTransaction } from '../slack';
11+
12+
export const processRecruiterPaddleEvent = async (event: EventEntity) => {
13+
switch (event?.eventType) {
14+
case EventName.SubscriptionCreated:
15+
await createOpportunitySubscription({ event });
16+
17+
break;
18+
case EventName.SubscriptionCanceled:
19+
await Promise.all([
20+
await cancelRecruiterSubscription({ event }),
21+
logPaddleAnalyticsEvent(event, AnalyticsEventName.CancelSubscription),
22+
]);
23+
24+
break;
25+
case EventName.SubscriptionUpdated:
26+
logger.info(
27+
{
28+
provider: SubscriptionProvider.Paddle,
29+
purchaseType: PurchaseType.Recruiter,
30+
event,
31+
},
32+
'Subscription updated',
33+
);
34+
35+
break;
36+
case EventName.TransactionCompleted:
37+
await Promise.all([
38+
logPaddleAnalyticsEvent(event, AnalyticsEventName.ReceivePayment),
39+
notifyNewPaddleRecruiterTransaction({ event }),
40+
]);
41+
42+
break;
43+
default:
44+
logger.info(
45+
{
46+
provider: SubscriptionProvider.Paddle,
47+
purchaseType: PurchaseType.Recruiter,
48+
},
49+
event?.eventType,
50+
);
51+
}
52+
};
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type {
2+
SubscriptionCanceledEvent,
3+
SubscriptionCreatedEvent,
4+
} from '@paddle/paddle-node-sdk';
5+
import { extractSubscriptionCycle, getPaddleSubscriptionData } from '../index';
6+
import type { PaddleSubscriptionEvent } from '../../../paddle';
7+
import createOrGetConnection from '../../../db';
8+
import {
9+
PurchaseType,
10+
SubscriptionProvider,
11+
SubscriptionStatus,
12+
} from '../../plus/subscription';
13+
import { logger } from '../../../logger';
14+
import { OpportunityJob } from '../../../entity/opportunities/OpportunityJob';
15+
import { recruiterPaddleCustomDataSchema } from './types';
16+
import {
17+
ensureOpportunityPermissions,
18+
OpportunityPermissions,
19+
} from '../../opportunity/accessControl';
20+
import { updateSubscriptionFlags } from '../../utils';
21+
22+
export const createOpportunitySubscription = async ({
23+
event,
24+
}: {
25+
event: SubscriptionCreatedEvent;
26+
}) => {
27+
const data = getPaddleSubscriptionData({ event });
28+
const con = await createOrGetConnection();
29+
const { opportunity_id, user_id } = recruiterPaddleCustomDataSchema.parse(
30+
event.data.customData,
31+
);
32+
33+
const subscriptionType = extractSubscriptionCycle(
34+
data.items as PaddleSubscriptionEvent['data']['items'],
35+
);
36+
37+
if (!subscriptionType) {
38+
logger.error(
39+
{
40+
provider: SubscriptionProvider.Paddle,
41+
purchaseType: PurchaseType.Recruiter,
42+
data: event,
43+
},
44+
'Subscription type missing in payload',
45+
);
46+
47+
return false;
48+
}
49+
50+
const opportunity: Pick<OpportunityJob, 'id'> = await con
51+
.getRepository(OpportunityJob)
52+
.findOneOrFail({
53+
select: ['id'],
54+
where: {
55+
id: opportunity_id,
56+
},
57+
});
58+
59+
await ensureOpportunityPermissions({
60+
con: con.manager,
61+
userId: user_id,
62+
opportunityId: opportunity_id,
63+
permission: OpportunityPermissions.Edit,
64+
});
65+
66+
await con.getRepository(OpportunityJob).update(
67+
{
68+
id: opportunity.id,
69+
},
70+
{
71+
subscriptionFlags: updateSubscriptionFlags<OpportunityJob>({
72+
cycle: subscriptionType,
73+
createdAt: data.startedAt ?? new Date(),
74+
updatedAt: new Date(),
75+
subscriptionId: data.id,
76+
priceId: data.items[0].price.id,
77+
provider: SubscriptionProvider.Paddle,
78+
status: SubscriptionStatus.Active,
79+
}),
80+
},
81+
);
82+
};
83+
84+
export const cancelRecruiterSubscription = async ({
85+
event,
86+
}: {
87+
event: SubscriptionCanceledEvent;
88+
}) => {
89+
const con = await createOrGetConnection();
90+
const { opportunity_id, user_id } = recruiterPaddleCustomDataSchema.parse(
91+
event.data.customData,
92+
);
93+
94+
const opportunity: Pick<OpportunityJob, 'id'> = await con
95+
.getRepository(OpportunityJob)
96+
.findOneOrFail({
97+
select: ['id'],
98+
where: {
99+
id: opportunity_id,
100+
},
101+
});
102+
103+
await ensureOpportunityPermissions({
104+
con: con.manager,
105+
userId: user_id,
106+
opportunityId: opportunity_id,
107+
permission: OpportunityPermissions.Edit,
108+
});
109+
110+
const subscriptionFlags: OpportunityJob['subscriptionFlags'] = {
111+
cycle: null,
112+
status: SubscriptionStatus.Expired,
113+
updatedAt: new Date(),
114+
};
115+
116+
con.getRepository(OpportunityJob).update(
117+
{
118+
id: opportunity.id,
119+
},
120+
{
121+
subscriptionFlags:
122+
updateSubscriptionFlags<OpportunityJob>(subscriptionFlags),
123+
},
124+
);
125+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import z from 'zod';
2+
3+
export const recruiterPaddleCustomDataSchema = z.object({
4+
user_id: z.string(),
5+
opportunity_id: z.uuid(),
6+
});

0 commit comments

Comments
 (0)