Skip to content

Commit adace9c

Browse files
authored
Merge pull request #1972 from dubinc/earnings-events
Supporting CPC earnings (+ lead events)
2 parents 2098429 + f27fc95 commit adace9c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+965
-460
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { getAnalytics } from "@/lib/analytics/get-analytics";
2+
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
3+
import { createId } from "@/lib/api/utils";
4+
import { verifyVercelSignature } from "@/lib/cron/verify-vercel";
5+
import { prisma } from "@dub/prisma";
6+
import { EventType, Prisma } from "@prisma/client";
7+
import { NextResponse } from "next/server";
8+
9+
export const dynamic = "force-dynamic";
10+
11+
/**
12+
* TODO:
13+
* - Use a cron job (similar to how we do it for usage cron) to account for the future where we have a lot of links to process
14+
* - Might be better to read directly from the Reward table and fetch relevant links from there
15+
* - Once these are ready, we'll add back the cron job in vercel.json
16+
{
17+
"path": "/api/cron/aggregate-clicks",
18+
"schedule": "0 0 * * *"
19+
},
20+
*/
21+
22+
// This route is used aggregate clicks events on daily basis for Program links and add to the Commission table
23+
// Runs every day at 00:00 (0 0 * * *)
24+
// GET /api/cron/aggregate-clicks
25+
export async function GET(req: Request) {
26+
try {
27+
await verifyVercelSignature(req);
28+
29+
const links = await prisma.link.findMany({
30+
where: {
31+
programId: {
32+
not: null,
33+
},
34+
partnerId: {
35+
not: null,
36+
},
37+
clicks: {
38+
gt: 0,
39+
},
40+
lastClicked: {
41+
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // find links that were clicked in the last 24 hours
42+
},
43+
},
44+
select: {
45+
id: true,
46+
programId: true,
47+
partnerId: true,
48+
},
49+
});
50+
51+
if (!links.length) {
52+
return NextResponse.json({
53+
message: "No program links found. Skipping...",
54+
});
55+
}
56+
57+
const now = new Date();
58+
59+
// Set 'start' to the beginning of the previous day (00:00:00)
60+
const start = new Date(now);
61+
start.setDate(start.getDate() - 1);
62+
start.setHours(0, 0, 0, 0);
63+
64+
// Set 'end' to the end of the previous day (23:59:59)
65+
const end = new Date(now);
66+
end.setDate(end.getDate() - 1);
67+
end.setHours(23, 59, 59, 999);
68+
69+
let commissions: Prisma.CommissionUncheckedCreateInput[] =
70+
await Promise.all(
71+
links.map(async ({ id: linkId, programId, partnerId }) => {
72+
const { clicks: quantity } = await getAnalytics({
73+
start,
74+
end,
75+
linkId,
76+
groupBy: "count",
77+
event: "clicks",
78+
});
79+
80+
return {
81+
id: createId({ prefix: "cm_" }),
82+
linkId,
83+
programId: programId!,
84+
partnerId: partnerId!,
85+
type: EventType.click,
86+
quantity,
87+
amount: 0,
88+
};
89+
}),
90+
);
91+
92+
commissions = commissions.filter((earning) => earning.amount > 0);
93+
94+
console.log({ start, end, commissions });
95+
96+
if (commissions.length) {
97+
await prisma.commission.createMany({
98+
data: commissions,
99+
});
100+
}
101+
102+
return NextResponse.json({
103+
start,
104+
end,
105+
commissions,
106+
});
107+
} catch (error) {
108+
return handleAndReturnErrorResponse(error);
109+
}
110+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
2+
import { verifyVercelSignature } from "@/lib/cron/verify-vercel";
3+
import { prisma } from "@dub/prisma";
4+
import { NextResponse } from "next/server";
5+
import { createPayout } from "../create-payout";
6+
7+
export const dynamic = "force-dynamic";
8+
9+
// This route is used to calculate payouts for clicks.
10+
// Runs once every day at 00:00 (0 0 * * *)
11+
// GET /api/cron/payouts/clicks
12+
export async function GET(req: Request) {
13+
try {
14+
await verifyVercelSignature(req);
15+
16+
const clicks = await prisma.commission.groupBy({
17+
by: ["programId", "partnerId"],
18+
where: {
19+
type: "click",
20+
status: "pending",
21+
payoutId: null,
22+
},
23+
});
24+
25+
if (!clicks.length) {
26+
return NextResponse.json({
27+
message: "No pending clicks found. Skipping...",
28+
});
29+
}
30+
31+
for (const { programId, partnerId } of clicks) {
32+
await createPayout({
33+
programId,
34+
partnerId,
35+
type: "click",
36+
});
37+
}
38+
39+
return NextResponse.json({
40+
message: "Clicks payout created.",
41+
clicks,
42+
});
43+
} catch (error) {
44+
return handleAndReturnErrorResponse(error);
45+
}
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { createId } from "@/lib/api/utils";
2+
import { prisma } from "@dub/prisma";
3+
import { EventType, Payout } from "@dub/prisma/client";
4+
5+
export const createPayout = async ({
6+
programId,
7+
partnerId,
8+
type,
9+
}: {
10+
programId: string;
11+
partnerId: string;
12+
type: EventType;
13+
}) => {
14+
await prisma.$transaction(async (tx) => {
15+
const commissions = await tx.commission.findMany({
16+
where: {
17+
programId,
18+
partnerId,
19+
payoutId: null,
20+
type,
21+
status: "pending",
22+
},
23+
select: {
24+
id: true,
25+
createdAt: true,
26+
earnings: true,
27+
quantity: true,
28+
},
29+
orderBy: {
30+
createdAt: "asc",
31+
},
32+
});
33+
34+
if (!commissions.length) {
35+
console.log("No pending commissions found for processing payout.", {
36+
programId,
37+
partnerId,
38+
type,
39+
});
40+
41+
return;
42+
}
43+
44+
// earliest commission date
45+
const periodStart = commissions[0].createdAt;
46+
47+
// end of the month of the latest commission date
48+
// e.g. if the latest sale is 2024-12-16, the periodEnd should be 2024-12-31
49+
let periodEnd = commissions[commissions.length - 1].createdAt;
50+
periodEnd = new Date(periodEnd.getFullYear(), periodEnd.getMonth() + 1);
51+
52+
const totalQuantity = commissions.reduce(
53+
(total, { quantity }) => total + quantity,
54+
0,
55+
);
56+
57+
const totalAmount = commissions.reduce(
58+
(total, { earnings }) => total + earnings,
59+
0,
60+
);
61+
62+
if (totalAmount === 0) {
63+
console.log("Total amount is 0, skipping payout.", {
64+
programId,
65+
partnerId,
66+
type,
67+
totalQuantity,
68+
totalAmount,
69+
});
70+
71+
return;
72+
}
73+
74+
// check if the partner has another pending payout
75+
const existingPayout = await tx.payout.findFirst({
76+
where: {
77+
programId,
78+
partnerId,
79+
type: `${type}s`,
80+
status: "pending",
81+
},
82+
});
83+
84+
let payout: Payout | null = null;
85+
86+
if (existingPayout) {
87+
payout = await tx.payout.update({
88+
where: {
89+
id: existingPayout.id,
90+
},
91+
data: {
92+
amount: {
93+
increment: totalAmount,
94+
},
95+
quantity: {
96+
increment: totalQuantity,
97+
},
98+
periodEnd,
99+
description: existingPayout.description ?? "Dub Partners payout",
100+
},
101+
});
102+
} else {
103+
payout = await tx.payout.create({
104+
data: {
105+
id: createId({ prefix: "po_" }),
106+
programId,
107+
partnerId,
108+
periodStart,
109+
periodEnd,
110+
amount: totalAmount,
111+
quantity: totalQuantity,
112+
description: "Dub Partners payout",
113+
type: `${type}s`,
114+
},
115+
});
116+
}
117+
118+
if (!payout) {
119+
throw new Error("Payout not created.");
120+
}
121+
122+
await tx.commission.updateMany({
123+
where: {
124+
id: {
125+
in: commissions.map(({ id }) => id),
126+
},
127+
},
128+
data: {
129+
status: "processed",
130+
payoutId: payout.id,
131+
},
132+
});
133+
134+
console.log("Payout created", payout);
135+
});
136+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
2+
import { verifyVercelSignature } from "@/lib/cron/verify-vercel";
3+
import { prisma } from "@dub/prisma";
4+
import { NextResponse } from "next/server";
5+
import { createPayout } from "../create-payout";
6+
7+
export const dynamic = "force-dynamic";
8+
9+
// This route is used to calculate payouts for leads.
10+
// Runs once every day at 00:00 (0 0 * * *)
11+
// GET /api/cron/payouts/leads
12+
export async function GET(req: Request) {
13+
try {
14+
await verifyVercelSignature(req);
15+
16+
const leads = await prisma.commission.groupBy({
17+
by: ["programId", "partnerId"],
18+
where: {
19+
type: "lead",
20+
status: "pending",
21+
payoutId: null,
22+
},
23+
});
24+
25+
if (!leads.length) {
26+
return NextResponse.json({
27+
message: "No pending leads found. Skipping...",
28+
});
29+
}
30+
31+
for (const { programId, partnerId } of leads) {
32+
await createPayout({
33+
programId,
34+
partnerId,
35+
type: "lead",
36+
});
37+
}
38+
39+
return NextResponse.json({
40+
message: "Leads payout created.",
41+
leads,
42+
});
43+
} catch (error) {
44+
return handleAndReturnErrorResponse(error);
45+
}
46+
}

0 commit comments

Comments
 (0)