Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion frontend/src/Committee.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,6 @@ SELECT * FROM final`;
<div class="schedule-col-1">
<StateContributors
scope={committeeScope}
formTypeFilter={isF3Committee ? 'SA11AI' : undefined}
homeState={isF3Committee ? pageData.candidate?.state : undefined}
/>
</div>
Expand Down
1 change: 0 additions & 1 deletion frontend/src/forms/F3/F3.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@
<StateContributors
scope={{ mode: 'single', filingId }}
{homeState}
formTypeFilter="SA11AI"
/>
</div>
<div class="schedule-col-2">
Expand Down
39 changes: 28 additions & 11 deletions frontend/src/forms/shared/StateContributors.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@
interface Props {
scope: FilingScope;
homeState?: string | null;
formTypeFilter?: string;
}

let { scope, homeState = null, formTypeFilter }: Props = $props();
let { scope, homeState = null }: Props = $props();

const dbName = get(databaseName);

Expand Down Expand Up @@ -60,16 +59,29 @@
}

function stateUrl(stateCode: string): string {
return buildStateUrl(dbName, scope, stateCode, formTypeFilter, filingIdsResult.data);
return buildStateUrl(dbName, scope, stateCode, filingIdsResult.data);
}

const total = $derived(
(contributions.data ?? []).reduce((sum, row) => sum + (row.total_contributions || 0), 0)
);
// On F3X (PACs, party committees, super PACs) line 11(a)(i) also carries
// contributions from corporations, LLCs and other non-committee "persons",
// which can dwarf the money from actual individuals at super PACs.
const isF3X = $derived(metadata.data?.form_type === 'F3X');
const title = $derived(
isF3X
? 'Contributions from Individuals & Other Persons by State'
: 'Individual Contributions by State'
);

const subtitle = $derived(() => {
const m = metadata.data;
if (!m?.committee_name) return null;
const parts = [`Top individual contributions to ${m.committee_name}`];
const kind = isF3X
? 'contributions from individuals and other persons'
: 'individual contributions';
const parts = [`Top ${kind} to ${m.committee_name}`];
if (m.coverage_from_date && m.coverage_through_date) {
parts[0] += ` between ${formatDate(m.coverage_from_date)} and ${formatDate(m.coverage_through_date)}`;
}
Expand All @@ -79,10 +91,7 @@
const sourceNote = $derived(() => {
const formType = metadata.data?.form_type;
if (!formType) return null;
if (formTypeFilter === 'SA11AI') {
return `Source: ${formType}, Schedule A, Line 11(a)(i)`;
}
return `Source: ${formType}, Schedule A`;
return `Source: ${formType}, Schedule A, Line 11(a)(i) and Line 12 joint fundraising memo entries`;
});

const N = 10;
Expand All @@ -96,12 +105,12 @@

{#if contributions.isLoading}
<div class="section-box">
<h4>Individual Contributions by State</h4>
<h4>{title}</h4>
<div class="loading">Loading...</div>
</div>
{:else if contributions.data && contributions.data.length > 0}
<div class="section-box">
<h4>Individual Contributions by State</h4>
<h4>{title}</h4>
{#if subtitle()}
<p class="subtitle">{subtitle()}</p>
{/if}
Expand Down Expand Up @@ -146,7 +155,15 @@
</tbody>
</table>
</div>
<p class="info-note">Only includes individuals who have given $200 or more this cycle.</p>
<p class="info-note">
Only includes itemized contributions (generally donors who have given more than $200 this
cycle).
</p>
{#if isF3X}
<p class="info-note">
Includes contributions from corporations, LLCs and other non-committee sources.
</p>
{/if}
{#if sourceNote()}
<p class="info-note">{sourceNote()}</p>
{/if}
Expand Down
27 changes: 21 additions & 6 deletions frontend/src/forms/shared/stateContributorsFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ export async function fetchScopeMetadata(
);
}

/**
* Schedule A rows that represent contributions from individuals, matching how
* FEC.gov builds its by-state breakdown:
* - Line 11(a)(i) itemized individual contributions (non-memo). Earmarked
* contributions (e.g. via WinRed) are itemized here under the donor; the
* conduit's own "earmarked-conduit details" rows are memo entries.
* - Line 12 memo entries for individuals (and tribes, which FEC also counts):
* the donors behind joint fundraising committee transfers. The non-memo line
* 12 row is the transfer from the JFC itself, which would otherwise be
* attributed to the JFC's state.
*
* Partnership/LLC contributions are counted once, via the non-memo 11(a)(i)
* row; the memo attributions to individual partners are skipped.
*/
export const INDIVIDUAL_CONTRIBUTIONS_WHERE = `(
(form_type = 'SA11AI' AND memo_code IS NOT 'X')
OR (form_type = 'SA12' AND memo_code = 'X' AND entity_type IN ('IND', 'ORG'))
)`;

export function fetchStateContributions(
dbName: string,
scope: FilingScope
Expand All @@ -54,7 +73,7 @@ export function fetchStateContributions(
WHERE ${where}
AND contributor_state IS NOT NULL
AND contributor_state != ''
AND memo_code != 'X'
AND ${INDIVIDUAL_CONTRIBUTIONS_WHERE}
GROUP BY contributor_state
ORDER BY total_contributions DESC
`;
Expand All @@ -65,18 +84,14 @@ export function buildStateUrl(
dbName: string,
scope: FilingScope,
stateCode: string,
formTypeFilter?: string,
filingIds?: string[]
): string {
const scopeParams = filingScopeUrlParams(scope, filingIds);
const params = new URLSearchParams({
_sort: 'rowid',
contributor_state__exact: stateCode,
...scopeParams,
memo_code__not: 'X',
_where: INDIVIDUAL_CONTRIBUTIONS_WHERE.replace(/\s+/g, ' ').trim(),
});
if (formTypeFilter) {
params.set('form_type__exact', formTypeFilter);
}
return `/${dbName}/libfec_schedule_a?${params}`;
}
Loading