Skip to content

Commit 5168a51

Browse files
authored
Merge pull request #616 from PotLock/staging
Staging
2 parents 509a89a + 33e391f commit 5168a51

File tree

5 files changed

+354
-1
lines changed

5 files changed

+354
-1
lines changed

src/common/api/indexer/hooks.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { AxiosResponse } from "axios";
2+
import useSWR from "swr";
23

4+
import { INDEXER_API_ENDPOINT_URL } from "@/common/_config";
35
import { NOOP_STRING } from "@/common/constants";
46
import { isAccountId, isEthereumAddress } from "@/common/lib";
57
import {
@@ -11,6 +13,7 @@ import {
1113

1214
import * as generatedClient from "./internal/client.generated";
1315
import { INDEXER_CLIENT_CONFIG, INDEXER_CLIENT_CONFIG_STAGING } from "./internal/config";
16+
import type { OrgVerification } from "./tax-verification";
1417
import { ByPotId } from "./types";
1518

1619
const currentNetworkConfig =
@@ -378,3 +381,25 @@ export const useCampaign = ({ campaignId }: { campaignId: number }) => {
378381

379382
return { ...queryResult, data: queryResult.data?.data };
380383
};
384+
385+
/**
386+
* Fetch 501(c)(3) verification status for an organization account.
387+
*/
388+
const orgVerificationFetcher = (url: string) =>
389+
fetch(url).then((r) => {
390+
if (r.status === 404) return null;
391+
if (!r.ok) throw new Error("Failed to fetch org verification");
392+
return r.json() as Promise<OrgVerification>;
393+
});
394+
395+
export const useOrgVerification = ({
396+
accountId,
397+
enabled = true,
398+
}: ByAccountId & ConditionalActivation) => {
399+
return useSWR(
400+
enabled && accountId
401+
? `${INDEXER_API_ENDPOINT_URL}/api/v1/tax-verification/org-verification/${accountId}`
402+
: null,
403+
orgVerificationFetcher,
404+
);
405+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { INDEXER_API_ENDPOINT_URL } from "@/common/_config";
2+
3+
const BASE_URL = INDEXER_API_ENDPOINT_URL;
4+
5+
export interface OrgVerificationInput {
6+
account_id: string;
7+
ein: string;
8+
}
9+
10+
export interface OrgVerification {
11+
id: number;
12+
account: { id: string };
13+
ein: string;
14+
legal_name: string;
15+
address: string | null;
16+
city: string | null;
17+
state: string | null;
18+
zip_code: string | null;
19+
subsection_code: number | null;
20+
ntee_code: string | null;
21+
ruling_date: string | null;
22+
status: "Pending" | "Approved" | "Rejected";
23+
rejection_reason: string | null;
24+
submitted_at: string;
25+
updated_at: string;
26+
}
27+
28+
export const taxVerificationApi = {
29+
async submitOrgVerification(
30+
data: OrgVerificationInput,
31+
): Promise<{ success: boolean; data?: OrgVerification; message?: string }> {
32+
try {
33+
const response = await fetch(`${BASE_URL}/api/v1/tax-verification/org-verification`, {
34+
method: "POST",
35+
headers: { "Content-Type": "application/json" },
36+
body: JSON.stringify(data),
37+
});
38+
39+
if (!response.ok) {
40+
const error = await response.json().catch(() => ({}));
41+
return { success: false, message: error?.error || "Verification failed" };
42+
}
43+
44+
const result = await response.json();
45+
return { success: true, data: result };
46+
} catch (error) {
47+
console.warn("Failed to submit org verification:", error);
48+
return { success: false, message: String(error) };
49+
}
50+
},
51+
52+
async getOrgVerification(
53+
accountId: string,
54+
): Promise<{ success: boolean; data?: OrgVerification; message?: string }> {
55+
try {
56+
const response = await fetch(
57+
`${BASE_URL}/api/v1/tax-verification/org-verification/${accountId}`,
58+
);
59+
60+
if (response.status === 404) {
61+
return { success: true, data: undefined };
62+
}
63+
64+
if (!response.ok) {
65+
const error = await response.json().catch(() => ({}));
66+
return { success: false, message: error?.error || "Fetch failed" };
67+
}
68+
69+
const result = await response.json();
70+
return { success: true, data: result };
71+
} catch (error) {
72+
console.warn("Failed to fetch org verification:", error);
73+
return { success: false, message: String(error) };
74+
}
75+
},
76+
};

src/features/profile-configuration/components/editor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ProjectCategoryPicker, Row, SubHeader } from "./editor-elements";
2121
import { ProfileConfigurationFundingSourcesTable } from "./funding-sources";
2222
import { ProfileConfigurationImageUpload } from "./image-upload";
2323
import { ProfileConfigurationLinktreeSection } from "./linktree-section";
24+
import { OrgVerificationSection } from "./org-verification-section";
2425
import { ProfileConfigurationRepositoriesSection } from "./repositories-section";
2526
import { LowerBannerContainer, LowerBannerContainerLeft } from "./styles";
2627
import { type ProfileFormParams, useProfileForm } from "../hooks/forms";
@@ -263,6 +264,9 @@ export const ProfileConfigurationEditor: React.FC<ProfileConfigurationEditorProp
263264
<ProfileConfigurationLinktreeSection form={form} />
264265
</Row>
265266

267+
<SubHeader title="501(c)(3) Tax-Exempt Verification" className="mt-16" />
268+
<OrgVerificationSection accountId={accountId} />
269+
266270
<div className="mt-16 flex gap-4 self-end">
267271
<Button variant="standard-outline" onClick={router.back}>
268272
{"Cancel"}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { useCallback, useState } from "react";
2+
3+
import { useOrgVerification } from "@/common/api/indexer/hooks";
4+
import { taxVerificationApi } from "@/common/api/indexer/tax-verification";
5+
import { TextField } from "@/common/ui/form/components";
6+
import { Button, FormLabel } from "@/common/ui/layout/components";
7+
import { cn } from "@/common/ui/layout/utils";
8+
9+
import { Row } from "./editor-elements";
10+
11+
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
12+
Pending: { bg: "bg-[#FFF0E1]", text: "text-[#EA6A25]", label: "Verifying..." },
13+
Approved: { bg: "bg-[#FFF0E1]", text: "text-[#EA6A25]", label: "Unverified 501(c)(3)" },
14+
Rejected: { bg: "bg-[#FFE1E1]", text: "text-[#ED464F]", label: "Not Verified" },
15+
};
16+
17+
const FieldDisplay = ({ label, value }: { label: string; value: string | null }) => (
18+
<div className="flex w-full flex-col gap-[0.45em] text-[14px]">
19+
<FormLabel className="m-0">{label}</FormLabel>
20+
<div className="rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 text-neutral-600">
21+
{value || "—"}
22+
</div>
23+
</div>
24+
);
25+
26+
type OrgVerificationSectionProps = {
27+
accountId?: string;
28+
};
29+
30+
export const OrgVerificationSection: React.FC<OrgVerificationSectionProps> = ({ accountId }) => {
31+
const { data: verification, mutate } = useOrgVerification({
32+
accountId: accountId ?? "",
33+
enabled: !!accountId,
34+
});
35+
36+
const [isSubmitting, setIsSubmitting] = useState(false);
37+
const [submitError, setSubmitError] = useState<string | null>(null);
38+
const [ein, setEin] = useState("");
39+
40+
const handleSubmit = useCallback(async () => {
41+
if (!accountId) return;
42+
43+
const trimmedEin = ein.trim();
44+
45+
// EIN format validation
46+
if (!/^\d{2}-?\d{7}$/.test(trimmedEin)) {
47+
setSubmitError("EIN must be in XX-XXXXXXX format (9 digits)");
48+
return;
49+
}
50+
51+
setIsSubmitting(true);
52+
setSubmitError(null);
53+
54+
const result = await taxVerificationApi.submitOrgVerification({
55+
account_id: accountId,
56+
ein: trimmedEin,
57+
});
58+
59+
setIsSubmitting(false);
60+
61+
if (result.success) {
62+
mutate();
63+
} else {
64+
setSubmitError(result.message ?? "Verification failed");
65+
}
66+
}, [accountId, ein, mutate]);
67+
68+
if (!accountId) return null;
69+
70+
const statusStyle = verification ? STATUS_STYLES[verification.status] : null;
71+
72+
// Approved view — show IRS data (unverified mode)
73+
if (verification?.status === "Approved") {
74+
return (
75+
<div className="mt-6 flex flex-col gap-4">
76+
{statusStyle && (
77+
<div
78+
className={cn(
79+
"inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider",
80+
statusStyle.bg,
81+
statusStyle.text,
82+
)}
83+
>
84+
{statusStyle.label}
85+
</div>
86+
)}
87+
88+
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
89+
This data is auto-populated from IRS records. It confirms this EIN belongs to a registered
90+
501(c)(3) organization, but does not verify that the submitter is an authorized
91+
representative.
92+
</div>
93+
94+
<Row>
95+
<FieldDisplay label="EIN" value={verification.ein} />
96+
<FieldDisplay label="Organization Name (IRS)" value={verification.legal_name} />
97+
</Row>
98+
99+
<Row>
100+
<FieldDisplay label="Address" value={verification.address} />
101+
<FieldDisplay
102+
label="City / State / Zip"
103+
value={
104+
[verification.city, verification.state, verification.zip_code]
105+
.filter(Boolean)
106+
.join(", ") || null
107+
}
108+
/>
109+
</Row>
110+
111+
<Row>
112+
<FieldDisplay label="NTEE Code" value={verification.ntee_code} />
113+
<FieldDisplay label="IRS Ruling Date" value={verification.ruling_date} />
114+
</Row>
115+
</div>
116+
);
117+
}
118+
119+
// Rejected view — show reason and allow retry
120+
if (verification?.status === "Rejected") {
121+
return (
122+
<div className="mt-6 flex flex-col gap-4">
123+
<div
124+
className={cn(
125+
"inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider",
126+
STATUS_STYLES.Rejected.bg,
127+
STATUS_STYLES.Rejected.text,
128+
)}
129+
>
130+
{STATUS_STYLES.Rejected.label}
131+
</div>
132+
133+
{verification.rejection_reason && (
134+
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
135+
{verification.rejection_reason}
136+
</div>
137+
)}
138+
139+
<p className="text-sm text-neutral-600">
140+
The EIN <strong>{verification.ein}</strong> could not be verified as a 501(c)(3). You can
141+
try again with a different EIN.
142+
</p>
143+
144+
<Row>
145+
<TextField
146+
label="EIN (Employer Identification Number)"
147+
required
148+
type="text"
149+
value={ein}
150+
onChange={(e) => {
151+
setEin(e.target.value);
152+
setSubmitError(null);
153+
}}
154+
placeholder="XX-XXXXXXX"
155+
maxLength={10}
156+
classNames={{ root: "w-full" }}
157+
/>
158+
</Row>
159+
160+
{submitError && (
161+
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
162+
{submitError}
163+
</div>
164+
)}
165+
166+
<div className="mt-2">
167+
<Button
168+
variant="standard-filled"
169+
onClick={handleSubmit}
170+
disabled={isSubmitting}
171+
type="button"
172+
>
173+
{isSubmitting ? "Verifying..." : "Try Again"}
174+
</Button>
175+
</div>
176+
</div>
177+
);
178+
}
179+
180+
// Default — no verification yet, show EIN input
181+
return (
182+
<div className="mt-6 flex flex-col gap-4">
183+
<Row>
184+
<TextField
185+
label="EIN (Employer Identification Number)"
186+
required
187+
type="text"
188+
value={ein}
189+
onChange={(e) => {
190+
setEin(e.target.value);
191+
setSubmitError(null);
192+
}}
193+
placeholder="XX-XXXXXXX"
194+
maxLength={10}
195+
classNames={{ root: "w-full" }}
196+
/>
197+
</Row>
198+
199+
{submitError && (
200+
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
201+
{submitError}
202+
</div>
203+
)}
204+
205+
<div className="mt-2">
206+
<Button
207+
variant="standard-filled"
208+
onClick={handleSubmit}
209+
disabled={isSubmitting}
210+
type="button"
211+
>
212+
{isSubmitting ? "Verifying..." : "Verify 501(c)(3) Status"}
213+
</Button>
214+
</div>
215+
</div>
216+
);
217+
};

0 commit comments

Comments
 (0)