Skip to content

Commit 72cb181

Browse files
authored
Handle duplicate identity (dubinc#3724)
1 parent 5b0bd2a commit 72cb181

File tree

9 files changed

+103
-25
lines changed

9 files changed

+103
-25
lines changed

apps/web/app/api/veriff/webhook/handle-decision-event.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { veriffDecisionEventSchema } from "@/lib/veriff/schema";
1+
import { computeVeriffIdentityHash } from "@/lib/veriff/compute-veriff-identity-hash";
2+
import { VeriffDecisionEvent } from "@/lib/veriff/schema";
23
import {
34
mergeVeriffMetadata,
45
parseVeriffMetadata,
@@ -9,9 +10,6 @@ import PartnerIdentityVerified from "@dub/email/templates/partner-identity-verif
910
import { prisma } from "@dub/prisma";
1011
import { IdentityVerificationStatus, Partner } from "@dub/prisma/client";
1112
import { logAndRespond } from "app/(ee)/api/cron/utils";
12-
import * as z from "zod/v4";
13-
14-
type VeriffDecisionEvent = z.infer<typeof veriffDecisionEventSchema>;
1513

1614
const veriffStatusMap: Record<
1715
VeriffDecisionEvent["verification"]["status"],
@@ -56,6 +54,7 @@ export const handleDecisionEvent = async ({
5654

5755
// since we're skipping verified partners, by default identityVerifiedAt is null
5856
let identityVerifiedAt: Date | null = null;
57+
let veriffIdentityHash: string | null | undefined = undefined;
5958

6059
let { sessionUrl, attemptCount, declineReason, sessionExpiresAt } =
6160
parseVeriffMetadata(partner.veriffMetadata);
@@ -65,12 +64,22 @@ export const handleDecisionEvent = async ({
6564

6665
// If the verification was approved, check for country mismatch
6766
if (effectiveStatus === "approved") {
67+
veriffIdentityHash = computeVeriffIdentityHash(verification);
68+
const isDuplicate = await checkDuplicateIdentity({
69+
partner,
70+
veriffIdentityHash,
71+
});
72+
6873
const isCountryMismatch = checkCountryMismatch({
6974
partner,
7075
verification,
7176
});
7277

73-
if (isCountryMismatch) {
78+
if (isDuplicate) {
79+
effectiveStatus = "declined";
80+
declineReason =
81+
"This identity has already been verified on another account.";
82+
} else if (isCountryMismatch) {
7483
effectiveStatus = "declined";
7584
declineReason = `Your document country (${verification.document?.country}) does not match your account country (${partner.country})`;
7685
} else {
@@ -100,6 +109,8 @@ export const handleDecisionEvent = async ({
100109
data: {
101110
identityVerificationStatus: veriffStatusMap[effectiveStatus],
102111
identityVerifiedAt,
112+
veriffIdentityHash:
113+
effectiveStatus === "approved" ? veriffIdentityHash : null,
103114
veriffMetadata,
104115
},
105116
select: {
@@ -137,6 +148,32 @@ function checkCountryMismatch({
137148
return partner.country.toUpperCase() !== veriffCountry;
138149
}
139150

151+
async function checkDuplicateIdentity({
152+
partner,
153+
veriffIdentityHash,
154+
}: {
155+
partner: Pick<Partner, "id">;
156+
veriffIdentityHash: string | null;
157+
}): Promise<boolean> {
158+
if (!veriffIdentityHash) {
159+
return false;
160+
}
161+
162+
const duplicatePartner = await prisma.partner.findFirst({
163+
where: {
164+
veriffIdentityHash,
165+
id: {
166+
not: partner.id,
167+
},
168+
},
169+
select: {
170+
id: true,
171+
},
172+
});
173+
174+
return !!duplicatePartner;
175+
}
176+
140177
async function sendEmailNotification({
141178
partner,
142179
attemptId,

apps/web/app/api/veriff/webhook/handle-session-event.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const handleSessionEvent = async ({
4040
},
4141
data: {
4242
identityVerificationStatus: action,
43+
veriffIdentityHash: null,
4344
veriffMetadata,
4445
},
4546
});

apps/web/lib/auth/partner.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ export const withPartnerProfile = (
224224
salesChannels: true,
225225
platforms: true,
226226
},
227+
omit: {
228+
veriffIdentityHash: true,
229+
},
227230
},
228231
},
229232
});

apps/web/lib/email/email-templates-map.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import BountyApproved from "@dub/email/templates/bounty-approved";
2-
import DubProductUpdateMar26 from "@dub/email/templates/broadcasts/dub-product-update-mar26";
2+
import IdentityVerificationAnnouncement from "@dub/email/templates/broadcasts/identity-verification-announcement";
33
import ConnectPayoutReminder from "@dub/email/templates/connect-payout-reminder";
44
import ConnectPlatformsReminder from "@dub/email/templates/connect-platforms-reminder";
55
import PartnerBanned from "@dub/email/templates/partner-banned";
@@ -22,9 +22,10 @@ export const EMAIL_TEMPLATES_MAP = {
2222
UnresolvedFraudEventsSummary,
2323
PartnerGroupChanged,
2424

25-
// special promo emails
25+
// special broadcast emails
2626
// DubPartnerRewind,
27-
DubProductUpdateMar26,
27+
// DubProductUpdateMar26,
28+
IdentityVerificationAnnouncement,
2829
// PayoutAutoWithdrawals,
2930
// ProgramMarketplaceAnnouncement,
3031
// StablecoinPayoutsAnnouncement,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createHash } from "crypto";
2+
import { VeriffDecisionEvent } from "./schema";
3+
4+
export function computeVeriffIdentityHash(
5+
verification: VeriffDecisionEvent["verification"],
6+
) {
7+
const { person, document } = verification;
8+
const documentNumber = document?.number?.trim();
9+
const documentCountry = document?.country?.trim();
10+
const firstName = person?.firstName?.trim();
11+
const lastName = person?.lastName?.trim();
12+
const dateOfBirth = person?.dateOfBirth?.trim();
13+
14+
// Prefer document number (passport/ID number) — strongest unique signal
15+
if (documentNumber) {
16+
const input = [
17+
"doc",
18+
documentNumber.toLowerCase(),
19+
...(documentCountry ? [documentCountry.toUpperCase()] : []),
20+
].join("|");
21+
return createHash("sha256").update(input).digest("hex");
22+
}
23+
24+
// Fall back to name + date of birth
25+
if ((firstName || lastName) && dateOfBirth) {
26+
const input = [
27+
"person",
28+
...(firstName ? [firstName.toLowerCase()] : []),
29+
...(lastName ? [lastName.toLowerCase()] : []),
30+
dateOfBirth,
31+
].join("|");
32+
return createHash("sha256").update(input).digest("hex");
33+
}
34+
35+
return null;
36+
}

apps/web/lib/veriff/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export const veriffDecisionEventSchema = z.object({
6767
}),
6868
});
6969

70+
export type VeriffDecisionEvent = z.infer<typeof veriffDecisionEventSchema>;
71+
7072
export const veriffEventSchema = z.union([
7173
veriffSessionEventSchema,
7274
veriffDecisionEventSchema,

apps/web/scripts/send-batch-emails.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1-
import DubProductUpdateMar26 from "@dub/email/templates/broadcasts/dub-product-update-mar26";
1+
import IdentityVerificationAnnouncement from "@dub/email/templates/broadcasts/identity-verification-announcement";
22
import { prisma } from "@dub/prisma";
33
import { chunk } from "@dub/utils";
44
import "dotenv-flow/config";
55
import { queueBatchEmail } from "../lib/email/queue-batch-email";
6-
import { generateUnsubscribeToken } from "../lib/email/unsubscribe-token";
76

87
async function main() {
98
while (true) {
109
const usersToNotify = await prisma.user.findMany({
1110
where: {
1211
sentMail: false,
13-
notificationPreferences: {
14-
dubPartners: true,
15-
},
16-
projects: {
12+
partners: {
1713
some: {
18-
project: {
19-
plan: {
20-
not: "free",
14+
partner: {
15+
payouts: {
16+
some: {
17+
status: "completed",
18+
amount: {
19+
gt: 10000,
20+
},
21+
},
2122
},
2223
},
2324
},
@@ -34,15 +35,13 @@ async function main() {
3435
}
3536
console.log(`Found ${usersToNotify.length} users to notify`);
3637

37-
const res = await queueBatchEmail<typeof DubProductUpdateMar26>(
38+
const res = await queueBatchEmail<typeof IdentityVerificationAnnouncement>(
3839
usersToNotify.map((user) => ({
3940
to: user.email!,
40-
subject: "Dub Partners Product Updates (Mar '26)",
41-
variant: "marketing",
42-
templateName: "DubProductUpdateMar26",
41+
subject: "Action Required: Verify your identity on Dub",
42+
templateName: "IdentityVerificationAnnouncement",
4343
templateProps: {
4444
email: user.email!,
45-
unsubscribeUrl: `https://app.dub.co/unsubscribe/${generateUnsubscribeToken(user.email!)}`,
4645
},
4746
})),
4847
);

packages/email/src/templates/broadcasts/identity-verification-announcement.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,12 @@ import {
1212
Tailwind,
1313
Text,
1414
} from "@react-email/components";
15-
import { Footer } from "src/components/footer";
15+
import { Footer } from "../../components/footer";
1616

1717
export default function IdentityVerificationAnnouncement({
1818
email = "panic@thedis.co",
19-
unsubscribeUrl = "https://partners.dub.co/account/settings",
2019
}: {
2120
email: string;
22-
unsubscribeUrl: string;
2321
}) {
2422
return (
2523
<Html>

packages/prisma/schema/partner.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ model Partner {
4848
identityVerificationStatus IdentityVerificationStatus?
4949
identityVerifiedAt DateTime?
5050
veriffSessionId String? @unique
51+
veriffIdentityHash String? @unique
5152
veriffMetadata Json? // see veriffMetadataSchema
5253
5354
// payout fields

0 commit comments

Comments
 (0)