Status: ✅ Complete — drafted and built September 23, 2026. Effort Class: Medium (two additive migrations, one importer service, one form; the hard part is the coverage rule, which is decided below) Surfaces: Alumni Lookup only —
Settings::EngagementActivitiesController,Csv::,app/models/. No held-surface writes. Prerequisites: None. Phase 26 is a peer — it asks who wins when two records describe one BUID; this phase only ever writesengagement_activitiesand one additivealumnicolumn. Held surfaces touched: None.
A comprehensive fiscal-year gift report arrives keyed by Salesforce contact ID — not BUID, not BQID. Turning it into engagement activity currently takes a case-sensitive VLOOKUP, a pivot, a hand-built level column, a manual delete of the fiscal year, and an upload. Can that be one file and one Import button — without ever letting a partial report overwrite a complete one?
The second clause is the whole phase. The first is a morning’s work.
| # | Today | After |
|---|---|---|
| 1 | Pull the comprehensive FY gift report (Contact ID, name, gift date, recognition credit amount, VSE category, degree year) | unchanged — same report, uploaded as-is |
| 2 | Case-sensitive VLOOKUP against a contact export to recover BUIDs | alumni.salesforce_contact_id, populated by the contact import already in use |
| 3 | Pivot to FY total + most recent gift date per person | Csv::GivingImporter, grouped by (buid, fiscal_year) |
| 4 | Assign a giving level from the total | GivingLevel.for_amount |
| 5 | Hand-build a BUID / activity_code / description / engagement_date table |
derived; description reads from EngagementType#name so the label cannot drift from the seed |
| 6 | Manually delete the FY’s giving activity, then import | delete + insert in one transaction, scoped to fiscal years the report provably covers |
Step 6 is where the current process can silently lose information, and §5 is the answer.
alumni column, and it is 15 charactersThe app stores no Salesforce ID today. alumni.contact_id is the BQID
(C-000000000); alumni.primary_contact_id is the assigned staff user, unrelated.
The contact export carries both Contact: Contact ID (15-char) and
Contact: Full Contact Id (18-char). The gift report carries only the 15-char. The
18-char form is the 15-char plus a case-recovery checksum, which is of no use to us
because Postgres compares strings case-sensitively by default — a plain = is the
case-sensitive VLOOKUP, for free. So: store 15, truncate whatever arrives, map both
header spellings.
salesforce_contact_id must never be case-foldedReal, observed collision: 003Uq00000QBvQK and 003Uq00000QbvQK are two different
contacts. This is why step 2 needs a case-sensitive lookup at all, and it is a live
hazard in the code:
# app/services/csv/affinaquest_contact_importer.rb:787
when :buid, :contact_id
value.to_s.strip.upcase
That is precisely where a reasonable person adds a third ID, and doing so would collapse
both contacts to 003UQ00000QBVQK — baking the spreadsheet bug into the database
permanently and irreversibly. salesforce_contact_id gets its own when branch that
only strips, carrying a comment that says why, and a test pins it: two fixtures
differing only in letter case stay two rows through a full import.
No VSE-category filter. VSE Student and Alumni Non-Degree may matter later, and
filtering on a category is a guess about intent; failing to resolve a BUID is a fact.
Rows whose contact ID resolves to no BUID are skipped and land in the issues CSV
(§7), same posture as the existing engagement importer.
contact_idalumni.contact_id carries a unique index. This one deliberately does not.
Two alumni rows sharing one Salesforce ID means either duplicate alumni records — a
known, recurring condition that staff resolve by hand — or a CRM-side mis-mapping.
Under a unique index, the contact import is what fails, mid-batch, on a routine job,
over a problem that is not urgent. AffinaquestContactImporter
already has the right posture for this: log an AffinaquestImportConflict and keep
going. This follows it.
The collision is then caught where a human is already looking: the giving preview flags any contact ID resolving to more than one BUID as a conflict, skips it, and names both BUIDs. Promote the index to unique later if data health shows a long clean run.
The column is also treated like contact_id on write: fill when blank, flag rather than
overwrite on mismatch.
Levels only. Concretely:
buid / activity_code / description / engagement_date.engagement_batch_logs.metadata records counts per level, never sums.Bands start at $0.01. A net-zero or negative FY total (refunds, adjustments) yields no activity — and because the fiscal year is replaced wholesale, a prior record correctly disappears. Negative line items do count toward the total; they are corrections, not noise.
engagement_batch_logsNot a new table. It is already the generic “a batch of engagement rows was written”
ledger. One clarification worth recording, because it was mis-remembered during planning:
that table is not written by the email-click importer. Its only writer is
Api::V1::EngagementsController,
where event_identifier is a caller-supplied idempotency key with a uniqueness
validation.
Two additive columns are needed: deleted_count (the replace step has a number worth
keeping) and metadata jsonb (per-level breakdown, FY coverage verdicts). Putting either
in errors_detail would be a lie about that column’s name. Both default, so the API path
is untouched. source: "giving_import", event_identifier synthesized as
giving_import_<fy list>_<utc timestamp>.
| Migration | Change | Risk | Reversible |
|---|---|---|---|
| 29.1 | alumni.salesforce_contact_id — string, limit: 15, nullable, non-unique index (Decision D) |
Low | Yes — drop column |
| 29.2 | engagement_batch_logs.deleted_count — integer, default: 0, null: false; engagement_batch_logs.metadata — jsonb, default: {} |
Low | Yes — drop columns |
Both additive. Neither touches existing rows. No new tables.
GivingLevel is a new shared value object at app/models/giving_level.rb
(unnamespaced — a taxonomy about a shared domain object, per CLAUDE.md rule 7). Its
consumers on day one are Csv::GivingImporter and the preview view; the header names
them.
GivingLevel.for_amount(250_000) # => 5
GivingLevel.code_for(250_000) # => "giving_level_5"
GivingLevel.for_amount(10.00) # => 1 (band is 0.01..10.00)
GivingLevel.for_amount(10.01) # => 2
GivingLevel.for_amount(0) # => nil (Decision F)
Bands, stated once so the boundary cases are testable in one place:
| Level | From | To |
|---|---|---|
| 1 | $0.01 | $10.00 |
| 2 | $10.01 | $100.00 |
| 3 | $100.01 | $1,000.00 |
| 4 | $1,000.01 | $10,000.00 |
| 5 | $10,000.01 | — |
A report pulled from November 1, and a report pulled from June 1 in a year where nobody
gave in June or July, produce identical earliest-gift dates. So any “does this file
reach back to June?” flag derived from min(gift_date) is a guess — and it guesses wrong
in the expensive direction: a genuinely quiet June looks like a truncated report, the
warning fires on a perfectly good file, and within a month it is clicked through
reflexively.
So the coverage window is an input, not an inference. The upload form asks for it, pre-filled so it is usually two keystrokes. Coverage then becomes a fact:
An FY is AUTHORITATIVE iff
window_start <= FiscalYear.first_day(fy) # reaches back to June 1
AND window_end >= required_end_for(fy, today) # reaches forward far enough
REVISED September 23, 2026, on first contact with a real report. This clause originally read
window_end >= [FiscalYear.last_day(fy), Date.current].min— the in-progress fiscal year had to be covered through today. That is unsatisfiable, and it refused the first real gift report uploaded. See §5A.
required_end_for splits the two cases:
| Fiscal year | Must be covered to | Why |
|---|---|---|
| Complete | its last day (May 31) | A report stopping May 20 does not describe FY26, and no amount of recency makes it so |
| In progress | within COVERAGE_MAX_STALE_DAYS (45) of today |
It cannot be covered to May 31, and demanding today is unsatisfiable (§5A) |
The in-progress branch still earns its keep: above 45 days the year is refused, which catches re-importing a comprehensive report pulled three months ago — that would lower levels for everyone who has given since.
Two edges the formula above hides and the implementation has to handle:
today - 45
lands in April, inside FY26. Without the today >= last_day branch, a report stopping in
April could replace the whole finished year. Pinned by its own test.today - 45 precedes the year itself. The requirement is floored
at the fiscal year’s own first day so it stays meaningful rather than going negative.Per fiscal year present in the file, independently:
giving_level_* activity with engagement_date inside
FiscalYear.range(fy), then insert the derived rows. Deletion is scoped by date
range, not by description text — description LIKE '%FY26%' breaks on one typo’d
label, and the label is the one part of the row a human has historically typed.FY27 — provisional (covers Jun 1 – Jun 4, 2026), 10 alumni, skipped.
Re-pull from June 1 to import.Delete and insert run inside one transaction per import, so a failure cannot leave a fiscal year emptied and unrefilled.
The obvious next idea — for a provisional FY, add rather than replace, raising a level but never lowering it — cannot be made correct without stored amounts, which Decision E rules out. Someone at $600 who gives another $600 has a true level 4; a partial report sees $600, computes level 3, finds an existing level 3, and changes nothing. It understates silently, and nothing on the record marks which levels are trustworthy.
“Only a June-1-anchored report produces a real level” is worth keeping as a bright line, and re-pulling from June 1 costs nothing — it is already how the report is run. If a raise-only mode is ever wanted, it needs amounts, and that is a different phase.
The first real report was uploaded September 23, 2026. It was pulled that morning, it covered FY27 from June 1, and the importer refused it:
FY27 — Provisional — skipped. 183 alumni.
Needs coverage: Jun 1, 2026 – Sep 23, 2026
Gifts in file: Jun 1, 2026 – Sep 15, 2026
Two things had gone wrong, and the second is the one worth carrying forward.
1. The gate was unsatisfiable. window_end >= today means the in-progress fiscal year can
only ever be imported by pulling the report and uploading it inside one calendar day. Nothing
about an eight-day-old window makes a report partial; it makes it older, and every total
in it is still correct as of its date.
2. The window’s end is not the file’s last gift — gift PROCESSING lags giving. The operator entered September 15 because that is what the pre-fill offered, and September 15 is the newest gift the report contains. But the report was pulled on the 23rd. Those two dates differ by processing lag on every report ever pulled, and the pre-fill was quietly converting that lag into missing coverage. The form was generating the wrong answer and the rule was then punishing it — the worst shape a validation can take, because the operator has no way to tell which half is wrong.
The spec also contradicted itself, and that is what should have caught this at design time. §6.2 already declared staleness a soft concern — “warning plus confirm, not a block”, justified with “a report pulled last Friday and imported on Monday is normal.” §5 made the same fact a hard gate. Two rules governing one fact, disagreeing, and the hard one silently swallowed the soft one: for the in-progress year §6.2 could never fire, because anything it would have warned about was already refused. The screenshot shows exactly that — the stale-window warning printed next to the refusal it was supposed to be softening.
The rule now has three bands, which is what it takes to separate “structurally partial” from “merely older”:
| Window ends | In-progress FY | Friction |
|---|---|---|
| ≤ 14 days ago | Authoritative | none |
| 15–45 days ago | Authoritative | explicit confirm checkbox naming the age |
| > 45 days ago | Provisional | refused |
And the pre-fill now takes its two ends from different sources, because they answer different
questions: window_start from the file’s earliest gift (conservative — it can only err toward
“provisional”, which skips a year rather than replacing it wrongly), window_end from
today, on the stated assumption the report was pulled today.
The generalizable lesson: when a spec states one rule twice at different strictness, that is not redundancy to tidy up later — it is an unresolved decision, and the harder copy wins silently at runtime. Resolve it at design time, or the softer copy becomes documentation for behavior that cannot happen.
Three, in descending order of how much they save you.
The skew this phase was raised to prevent costs one person one level. The failure that costs the whole fiscal year is uploading an accidentally-filtered report — one gift society, one campaign — with a window that claims full coverage. Replace then wipes the FY, inserts the handful of rows, and looks like it worked.
For each authoritative FY, compare existing distinct BUIDs against incoming. A net loss over 20% requires a separate confirm checkbox spelling out the real numbers:
FY26: 1,240 existing → 180 incoming, 1,060 net loss. Confirm this report is comprehensive.
Legitimate year-over-year attrition never looks like that. A mis-filtered report always does.
window_end more than 14 days before today, and only when the file contains the fiscal
year still in progress: a warning plus confirm, not a block. A report pulled last Friday and
imported on Monday is normal; one pulled in June and imported in September is refused outright
by §5’s 45-day ceiling, so nothing reaches this guard to block.
The scoping is load-bearing. A completed fiscal year’s report is supposed to end months ago — warning that an FY26 report stops at May 31 would fire on every prior-year import, and noise is how a guard gets clicked through.
What is deliberately not checked: the gap between window_end and the newest gift in the
file. That gap is processing lag, it is normal, and treating it as missing coverage is
precisely the bug §5A documents.
If the claimed window_start precedes the file’s earliest gift date by more than 30
days on a file with more than 100 rows, warn. This is the one field a human can get
wrong, and getting it wrong invents authority the report does not have. Warn, never block
— a small report legitimately has gaps.
Service — app/services/csv/giving_importer.rb, following the Csv:: interface
(.preview / .call). Owns: header mapping, per-(buid, fy) aggregation, level
assignment, coverage verdicts, guard evaluation, issues CSV construction. The controller
instantiates and renders; no logic in the controller (CLAUDE.md, Key Services).
Controller — three actions on the existing Settings::EngagementActivitiesController,
which already carries the import → preview → commit shape twice:
import_giving, import_giving_preview, import_giving_commit. Routes join the existing
collection block. Admin-only, inherited from Settings::BaseController.
Views — app/views/settings/engagement_activities/import_giving.html.erb (upload +
window fields) and import_giving_preview.html.erb (coverage verdict per FY, guard
confirms, per-person table with on-screen totals, issues download).
Issues CSV — salesforce_contact_id, last_name, first_name, degree_year, fiscal_year,
reason. No amounts (Decision E). Reasons: Unmatched contact ID, Multiple BUIDs,
Provisional fiscal year, Zero or negative total, Invalid gift date. Downloadable
from both the preview and the post-commit result, following the
gap_csv_employment_import_batch download pattern.
Writes — insert_all for the derived rows, not the current per-row create! loop.
A comprehensive FY report is thousands of rows, and the existing loop also runs a dead
EngagementType.find_by on every iteration.
Known constraint, checked rather than assumed: the preview → commit handoff in this
controller stashes a Tempfile path in the session. With more than one web dyno, a commit
landing on a different dyno than the preview finds no file and imports zero rows —
silently. This is already true of both existing engagement importers, but this is the
first importer to pair a silent zero-row run with a delete, so it was checked before the
design was settled: production runs one web dyno (verified September 23, 2026), so the
tempfile pattern stays. Scaling web past one dyno is now a prerequisite for moving the
manifest into the database, the way the education and employment importers already do with
manifest_data — see the launch guide §1.
Written as the code is written (CLAUDE.md rule 9), not after.
GivingLevel — every band boundary in both directions: 10.00 → 1, 10.01 → 2,
100.00 → 2, 100.01 → 3, 1000.00 → 3, 1000.01 → 4, 10000.00 → 4,
10000.01 → 5; 0 → nil; -50 → nil.
Case sensitivity (Decision B) — two alumni whose salesforce_contact_id differs only
in letter case survive a full contact import as two distinct rows with distinct IDs. This
test must fail if anyone adds the column to the .upcase branch.
Coverage (§5) — a file spanning FY26 (full) and FY27 (June 1–4) with a window of
2025-06-01 .. 2026-06-04: FY26 is replaced, FY27 activity is untouched and reported
provisional. Inverted: a window starting 2025-11-01 leaves FY26 untouched too.
Replace semantics — an alum with an existing FY26 giving_level_4 who appears in a
new authoritative FY26 report at level 2 ends with exactly one FY26 giving row, at level 2.
An alum with an existing FY26 row who is absent from an authoritative FY26 report ends with
none. A provisional FY never deletes anything.
Deletion scope — a giving_level_3 row dated inside FY25 is untouched by an
authoritative FY26 import, and a giving row whose description says FY26 but whose
engagement_date falls in FY25 is treated by its date, not its label.
Aggregation — multiple gifts for one contact in one FY sum to one row, dated the latest
gift; engagement_date is the max, not the min or the first-seen.
Guards — the 20% mass-loss threshold blocks commit without the confirm and permits it with; a stale window warns; a contact ID resolving to two BUIDs is skipped and named.
Description — reads EngagementType#name, so renaming the seed renames the output.
Asserted against the literal "Level 5 Giving FY26".
No amounts persisted — the manifest written between preview and commit contains no
numeric amount field, and no engagement_activities or engagement_batch_logs column
receives one.
Sabotage pass (per /testing): removing the coverage gate, and flattening the level
bands, must each break tests written for them.
None. This is a staff-only admin screen on Alumni Lookup; neither
ChampionSignupEvent nor the held Cp::ActivityEvent applies. The
engagement_batch_logs row is the audit record (Decision G).
docs/features/CSV_IMPORTERS.md — new “Giving Importer” section following the Employment Importer’s shape (purpose, expected columns, coverage rule, upload UI, verifying an import), plus the new Salesforce column in the Affinaquest Contact Importer’s column listdocs/development/MODEL_RELATIONSHIPS.md — alumni.salesforce_contact_id and what it is not (not BUID, not BQID)CLAUDE.md → ID Terminology — a third ID now exists; name it and mark it case-sensitiveapp/controllers/roadmap_controller.rb — phase 29 entry (rule 7: this is the status source of truth)docs/planning/phases/README.md — index row.claude/skills/debug/SKILL.md + .github/copilot-instructions.md — the .upcase gotcha from Decision B, whether or not anyone trips on itCHANGELOG.md (docs/CHANGELOG.md)Not split into sub-phase files — the work is one session and one spec. But it is two deploys, and the order is not optional:
salesforce_contact_id + the contact-importer mapping. Deploy, then run
a full contact import to populate the column.Unmatched contact ID.Stage 2 is testable on staging against fixtures without the backfill. It is not testable against real data without it.
| Item | Why | Where it goes |
|---|---|---|
| Raise-only merge for provisional fiscal years | Cannot be correct without stored amounts (§5) | Needs a phase that revisits Decision E |
| Storing FY gift totals | Decision E | — |
A batch-history UI for engagement_batch_logs |
Nothing displays that table today; the issues CSV is reachable from preview and commit without one | BACKLOG |
Promoting salesforce_contact_id to a unique index |
Decision D — revisit after a clean run | BACKLOG |
| VSE-category filtering | Decision C | — |
Built September 23, 2026, and revised the same day after the first real report was
uploaded — see §5A, which is the most useful thing in this document. bin/test: 6,060 runs,
17,574 assertions, 0 failures, 0 errors.
| File | Role |
|---|---|
db/migrate/20260923100000_add_salesforce_contact_id_to_alumni.rb |
29.1 |
db/migrate/20260923100100_add_giving_import_columns_to_engagement_batch_logs.rb |
29.2 |
app/models/giving_level.rb |
Shared value object — the bands, once |
app/services/csv/giving_importer.rb |
Parse, aggregate, coverage verdicts, guards, replace, batch log |
app/services/csv/affinaquest_contact_importer.rb |
+FILL_ONLY_FIELDS, the salesforce_contact_id header aliases and its own normalize_value branch |
app/controllers/settings/engagement_activities_controller.rb |
import_giving, _preview, _commit, _issues_csv |
app/views/settings/engagement_activities/import_giving{,_preview}.html.erb |
Upload + preview |
app/javascript/controllers/giving_window_controller.js |
Window pre-fill (see deviation 2) |
test/models/giving_level_test.rb |
6 tests |
test/services/csv/giving_importer_test.rb |
37 tests |
test/services/csv/salesforce_contact_id_case_test.rb |
7 tests — Decision B’s tripwire |
test/controllers/settings/engagement_activities_giving_import_test.rb |
19 tests |
Integration (anti-orphan): the giving import is linked from the engagement activities index header and from the standard import page’s options list; both links are asserted by tests.
Three, all small, none changing a decision.
1. .preview / .commit, not .preview / .call. §7 named the Csv::
interface as .preview / .call. Every existing Csv:: importer that has a
two-pass shape uses .preview / .commit (EmploymentImporter,
EducationAreaOfStudyImporter); .call in this namespace means a one-shot full
import (AffinaquestContactImporter.call). Following the codebase rather than
the spec sentence, because the spec sentence would have made .call mean two
different things inside one namespace.
2. The window pre-fill is client-side, and it does not use the file’s max
gift date. §5 says the form is “pre-filled from the file’s min and max gift
dates.” A server-side default would have been the one thing §5 forbids — an
inferred window. So giving_window_controller.js fills the inputs in the
browser, and they stay required and editable; the server never derives a
window, and a preview with a blank one redirects with an alert.
The max half of that sentence turned out to be the bug in §5A: the file’s
newest gift is systematically older than the day the report was pulled, because
gift processing lags giving, so filling it into window_end understated coverage
on every import. window_end is pre-filled with today instead, on the stated
assumption the report was pulled today. window_start still comes from the
file’s earliest gift, because that direction is conservative — it can only err
toward “provisional”, which skips a year rather than replacing it wrongly.
3. A fourth route. §7 describes the issues CSV as downloadable “from both
the preview and the post-commit result” without naming an action.
import_giving_issues_csv serves it, the preview links to it, and the upload
page offers it after a commit (the commit redirects there). The issues tempfile
deliberately outlives the manifest for exactly that reason, and is cleaned up
when the next preview replaces it.
The fixture pair is load-bearing. alumni.yml’s john_doe and jane_smith
carry 003Uq00000QBvQK and 003Uq00000QbvQK — the same letters, different case.
That is Decision B’s test data, and a comment at the top of the file says not to
“fix” the casing. It also means a global
Alumni.where(salesforce_contact_id: ...).count in a test is measuring the
fixtures as much as the code; scope such assertions to the BUIDs under test.
The engagement_activities fixtures put two giving rows in the fiscal year in
progress (engagement_date: 1.month.ago), which means any test that replaces
the current fiscal year with fewer than two alumni trips the mass-loss guard
before reaching whichever guard it was written for. clear_in_progress_giving!
in the controller test exists for that, and says so. Those same fixtures are
described as “Level 1 Giving FY26” while being dated in FY27 — an accidental but
perfect demonstration of why deletion is scoped by date and not by label.
EngagementType#level is not the giving band number. The seeds give
giving_level_5 a level of 4 — that column is the engagement score weight,
and levels 4 and 5 deliberately share it. The fixtures match the seeds. Nothing
in this phase reads EngagementType#level; GivingLevel owns the band numbers
and EngagementType#name owns the label.
Two deploys, per §11, and the order is not optional. Migrations are manual —
there is no release: phase in the Procfile:
heroku run rails db:migrate --app alumni-lookup-staging
heroku run rails db:migrate --app alumni-lookup
Stage 1 is the schema plus the contact-importer mapping, followed by a full
contact import to populate salesforce_contact_id. Stage 2 is the importer,
which reports Unmatched contact ID for every row until that backfill has run.
Stage 2 is testable on staging against fixtures without the backfill; it is not
testable against real data without it.