Code Reference
Apex Reference#
Every packaged Apex class, grouped by domain and FFLib layer. The vendored fflib-apex-mocks/apex-common library is excluded. Return types, extends/implements, and qualified method calls link to their definitions on this page when they resolve to another packaged class.
Generated file
Don't hand-edit this page — edit the class's ApexDoc comment or add a // doc: ... line and rerun the generator.
Contents
-
36 classes
Async (Batch/Queueable/Schedulable) · Controllers · Domains · Selectors · Services
-
54 classes
Async (Batch/Queueable/Schedulable) · Controllers · Domains · Selectors · Services · Staging
-
11 classes
Controllers · Domains · Selectors · Services
-
17 classes
Controllers · Domains · Selectors · Services
-
1 class
-
13 classes
Async (Batch/Queueable/Schedulable) · Controllers · Handler · Selectors · Services
-
20 classes
Core · Async (Batch/Queueable/Schedulable) · Selectors · Services · Stripe
-
13 classes
Async (Batch/Queueable/Schedulable) · Controllers · Domains · Selectors · Services
-
23 classes
Controllers · Domains · Selectors · Services
-
17 classes
Campaign#
Async (Batch/Queueable/Schedulable)#
CampaignRollupBatch#
class · public without sharing
implements Database.Batchable<SObject>, Schedulable
campaign/
Recomputes Campaign fundraising totals (Amount_Raised__c and Donor_Count__c) for every campaign that has at least one Closed Won gift. Each campaign is recalculated from its gifts rather than adjusted, so the batch is a true repair: running it twice changes nothing, and running it once fixes a total no matter how it drifted.
Docs note
The campaign-side counterpart to DonorSummaryRollupBatch, and the reason a missed rollup is recoverable at all. Campaign totals are maintained by the Opportunities domain in the same transaction as the gift, but a rollup can still be skipped by a path no trigger sees: a Data Loader import that bypasses the domain, a rollup that failed and was swallowed as best-effort, or an upgrade that lands the fixed handler after the drift already happened. Before this class existed the only cure was a hand-written anonymous-Apex script, so in a subscriber org there was effectively none. Scheduled nightly by PostInstallHandler and re-runnable on demand from the setup console (SetupController.runJobNow).
| Method | Returns | |
|---|---|---|
execute(1 arg) |
void |
|
start(1 arg) |
Database.QueryLocator |
|
execute(2 args) |
void |
|
finish(1 arg) |
void |
|
| calls: AppLogger.info() |
CancelGoalReachedRecurringQueueable#
class · public without sharing
implements System.Queueable
campaign/
Async job that cancels all active recurring donations for campaigns that have just reached their fundraising goal and have Recurring_Behaviour_At_Goal__c = 'Cancel'.
Enqueued by CampaignService.rollupCampaignTotals() when a goal-crossing is detected.
Cancellation is a Salesforce-only status update (self-managed recurring — there is no Stripe subscription to cancel remotely), so there are no callouts. The whole chunk is cancelled in a SINGLE bulk Unit of Work commit; a campaign with hundreds of active recurring donations used to blow the 150-DML governor limit via a per-record commit loop. To stay within DML-row/CPU limits at very high volume, at most effectiveChunk are cancelled per transaction and the job re-enqueues itself for the remainder (cancelled rows drop out of selectActiveByCampaignIds on the next run).
AUTO-RECOVERY: a bulk cancel at very high volume can trip an UNCATCHABLE system exception — a governor LimitException (typically CPU timeout, since each RD update cascades into Campaign/donor rollup triggers). Such a LimitException aborts the whole transaction and the try/catch below can NOT trap it, so the self-chaining enqueueJob at the end of execute() never runs and the remaining campaigns' schedules would be stranded.
A Transaction Finalizer fixes that: it runs in a fresh transaction with a fresh governor pool even when execute() is killed, detects the UNHANDLED_EXCEPTION result, and re-enqueues the SAME campaign set with a HALVED chunk (up to MAX_RETRIES).
Halving the chunk directly reduces the per-transaction CPU/DML pressure, so the retry fits under the limit.
| Method | Returns | |
|---|---|---|
execute(1 arg) |
void |
|
| calls: AppLogger.error() |
Controllers#
Docs note
Guest-facing entry point for the public campaign detail page and gallery. Most of the real work is delegated to CampaignService (slug resolution, theme tokens, FAQs, ticket tiers) — this class stays thin: validate input, call the service, return the envelope. getCampaignGalleryData deliberately lists Campaign records only.
The org-default palette on its own, for the public pages that have no campaign to read a design from — the donor portal, the thank-you page, the receipt, the ticket wallet, the event registration page. It lives here rather than on each of those five controllers because it is one identical answer: threading the same slice through five unrelated payloads would couple five services to a styling concern and give five chances to drift. cacheable, so the five pages that ask for it share a single round-trip per session, and a donor who has not yet signed in still gets a themed sign-in screen.
The SAME rule the donation form and the checkout guard use, not a second definition of "closed". A grey card is a promise that clicking it leads to a page which will refuse the gift; deriving that promise from anything other than isAcceptingDonations is how the directory and the form start disagreeing.
The hero ledger. Every figure is an exact sum over the rows above, so the directory needs no extra aggregate query and cannot quote a number the cards below it contradict. Donor counts are deliberately NOT totalled here: they are per-campaign, so summing them double-counts anyone who supported two appeals, and an inflated donor count on the page that asks a stranger for money is the one number here worth being pedantic about.
Identity and contact for the masthead and the shared contact band, resolved through the SAME campaign-over-org helpers the campaign pages use — passed an empty in-memory Campaign so every override reads blank and each value falls through to the org default. A directory belongs to the organisation, not to any one appeal, so that fall-through IS the correct answer rather than a shortcut.
The ORG default design, never a campaign's. The directory has to look like the same product as the campaign page a visitor reaches from it, and the only palette both can agree on is the org default an admin set in the settings console. Adopting one appeal's colours here would tell every visitor the whole organisation is that appeal.
CampaignFaqController — admin API for the reusable FAQ library. Backs the "FAQs" editor: browse the shared template library, load a campaign's selected FAQs, and persist a new selection (creating brand-new templates as needed).
All work delegates to ICampaignFaqService. Every @AuraEnabled returns the { success, data/error } envelope via ControllerAction.run().
| Method | Returns | |
|---|---|---|
getFaqLibrary(0 args) |
Map<String, Object> |
@AuraEnabled |
getCampaignFaqs(1 arg) |
Map<String, Object> |
@AuraEnabled |
saveCampaignFaqs(3 args) |
Map<String, Object> |
@AuraEnabled |
CampaignPostController — admin API behind the two campaign authoring panels: admCampaignUpdatesTab (text updates) and admCampaignDocumentsTab (downloadable files).
Reads via CampaignPostsSelector; all DML runs through CampaignPostService and the FFLib Unit of Work. Every @AuraEnabled returns the { success, data/error } envelope via ControllerAction.run().
Docs note
One controller serves two panels because one object (Campaign_Post__c) backs both — see that object's header for why. Every method therefore takes a postType and passes it straight through to the service, which scopes the query and the Campaign visibility toggle by it. The panels stay independent: neither can read, edit or delete the other's rows, and neither ever sends a type the admin chose — each panel hardcodes its own.
No read here is cacheable=true. Both panels are editors that reload their own list straight after every save, and a cached read hands back the pre-save list until the page is reloaded.
The panel hides its delete buttons when this is false rather than letting the admin press one and read a permission error. Fundraising_User ships with allowDelete=false on Campaign_Post__c, so this is the common case, not an edge.
The campaign and the type both come from the STORED row, never from the client. Trusting the posted campaignId would let a caller pair a campaign they can see with a post Id belonging to one they cannot and edit it anyway; trusting the posted type would let the Updates panel retype a document row (and so publish a document card with no file behind it, past the service's guard) or hide a row from the panel that owns it.
fflib's Unit of Work commits at SYSTEM_MODE, so nothing downstream re-checks delete permission — without this the service would delete for any user who can reach the class. See ControllerAction.requireDeletable.
Campaign_Post__c is a Lookup child with an org-wide default of Public Read/Write, so its own sharing refuses nobody and a campaignId arriving from an LWC is unvetted. The record it belongs to is the thing worth protecting, so every action asks the same question the fundraisers console asks: can the running user see the parent Campaign? Same shape as EventTicketAdminController.requireCampaignVisible, and for the same reason the message is caller-supplied — a distinct "no access" reply would confirm the record exists.
Rejecting an unknown type in doValidate, rather than letting it fall through to the service's "anything that is not a Document is an Update" default, keeps a typo in a future panel from silently listing and writing Update rows while the developer believes they are working on a third type. Returns the message rather than calling addError itself because addError is protected on ControllerAction — only the inner action classes can reach it.
Campaign__c and Type__c are deliberately absent — both are set once, at insert, by the caller above. A post never moves campaign and never changes type; letting either arrive through the form is the whole attack surface this controller is guarding.
What the Documents panel keys its "attach a file" prompt and its publish toggle off. File_URL__c and not the presence of a ContentDocumentLink, because the URL is the only thing the public card can actually use — a file that is attached but has no stored URL would still render a dead card.
CampaignQuestionController#
class · public with sharing
campaign/
CampaignQuestionController — admin API for the reusable custom-question library. Backs the "Questions" editor: browse the shared template library, load a campaign's selected questions, and persist a new selection (creating brand-new templates as needed).
All work delegates to ICustomQuestionService. Every @AuraEnabled returns the { success, data/error } envelope via ControllerAction.run().
Docs note
Structural twin of CampaignFaqController — same three-method shape (library, selected, save), same delegate-to-service design, but backed by ICustomQuestionService (Campaign. Custom_Question_Ids__c/Custom_Questions_Active__c) instead of ICampaignFaqService (Campaign. FAQ_Ids__c/FAQs_Active__c). Both share the Content_Template__c object underneath, distinguished by RecordType ('Custom_Question' vs 'FAQ') — see CustomQuestionService for the record-type and selection-JSON details.
| Method | Returns | |
|---|---|---|
getQuestionLibrary(0 args) |
Map<String, Object> |
@AuraEnabled |
getCampaignQuestions(1 arg) |
Map<String, Object> |
@AuraEnabled |
saveCampaignQuestions(3 args) |
Map<String, Object> |
@AuraEnabled |
CampaignStoryController#
class · public with sharing
campaign/
CampaignStoryController — read and write a single campaign's story from the Campaign record page.
Backs the "Story" tab (c/lexCampaignStory): load the Markdown plus the publish flag, and save them back. Two fields, nothing else — a record-page tab must never round-trip the whole fundraiser form the way FundraiserAdminController.applyForm does, because a payload that omits a key there blanks the field it stands for.
Every @AuraEnabled returns the { success, data/error } envelope via ControllerAction.run().
Docs note
This is the SECOND write path to Campaign.Story__c, after the fundraiser wizard's FundraiserAdminController.applyForm. Both funnel through CampaignService.normalizeStoryMarkdown for exactly that reason: the sanitizer is the choke point, so adding an entry point must never mean adding a way around it. A third caller that assigns Story__c directly reopens the hole. The read is deliberately NOT cacheable=true. The tab loads imperatively and reloads after its own save; a cached read would serve the pre-save value back to the component that just wrote it and the story would appear to revert until the page was reloaded.
Drives whether the tab renders an Edit button at all. It is a UI courtesy, not the control — SaveStory re-checks the same two fields server-side, because a client can call the save method whatever the read told it.
Sent so the tab's reading view shows the same video treatment the donor gets: a facade unless the org opted in. The component never reads Settings itself.
The whole reason this controller exists rather than a lightning-record-edit-form bound straight to Story__c: a raw field edit would let anything into the field, and the public renderer's safety rests on what this method guarantees.
| Method | Returns | |
|---|---|---|
getStory(1 arg) |
Map<String, Object> |
@AuraEnabled |
saveStory(3 args) |
Map<String, Object> |
@AuraEnabled |
Docs note
Backs the embedWidget static resource (an external <script> tag any third-party site can drop in) — this is the ONLY REST-callable surface in this domain; every other public read goes through @AuraEnabled/LWC on the Experience site. Reuses CampaignService.getPublicExperienceConfig so the embed widget and the full Experience donation page never drift on theme tokens, FAQs, or ticket tiers. CORS is opt-in per request Origin against Settings__c.Allowed_Embed_Origins__c (managed on the setup screen via EmbedController) — there is no wildcard fallback.
| Method | Returns | |
|---|---|---|
doGet(0 args) |
void |
|
| calls: SettingsService.isEmbedEnabled(), SettingsService.donationPagePath(), AppLogger.error(), SettingsService.allowedEmbedOrigins() |
FundraiserAdminController#
class · public with sharing
campaign/
FundraiserAdminController — internal admin console for launching and managing fundraisers (Campaigns). Lets staff create a whole fundraiser in one flow — Campaign + linked page design + fund (Designation, existing or new) — without opening multiple Salesforce objects.
Reads via CampaignsSelector/DesignationsSelector/CampaignDesignsSelector; all DML runs through the FFLib Unit of Work. Every @AuraEnabled returns the { success, data/error } envelope via ControllerAction.run().
applyForm is also the enforcement point for story normalization: it routes every wizard save through CampaignService.normalizeStoryMarkdown before Story__c is written.
Docs note
"Fundraiser" is admin-console terminology for a Campaign with a subtype (Campaign_Subtype__c) — there is no separate Fundraiser object. This controller and FundraiserDetailController both operate directly on Campaign/CampaignsSelector.
source of truth is the Campaign_Closed__c formula field (EndDate-based), not inline date math — see the field's doc comment for why.
The logo is the same for every design because it is org identity, not a theme: it comes off Settings__c.Org_Logo_URL__c, which the logo-upload flow maintains as a ContentDistribution link. The wizard's design cards still show it so an admin previews the real masthead, but picking a different design can never change it.
org-wide starting values for a new fundraiser's donor-option toggles (see SettingsService.defaultAllowTribute/defaultAllowCompanyMatch); the wizard applies these only when creating a NEW Campaign, never on edit.
Not a fundraiser setting — it is what the Story step's preview needs to render video the way the donor will see it. Without it the preview always shows the facade card, so an org that opted into inline playback would show its authors the one thing they cannot check by looking. The record page's Story tab gets the same value from CampaignStoryController.getStory.
Not a plain sVal assignment, and deliberately so — this is the choke point every story passes through, so a client that skips the editor cannot land markup or a hostile URL. See CampaignService.normalizeStoryMarkdown.
FundraiserDetailController#
class · public with sharing
campaign/
FundraiserDetailController — powers the fundraiser record drawer in the admin console. Returns a summary + overview aggregates (for the charts) and paginated related lists (donations, recurring gifts, tickets) so each tab lazy-loads on demand.
All SOQL runs through selectors (aggregate/count queries excepted — the fflib QueryFactory can't express them). Every @AuraEnabled returns the { success, data/error } envelope.
Docs note
mirrors CampaignService.buildCampaignResult()'s designation resolution so the drawer's Fund related list shows the same mode-aware, fail-safe-resolved picture the donor sees on the public form — never re-derive fund scope from the raw stored fields.
Realized fund-split table for the drawer's "Realized fund split" related list — net-raised/donation-count per fund actually used by this campaign's Closed Won gifts, plus the Undesignated bucket. isAllowed diffs each fund's Id against the CURRENT resolved allowed set so a fund removed from the campaign after receiving donations still shows up (marked "not currently allowed" by the LWC), matching the mock's reconciliation intent — this is computed here, not filtered out at the query layer.
Domains#
CampaignPosts#
class · public with sharing
extends fflib_SObjectDomain
campaign/
Domain behaviour for Campaign_Post__c.
FFLib role: Domain.
Docs note
Posted_Date__c drives both the update timeline's ordering and the date a visitor reads beside each entry, so a blank one would sort unpredictably and render an empty line. The field is left optional on the layout deliberately — an admin writing an update today should not have to pick today's date — so the default lives here rather than as a formula or a required field. onApplyDefaults covers the insert; onBeforeUpdate covers an admin clearing the field on an existing post, which onApplyDefaults never sees.
The file details are stamped here, on before update, because the service assigns them in memory — a before trigger must not run DML on its own records, which also means this path needs no recursion guard. Insert is deliberately not covered: a post has no Id before it is saved, so there is nothing a file could be attached to yet. Saving the record again is what the field help tells an admin to do when a link looks stale, and this is the line that makes that work.
| Method | Returns | |
|---|---|---|
construct(1 arg) |
fflib_SObjectDomain |
|
onApplyDefaults(0 args) |
void |
|
onBeforeUpdate(2 args) |
void |
|
| calls: CampaignPostService.newInstance() |
Campaigns#
class · public with sharing
extends fflib_SObjectDomain · implements ICampaigns
campaign/
Docs note
Status is the package's real lifecycle source of truth (Draft/Active/Ended — see FundraiserAdminController.GetFundraisers and CampaignsSelector's Status = 'Active' gates), but IsActive is a standard checkbox an admin can also see/toggle directly on the record (e.g. via the standard Active checkbox on the layout or a data load). Without this sync, editing Status through any path other than FundraiserAdminController's wizard would leave IsActive stale. 'Active' is a literal string value, not one of Status's defined standard picklist choices (Planned/In Progress/Completed/Aborted) — matches the convention already used everywhere else in this package.
| Method | Returns | |
|---|---|---|
newInstance(1 arg) |
[ICampaigns](#apex-icampaigns) |
|
construct(1 arg) |
fflib_SObjectDomain |
|
onBeforeInsert(0 args) |
void |
|
onBeforeUpdate(2 args) |
void |
|
onBeforeDelete(0 args) |
void |
ContentVersions#
class · public with sharing
extends fflib_SObjectDomain
campaign/
Domain behaviour for ContentVersion, which this package cares about for exactly one reason: a file attached to a Document post has to leave its details on the post itself, because a public-site visitor cannot read Salesforce Files.
FFLib role: Domain.
Docs note
This exists to cover the order of operations the Campaign_Post__c before-update trigger cannot: publish the post first, attach the file afterwards. That attach saves a ContentVersion and nothing else, so without this hook the post keeps a blank File URL until somebody happens to edit it again — and nobody would, because the record looks finished.
ContentVersion is a standard object every file upload in the org touches, not just ours, so the service filters on Campaign_Post__c before doing any work and a version belonging to anything else costs one type check.
| Method | Returns | |
|---|---|---|
construct(1 arg) |
fflib_SObjectDomain |
|
onAfterInsert(0 args) |
void |
|
| calls: CampaignPostService.newInstance() |
Selectors#
CampaignPostsSelector#
class · public with sharing
extends fflib_SObjectSelector · implements ICampaignPostsSelector
campaign/
Reads Campaign_Post__c rows for the fundraisers console (user mode) and the public donation/event page (system mode).
FFLib role: Selector.
Docs note
The guest method mirrors CampaignDesignsSelector.selectByIdSystemMode — AccessLevel SYSTEM_MODE lifts CRUD and FLS but NOT sharing, so the without-sharing inner class below is what actually lets an unauthenticated visitor read a published post. The user-mode method deliberately does not filter on Is_Published__c: an admin editing a campaign needs to see drafts, and a guest must never see them.
The three file fields are read here rather than resolved from Salesforce Files at render time, because a site guest cannot query a file at all — ContentDocumentLink, ContentVersion and ContentDistribution are gated by the Query All Files permission, which a Guest licence cannot hold. CampaignPostService stamps them as an internal user; this query is the only thing the public document card needs.
The read behind admCampaignUpdatesTab and admCampaignDocumentsTab. Drafts are included on purpose — an admin has to see what is not yet live — and the Type__c filter is what keeps the two panels independent of each other even though one object backs both. User mode, unlike the guest method below: an internal editor must obey the running user's sharing and FLS, and CampaignPostController has already checked that they can see the parent Campaign.
Used by the save and delete paths to re-read a post the client named, so the controller can gate on its parent Campaign and read back the stored File_URL__c before allowing a document to be published.
Sort_Order__c leads with NULLS LAST so a blank value falls through to pure date ordering rather than pinning every unordered post above the ordered ones.
CampaignsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements ICampaignsSelector
campaign/
Docs note
selectPublicByIdSystemMode vs selectByIdSystemMode is a deliberate security split, not duplication — a Campaign Id is enumerable (a slug is not), so any guest endpoint that accepts a client-supplied Id must use the "public" variant, which adds the same Status = 'Active' gate selectByPublicUrlSlug applies; selectByIdSystemMode stays ungated for internal/admin callers that legitimately need to read a draft campaign. selectFundraisers/ selectFundraiserById back the internal FundraiserAdminController/FundraiserDetailController screens, not the guest site.
Row-visibility GATE, not a data read — it answers "which of these campaigns may the running user see?" and nothing else. It exists because Event_Ticket_Type__c is a master-detail child of Campaign whose own object permissions are ungrantable in 2GP, so its selector must query SYSTEM_MODE (see EventTicketTypesSelector's class header).
The access check the platform can no longer make on the child is made HERE instead, on the master, which is where master-detail access actually lives. A caller that hands a client-supplied campaign Id to those child reads without passing it through this method first has built an IDOR.
Raw SOQL rather than newQueryFactory() is deliberate and is the point of the method: the projection must be Id ONLY. Building this through the factory would pull the full selector field list and make the gate fail on the FLS of some unrelated Campaign field instead of on row visibility — turning a security check into a spurious error.
A user with no Read on Campaign at all makes the USER_MODE query THROW rather than return nothing (the platform reports the sObject as unsupported once the describe is inaccessible). Caught and folded into "visible = none": both answers mean deny, and a gate that can throw its own exception past the caller would replace the caller's chosen not-found message with a platform one that names the object.
Everything the public directory (publicCampaignGallery) lists. Renamed from selectActive and widened past Status = 'Active' on 2026-08-14, when the gallery started rendering finished appeals greyed out instead of dropping them: a campaign someone bookmarked or was linked to has to resolve to something, and silently omitting it makes the directory look like it lost the record. 'Completed' is in; 'Planned' (never launched) and 'Aborted' (cancelled) stay out, because neither was ever a page a donor could have seen. Whether a listed campaign still TAKES money is a separate question, answered per row by CampaignService.isAcceptingDonations — not by this filter.
| Method | Returns | |
|---|---|---|
newInstance(0 args) |
[ICampaignsSelector](#apex-icampaignsselector) |
|
getSObjectFieldList(0 args) |
List<Schema.SObjectField> |
|
getSObjectType(0 args) |
Schema.SObjectType |
|
selectById(1 arg) |
List<Campaign> |
|
selectIdsVisibleToUser(1 arg) |
Set<Id> |
|
selectByIdSystemMode(1 arg) |
List<Campaign> |
|
selectPublicByIdSystemMode(1 arg) |
List<Campaign> |
|
selectByPublicUrlSlug(1 arg) |
List<Campaign> |
|
| calls: QueryCondition.of(), QueryCondition.literal() | ||
selectWithCustomQuestions(1 arg) |
List<Campaign> |
|
| calls: QueryCondition.of() | ||
selectWithFaqs(1 arg) |
List<Campaign> |
|
| calls: QueryCondition.of() | ||
selectWithDesignations(1 arg) |
List<Campaign> |
|
| calls: QueryCondition.of() | ||
selectContentSelections(1 arg) |
List<Campaign> |
|
countByDesignId(1 arg) |
Integer |
|
countByPaymentAccountId(1 arg) |
Integer |
|
selectFundraisers(0 args) |
List<Campaign> |
|
selectFundraiserById(1 arg) |
List<Campaign> |
|
| calls: RecordTypeService.getPackageRecordTypeIds() | ||
selectEventFlagsByIdAsSystem(1 arg) |
List<Campaign> |
|
getQueryLocatorForCampaignRollup(0 args) |
Database.QueryLocator |
|
selectPubliclyListed(0 args) |
List<Campaign> |
ContentTemplatesSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IContentTemplatesSelector
campaign/
Docs note
Content_Template__c is one shared object reused for two purposes, distinguished only by RecordType — 'FAQ' (consumed by CampaignFaqService) and 'Custom_Question' (consumed by CustomQuestionService). selectActiveByRecordType is generic across both; selectByIds is the guest donation/finalize path's resolver for a campaign's Selected_*_Ids__c JSON array.
ICampaignPostsSelector#
interface · public
extends fflib_ISObjectSelector
campaign/
Selector contract for Campaign_Post__c — the documents and updates shown on a public page.
FFLib role: Selector. All Campaign_Post__c SOQL lives behind this interface.
Docs note
The admin editors' read. Drafts included and one Type__c only, because Updates and Documents are two separate panels that must not see each other's rows.
ICampaignsSelector#
interface · public
extends fflib_ISObjectSelector
campaign/
IContentTemplatesSelector#
interface · public
extends fflib_ISObjectSelector
campaign/
IThemeTokensSelector#
interface · public
extends fflib_ISObjectSelector
campaign/
ThemeTokensSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IThemeTokensSelector
campaign/
Docs note
selectAll() is the single source of truth for which CSS custom properties a page-design record is allowed to override — Theme_Token__mdt is a protected, packaged CMDT type (64 records), changeable only via package upgrade. Two real consumers: CampaignService's guest runtime (normalizeThemeTokensJson strips any key this catalog doesn't list) and ThemeTokenCatalogService for the internal setDesigns editor. There is no separate hand-synced allowlist anywhere else — add a Theme_Token__mdt record to make a new style overridable.
Services#
CampaignDesignationConfig#
class · public inherited sharing
campaign/
Resolved designation (fund) picker configuration for a campaign, produced by CampaignDesignationService.resolveDesignationConfig(). Carries the effective mode after fail-safe resolution (never blindly Campaign.Designation_Mode__c verbatim — a mode can fall through to 'Assigned' or 'None' when its configured fund(s) turn out stale).
Docs note
mode is always one of 'All_Active' / 'Assigned' / 'Selectable' / 'None' — 'None' only occurs when every configured fund (including the Settings__c org default) fails to resolve, meaning the gift proceeds fully undesignated.
CampaignDesignationService#
class · public inherited sharing
implements ICampaignDesignationService
campaign/
Resolves which Designation__c (fund) records a campaign's public donation form should offer, per Campaign.Designation_Mode__c. Mirrors CampaignFaqService structurally: a JSON-array-in-a-field (Allowed_Designation_Ids__c) instead of a junction object, with the same silent stale-Id fail-safe as CampaignFaqService.getSelectedFaqs() — a listed Id that no longer resolves to an active Designation__c is dropped, never surfaced as an error. See docs/superpowers/specs/2026-08-04-campaign-designation-scoping-design.md for the full design.
| Method | Returns | |
|---|---|---|
resolveDesignationConfig(1 arg) |
[CampaignDesignationConfig](#apex-campaigndesignationconfig) |
|
| calls: AppLogger.warn() | ||
filterAllowedSplits(3 args) |
List<[DesignationSplits](#apex-designationsplits).Split> |
|
| calls: AppLogger.warn(), SettingsService.defaultDesignationId() |
CampaignFaqService#
class · public inherited sharing
implements ICampaignFaqService
campaign/
Docs note
Structural twin of CustomQuestionService — same three-method shape and the same two-phase saveSelection (create new Content_Template__c rows first so their Ids exist, THEN the campaign's own selection fields are updated), differing only by RecordType ('FAQ' here, 'Custom_Question' there) and target fields (Campaign.FAQ_Ids__c/FAQs_Active__c vs Campaign.Custom_Question_Ids__c/Custom_Questions_Active__c). Formerly backed by a standalone Campaign_FAQ__c object; merged into Campaign fields (2026-08) since it only ever held a single selection row per campaign.
| Method | Returns | |
|---|---|---|
getSelectedFaqs(1 arg) |
List<Content_Template__c> |
|
| calls: CampaignsSelector.newInstance(), ContentTemplatesSelector.newInstance() | ||
getLibrary(0 args) |
List<Content_Template__c> |
|
| calls: ContentTemplatesSelector.newInstance() | ||
saveSelection(3 args) |
void |
CampaignPostService#
class · public with sharing
implements ICampaignPostService
campaign/
Turns published Campaign_Post__c rows into the two lists the public page renders: a document shelf and an update timeline. Also keeps each Document post's stored file details in step with the file attached to it.
FFLib role: Service.
Deliberate FFLib exception: ContentDocumentLink, ContentVersion and ContentDistribution are read with inline SOQL and ContentDistribution is inserted directly rather than through a selector and Unit of Work. These are platform file objects with no package selector, and DistributionPublicUrl is populated by the platform only after commit — a UoW-deferred insert could not return the URL in the same transaction, which is the whole point of the call.
Docs note
The two Type__c values, public so CampaignPostController can validate the type its panels send without a second copy of the strings. They are a discriminator, not a user choice — the admin never picks one, because each authoring panel owns exactly one type and passes it on the admin's behalf. Anything that is not a Document is read as an Update, so a row carrying an unexpected value still renders as text rather than as a document card with no file behind it.
Set while stampFromVersions saves its own results. That save fires the Campaign_Post__c before-update trigger, which calls applyFileDetails and would re-resolve from ContentDocumentLink — and inside a ContentVersion after-insert trigger the link row for a just-attached file is not queryable yet, so the second pass would find nothing and clear the values the first one had just worked out. The flag makes the write authoritative.
No file object is touched here. A site guest cannot query ContentDocumentLink, ContentVersion or ContentDistribution at all — that access is gated by the Query All Files permission, which a Guest licence cannot hold and which a fundraising app has no business asking for. Resolving the download link while the public page rendered therefore returned nothing, and every document rendered as an untyped, unsized, unclickable card. The details are resolved once by an internal user and stored on the post instead; this read just plays them back.
This block serves the two internal authoring panels; the rest of the class serves the public page. They meet only at the record. The panels are independent by design — an update is text and a document is a file, and neither needs the other — so every method here is scoped by postType and no admin path ever sees both types at once.
A Document is created first and its file attached afterwards, because lightning-file-upload needs a record to attach to. That ordering is the reason for the publish guard: between those two steps the row is a document with no file, and publishing it would put a card on the public page whose href is null — pfSectionTabs renders <a href={doc.url}>, so the donor gets a card that looks live and does nothing.
The stored File_URL__c is the only signal available here, and it is the right one: it is what the public page actually reads, so if it is blank the card would be dead whatever the file situation looks like elsewhere.
The file is read BEFORE the post is deleted and deleted AFTER, because the platform cascades ContentDocumentLink away with the record — asking afterwards finds nothing and the file survives as an orphan with a live public URL. Deleting the ContentDocument (not the link) is deliberate: the link alone would leave the ContentDistribution serving the file to anyone who kept the URL.
SYSTEM_MODE because deleting the file is cleanup the app owes the record, not an action the admin asked for in its own right — they deleted a post. The caller has already checked they may delete Campaign_Post__c (ControllerAction.requireDeletable).
Assigns rather than saves, because the caller is a before-update trigger and a before trigger must not run DML on its own records. A Document post with nothing attached any more has its three fields cleared, which is what makes "save the record again" the repair action the field help tells the admin to use.
FirstPublishLocationId is the record the file was attached to, and for a brand-new file it is the only link available here — the ContentDocumentLink row the platform writes alongside the version is not queryable from this trigger yet. A new version of a file that is ALREADY attached carries no FirstPublishLocationId, and for those the link does exist by now, so the two lookups together cover both ways a file can arrive.
A version that names where it was first published has already answered the question, so a file attached to anything else costs one type check and no query — which matters, because this trigger sees every file uploaded in the org.
FileExtension is derived by the platform during the save, so it is still null on the trigger's own copy of the record and a post stamped straight from Trigger.new came out with no file type at all. Re-reading is the fix, and it happens only for the versions that survived the filter above — an unrelated file upload still costs no query.
Attaching a file to a post that already has one is a REPLACEMENT, not a second attachment — the row renders a single download card and File_URL__c holds a single link, so the superseded file becomes invisible to every screen the app has while its ContentDistribution goes on serving it to anyone holding the URL.
That is the same orphan deletePost exists to prevent, and it matters more here: "Replace file" is what an admin reaches for after uploading the wrong document, so leaving the old one live defeats the one repair the UI offers. Runs after the stamp commit so the post already points at the survivor, and keeps by ContentDocumentId rather than by what the link query returns — the link row for a just-attached file is not queryable yet on the FirstPublishLocationId path.
SYSTEM_MODE for the same reason as deletePost — revoking the file the admin just replaced is cleanup the record owes itself, not a delete they asked for by name.
ContentDistribution is queried before it is created. A distribution is per ContentVersion and permanent, so minting one on every save would leak a fresh public URL each time an admin touched the record and eventually hit the daily DML ceiling.
Formatting lives here rather than in the stored value so there is one rounding rule in one place, and so File_Size__c stays a plain byte count that nothing downstream has to reparse. Static and public because CampaignPostController renders the same label in the Documents editor — an admin comparing the console against the live page should not be reading two different roundings of the same file. It is not on ICampaignPostService: a pure formatter has no behaviour worth mocking, and putting it on the interface would force every test double to stub it.
| Method | Returns | |
|---|---|---|
newInstance(0 args) |
[ICampaignPostService](#apex-icampaignpostservice) |
|
getPublicPosts(1 arg) |
Map<String, Object> |
|
| calls: CampaignPostsSelector.newInstance() | ||
getAdminPosts(2 args) |
List<Campaign_Post__c> |
|
| calls: CampaignPostsSelector.newInstance() | ||
savePost(1 arg) |
Campaign_Post__c |
|
deletePost(1 arg) |
void |
|
setVisibility(3 args) |
void |
|
applyFileDetails(1 arg) |
void |
|
stampFromVersions(1 arg) |
void |
|
fileSizeLabel(1 arg) |
String |
CampaignService#
class · public inherited sharing
implements ICampaignService
campaign/
Docs note
Central hub for the guest campaign/event experience — slug/Id resolution, theme-token normalization, FAQ/custom-question assembly, ticket-tier availability, and rollup math all converge here. Real consumers: CampaignController, EmbedConfigResource, and EventRegistrationController.GetEventCampaign on the read side; DonationFormController, Opportunities (domain), and DonorSummaryRollupBatch on the write/rollup side.
normalizeThemeTokensJson is the SOLE validation authority for page-design theme JSON — it loads the allowed key set from ThemeTokensSelector.selectAll() (CMDT-driven catalog) and strips anything not in it before a guest page ever sees it, plus isUnsafeThemeValue guards against CSS injection (;, {, }, url(, comment delimiters). resolveThankYouCta pairs with isSafeExternalUrl (http/https-only) for the same reason — never trust an admin-entered redirect URL as-is on a guest-facing page.
The formContent map carries BOTH the resolved 'title' and the raw 'titleOverride' (Campaign.Form_Title_Override__c, blank when unset). They look redundant and are not: 'title' has already collapsed override-or-org-default into one string, and the embed widget needs to tell the two apart so it can lead with the campaign's name on a third-party site without discarding a heading an admin deliberately typed (F-40, 2026-08-18). The hosted donation page still reads 'title' and is unchanged.
rollupCampaignTotals enqueues CancelGoalReachedRecurringQueueable when a rollup crosses the campaign's goal — see that class for why an active recurring series is auto-cancelled and how it recovers from a mid-batch governor LimitException.
Subtypes whose public surfaces may carry the goal progress bar. Open_Donation was added 2026-08-18 (F-42): an open-ended appeal that bothers to set a goal wants the same crowdfunding affordance, and the donation form was already drawing one, so the narrower set left the hero and the embed disagreeing with the form on the same page. Event_Fundraiser stays out — a ticketed event's progress is seats, not dollars.
The event ticket page. It loads the form payload — an event registration IS a donation form whose narrow column sells seats — and then the section keys on top, because the page it renders is a whole page rather than an embedded form: a gala has a story, a sponsorship deck to download and an FAQ about parking, exactly as a campaign does.
This is the "form-only entry point that grew those sections" buildCampaignResult's closing comment anticipated, and it resolves them the way that comment asked — through sectionData(), shared verbatim with the donation shell — rather than by widening buildCampaignResult, which would put a file query and a ContentDistribution insert on every embedded-form load for data no embed renders.
The two keys that turn a payload into a page with sections: what content exists (posts) and which sections may show it (sections). One helper, two callers — the donation shell and the event ticket page — so a campaign's Show_Documents__c means the same thing on both, and a change to the "empty tab is worse than no tab" rule in resolveSections cannot land on one page and miss the other.
An in-memory mask, never persisted — the Campaign is a queried row that no Unit of Work ever registers, so the admin's per-campaign choice survives the switch being flipped off and comes back untouched when it is flipped on again. Overwriting the stored field instead would silently destroy that choice across every campaign in the org.
Deliberately WITHOUT the shell payload's sections/posts/contact keys. This is the form-only payload (embed, standalone donation form, event registration); the story/tabs/contact band are rendered by publicFundraisingExperience, which loads getPublicExperienceConfig instead. Adding them here would run a file query and a ContentDistribution insert on every embed load for data nothing on this path renders. If a form-only entry point ever grows those sections, move the three keys into a shared helper rather than duplicating the resolvers.
description + highlightLabel are what the tier card renders under and above its name. They are emitted here for the same reason the sale state is: this payload and EventRegistrationController's must describe a tier identically, or the same tier reads differently depending on which surface loaded it.
A tab is emitted as visible only when its toggle is on AND it has something to show — an empty tab is worse than no tab, because the tab bar promises content behind it. faq has no toggle of its own for the same reason: an empty FAQ list is already an absent tab.
Every value here is org identity resolved through its campaign override. None of it belongs to the design record — a theme change must never alter a privacy policy URL, and until 2026-08-12 it could: all seven lived on Campaign_Design__c. The logo is the one value with no campaign override, because Settings__c.Org_Logo_URL__c is a ContentDistribution link maintained by the upload flow rather than a typed-in URL; a campaign that wants its own mark uses its hero image, not a second logo.
Public because the thank-you page renders the same footer band as the donation page and must resolve it the same way — same precedent as resolveThankYouCta. A second resolver there would be a second place for a privacy URL to come from, which is exactly the divergence this method exists to prevent.
Public for the same reason as resolveIdentity above — ThankYouController renders the same band and must not grow its own copy of the campaign-over-org fallback.
The single override rule for every campaign-over-org field on the public page. Blank means "use the org's", never "show nothing" — a campaign that fills in nothing still gets a complete page. Named campaignOverride, not override, because override is an Apex reserved word.
The venue row is the one contact detail a ticket-holder needs and a donor never does. It renders when Venue_Name__c is set and is absent otherwise — no toggle, no new field, so a plain donation campaign never has to think about it.
The ORGANISATION's design — the Campaign_Design__c an admin flagged Is_Default__c in the settings console — with no campaign in play. Every public page EXCEPT the donation page paints itself with this: the directory, the donor portal, the thank-you page, the receipt, the ticket wallet and the event registration page. Only the donation/campaign page reads a campaign's own Design__c, because only a campaign can carry one. Without this the other six would each ship a hard-coded palette that silently disagrees with the console the moment an admin picks a different theme — which is exactly how the portal came to be warm clay-and-moss while the directory next to it was teal.
Deliberately a SLICE, not the whole designToMap payload: a directory has no form, so the block JSON, form copy defaults and exit-intent config would be dead weight on a page that loads before anything else. Every colour here has already passed safeHex; the two font values are restricted picklist TOKENS (Google_Lora, not a CSS stack) which the LWC resolves through its own closed allowlist, so nothing on this map can reach CSS raw.
What a non-donation public page needs to paint itself in the org's design. The slice is narrower than the donation page's full config on purpose — no hero image, no form copy, no exit-intent prompt — but it must carry the GEOMETRY as well as the palette. It did not until 2026-08-14, and the result was reported as "this page is not following the default page design": the colours and fonts arrived, while the card shadow, column width, gutters and every token the admin set by hand in the editor stayed on the package's shipped values.
Theme_Tokens_JSON__c is safe to hand a guest page because designToMap has already run it through normalizeThemeTokensJson — the sole validation authority, which drops any key not in the Theme_Token__mdt catalog. Passing the raw field instead would bypass that.
Last-chance prompt config. Only the enable flag is resolved here; the four copy fields pass through raw so dfForm owns the packaged fallback wording in one place rather than having two sets of defaults drift apart. See Exit_Intent_Enabled__c for why this is pointer-driven rather than a beforeunload handler.
The public page is always two columns; this decides only the proportion and which side the payment box sits on. It replaces resolveFormLayoutMode and resolveFormSide, whose six layout modes and separate side field could disagree with each other. The value is a bare picklist string ('60/40' | '50/50' | '40/60'); the grid template is derived from it in the shell, deliberately NOT overridable as a theme token, so the two columns can never be themed into disagreement.
EndDate was presentation-only until 2026-08-06 — the public page counted down to it, the Fundraisers console tagged the row "Ended", and the donation form went on charging cards forever. That is right for an appeal whose date is a milestone and wrong for a time-boxed match or an event that already happened, so the choice is per campaign rather than global. The default is "keep accepting", which is why the field is phrased as a permission rather than a deadline: an org upgrading into this release sees no behaviour change at all until an admin unchecks the box on a specific campaign.
Public because the confirmation moved out of the donation form and onto the routed /thank-you page — ThankYouController resolves the same CTA through this one method so an admin's configured "what next" button survives the hand-off instead of silently disappearing, and so the safe-URL rule has exactly one implementation.
The sole server-side authority on what may live in Story__c. FundraiserAdminController's applyForm — the shared helper behind both createFundraiser and updateFundraiser — routes every wizard save through this method, so a client that bypasses the editor entirely — a Data Loader import, a Flow, a hand-rolled API call — still cannot land markup or a hostile URL through that path.
HTML stripping here is best-effort defense-in-depth, not a guarantee: see STORY_HTML_TAG's comment for the prose collision it accepts. The actual guarantee against markup execution is that the renderer never uses innerHTML, so even markup that slipped past this method still renders as literal text there — stripping at save is a second, independent layer so a future renderer change cannot resurrect it on its own.
Strip-then-scan is applied REPEATEDLY, to a fixed point, because each half can manufacture fresh work for the other out of its own output. Four directions are known, three of them found as live defects.
Stripping creates markup. Deleting a tag closes the gap around it, so a ']' and a '(' that were separated in the source become adjacent. The minimal repro needs the tag to become strippable only AFTER a scan, since a leading strip would otherwise remove it first: [a]<b [](javascript:y) >(javascript:x) matches no tag on the first strip (the [](...) inside <b ... > is not a legal attribute), scans to [a]<b
>(javascript:x), and only then does the second strip remove <b > and leave the live link [a](javascript:x).
Scanning creates tags. <img [](javascript:y)src=x onerror=alert(1)> survives the strip intact — [](...) in the middle is not a legal attribute, so the tag grammar does not match — and then dropping the unsafe link splices the fragments into the live tag <img src=x onerror=alert(1)>.
Scanning creates markup. This is the subtle one: a re-emitted ']' can land against a following '(' when the characters between them are dropped, so the 21-character [a][(javascript:evil) — not a link, because the '[' before the '(' has no ']' — comes out of a single pass as [a](javascript:evil), which is. A 600,000-case fuzz found 66 distinct outputs of this shape. The scanner's contract holds over its INPUT; only re-running it over its own OUTPUT extends that guarantee to what actually ships.
Scanning creates reference links, and this fourth direction is NOT closed by the fixed point. When the scanner neutralizes a construct it emits the bare label [x], which is itself a valid CommonMark shortcut reference; if the story also carries a definition [x]: javascript:alert(1) the pair is a live anchor. It survives because the scanner never examines it, not because it was checked — so iterating cannot help, and the guarantee below is scoped accordingly.
Termination is bounded by the loop counter alone (five iterations), NOT by the fact that both halves only delete or shorten — a length-preserving two-cycle would satisfy monotonicity and still never converge. What monotonicity buys is that a fixed point, once reached, is a real one: scan(strip(r)) == r forces strip to have changed no length, hence strip(r) == r and scan(r) == r, so both halves are the identity on the returned value rather than merely composing to it.
In practice legitimate content is stable on the second pass. The cap exists for the pathological case, and reaching it is not treated as success: the fallback puts a space into every remaining ](, which cannot be a link in CommonMark (the '(' must immediately follow the ']'), so the output is inert by construction rather than by exhaustion.
The guarantee, stated precisely, and it is narrower than "the output is safe". On return, stripStoryHtmlTags and normalizeStoryMarkup both leave the value unchanged; since the scanner rewrites any INLINE construct whose destination fails validation, a value it leaves unchanged contains no such inline construct.
Two shapes are deliberately NOT modelled by the scanner and therefore reach a fixed point unexamined: CommonMark reference links ([a][b], the shortcut [a], and the definition [b]: dest) — including the bare [label] this scanner itself emits when neutralizing — and angle autolinks (<javascript:...>, which STORY_HTML_TAG does not match because "javascript:" is not a tag name). THE RENDERER MUST NOT RESOLVE EITHER.
Idempotence is the guarantee for inline constructs only; for those two shapes it is the absence of a check wearing the same clothes, and a renderer built on the assumption that this method closed them will ship a live javascript: anchor.
One further non-guarantee, accepted rather than fixed: the third direction above is closed for SAFETY but not for AUTHORSHIP. When the manufactured destination passes validation the fixed point keeps it, so [Donate securely][(https://evil.example/pay) returns [Donate securely](https://evil.example/pay) — an anchor the author never wrote, with attacker-chosen text and an attacker-chosen https destination. It is accepted because it crosses no privilege boundary (a story author can type that link directly), and because the obvious blanket remedy — spacing every ]( whose adjacency is new — would also break the legitimate links elsewhere in the same story. Closing it properly needs per-character provenance in the scanner, not another pass.
A hand-written single-pass character scanner over every [text](url) /  / @[text](youtube:id [poster]) construct. It replaced a regular expression, deliberately and permanently — do not put one back.
WHY NOT A REGEX. Two independent reasons, each fatal on its own. (1) A regex cannot count. CommonMark permits BALANCED BRACKETS inside link text, so [[x]](javascript:evil) is a live anchor whose label is [x]. Every regex this method has carried failed to match that shape and therefore copied it through byte-for-byte — the single worst class of bug possible here, since the output is rendered on a public unauthenticated page. Widening the grammar to one nesting level only moved the goalposts ([[[a](https://ok.com)](https://mid.com)](javascript:evil) still escaped); arbitrary nesting is not expressible in a regular language at all. A counter expresses it in one integer.
(2) Every widening attempt bought catastrophic backtracking. The last grammar's nested alternation cost 1,358 ms on a 211-byte input ([ +  x30) against a 131,072-byte field ceiling. An Apex CPU LimitException cannot be caught, so that is not a slow save — it is an uncatchable failure an attacker triggers by typing into the story box. The scanner touches each character a bounded number of times and has no lookahead and no nested loop over the same span, so its cost is linear in the input length.
THE CONTRACT: nothing is emitted unexamined. For every '[', '![' or '@[' the scanner reaches exactly one of two outcomes — the construct fully validates and is RE-EMITTED FROM ITS VALIDATED PARTS, or it is NEUTRALIZED (destination dropped, brackets rendered inert). There is deliberately no "I don't understand this, pass it along" path; that path is what every previous round's bug was.
HOW. A stack of label frames plus at most one destination frame. '[' (with its optional '!'/'@' marker) pushes a frame; ']' pops it and, only if the very next character is '(', opens the destination frame; the closing ')' is found by counting parens, so https://en.wikipedia.org/wiki/X_(disambiguation) survives.
Text accumulates into whichever frame is innermost, so an inner construct is fully normalized BEFORE its enclosing label is judged — which is why the nested payloads above collapse: the outer unsafe destination drops to a label whose own contents were already validated.
A frame that never closes is unwound at end of input (and at a blank line, since a CommonMark link cannot span one) with its '[' dropped and any destination text stripped of brackets, so no unmatched '[' and no raw ]( can survive into the output to be re-parsed by a client renderer.
Nesting is capped at STORY_MAX_LABEL_DEPTH frames; brackets past the cap are treated as ordinary text, which bounds heap on a pathological [[[[... input. Output accumulates into List<String> and is joined once — Apex has no StringBuilder, and += over a 131,072-byte input copies the whole accumulator per append.
Donor_Count__c is written here and nowhere else. Until 2026-08-06 nothing in the package wrote it at all, so the "donors" figure on the public fundraiser page and the internal campaign console sat at whatever it was created with — zero — for the life of the appeal.
| Method | Returns | |
|---|---|---|
getCampaignWithQuestions(1 arg) |
Map<String, Object> |
|
getCampaignWithQuestionsById(1 arg) |
Map<String, Object> |
|
getPublicExperienceConfig(1 arg) |
Map<String, Object> |
|
getEventCampaignConfig(1 arg) |
Map<String, Object> |
|
resolveSlug(1 arg) |
SlugResolution |
|
resolveIdentity(1 arg) |
Map<String, Object> |
|
| calls: SettingsService.orgLogoUrl(), SettingsService.footerText(), SettingsService.termsUrl(), SettingsService.privacyUrl(), SettingsService.socialShareImageUrl(), SettingsService.socialTwitterUrl(), SettingsService.socialFacebookUrl(), SettingsService.socialInstagramUrl() | ||
resolveContact(1 arg) |
Map<String, Object> |
|
| calls: SettingsService.contactEmail(), SettingsService.contactPhone(), SettingsService.contactHours() | ||
orgDefaultDesignTheme(0 args) |
Map<String, Object> |
|
isAcceptingDonations(1 arg) |
Boolean |
|
| calls: SettingsService.defaultThankYouCtaUrl(), SettingsService.defaultThankYouCtaLabel() | ||
assertAcceptingDonations(1 arg) |
void |
|
resolveThankYouCta(1 arg) |
Map<String, Object> |
|
| calls: SettingsService.defaultThankYouCtaUrl(), SettingsService.defaultThankYouCtaLabel() | ||
normalizeStoryMarkdown(1 arg) |
String |
|
rollupCampaignTotals(1 arg) |
void |
|
rollupDonorSummary(1 arg) |
void |
|
rollupDonorAccountSummary(1 arg) |
void |
CustomQuestionService#
class · public inherited sharing
implements ICustomQuestionService
campaign/
Docs note
Structural twin of CampaignFaqService — same three-method shape and the same two-phase saveSelection (new Content_Template__c rows committed first so their Ids exist, THEN the campaign's own selection fields are updated), differing only by RecordType ('Custom_Question' here, 'FAQ' there) and target fields (Campaign.Custom_Question_Ids__c/ Custom_Questions_Active__c vs Campaign.FAQ_Ids__c/FAQs_Active__c). Consumed by CampaignQuestionController and, at donation time, by DonationService when resolving submitted answers against the campaign's active questions. Formerly backed by a standalone Custom_Question__c object; merged into Campaign fields (2026-08) since it only ever held a single selection row per campaign.
| Method | Returns | |
|---|---|---|
getSelectedTemplates(1 arg) |
List<Content_Template__c> |
|
| calls: CampaignsSelector.newInstance(), ContentTemplatesSelector.newInstance() | ||
getLibrary(0 args) |
List<Content_Template__c> |
|
| calls: ContentTemplatesSelector.newInstance() | ||
saveSelection(3 args) |
void |
Service contract for turning published Campaign_Post__c rows into the public page's document shelf and update timeline, and for keeping each Document post's stored file details in step with the file actually attached to it.
FFLib role: Service interface.
Docs note
The three admin methods below back CampaignPostController, which is what the Updates and Documents editors call. They take a postType because one object serves both panels and neither may see the other's rows — see the object's own header for why it is one object.
Upsert. Refuses to publish a Document that has no stored File_URL__c, because the public document card is an anchor around that URL and renders as an unclickable dead card without it.
Deletes the post AND the file attached to it. Leaving the file behind would leave its ContentDistribution public URL live after the record that offered it is gone.
Writes the one Campaign toggle that owns this post type's public tab (Show_Updates__c / Show_Documents__c), so each editor can own its own tab's visibility without sending the admin to the campaign's field layout.
Fills File_URL__c/File_Type__c/File_Size__c on the records handed in, in memory, and clears them when nothing is attached any more. Called from CampaignPosts.onBeforeUpdate, which is why it assigns rather than saves — a before trigger must not update its own records.
The attach-after-save path. Called from the ContentVersion trigger with the versions just inserted; resolves which Campaign_Post__c rows they belong to and saves the file details onto them.
Pure Apex helper that normalizes the two legacy JSON block payloads on Campaign_Design__c (Form_Builder_JSON__c — "form blocks", Page_Blocks_JSON__c — "content blocks") into ONE ordered v2 canvas payload: { "version": 2, "blocks": [ { id, type, step, enabled, settings }... ] }. The v2 "step" on each block drives the 3-step donation wizard (Compose=1, Pay=2, Thank You=3).
NO SOQL, NO DML, NO callouts — nothing in this class touches the database or network. It only shapes JSON strings. Called by CampaignService (buildUnified at read time, for both the guest public-experience payload and the admin design editor) and by SetupController (design save path).
Design decisions (see task-4-report.md for the full write-up):
- "Known" block types are exactly the rows of STEP_BY_TYPE plus CONTENT_TYPES plus SHELL_TYPES. Anything else is silently dropped during normalization/migration — including faq, which was a content type until 2026-08-10 and is now rendered by dfForm as fixed chrome instead.
- disclaimer/footer ("shell" family) are never step-clamped — they pass through unchanged, wherever they appear in the source list.
- "Ensure required blocks exist" is applied uniformly after both the v2-normalize path and the v1-migrate path, and guarantees FOUR types are present: amount, donor_info, payment, confirmation (append a minimal default — empty settings, no invented copy — if missing). This single rule satisfies both "always ensure payment + confirmation" (spec, v2 branch) and "malformed/blank input still returns a minimal valid list with amount/donor_info/payment/ confirmation" (spec, error-guard clause) without two divergent code paths.
- v1 migration order: field blocks (existing order, step 1) → content blocks from Page_Blocks_JSON__c (existing order, step 1, id/settings preserved) → the v1 payment block if present (step 2) → [ensure step] any of the four required types still missing (appended, in the fixed order amount, donor_info, payment, confirmation) → disclaimer/footer (if present in v1), pass through, appended last since they are shell chrome outside the step wizard.
- A block's type is only ever read via asString(), never a direct (String) cast — a JSON type that deserialized to a non-string (number/boolean/object) resolves to null and is dropped as unknown, so a corrupted payload can never throw System.TypeException here.
- normalizeV2Blocks dedupes SINGLETON_TYPES (every field type + payment + confirmation) to their first occurrence, so a hand-edited v2 payload with e.g. two payment blocks yields exactly one; content-family types (incl. share/cta) may still repeat.
Docs note
faq is deliberately absent. FAQs are not a canvas block — dfForm renders them itself as the answer shelf below the sticky action bar on the Compose and Payment steps, so an admin cannot position them between the last field and the CTA.
Because faq is now an unknown type, any marker still stored in an older Campaign_Design__c record is dropped by normalizeV2Blocks on read — that is the whole migration, and it applies equally to the guest payload and the admin editor because both go through this one path.
This set is also the filter on the legacy Page_Blocks_JSON__c merge below, so a pre-v2 design that authored an FAQ into its content blocks loses it on the same read. Only faq was withdrawn — every other content type, and the Page_Blocks_JSON__c seam itself, stays.
| Method | Returns | |
|---|---|---|
clampStep(2 args) |
Integer |
|
isSafeVideoUrl(1 arg) |
Boolean |
|
buildUnified(2 args) |
String |
ThemeTokenCatalogService#
class · public with sharing
implements IThemeTokenCatalogService
campaign/
Docs note
Internal-editor counterpart to CampaignService.normalizeThemeTokensJson — both read the same ThemeTokensSelector.selectAll() catalog, but this one shapes it for the setDesigns admin UI (grouped, labeled, with an example value) while normalizeThemeTokensJson uses it as a guest-runtime allowlist. Exposed to the editor via SetupController.getThemeTokenCatalog().
Lockstep with the Group__c picklist on Theme_Token__mdt — a value present there but missing here renders as a blank group heading in the editor's "Add override" dropdown, which reads as a broken catalog rather than an unnamed group. The last five were added 2026-08-12 with the two-column public page.
| Method | Returns | |
|---|---|---|
newInstance(0 args) |
[IThemeTokenCatalogService](#apex-ithemetokencatalogservice) |
|
getCatalog(0 args) |
List<Map<String, Object>> |
|
| calls: ThemeTokensSelector.newInstance() |
Donation#
Async (Batch/Queueable/Schedulable)#
DonorSummaryRollupBatch#
class · public without sharing
implements Database.Batchable<SObject>
donation/
Rolls up donor lifetime totals across BOTH donor models of the dual-field strategy: • CONTACT arm — standard Contact donors (Opportunity.ContactId set). • ACCOUNT arm — Person Account and Organization donors (Opportunity.AccountId set, ContactId null).
A single kickoff (default constructor) runs the Contact arm and then CHAINS the Account arm from finish(), so both donor types are refreshed by one scheduled run without the caller having to know whether the org uses Person Accounts. The scope object differs per arm, so the batch is typed on SObject and branches on arm.
Docs note
Kicked off nightly by RecurringDonationScheduler and re-runnable on demand from SetupController (the setup console's "recalculate donor totals" action) — both callers use the default (CONTACT-arm) constructor; the ACCOUNT arm only runs via the finish() chain.
| Method | Returns | |
|---|---|---|
start(1 arg) |
Database.QueryLocator |
|
execute(2 args) |
void |
|
finish(1 arg) |
void |
|
| calls: AppLogger.info() |
ReconciliationHeartbeat#
class · public without sharing
implements Schedulable
donation/
Ops alarm for the webhook-free pipeline (now critical infrastructure). Since donations finalize only via the scheduler, a stalled/unscheduled reconciler would silently stop creating records while donors still see success. This schedulable flags donations the donor CONFIRMED but that remain unfinalized past a threshold: it writes a HEARTBEAT_ALERT Transaction_Log__c and emails the Fundraising admins. Pair with the "Stuck / Unfinalized" staging list view.
| Method | Returns | |
|---|---|---|
execute(1 arg) |
void |
RefundReconciliationBatch#
class · public without sharing
implements Database.Batchable<SObject>, Database.AllowsCallouts, Database.Stateful
donation/
Webhook-free Stripe reconciler — refunds AND disputes (chargebacks). Once an hour:
REFUNDS: list recent Stripe refunds to find which PaymentIntents had recent refund activity (and advance the refund cursor), then re-fetch EACH matched PaymentIntent from Stripe for its authoritative amount_refunded cumulative total. That total — not a sum of refunds seen in this run's time window — is compared against each Opportunity's Refunded_Amount__c (compare-totals — never issues a refund, never decreases).
Why not sum the windowed refund list? listRecentRefunds(since) is a TIME-WINDOWED page keyed off a GLOBAL cursor high-water mark. A PaymentIntent with two partial refunds spread across two reconcile runs would only have its NEWER partial visible once the cursor advances past the older one — summing would silently understate the true cumulative. Stripe's PaymentIntent.amount_refunded is maintained by Stripe as the authoritative running total for that PI regardless of how partials are spread over time, so re-fetching it per matched PI is the only correct source of truth.
DISPUTES: list recent disputes (one callout — the list returns full dispute objects, so no per-dispute re-fetch is needed) and drive the full won/lost lifecycle on the matched Opportunity's Dispute_* fields. On first entry into an evidence-due state (needs_response / warning_needs_response) a one-time staff email alert fires (ReceiptService.sendDisputeAlert).
On LOSS the funds are gone exactly as with a refund, so the loss is reversed through the same RefundService.recordRefundedTotal path — unwinding rollups, designations, GAU allocations and event seats consistently.
Dispute matching is SYSTEM_MODE and NOT bounded by the refund window: a chargeback can arrive months after the gift and on any owner's record. The dispute cursor advances independently of the refund cursor.
Both refund and dispute reconciliation ride the single refundsEnabled() master switch — they are the two halves of external Stripe reconciliation and share one gate rather than adding a toggle.
Multi-account: refunds/disputes for a PaymentIntent are visible only through the Stripe account that created it, so start() lists against EVERY active Payment_Account__c and remembers which account produced each PI (gatewayByPI) for the authoritative re-fetch. All Stripe callouts happen in start() (two list calls per active account, plus one retrievePaymentIntent per matched-refund PI — capped by piBudget, which shrinks as account count rises to stay under the per-transaction callout limit); Stateful carries the per-PI authoritative totals, the per-PI dispute snapshots, and both cursor high-water marks through to finish().
Docs note
Populated ONLY from the external-refund branch, never from a lost dispute. A lost chargeback reverses the gift through the same recordRefundedTotal, but the donor raised it with their own bank and has already been told the outcome by that bank — a "your gift has been refunded" note from the charity there is redundant at best and tone-deaf in a case the org may still contest.
That isolation has a price the batch scope pays for it: DML statements scale with the chunk size. One commit is not one statement — recordRefundedTotal writes the Opportunity, a fully-refunded event order also releases seats and cancels attendees through releaseForOpportunity, and the Opportunity update then fires the rollup triggers, whose own DML counts against the SAME per-transaction ceiling of 150. So the scope passed by RefundReconciliationScheduler (10) is a correctness contract with this loop, not a tuning knob — see the rationale there before changing it.
Note why the fix is a smaller scope and not "stop committing when DML runs low": the records this loop skips are NOT re-offered. finish() advances the refund and dispute cursors unconditionally once every chunk has run, so a record deferred here is never re-listed by a future start() — it is dropped permanently, which is exactly the hazard the cursorTarget hold-back exists to prevent on the callout side. Bounding the chunk keeps every record inside a transaction that can afford to commit it.
Both sends are BULK calls, and that is a governor-limit requirement rather than a style preference. Messaging.sendEmail may be invoked at most 10 times per Apex transaction; looping the singular sendDisputeAlert/sendRefundNotification spent one invocation per record, so an eleventh notifiable record in a chunk threw LimitException. LimitException is UNCATCHABLE — the try/catch wrapped around this block could not have contained it — and it would have landed here, AFTER every commit above, marking as failed a chunk whose reconciliation had actually succeeded. Never revert either of these to a per-record loop.
| Method | Returns | |
|---|---|---|
compareTo(1 arg) |
Integer |
|
start(1 arg) |
Iterable<SObject> |
|
| calls: SettingsService.refundsEnabled(), SettingsService.refundWindowDays(), PaymentGatewayFactory.getByAccount(), AppLogger.error(), AppLogger.warn(), RefundService.refund(), RefundService.recordRefundedTotal(), StripeAmount.toMajor(), AppLogger.info() | ||
execute(2 args) |
void |
|
| calls: RefundService.refund(), AppLogger.error(), RefundService.recordRefundedTotal(), AppLogger.warn() | ||
finish(1 arg) |
void |
|
| calls: AppLogger.info() |
RefundReconciliationScheduler#
class · public without sharing
implements Schedulable
donation/
Schedules the webhook-free refund reconciler. See RefundReconciliationBatch for the compare-totals logic and the master switch (SettingsService.refundsEnabled()).
Docs note
Scheduled by PostInstallHandler.scheduleJobs() at install. The name and :23 offset match what scripts/apex/schedule-reconciliation-jobs.apex has always used, so an org that ran that dev script before upgrading stays idempotent under scheduleIfAbsent.
Scope is deliberately TINY (10), and is a correctness contract with the batch, not a tuning knob. RefundReconciliationBatch.execute commits one UnitOfWork PER Opportunity to keep a single bad record from rolling back its siblings (see the rationale on that loop), so DML statements scale with the chunk size.
Each commit is far more than one statement: RefundService.recordRefundedTotal writes the Opportunity, a fully-refunded event order additionally releases seats and cancels attendees through releaseForOpportunity, and the Opportunity update then fires the rollup triggers, whose own DML counts against the same per-transaction ceiling of 150.
At the old scope of 200 a busy chunk blew that ceiling and threw LimitException — which is UNCATCHABLE, so it killed the chunk past the per-record try/catch, after some commits had already landed.
Ten records leaves room for well over a dozen statements each. The cost is only more chunks, and chunks are free; do not raise this to reduce the batch-execution count.
| Method | Returns | |
|---|---|---|
execute(1 arg) |
void |
StagingReconciliationBatch#
class · public without sharing
implements Database.Batchable<SObject>, Database.AllowsCallouts
donation/
The webhook-free finalizer. Runs in system (batch) context — which HAS External Credential access to the payment account's Named Credential (proven by the Task-1 spike) — and is the SOLE creator of donation Opportunities. For each non-terminal staging it re-fetches the PI status from Stripe (authoritative) and:
- succeeded → routed by Purchase_Type__c: 'Event_Ticket' goes to
IEventPurchaseService.finalizeSucceededEventStaging (one Opportunity + OLIs + attendees); everything else toDonationService.finalizeSucceededStaging(plain donation). Routing on the wrong branch would finalize a paid ticket order as an amount-only donation with no line items/attendees and burn the unique Stripe_Payment_Intent_Id__c, making the order unrepairable. - canceled → staging Failed (event tickets: seats released first).
- pending past TTL → staging Expired (event tickets: seats released first).
- still pending → left for the next run.
- no intent at all → nothing to re-fetch; retired on the TTL fallback like any unpaid row.
- exception → Finalization_Error__c recorded; left for retry (event tickets: seats released too if the error pushes the staging past TTL into Expired).
Every outcome writes a Transaction_Log__c row (the reconciliation audit trail).
execute() is two-phase — ALL Stripe callouts first, THEN all DML (finalize / status / logs) — so callout-before-DML holds at any scope. Keep the scope modest (scheduler uses 5) because each finalize is SOQL/DML-heavy (donor resolution + Opportunity + rollup triggers); do not raise it near the SOQL/DML governor limits.
Docs note
Direct DML, not UnitOfWork, and SYSTEM_MODE — same reasoning as StripePaymentSweep's cursor: this is the job's own bookkeeping on a config record, written after the finalize work has already committed through its own services, and the scheduled user (whoever installed the package) may hold no FLS on the field. Swallowed on failure: a missed stamp costs a false stale alarm, which is far cheaper than failing a pass that already recorded real gifts.
| Method | Returns | |
|---|---|---|
start(1 arg) |
Database.QueryLocator |
|
execute(2 args) |
void |
|
| calls: PaymentGatewayFactory.getByAccount(), AppLogger.debug() | ||
finish(1 arg) |
void |
|
reconcileOne(1 arg) |
void |
|
| calls: PaymentGatewayFactory.getByAccount() |
StagingReconciliationScheduler#
class · public without sharing
implements Schedulable
donation/
Schedules the webhook-free finalizer. Scope 1 keeps each record's Stripe callout + finalize DML in its own transaction (callout-before-DML). Schedule as an admin/system user that is assigned the org-local "Fundraising Payment Credentials" permission set — without the external-credential grant it carries, every Stripe callout in the batch fails.
| Method | Returns | |
|---|---|---|
execute(1 arg) |
void |
StripePaymentSweep#
class · public without sharing
implements Database.Batchable<SObject>, Database.AllowsCallouts, Schedulable
donation/
Orphan-PI safety net. Belt-and-suspenders on top of StagingReconciliationBatch: lists recent succeeded PaymentIntents tagged source=pledgivo and, for any whose staging exists but was never finalized (no linked Opportunity), repairs it — routed by Purchase_Type__c the same way as the reconciler: 'Event_Ticket' via IEventPurchaseService.finalizeSucceededEventStaging, everything else via DonationService.finalizeSucceededStaging. Guards the rare case where a Pending staging with a real charge slipped past normal reconciliation.
Multi-account: the batch scope is every ACTIVE Payment_Account__c, one per execute(). A PaymentIntent is only visible through the processor account that created it, so an org running two Stripe accounts needs both listed — until 2026-08-10 this job called PaymentGatewayFactory.getDefault() and only ever saw the alphabetically-first account, leaving abandoned checkouts on every other account charged at the processor and unrepaired in Salesforce. One account per execute() also means each gets its own callout budget and its own 150-DML allowance, so a busy account can never starve the next one.
Each account carries its own high-water mark in Payment_Sweep_Cursor__c (see that field's doc comment for why this cursor is per-account where the refund/dispute cursors are org-wide).
Batchable (AllowsCallouts) with scope 1 so the list callout + repair DML run in one execute (callout-before-DML: list first, then finalize). Also Schedulable (hourly enqueue).
Docs note
Scheduled by PostInstallHandler.scheduleJobs() at install. The name and :07 offset match what scripts/apex/schedule-reconciliation-jobs.apex has always used, so an org that ran that dev script before upgrading stays idempotent under scheduleIfAbsent.
| Method | Returns | |
|---|---|---|
execute(1 arg) |
void |
|
| calls: PaymentGatewayFactory.hasActiveAccount() | ||
start(1 arg) |
Database.QueryLocator |
|
execute(2 args) |
void |
|
finish(1 arg) |
void |
Controllers#
DonationFormController#
class · public without sharing
donation/
Docs note
Backs the dfForm LWC (guest donation form). createDonorAndIntent only stages the gift — DonationService.finalizeSucceededStaging (run later by StagingReconciliationBatch or StripePaymentSweep) is what actually creates the Contact/Account and the Opportunity, so nothing here performs donor or donation DML directly.
DonationMonitorController#
class · public with sharing
donation/
DonationMonitorController — backs the lexDonationMonitor tab (Donation Pipeline Monitor).
FFLib role: controller. Reads Donation_Staging__c through IDonationStagingSelector and writes nothing itself; "Retry now" delegates to StagingReconciliationBatch.reconcileOne.
Docs note
Split out of SetupController on 2026-08-14, in the same change that moved the monitor from a Settings-console panel to its own Lightning tab. The two methods below are unchanged; what changed is who may call them.
SetupController is granted to Fundraising_Admin ONLY, and deliberately so — it is the single Apex entry point behind all 16 settings-console panels, so granting it to reach the monitor would have handed program staff the entire console (its absence from Fundraising_User carries a "must not be added back" note saying exactly that).
Watching the finalize pipeline is daily operational work for the same staff who work donations, so it needs a grant of its own with nothing else behind it. The surface here is narrow by construction: one read of non-terminal staging rows, and one retry that re-runs the scheduled reconciliation for a single record. Neither takes any caller-supplied filter or field list, so widening this grant widens nothing else.
Test seam, null in every real context — shipped behaviour is always the CronTrigger query in isFinalizerScheduled() below. It exists because that query reads org state, not transactional data: a Finalizer job created by PostInstallHandler.scheduleJobs is visible from inside a test transaction and no rollback removes it. The "pipeline stopped" test therefore used to assert that the org has no Finalizer job at all, which is true only BEFORE the package is configured — so the suite went red in precisely the correctly configured orgs it exists to protect (audit F-43, 2026-08-18). The positive case still exercises the real query by scheduling a job; only the negative case sets this.
Returns { rows, rowLimit, schedulerScheduled } rather than a bare list. Every row here is waiting on StagingReconciliationBatch, so when the Finalizer family is not scheduled the whole page is measuring a stopped clock — an admin reading a growing list has no way to tell "payments are failing" from "the job that resolves them was aborted".
needsAttention splits the list the same way: only a row the donor actually confirmed, or one carrying an error, or an expired ticket hold still holding seats, is a problem — a plain Pending row is an abandoned checkout nobody was charged for, and counting those as stuck made a healthy org look broken (25 rows, 21 of them merely abandoned).
expiresAt, reservationReleased and seatsHeld feed the page's one filterable list: each row prints its own expiry window, and an expired ticket hold names how many seats it is still keeping off sale. needsAttention is what the page's Show filter switches on, so the abandoned/needs-attention split stays a server-side rule with a single definition.
Runs the exact same callout + outcome logic as the scheduled reconciliation pass for one record, so an admin does not have to wait for the next Finalizer tick. It is therefore idempotent in the same way that pass is — a row already finalized simply resolves again.
| Method | Returns | |
|---|---|---|
getStuckDonations(0 args) |
Map<String, Object> |
@AuraEnabled |
retryDonationStagingNow(1 arg) |
Map<String, Object> |
@AuraEnabled |
GivingStatementController#
class · public with sharing
donation/
Internal (staff) controller for the annual giving statement, backing the lexGivingStatement record action on Contact and Account.
WITH SHARING, and every read runs USER_MODE inside GivingStatementService's staff path: a fundraiser who cannot see a donor's gifts must not be able to print them onto a statement. The token-authorized donor-portal equivalent lives on DonorPortalController and is the ONLY caller allowed the privileged read.
Docs note
recordId is the donor record the action was launched from, so it is trusted only as far as sharing allows — an id for a record the running user cannot see simply yields an empty statement from the USER_MODE selector rather than leaking anything.
| Method | Returns | |
|---|---|---|
getStatementYears(1 arg) |
Map<String, Object> |
@AuraEnabled |
getStatement(2 args) |
Map<String, Object> |
@AuraEnabled |
ReceiptAdminController — backs the lexResendReceipt RecordAction on Opportunity.
FFLib role: controller. Reads through selectors, writes nothing; the send itself is delegated to IReceiptService.resendReceipt.
Docs note
Kept separate from ReceiptController on purpose. That class is without sharing and guest- reachable (it serves the public token-addressed receipt page), so a staff-only method placed there would run without sharing for no reason and widen a guest-facing surface. This one is with sharing: staff can only resend a receipt for a donation they can already see.
Unlike RefundController there is no custom-permission gate here. A resend is not destructive and cannot be aimed anywhere — the recipient is always the donor already on the record, never an address supplied by the caller — so record visibility plus the Fundraising_User class grant is the whole authorization story. Adding a third custom permission for it would be ceremony, not security.
Not cacheable. Payment_Status__c moves under this panel — a refund or a late settlement flips whether the receipt may be resent at all — and a cached read would show staff a live button for a gift that has since been refunded.
| Method | Returns | |
|---|---|---|
getResendContext(1 arg) |
Map<String, Object> |
@AuraEnabled |
resendReceipt(1 arg) |
Map<String, Object> |
@AuraEnabled |
Docs note
Backs the publicDonationReceipt LWC. Receipt_Token__c is stamped by Opportunities.setReceiptToken on every package donation, so this controller never needs (and never accepts) an Opportunity Id.
The fund the gift was earmarked for — "Emergency Shelter Fund". Comes from the Donation_Designation__c splits rather than a field on the Opportunity, because a gift can be split across several funds; the receipt names them all rather than picking one and quietly dropping the rest.
| Method | Returns | |
|---|---|---|
getReceiptData(1 arg) |
Map<String, Object> |
@AuraEnabled |
Docs note
Backs the lexRefundDonation RecordAction and the admFundraiserDetail refund panel. Gated by the Process_Refunds custom permission, checked in RefundAction.doValidate — and checked ONLY there. RefundService.refund contains no permission check of its own, so this controller is the single thing standing between a user and a real Stripe refund, not the outer half of a pair. Any future second entry point into RefundService must carry its own Process_Refunds check; adding one and forgetting the check issues live refunds to a user who was never granted them.
Gated on the same custom permission as the refund itself. The row carries a donor's email address and whatever they chose to write about their gift, so it is shown to the people who are allowed to act on it and to nobody else.
| Method | Returns | |
|---|---|---|
refundDonation(3 args) |
Map<String, Object> |
@AuraEnabled |
getRefundConfig(0 args) |
Map<String, Object> |
@AuraEnabled |
| calls: SettingsService.refundsEnabled() | ||
getDonorRequest(1 arg) |
Map<String, Object> |
@AuraEnabled |
Domains#
DonationDesignations#
class · public with sharing
extends fflib_SObjectDomain · implements IDonationDesignations
donation/
Domain for the Donation_Designation__c junction. On any change to a gift's fund split it recomputes the parent Designation__c.Total_Raised__c from its Closed-Won splits — the single rollup path for BOTH one-time gifts (DonationService.createDesignationSplit) and recurring installments (RecurringDonationService.processRenewal). Keeping the rollup here (not in the services) means every write route stays consistent and bulk-safe.
Docs note
disableTriggerCRUDSecurity() is required, not a relaxation. fflib_SObjectDomain's handleAfterInsert/Update/Delete/Undelete assert isCreateable()/isUpdateable()/... on Donation_Designation__c before dispatching. That object is a master-detail DETAIL of the standard Opportunity, and 2GP refuses <objectPermissions> for such an object, so no permission set this package ships can make those describes true — leaving the assertion permanently unsatisfiable and every split write (guest gift, recurring installment) throwing "Permission to create an Donation_Designation__c denied." Access is still enforced: a master-detail child inherits it from the Opportunity at runtime, which the DML itself honours. The rollup below reads/writes only Designation__c totals.
| Method | Returns | |
|---|---|---|
newInstance(1 arg) |
[IDonationDesignations](#apex-idonationdesignations) |
|
construct(1 arg) |
fflib_SObjectDomain |
|
onAfterInsert(0 args) |
void |
|
onAfterUpdate(2 args) |
void |
|
onAfterDelete(0 args) |
void |
|
onAfterUndelete(0 args) |
void |
|
recompute(1 arg) |
void |
|
| calls: AppLogger.error() |
IDonationDesignations#
interface · public
extends fflib_ISObjectDomain
donation/
Opportunities#
class · public with sharing
extends fflib_SObjectDomain · implements IOpportunities
donation/
Docs note
Central donation domain — every package Opportunity insert/update flows through here. Rollups delegate to CampaignService (rollupDonorSummary / rollupDonorAccountSummary / rollupCampaignTotals) and DonationDesignations.recompute, never DML directly; brownfield Opportunities (non-package record types) are excluded via filterToPackageRecordTypes so this trigger cannot touch an org's pre-existing sales pipeline.
A gift's totals are recomputed from scratch by CampaignService/DonationDesignations, so the only question this handler has to answer is WHICH parents to recompute. Every field the aggregates read is listed here — stage and amount and refund feed both the campaign totals and the fund splits (DonationDesignationsSelector prorates a refund by each split's share of Opportunity.Amount), and the three lookups decide which parents the gift counts toward at all.
Until 2026-08-06 this was three narrower handlers: a stage change rolled up only the donor, an amount change only the campaign, and neither touched fund totals — so moving a gift between campaigns, or closing one as Lost, silently left the old parent overstated for good.
The Amount clause also carries a load-bearing case that predates the merge: a line-item-priced order (an event ticket purchase) inserts with Amount = null and only gets its real value when the platform derives Amount = SUM(OpportunityLineItem.TotalPrice) in a second DML, which reaches this class as an update. Without it, the insert-time rollup captures $0 and the campaign never picks the order up.
ContactId is deliberately absent: the platform rejects an update to Opportunity.ContactId ("Field is not writeable"), because it is derived from the primary OpportunityContactRole — so a contact-donor gift has no reparent path through this trigger at all. AccountId does, and it is the lookup an organization/Person Account gift is re-attributed through.
Deleting a gift removes it from every total it fed, and its Donation_Designation__c splits go with it — but a MASTER-DETAIL CASCADE DOES NOT FIRE THE CHILD'S TRIGGERS, so DonationDesignations never runs and the funds those splits fed would stay overstated for good. The splits are only readable while their parent still exists, so the affected funds are captured here in before-delete and recomputed in after-delete, once the rows are actually gone and the aggregate reads the truth.
Undelete restores the splits by the same silent cascade, so their funds are recomputed here too — this time by reading the rows back, which are queryable again.
KNOWN LIMITATION — a restored CONTACT donor's lifetime total is not repaired, here or later. Opportunity.ContactId is derived from the primary OpportunityContactRole, and undeleting the gift does not bring that role back: ContactId reads as null in Trigger.new, on a re-query from inside this handler, and in the DB afterwards, so even the nightly DonorSummaryRollupBatch has nothing to find (all three measured, not assumed — see Opportunities_Test).
The campaign total and an ACCOUNT donor's total do restore immediately. Recovering a contact donor's total means re-adding the contact role, then waiting for (or running) the nightly rollup.
Deliberately NOT limited to the transition into Closed Won. Any save of an unnumbered Closed Won gift stamps it, so gifts that predate this change get their number the next time anyone touches them, rather than needing a migration script that would consume a block of numbers for records nobody was looking at.
Pledge is excluded because a Pledge is by definition unpaid ("a committed future gift not yet paid" — see the record type's own description). Issuing a tax receipt number for money that has not arrived is the one error in this area a tax authority would actually care about. Grant is included: an awarded grant is money received. Non-package record types are excluded by filterToPackageRecordTypes like every other handler here — the package must not write to an org's pre-existing sales pipeline.
Runs in the BEFORE context on purpose. Stamping a field on a record the platform is already saving costs no DML and cannot recurse; doing it after insert would mean an extra update per gift and a re-entrant trigger for a value that was knowable all along.
Records where an individual soft credit came from, which is what lets SoftCreditService promise never to overwrite a staff member's entry. The service writes the lookup and Soft_Credit_Source__c = 'Tribute Honoree' in one save, so its own writes arrive with the source already changed; a person editing the record in the UI changes the lookup and nothing else, and that difference is the whole signal this method reads.
Clearing the lookup clears the source with it — provenance for a credit that no longer exists would be stale the moment it was left behind, and would make the next automated pass treat an empty slot as spoken for.
Selectors#
Docs note
DTO returned by DonationDesignationsSelector.selectAggregateRaisedByCampaignsForDesignation — the per-campaign mirror of DesignationNetRaised. Exists for the same reason that class does: SOQL SUM() cannot prorate a split gift's refund across the funds it touched, so the selector reads raw split rows and accumulates in Apex. Grouping key here is the CAMPAIGN, not the fund. campaignId is null for gifts booked with no campaign — the reader renders those as "No campaign" rather than dropping them, so the breakdown always reconciles to the headline.
Docs note
DTO returned by DonationDesignationsSelector's per-designation net-raised queries. Replaced a raw AggregateResult return (SUM(Opportunity__r.Refunded_Amount__c) GROUP BY Designation__c) which double-counted a split gift's refund once per fund it touched — SOQL SUM() can't express "prorate this Opportunity's refund across its splits", so the selector queries raw rows and this class carries the per-designation accumulation the selector computes in Apex instead.
DesignationsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IDesignationsSelector
donation/
DonationDesignationsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IDonationDesignationsSelector
donation/
Docs note
Reads the per-fund split rows written by DonationService (one-time) and RecurringDonationService (recurring) via DesignationSplits. selectByOpportunityIdsWithGau feeds NpspSyncService.syncAllocations — the only other consumer of the GAU-mapped variant.
newQueryFactory(false, false, true) — assertCRUD OFF, and deliberately so. Object-level Read on Donation_Designation__c is UNGRANTABLE: 2GP rejects <objectPermissions> on a master-detail DETAIL object, so no permission set here can make isAccessible() true, and fflib asserts it at query-BUILD time — before AccessLevel.SYSTEM_MODE is consulted. The raw-SOQL sibling below documents the guest failure this same assertion already caused.
Feeds the Designation row on ThankYouController's thank-you payload.
The three net-raised aggregates below are WITH SYSTEM_MODE for the same unavoidable reason documented on EventTicketTypesSelector: Donation_Designation__c is a master-detail DETAIL object of a standard master (Opportunity), so 2GP rejects <objectPermissions> on it and no permission set this package ships can give a non-admin object-level Read. Without that grant every field — Id included — describes as inaccessible, and a WITH USER_MODE query THROWS ("sObject type 'Donation_Designation__c' is not supported") rather than returning zero rows.
Verified 2026-08-09 against a user holding Fundraising_User, which carries every field grant the package can ship for this object.
That failure was worst here: selectAggregateRaisedByDesignations is called by DonationDesignations.recompute inside a deliberate swallow-and-log, so for any non-admin it did not error — fund totals simply stopped rolling up, silently.
Callers own the access decision instead, and all three already resolve the master under USER_MODE and throw on empty before asking for these numbers: RecordPageService.getDesignationPerformance gates on DesignationsSelector.selectById, FundraiserDetailController.GetDetail on CampaignsSelector.selectFundraiserById. recompute itself is trigger-internal — it is handed ids it just wrote, never client input. A new caller taking a fund or campaign Id from an LWC MUST add the same gate.
Per-fund realized split for one campaign's donations — feeds admFundraiserDetail's "Realized fund split" table. Includes funds since removed from the campaign's allowed list (the LWC/controller layer marks those "not currently allowed" by diffing against the current CampaignDesignationConfig, not by filtering them out here). Same net-raised math as selectAggregateRaisedByDesignations, scoped by campaign instead of by designation set.
Inverse of selectAggregateRaisedByCampaign — one fund's realized total broken down by the campaigns that raised it, feeding lexDesignationPerformance's campaign table. Gifts booked with no campaign are kept under a null key rather than filtered out, so the breakdown always sums back to the headline net-raised figure the same card shows above it.
"Where it went" on the donor portal — this donor's own giving broken down by fund, net of refunds. SYSTEM_MODE for the same reason as selectByOpportunityIdsWithFundName: the visitor is authorized by a magic-link token, not by sharing on their own split rows, and USER_MODE would render an honest-looking but empty breakdown. The LIMIT is a safety stop — a donor past it loses only the tail of a proportional chart, never a receipt or a total.
IDesignationsSelector#
interface · public
extends fflib_ISObjectSelector
donation/
IDonationDesignationsSelector#
interface · public
extends fflib_ISObjectSelector
donation/
IOpportunitiesSelector#
interface · public
extends fflib_ISObjectSelector
donation/
IReceiptSequenceSelector#
interface · public
extends fflib_ISObjectSelector
donation/
Selector contract for Receipt_Sequence__c, the per-tax-year receipt number counter. Registered in Application so ReceiptNumberService resolves it through the factory and tests can mock it without inserting counter rows.
OpportunitiesSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IOpportunitiesSelector
donation/
Docs note
The largest selector in the package — one-time and recurring donation receipts, refunds, disputes, the donor portal dashboard, event-ticket orders, and the internal fundraiser-detail console all read Opportunity through this class rather than ad hoc SOQL elsewhere.
Receipt_Number__c is deliberately NOT added to getSObjectFieldList(). That list feeds every USER_MODE query this selector builds, and the guest permission set does not grant the field — a guest-reachable USER_MODE read would start failing outright on an FLS violation rather than merely omitting a column. Widening one staff-only query is the narrow version of that change; the guest receipt page reads the number through selectByReceiptToken, which is SYSTEM_MODE and unaffected either way.
The whole address, not just venue + city. A ticket is the one artifact a donor navigates by — it gets printed, or held up on a phone in a taxi — so the street line has to be on it. The fields already exist on Campaign and the fundraiser wizard already captures them; this selector was simply dropping them, which is why the page could only say "The Grand Ballroom · New York".
The campaign's public slug, so a fully refunded order can offer "Book again" instead of a dead end. The donate route keys on ?slug=, not a Campaign Id, so CampaignId alone cannot build that link.
Feeds ThankYouController.getThankYou. Adding a field the thank-you page renders? Add it here — the page must never gain a second SOQL round trip for one more label.
Bulk twin of selectDonorContactById — identical projection and identical SYSTEM_MODE rationale, differing only in taking a set. It exists for ReceiptService.sendRefundNotifications, which builds one email per donation in a RefundReconciliationBatch chunk: resolving the donor one donation at a time would spend one query per email inside a transaction that has already spent queries re-reading the chunk, and is the kind of per-record read that only shows up as a governor limit once a real org reconciles a burst of refunds.
ContactId + AccountId are the two halves of the Person Account dual-lookup, resolved by DonorResolutionService.resolveReceiptContactId into the Contact the tribute template merges from. This used to select Account.Name instead of AccountId, because the e-card body was assembled in Apex and needed a display name; the body now lives in a packaged EmailTemplate that merges {!Contact.*} directly, so the name is no longer read here.
What must NOT come back is Contact.FirstName or any other "Contact." path — Opportunity.ContactId has no relationshipName (Schema describe returns null for it), so the query is rejected at runtime with "Didn't understand relationship 'Contact'". Because the whole tribute send is best-effort, that failure is swallowed and shows up only as a tribute e-card that silently never arrives. Account traverses fine; only Contact doesn't.
No WHERE clause beyond the id set — unlike the campaign-member sync, there is no single condition that identifies a candidate. A gift may qualify for a tribute credit, a household credit, both, or neither, and the three soft-credit slots are filled independently, so SoftCreditService has to see every row and decide per slot.
Distinct donors per campaign, resolving each gift's donor exactly the way the rest of the package does: its Contact when it has one, otherwise its Account (the dual-field Person Account convention). Two aggregates rather than one COUNT_DISTINCT over both lookups, because a contact gift ALSO carries that contact's Account — counting both fields would count the same donor twice. Returns a map rather than raw AggregateResults so the pairing of the two halves stays here, next to the reason there are two.
Carries the refund-eligibility fields (Donation_Type__c, Refunded_Amount__c, Stripe_Payment_Intent_Id__c) even though the dashboard never displays them, because this is also the query DonorPortalService gates a donor's refund request against. Deciding eligibility from these rows means the "Request a refund" button and the IDOR check are answering from the same read — a second, differently-shaped query for the submit path is how the button and the gate end up disagreeing.
Bounded by a calendar-year CloseDate range rather than CALENDAR_YEAR(CloseDate) so the filter stays on the indexed raw field. LIMIT 2000 is a safety stop, not an expected ceiling: a donor with more than 2000 receiptable gifts in one year is a data-quality signal, and the statement reports the truncation rather than silently understating a total.
SUM(Amount) rides along on the aggregate the statement picker already runs, so the donor portal can print a per-year subtotal in each ledger year header without a second query — and without summing the history list client-side, which caps at 100 rows and would under-report a long-standing donor's oldest years.
Grouped by payment status as well as year (2026-08-08) so ONE query feeds two totals that are deliberately different numbers: the ledger year header, which must equal the gross of every Closed Won row printed beneath it, and the downloadable statement's headline figure, which counts receiptable gifts only and nets out refunds. With a single SUM(Amount) behind both, the portal's statement rail advertised a year total the tax document it generated then contradicted. SUM(Refunded_Amount__c) rides along for the partially-refunded half of that net; GivingStatementService.toYears folds the status rows back into one row per year through ReceiptStatusHelper, so the rule for what counts is still stated in exactly one place.
MIN(CloseDate) rides along on the same aggregate rather than costing a second query — the lifetime call (since = 1900-01-01) yields the donor's first-ever gift date, which the portal renders as "Giving since <month year>".
Won gifts on this campaign with zero Donation_Designation__c rows — the "Undesignated" bucket for admFundraiserDetail's realized fund-split table. Net of refunds, same convention as aggCampaignSummary/selectAggregateRaisedByCampaign.
| Method | Returns | |
|---|---|---|
newInstance(0 args) |
[IOpportunitiesSelector](#apex-iopportunitiesselector) |
|
getSObjectFieldList(0 args) |
List<Schema.SObjectField> |
|
getSObjectType(0 args) |
Schema.SObjectType |
|
selectById(1 arg) |
List<Opportunity> |
|
selectWithReceiptNumberById(1 arg) |
List<Opportunity> |
|
selectByCampaignId(1 arg) |
List<Opportunity> |
|
| calls: QueryCondition.of() | ||
selectByContactId(1 arg) |
List<Opportunity> |
|
| calls: QueryCondition.of(), QueryCondition.literal() | ||
selectByPaymentIntentId(1 arg) |
List<Opportunity> |
|
| calls: QueryCondition.of() | ||
selectByPaymentIntentIdAsSystem(1 arg) |
List<Opportunity> |
|
| calls: QueryCondition.of() | ||
selectByIdAsSystem(1 arg) |
List<Opportunity> |
|
selectByReceiptToken(1 arg) |
List<Opportunity> |
|
selectEventOrderByReceiptToken(1 arg) |
List<Opportunity> |
|
selectThankYouByReceiptToken(1 arg) |
List<Opportunity> |
|
selectReceiptTokenById(1 arg) |
List<Opportunity> |
|
selectDonorContactById(1 arg) |
List<Opportunity> |
|
selectDonorContactByIds(1 arg) |
List<Opportunity> |
|
selectTributeById(1 arg) |
List<Opportunity> |
|
selectPendingCompanyMatch(1 arg) |
List<Opportunity> |
|
selectForCampaignMemberSync(1 arg) |
List<Opportunity> |
|
selectForSoftCredit(1 arg) |
List<Opportunity> |
|
selectByIdWithSubqueries(1 arg) |
List<Opportunity> |
|
selectLastGiftWithCampaign(2 args) |
List<Opportunity> |
|
selectDonorCountByCampaigns(1 arg) |
Map<Id, Integer> |
|
selectAggregateRaisedByCampaigns(1 arg) |
List<AggregateResult> |
|
selectAggregateDonorSummaryByContacts(1 arg) |
List<AggregateResult> |
|
selectAggregateDonorTotalByAccounts(1 arg) |
List<AggregateResult> |
|
selectDonorGiftSummary(2 args) |
List<AggregateResult> |
|
selectOneTimeDonationTotal(2 args) |
List<AggregateResult> |
|
selectMonthlyDonationTotals(3 args) |
List<AggregateResult> |
|
selectDonorHistorySystem(2 args) |
List<Opportunity> |
|
selectDonorGiftsForYear(3 args) |
List<Opportunity> |
|
selectDonorGiftsForYearSystem(3 args) |
List<Opportunity> |
|
selectDonorGivingYears(2 args) |
List<AggregateResult> |
|
selectDonorGivingYearsSystem(2 args) |
List<AggregateResult> |
|
selectDonorEventOrdersSystem(2 args) |
List<Opportunity> |
|
selectDonorTotalSinceSystem(3 args) |
List<AggregateResult> |
|
selectByIdForRefund(1 arg) |
List<Opportunity> |
|
selectByIdForRefundRequestSystem(1 arg) |
List<Opportunity> |
|
selectByIdsForRefund(1 arg) |
List<Opportunity> |
|
selectOwnerEmailById(1 arg) |
List<Opportunity> |
|
selectDonationsPaged(3 args) |
List<Opportunity> |
|
countDonations(1 arg) |
Integer |
|
aggCampaignSummary(1 arg) |
List<AggregateResult> |
|
aggUndesignatedByCampaign(1 arg) |
List<AggregateResult> |
|
selectByRecurringDonationId(1 arg) |
List<Opportunity> |
ReceiptSequenceSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IReceiptSequenceSelector
donation/
Docs note
Sole reader of Receipt_Sequence__c. The only query it exposes takes a row lock, because reading this counter without intending to advance it has no use case — see ReceiptNumberService for the allocation rules the lock protects.
| Method | Returns | |
|---|---|---|
getSObjectType(0 args) |
Schema.SObjectType |
|
getSObjectFieldList(0 args) |
List<Schema.SObjectField> |
|
selectByTaxYearsForUpdate(1 arg) |
List<Receipt_Sequence__c> |
Services#
CampaignMemberSyncService#
class · public inherited sharing
implements ICampaignMemberSyncService
donation/
Adds donors to the Campaign their gift came in on as Campaign Members, so campaign response can be reported with the standard Salesforce campaign tools instead of a custom rollup. FFLib Service layer — registered as ICampaignMemberSyncService in Application.
Driven by DonationFinalizedSubscriberHandler, one isolated seam among several on the DonationFinalized__e event.
Docs note
Off unless the org opts in (Settings__c.Donation_Sync_Campaign_Members__c). Campaign membership is data many orgs already maintain with their own automation, and silently adding rows to it would corrupt their reporting rather than improve ours.
Why this runs off the platform event rather than the Opportunity trigger: the guest user who inserts a public donation cannot create a CampaignMember. The DonationFinalized__e subscriber runs as Automated Process, which can — the same reason NpspSyncService and CompanyMatchService hang off this event.
The event-ticket path has its own, separate sync inside EventPurchaseService (Event_Sync_Campaign_Members__c). They are intentionally not merged: an event order syncs every ATTENDEE inline while the order is being finalized, whereas a donation syncs the one DONOR asynchronously. Sharing an implementation would mean one of the two doing its work at the wrong time for the wrong set of people.
The org-shape question is asked through DonorResolutionService.isPersonAccountOrg(), not a describe check written here, because that is the one place in the package allowed to decide which account model an org is on. The try/catch behind it is the second belt: if the lookup fails for any reason, the donors it would have resolved are skipped and every other donor in the batch still gets their membership.
| Method | Returns | |
|---|---|---|
syncForDonations(1 arg) |
void |
|
| calls: SettingsService.donationSyncCampaignMembers(), DonorResolutionService.isPersonAccountOrg(), AppLogger.warn() |
CompanyMatchService#
class · public inherited sharing
implements ICompanyMatchService
donation/
Turns a donor's "my employer will match this" tick into work someone will actually do: a follow-up Task for staff and an instructions email for the donor. FFLib Service layer — registered as ICompanyMatchService in Application, so callers resolve it through the factory and tests can mock it.
Driven by DonationFinalizedSubscriberHandler, one isolated seam among several on the DonationFinalized__e event.
Docs note
Why this runs off the platform event rather than the Opportunity trigger: the donation that carries the match flag is inserted by the guest donation path, and the Guest License cannot create Tasks (and could not own one anyway). The DonationFinalized__e subscriber runs as Automated Process, which can — the same reason NpspSyncService hangs off this event. Doing it in the trigger would fail for exactly the donors this feature is for.
The whole flow is deliberately one-directional and human-terminated. Apex raises the task and sends the donor the org details their employer's portal will ask for, then stops. Whether the employer honours the match, and for how much, is recorded by staff on Company_Match_Status__c / Company_Match_Amount__c. Nothing here ever moves money or creates a second Opportunity — the employer's gift, when it lands, is its own donation.
| Method | Returns | |
|---|---|---|
createFollowUps(1 arg) |
void |
|
| calls: SettingsService.companyMatchFollowupDays() |
Shared parser/allocator for a donor's fund allocation. One place both the one-time finalize (DonationService) and the recurring schedule (RecurringDonationService) use, so the split contract stays identical across gift types.
Options_JSON__c contract (donor allocation), in priority order:
1. "designations": [ { "id": "<Designation Id>", "amount": <Decimal> }, ... ] (multi-split)
2. "designationId": "<Designation Id>" (single fund)
3. SettingsService.defaultDesignationId() (org default)
4. none → empty list (undesignated gift)
Amounts are always NORMALIZED to the authoritative charged total: the parts are scaled to sum EXACTLY to total (remainder on the last row) so a rounding drift or a client that sends amounts not matching the charge can never mis-total the splits.
| Method | Returns | |
|---|---|---|
parse(2 args) |
List<Split> |
|
| calls: SettingsService.defaultDesignationId() | ||
allocateByPercentage(2 args) |
List<Split> |
DonationFinalizedSubscriberHandler#
class · public inherited sharing
donation/
Handles the base package's DonationFinalized__e platform event: fans out to the optional downstream seams that react to a finalized donation — NPSP allocation sync, employer-match follow-up, and campaign membership. Each seam is isolated in its own try/catch — the donation itself has already committed by the time this event fires, so one seam failing must never block the others or cause the whole event batch to redeliver/retry.
| Method | Returns | |
|---|---|---|
handle(1 arg) |
void |
|
| calls: AppLogger.error() |
DonationService#
class · public inherited sharing
implements IDonationService
donation/
Docs note
finalizeSucceededStaging is the SOLE creator of donation Opportunities and is idempotent on Stripe_Payment_Intent_Id__c, so it is safe to call from more than one path: StagingReconciliationBatch (the normal webhook-free finalizer) and StripePaymentSweep (the orphaned-PaymentIntent repair path) both invoke it directly. It publishes DonationFinalized__e (consumed by DonationFinalizedSubscriber consumed by NpspSyncService) and calls ReceiptService for the donor-facing email.
The org-wide master switch is re-checked here and not merely on the way out to the form. Options_JSON__c comes from a guest-callable endpoint, so "the form didn't show the section" is a statement about the browser, not about what can reach this method. Refusing the capture is also what makes the switch a real off switch: with no Tribute_Type__c on the Opportunity, ReceiptService.buildTributeNotification returns null on its existing gates and no e-card goes out.
This runs on every finalized gift including a recurring gift's first installment, because that installment does have a real URL behind it. Later renewals have no URL at all — they are created by the scheduler — so RecurringDonationService copies the attribution stored on the Recurring_Donation__c instead. See AttributionCapture.copy().
Raises the "gifts are being charged but not recorded" alarm without touching the donor's journey. Called from the guest path after the staging row is committed, so the log row can point at the specific gift at risk.
Channel is Transaction_Log__c at Status__c = 'Failed', which is precisely what the setup console's Recent Errors panel queries (TransactionLogsSelector.selectRecentErrors) — so the alarm surfaces to the admin with no new UI and no new permission. It needs no guest grant either: the insert is as system, the same mechanism StripePaymentSweep.logSweep uses, and Transaction_Log__c has no trigger to re-assert CRUD. Fundraising_GuestDonor still grants nothing on the object, which is the rule that matters — a guest must not be able to READ the org's payment-failure log.
Everything here is swallowed. This method exists to report a problem; it must never become one, and an alarm that could abort a paid donation would be worse than the outage it warns about. AppLogger.error is the fallback so the signal is not lost entirely if the log insert itself fails.
| Method | Returns | |
|---|---|---|
createDonorAndIntent(10 args) |
Map<String, Object> |
|
| calls: CampaignService.assertAcceptingDonations(), AppLogger.error(), SecureTokens.uuid(), SecureTokens.opaque(), PaymentGatewayFactory.getByAccount(), DonorResolutionService.findLinkedStripeCustomerId() | ||
checkStagingPaymentStatus(1 arg) |
String |
|
| calls: PaymentGatewayFactory.getByAccount() | ||
markStagingSubmitted(2 args) |
void |
|
finalizeSucceededStaging(2 args) |
Map<String, Id> |
|
| calls: AppLogger.debug() |
FeeCoverageService#
class · public with sharing
implements IFeeCoverageService
donation/
Donor-covered processing fees. Decides whether a campaign offers the option, and computes the amount to add on top of a gift so the org nets the full gift after the payment processor takes its cut. FFLib Service layer; resolve via Application.Service, never new.
Docs note
This class is the ONLY place the fee formula lives. Every write path that charges a donor-covered fee — DonationService.createDonorAndIntent for one-time gifts and recurring signups, RecurringRenewalBatch for every later renewal — calls computeFee here rather than carrying its own arithmetic, so a rate change in Settings__c reaches all of them at once.
Event ticket orders deliberately do NOT offer fee coverage. A ticket order already has a quid-pro-quo split to disclose on the receipt (goods value vs deductible remainder); adding a third component the donor chose would make that letter harder to read for no fundraising gain.
NOT gift * percent. The processor charges its percentage on the TOTAL it captures, so a naive percentage-of-gift leaves the org short by the fee on the fee. At Stripe's US standard 2.9% + $0.30 a $100 gift needs $3.30 added, not $3.20: the org is charged 2.9% of $103.30 ($3.00) plus $0.30, netting exactly $100.00.
The rate components go to the browser, not a precomputed fee, because the donor changes the amount as they type and a round-trip per keystroke is not worth it. The client estimate is display only — createDonorAndIntent recomputes the authoritative fee server-side from these same settings and charges THAT, so a tampered client cannot choose what it pays.
| Method | Returns | |
|---|---|---|
isOffered(1 arg) |
Boolean |
|
| calls: SettingsService.feeCoveragePercent(), SettingsService.feeCoverageFixed() | ||
computeFee(1 arg) |
Decimal |
|
| calls: SettingsService.feeCoveragePercent(), SettingsService.feeCoverageFixed() | ||
describe(1 arg) |
Map<String, Object> |
|
| calls: SettingsService.feeCoveragePercent(), SettingsService.feeCoverageFixed() |
GivingStatementService#
class · public inherited sharing
implements IGivingStatementService
donation/
Builds a donor's annual giving statement — one document listing every receiptable gift they made in a tax year, with the year's totals and the organisation's identifying details.
ON DEMAND ONLY. There is deliberately no year-end batch that mails statements to every donor: the two entry points are a donor pulling their own from the magic-link portal, and staff generating one from the donor's Contact or Account record page.
INHERITED SHARING is load-bearing. The staff path reaches this class from a with sharing controller and must stay inside the running user's record access; the portal path reaches it from DonorPortalService (without sharing) because an Experience Cloud guest has no sharing on their own Opportunities. Marking this class either way would break one of the two callers — with sharing would hand a donor an official-looking statement listing zero gifts.
Docs note
The per-gift figures are NOT recomputed here. Every line's deductible amount comes from ReceiptStatusHelper, the same class the web receipt, the printed receipt and the emailed receipt all use, so a statement can never contradict the individual receipts it summarises — including the deliberate "cannot state a deductible" case for a partially refunded event order, which the statement reports as an unresolved line rather than guessing a number.
The selector groups by payment status as well as year, so a year arrives as several rows and is folded back into one here. The fold produces TWO totals on purpose. yearTotal / giftCount stay gross over every Closed Won gift — the donor portal's ledger prints them in each year header above the rows themselves, refunded and pending gifts included, and a header that disagreed with the rows under it would read as a broken page. statementTotal / statementGiftCount describe the document the download button actually produces: only the gifts assemble() will list, net of refunds.
The "which gifts count" rule is NOT restated here. Each status group is passed through ReceiptStatusHelper exactly as assemble() passes each individual gift, which works because resolve() is linear in amount and refund: summing the group and resolving once gives the same figure as resolving each row and summing. That is what keeps the rail's label and the document's TOTAL RECEIVED from drifting apart again.
Gross Closed Won total for the year. Belongs to the donor portal's giving-history year header, which lists every gift the donor made — including ones that were refunded or never completed — so its subtotal has to be the gross of the rows printed under it. Do NOT label a statement download with this: use statementTotal.
What buildStatement() will actually report for this year: gifts that produce a receipt line (Succeeded in full, Partially_Refunded net of the refund), with fully refunded and never-completed gifts left out entirely. This is the pair every statement-download surface must label itself with — the donor portal's annual-statement rail and the internal year picker — so the number on the control equals the TOTAL RECEIVED on the document it generates.
Still not the deductible total. The non-deductible half of an event ticket comes off only inside the built statement, which needs Event_Deductible_Amount__c per gift and cannot be reduced to an aggregate.
Service contract for adding donors to the Campaign their gift came in on as Campaign Members. Registered in Application so callers resolve it through the factory and tests can mock it.
Service contract for donor-covered processing fees — the "add 3% so 100% of my gift reaches you" option on the donation form.
Annual giving statement — a single year-end summary of one donor's receiptable gifts.
On-demand only: a donor pulls their own statement from the magic-link portal, or staff generate one from the donor's Contact/Account record page. The package never mails statements in bulk.
Every read exists twice because the two callers must not share an access mode: the staff path runs under the running user's sharing and FLS, the portal path runs privileged because a guest has no record access to their own Opportunities. See OpportunitiesSelector's statement section.
Optional NPSP integration seam. Mirrors finalized donation data into the NPSP managed package (an OPTIONAL dependency detected at runtime) when the org has NPSP installed and the matching Settings__c sub-toggles are on. Every method is a best-effort no-op when NPSP is absent or the relevant toggle is off.
Service contract for assigning the serial number that appears on a gift's official tax receipt. Registered in Application so callers resolve it through the factory and tests can mock it.
Service contract for assigning soft credit — recognition for a gift someone else paid for — to finalized donations. Registered in Application so callers resolve it through the factory and tests can mock it.
NpspSyncService#
class · public without sharing
implements INpspSyncService
donation/
Optional NPSP (Nonprofit Success Pack) integration. NPSP is an OPTIONAL managed-package dependency — its objects (npsp__Allocation__c, npe03__Recurring_Donation__c) do not exist at compile time in a plain org, so this service reaches them ONLY through dynamic Schema.getGlobalDescribe() + generic SObject.put(). Every entry point is a hard no-op when NPSP is not installed or the matching Settings__c sub-toggle is off (see SettingsService.npsp*).
without sharing: it runs from the DonationFinalized__e platform-event subscriber (the Automated Process user) and must write NPSP records the running user has no sharing/CRUD grant on — our permission sets can't grant FLS on another package's fields. which finalizes the same event stream. Writes are best-effort: an NPSP validation/trigger rejection is logged and swallowed so it can never roll back an already-charged donation.
Docs note
The sibling read at the top of this method pins SYSTEM_MODE for the same reason.
| Method | Returns | |
|---|---|---|
syncAllocations(1 arg) |
void |
|
| calls: SettingsService.npspGauSyncEnabled(), DonationDesignationsSelector.newInstance(), AppLogger.warn(), SettingsService.npspRecurringSyncEnabled(), RecurringDonationsSelector.newInstance() | ||
syncRecurringDonations(1 arg) |
void |
|
| calls: SettingsService.npspRecurringSyncEnabled(), RecurringDonationsSelector.newInstance() |
ReceiptNumberService#
class · public inherited sharing
implements IReceiptNumberService
donation/
Docs note
Issues the serial number printed on every official tax receipt, in the form PREFIX-YEAR-NNNNNN (RCPT-2026-000142). Called by the three paths that finalize a paid gift — DonationService.finalizeSucceededStaging, EventPurchaseService and RecurringDonationService.processRenewal — immediately before the gift is registered on the Unit of Work, so the number is committed by the time the receipt email merges it.
WHY NOT AN AUTO NUMBER FIELD. Auto Number stamps every Opportunity the moment it is inserted, so pledges, abandoned checkouts and payments that never succeed would all consume numbers and the receipt series would be mostly holes; its format is frozen once packaged; and it cannot restart each tax year. Numbers are issued here instead, at the point a gift actually becomes receiptable.
UNIQUE, NOT GAPLESS. Tax authorities require each receipt to carry a unique serial number (the CRA wording is "a unique serial number"); none require the run to be unbroken. A transaction that rolls back after allocating leaves its number permanently unused, and that is accepted deliberately — the alternative is holding the counter lock across every gift's whole finalize. Uniqueness, which is the part that actually matters, is guaranteed twice over: the FOR UPDATE read below, and the unique index on Opportunity.Receipt_Number__c as a database-level backstop if that lock is ever bypassed.
Only the counter row is committed here. The gift itself is stamped in memory, in a before trigger, so it costs no DML and cannot recurse. The two still land atomically: this commit is nested inside the caller's transaction, so if the gift's save fails afterwards the counter advance rolls back with it and the number is not consumed.
Not merged into assign() with a null-uow branch. A method that sometimes commits and sometimes defers to its caller is the kind of thing that gets called from the wrong context; the two names make the contract visible at the call site.
| Method | Returns | |
|---|---|---|
assign(2 args) |
void |
|
assignWithoutUnitOfWork(1 arg) |
void |
ReceiptService#
class · public with sharing
implements IReceiptService
donation/
Docs note
Every outbound donor/staff email in the package funnels through this class — donation and event-ticket receipts, recurring confirmation/failed-payment/dunning/re-auth notices, dispute alerts, and tribute notifications. Callers include DonationService.finalizeSucceededStaging, EventPurchaseService, RecurringDonationService, RecurringRenewalBatch, and RefundReconciliationBatch. Every send method is best-effort: a template/limit/missing-email failure is logged via AppLogger and swallowed so it can never roll back an already-finalized donation or payment.
The eligibility rules live in resendBlockReason below and are read a second time by ReceiptAdminController.getResendContext, which uses them to grey the button out with a reason rather than letting staff click into an error. That controller read is the courtesy; this call is the enforcement — a caller that skips the panel still gets checked.
Split out from resendReceipt for the same reason as buildDisputeAlert and buildTributeNotification — Messaging.sendEmail is metered against the org's DAILY email allocation even inside a test, so a test that depends on a successful send starts failing once a suite run exhausts it. Every eligibility rule lives here rather than in the sender, which makes "would this resend be allowed, and which template would it use?" assertable without spending a send.
Public and static rather than private because ReceiptAdminController.getResendContext calls it to grey the button out with the same wording the send would have thrown. It is a pure predicate over one record — no SOQL, no DML, no state — so sharing it directly is safe and keeps a single copy of the policy. Reaching past IReceiptService for it is deliberate: putting it on the interface would imply an instance behaviour it isn't.
Deliberately NOT gated on Settings__c.Auto_Receipt_Enabled__c. That switch turns off the thank-you receipt, which is a courtesy; this is a correction to a tax document the donor already holds, and an org that suppresses its receipts has no reason to want its refunds kept quiet. Closes finding 2.2 of docs/gap-audit-2026-08-07.md.
Bulk twin of sendRefundNotification, for RefundReconciliationBatch — the one caller that can have a whole chunk's worth of donors to notify in a single transaction.
Messaging.sendEmail may be INVOKED at most 10 times per Apex transaction. The cap counts invocations, not messages: one call carries a whole list, and the documented per-call limits are on recipients (100 To / 25 Cc / 25 Bcc), not on how many SingleEmailMessage objects the list holds.
Looping the singular method therefore burned one invocation per donor, and the eleventh refund reconciled in a chunk threw LimitException — which is UNCATCHABLE, so the batch's own try/catch could not contain it. The refunds had already been committed by then, so the failure mode was: money correctly recorded, the remaining donors never told, and the chunk reported as failed. One invocation for the whole set removes the ceiling rather than merely raising it.
The donor lookup is bulked for the same reason — one query for the set instead of one per donation, so a chunk of refunds cannot walk into the SOQL ceiling either.
Bulk twin of sendDisputeAlert, for RefundReconciliationBatch. Same reasoning as sendRefundNotifications: Messaging.sendEmail is capped at 10 INVOCATIONS per transaction, so alerting per Opportunity in a loop put an uncatchable LimitException between the batch and its own error handling once a chunk detected an eleventh new chargeback. Disputes are rare enough that the old loop would have survived most runs, which is precisely why this needed fixing rather than watching: the day it broke would be the day a processor-side incident opened a burst of chargebacks — exactly when the alerts matter most.
Split out from sendDisputeAlert so the addressing and body can be asserted without a real send. Messaging.sendEmail is subject to the org's DAILY email allocation even inside a test (the message is never delivered, but the quota check still runs), so a test that asserts on Limits.getEmailInvocations() fails once a suite run exhausts the allocation — in a scratch org that is a matter of a few sends. Asserting the built message is both deterministic and a stronger check than "one send was attempted".
Row-level half of the builder above, so sendDisputeAlerts can resolve a whole set of Opportunities in ONE selector call and still reuse the exact addressing and body logic. The Id overload is what the tests assert against and is kept as the single-row entry point.
Split out from sendTributeNotification for the same reason as buildDisputeAlert — Messaging.sendEmail is metered against the org's DAILY email allocation even inside a test, so a test that depends on a successful send starts failing once a suite run exhausts it. The addressing is the thing worth asserting: the message must reach the honoree's address and no one else.
The message is produced by Messaging.renderStoredEmailTemplate(templateId, whoId, whatId), which renders a stored template into a SingleEmailMessage WITHOUT sending it (one SOQL query). That indirection is what makes an admin-editable template possible here at all: the recipient is an arbitrary address the donor typed in, not an org Contact, so the usual setTemplateId + setTargetObjectId route — which can only deliver to the target Contact — is unavailable. Rendering first, then addressing, separates "whose fields merge in" (the donor Contact, as whoId) from "who receives it" (the honoree, via setToAddresses).
Tribute_Ecard_Requested__c is the donor's consent, and it is the FIRST thing checked — an address on the record is not permission to use it. The form hides the address input when the donor unticks "send an e-card" but keeps the typed value in component state, so a declined e-card still arrives here with Tribute_Notification_Email__c populated. dfForm now clears the address on untick as well; this gate is the half that also covers rows written before that fix and any future caller that fills the field directly. Getting this wrong emails an In_Memory_Of notice to a bereaved family the donor deliberately chose not to contact — see docs/gap-audit-2026-08-07.md §1.1.
The donor Contact is the template's merge source, and its absence is a hard stop rather than a fallback. The previous Apex-built body degraded to "A donor has made a donation…" when neither donor lookup resolved (an imported or organization-donor row); that is precisely the unattributable message the audit objected to — an unsolicited email to a stranger who cannot tell who sent it or why. Skipping is also what sendRefundNotification does in the same situation. See docs/gap-audit-2026-08-07.md §2.1.
Null the target explicitly. Whether renderStoredEmailTemplate leaves whoId on the returned message is not something to depend on either way: if it does, the platform rejects a message that carries both a targetObjectId and toAddresses, and worse, a surviving target would make the DONOR a recipient of a message written to the honoree's family. One line removes the question.
| Method | Returns | |
|---|---|---|
sendDonationReceipt(1 arg) |
void |
|
| calls: SettingsService.autoReceiptEnabled(), AppLogger.warn(), EmailSenderService.applySender() | ||
sendEventTicketReceipt(1 arg) |
void |
|
| calls: AppLogger.warn(), EmailSenderService.applySender() | ||
resendReceipt(1 arg) |
void |
|
| calls: AppLogger.warn() | ||
resendBlockReason(1 arg) |
String |
|
sendCompanyMatchInstructions(1 arg) |
void |
|
| calls: AppLogger.warn(), EmailSenderService.applySender() | ||
sendRefundNotification(1 arg) |
void |
|
sendRefundNotifications(1 arg) |
void |
|
| calls: AppLogger.warn(), EmailSenderService.applySender() | ||
sendRecurringConfirmation(1 arg) |
void |
|
sendFailedPaymentAlert(2 args) |
void |
|
sendDunningNotice(1 arg) |
void |
|
sendReAuthLink(1 arg) |
void |
|
sendDisputeAlert(1 arg) |
void |
|
sendDisputeAlerts(1 arg) |
void |
|
| calls: AppLogger.warn() | ||
sendTributeNotification(1 arg) |
void |
|
| calls: AppLogger.warn() |
Shared receipt/refund status-gate rule. One place every receipt surface — the internal receipt (RecordPageService, viewed on the Opportunity record page), the guest-facing receipt (ReceiptController, viewed via a donor's magic-link token) and the post-gift confirmation (ThankYouController) — resolves the same tax-deductibility outcome, so the rule can never drift between them:
Succeeded -> FULL full letter for the gross Amount Partially_Refunded -> PARTIAL letter for the net (Amount - Refunded_Amount__c) Refunded -> NOT_DEDUCTIBLE no letter; the gift was fully refunded, nothing to deduct Pending/Processing/Failed/Cancelled/other -> NO_RECEIPT no letter; payment never completed
On top of the state gate it resolves the QUID-PRO-QUO split: for an event order the donor received goods or services (a seat, a dinner) in exchange for part of the payment, and only the remainder is a charitable contribution.
Docs note
The goods/deductible split lived only in ThankYouController until 2026-08-06. The two older receipt surfaces called the 3-argument resolve(), which cannot see Event_Amount__c, so both published the GROSS as the tax-deductible figure for a gala ticket and then printed "no goods or services were provided" underneath it — while EventTicketConfirmation.email, for the very same order, split it correctly.
That is an IRC section 6115 quid-pro-quo disclosure problem for a US 501(c)(3) subscriber, so the logic was moved down here and every surface now reads goodsValue/deductibleAmount/legalStatement from this one class.
A second round on 2026-08-08 separated "goods were provided" (goodsProvided) from "here is what they were worth" (goodsValue).
Deciding the first from the second meant an event order whose ticket tier had no Fair_Market_Value__c recorded — Event_Amount__c 0, so nothing to print — fell through to the plain-gift wording and denied that the donor received anything.
| Method | Returns | |
|---|---|---|
resolve(3 args) |
ReceiptStatus |
|
resolve(4 args) |
ReceiptStatus |
|
resolve(5 args) |
ReceiptStatus |
|
isEventOrder(1 arg) |
Boolean |
|
legalStatement(4 args) |
String |
DTO returned by RefundService.refund — the outcome of a single Stripe refund call.
RefundService#
class · public inherited sharing
implements IRefundService
donation/
Salesforce-initiated Stripe refund. Validates the donation is refundable, issues the refund against Stripe, then records the cumulative refunded amount and status via a single Unit of Work commit that happens AFTER the callout (Apex callout-before-DML rule).
The gateway is resolved from the donation's Payment_Account__c (stamped at finalize) so a refund routes to the Stripe account the gift was actually charged against in multi-account orgs; rows predating that stamp fall back to the org's sole active account when there is exactly one, and are refused with an explicit "set the Payment Account" error when there is not.
The donor is emailed a refund notification (ReceiptService.sendRefundNotification) after the commit, best-effort. Staff notification remains a deferred follow-up; this service logs via AppLogger instead.
Docs note
recordRefundedTotal is the single refunded-total → Payment_Status__c mapping, shared with RefundReconciliationBatch for BOTH externally-refunded-in-Stripe totals and lost-dispute reversals — keeping one status transition rule for every path a donation can end up refunded.
The one place that answers "could this gift's status be refunded at all?". Exposed as a method rather than the set itself so callers cannot mutate it, and so RefundRequestHandler (which screens a donor's refund request before parking it for review) asks the refund service the same question refund() asks itself instead of keeping a second copy that quietly drifts out of step.
| Method | Returns | |
|---|---|---|
isRefundableStatus(1 arg) |
Boolean |
|
recordRefundedTotal(4 args) |
String |
|
| calls: AppLogger.warn(), SettingsService.refundsEnabled(), SettingsService.refundWindowDays(), StripeAmount.toMinor(), PaymentGatewayFactory.soleActiveAccountId(), AppLogger.error(), PaymentGatewayFactory.getByAccount(), AppLogger.info(), GuestRequestService.newInstance() | ||
refund(3 args) |
[RefundResult](#apex-refundresult) |
|
| calls: SettingsService.refundsEnabled(), SettingsService.refundWindowDays(), StripeAmount.toMinor(), PaymentGatewayFactory.soleActiveAccountId(), AppLogger.error(), PaymentGatewayFactory.getByAccount(), AppLogger.info(), AppLogger.warn(), GuestRequestService.newInstance() |
Copies a gateway's settlement figures — the processor's own fee, the net that reached the balance, and the availability date — onto the Opportunity being finalized.
Stateless helper shared by the three paths that create a gift from a charge: DonationService (one-time and recurring gift #1), EventPurchaseService (ticket orders), and RecurringDonationService (renewals).
Docs note
This exists as one helper rather than four assignments repeated three times because the blank-versus-zero rule below is the entire correctness of the feature, and a rule that lives in three places is a rule that will be right in two of them.
The figures cost nothing to obtain: every caller is already holding the GatewayResult.Intent it fetched to learn the charge succeeded, and StripeGateway asks for the balance transaction on that same call. Nothing here makes a callout.
Deliberately NOT applied to refunds or disputes. A refund posts its own balance transaction with its own fee treatment, so overwriting the original charge's figures from a refund would destroy the record of what the gift originally settled for. Refund_Amount__c/Dispute_* already carry that story; this field set describes the original charge and only ever the original charge.
| Method | Returns | |
|---|---|---|
applyTo(2 args) |
void |
SoftCreditService#
class · public inherited sharing
implements ISoftCreditService
donation/
Assigns soft credit to finalized donations — recognition for a gift that someone else paid for. FFLib Service layer — registered as ISoftCreditService in Application.
Driven by DonationFinalizedSubscriberHandler, one isolated seam among several on the DonationFinalized__e event.
Docs note
Soft credit answers "who else should this gift show up for?" without touching who paid. The money, the donor of record and the receipt are all unchanged — only the three Soft_Credit_* lookups are written, and only ever from blank to filled.
Why this runs off the platform event rather than the Opportunity trigger: the honoree lookup is a query the guest user who inserts a public donation cannot run, and the guest has no field-level access to the soft-credit lookups either. The DonationFinalized__e subscriber runs as Automated Process, which has both — the same reason NpspSyncService, CompanyMatchService and CampaignMemberSyncService hang off this event.
On by default (Settings__c.Soft_Credit_Automation_Enabled__c), unlike the campaign-member switches which are off by default. See that field's own note for why the asymmetry is deliberate rather than an oversight.
Restricted to In_Honor_Of on purpose. A memorial gift's Tribute_Notification_Email__c is the address of a surviving relative being told about the gift, not of the person being remembered — crediting them would attribute the gift to someone who never gave it, and would be near-impossible for staff to spot afterwards.
Both org shapes resolve the honoree through Contact — a person account's email lives on its person Contact and is only mirrored onto Account.PersonEmail, so one query shape reaches both. The only difference is which id the credit is filed under: the Contact itself in a standard org, its parent Person Account in a PA org.
The household is read from the donor Contact's own AccountId, which is the household account in a standard org and in NPSP alike — so one rule covers both without the package ever naming an NPSP field.
Person Account orgs get no household credit here. There the donor already IS an Account, so the gift's AccountId is the household and a second reference to it would say nothing.
| Method | Returns | |
|---|---|---|
assignSoftCredits(1 arg) |
void |
|
| calls: SettingsService.softCreditAutomationEnabled() |
Staging#
DonationStagingSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IDonationStagingSelector
donation/
Docs note
Donation_Staging__c is the pending-donation session record created by DonationFormController.createDonorAndIntent and addressed only by its unguessable Access_Token__c — never by Id — until DonationService.finalizeSucceededStaging links it to a real Opportunity. No CRUD/FLS enforcement (constructor below): every caller is either the unauthenticated guest or an Automated Process batch/scheduler.
Intent-less rows are deliberately INCLUDED (they were excluded until 2026-08-14). A row with no Stripe_Intent_Id__c has nothing to re-fetch, but excluding it left it non-terminal forever: no pass ever selected it, so it could never expire and sat in the admin monitor permanently. The batch now selects it and retires it on the TTL fallback instead (StagingReconciliationBatch.effectiveExpiry). No production path creates such a row — DonationService and EventRegistrationController both mint an intent before insert — but seed scripts and hand-built rows do, and an unreachable terminal state is a defect regardless of who writes the row.
SYSTEM_MODE for the same reason every other read on this object is: Donation_Staging__c carries the guest's own submission and is never granted to a reading user directly.
IDonationStagingSelector#
interface · public
extends fflib_ISObjectSelector
donation/
Donor#
Controllers#
DonorPortalAdminController#
class · public with sharing
donor/
DonorPortalAdminController — backs the lexSendPortalLink RecordAction on Contact and Account.
FFLib role: controller. Reads through selectors, writes nothing; minting and sending the magic link is delegated to IDonorPortalService.issueAccessLinkForDonor.
Docs note
Kept separate from DonorPortalController on purpose. That class is the guest-facing side of the magic-link portal — it is without sharing and every one of its methods is reachable by an unauthenticated visitor, authorized only by possession of a token. A staff method placed there would inherit that posture for no reason. This one is with sharing, so a staff member can only issue a link for a donor they can already see, and the two audiences never share a class.
No custom-permission gate. Issuing a link is not destructive and cannot be aimed anywhere — the address is always re-read from the donor record inside the service, never accepted from the caller — so record visibility plus the Fundraising_User class grant is the whole authorization story, the same call made for ReceiptAdminController.
Not cacheable. The donor's email address is the one thing this panel exists to confirm before a mail leaves the org, and a cached read would let staff send to an address that was corrected on the record minutes earlier.
Same mint, no mail. Kept as its own action rather than a flag on SendPortalLinkAction so the two are separately grantable and separately readable in a log — one of them puts a bearer credential on a staff member's screen and the other puts it in the donor's inbox.
| Method | Returns | |
|---|---|---|
getPortalLinkContext(1 arg) |
Map<String, Object> |
@AuraEnabled |
sendPortalLink(1 arg) |
Map<String, Object> |
@AuraEnabled |
createPortalLink(1 arg) |
Map<String, Object> |
@AuraEnabled |
Guest-facing controller for the magic-link donor portal (Cycle 6, Approach B).
Every data method takes the opaque magic-link token, re-validates it server-side, and scopes all work to the resolved donor — no client-supplied record id is trusted. Every @AuraEnabled method delegates to a ControllerAction inner class, so the { success, data / error } envelope, try/catch, and AppLogger wiring live in ONE place (ControllerAction.run()) — never inline here. requestAccessLink is deliberately enumeration-safe (see its action class).
Domains#
Contacts#
class · public with sharing
extends fflib_SObjectDomain · implements IContacts
donor/
Docs note
There is deliberately no onAfterInsert override here. Campaign membership for donors is handled by CampaignMemberSyncService off the DonationFinalized__e event, not by a Contact trigger — a Contact insert has no campaign context, and the Guest License user who creates the donor on a public gift cannot create a CampaignMember at all.
| Method | Returns | |
|---|---|---|
newInstance(1 arg) |
[IContacts](#apex-icontacts) |
|
construct(1 arg) |
fflib_SObjectDomain |
Selectors#
AccountsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IAccountsSelector
donor/
Docs note
The ACCOUNT arm of the donor dual-field strategy (DonorResolutionService.isPersonAccountOrg decides which arm applies). Only Person Account / organization donors live here — a standard Contact donor's business Account is never rolled up through this selector.
Carries Portal_Token_Issued_At__c for the same reason as the Contact twin (ContactsSelector.selectIdByEmailSystem): the issue cooldown must be evaluated from the resolving query, not a follow-up one.
PersonEmail and IsPersonAccount do not exist as compilable fields in an org without Person Accounts, so this is dynamic SOQL and the caller MUST check DonorResolutionService.isPersonAccountOrg() first — invoking it in a standard org throws an INVALID_FIELD query exception, it does not simply return nothing.
PersonEmail is the Person Account twin of Contact.Email — the portal's statement panel prints the address the magic link went to so a donor can confirm which record they are about to change. Guarded at read time: on a non-Person-Account org the field does not exist, so the caller resolves it dynamically rather than naming it in the SELECT.
ContactsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IContactsSelector
donor/
Docs note
The CONTACT arm of the donor dual-field strategy — used in standard (non-Person-Account) orgs. Portal_Token_Hash__c / Portal_Token_Expires_At__c back the magic-link donor portal (DonorPortalService) and are deliberately excluded from getSObjectFieldList so a normal USER_MODE query can never leak them to a user without explicit FLS.
Deduping donors must not depend on the running user's visibility — a caller who can't see an existing Contact would silently create a duplicate donor and a second Stripe Customer, so SYSTEM_MODE is the correct mode for internal callers (finalization batch) too, not a guest workaround. Do NOT "harden" this back to USER_MODE.
Portal_Token_Issued_At__c rides along because the public "email me a link" form has to decide whether this donor is inside the issue cooldown, and it must decide that in the SAME query that resolved them — a second lookup would show up as a timing difference between a matched and an unmatched address, which is exactly the enumeration signal the issue path is built to hide.
Deliberately NOT selectDonorMatchByEmailSystem — that one is capped at a single row because it answers "who is this one donor?", whereas an event batch can carry many gifts with many honorees and must resolve them in one query rather than one per gift. Email is the only key used: matching an honoree on Tribute_Name__c would credit the wrong person whenever two supporters share a name, and a soft credit is a permanent claim about who gave, not a display string.
Person Account honorees are resolved through Contact rather than through Account and PersonEmail, even though the credit lands on the Account. A person account's email lives on its person Contact and is only mirrored onto Account.PersonEmail, so the Contact route reaches the same rows — and it hands SoftCreditService two ordinary fields (Id, AccountId) instead of one that cannot be referenced outside a PA org.
The Account.IsPersonAccount filter is what keeps a business contact who happens to share the honoree's email from crediting their employer's account.
It only exists once Person Accounts are enabled, which is why this is a raw SOQL string and why it is only ever reached behind DonorResolutionService.isPersonAccountOrg().
Email rides along so the donor portal's statement panel can print the address the magic link was sent to — a donor with two records needs to see WHICH one they are looking at before they change a card or cancel a gift. Renamed from selectFirstNameByIdSystem when the query stopped being first-name-only.
IAccountsSelector#
interface · public
extends fflib_ISObjectSelector
donor/
IContactsSelector#
interface · public
extends fflib_ISObjectSelector
donor/
Services#
DonorPortalService#
class · public without sharing
implements IDonorPortalService
donor/
Magic-link donor portal authentication (Approach B — zero per-donor licenses).
A donor requests a link by email; if a donor record matches, an unguessable token is generated, its SHA-256 hash is stamped on the donor's Contact (or Person Account) with an expiry (SettingsService.portalTokenTtlHours(), 3h by default), and the raw token is emailed. Possession of the raw token authorizes read/manage access to that one donor's portal until it expires — the raw token is never stored.
What the token is NOT, so no surface claims otherwise: it is not single-use (it keeps working for the whole window, which is what lets a donor reopen the mail later) and it is not bound to a device, browser or IP (it is a bearer credential in an inbox). The expiry window and the issue cooldown below are therefore the entire defence, which is why the window is short.
WITHOUT SHARING: runs privileged so the Experience Cloud guest user (which has no FLS/ sharing on Contact/Account) can resolve and stamp donor records. All access is gated by the token; no client-supplied record id is ever trusted.
Docs note
Every recurring-management mutation (pause/resume/cancel/retryNow/updateCard/reinstate) delegates to RecurringDonationService after requireOwnership's IDOR check — this class owns token validation and donor scoping only, never the Stripe subscription logic itself.
The no-send twin. Staff who already have the donor on the phone do not need a mail round trip, and a donor with no address on file cannot be mailed at all — but the link itself works either way, so refusing to produce one would strand exactly the donor who needs staff help most. Token rotation is identical to the send path, which is why the panel warns before either one and offers neither twice.
Split out of issueAccessLinkForDonor for the same reason as ReceiptService.buildResendReceipt — Messaging.sendEmail is metered against the org's DAILY allocation even inside a test, so a test that depends on a successful send starts failing once a suite run exhausts it. Every eligibility rule and the token rotation itself live here, which makes "would this link be issuable, and to what address?" assertable without spending a send.
Resolution order is deliberate. Site.getBaseUrl() first, because inside the Experience site it is the host the visitor actually arrived on. Off-site (the staff quick action, any batch) it returns blank, and the configured Experience Site URL is the only value that yields a link a donor can open — URL.getOrgDomainUrl() is the INTERNAL My Domain host, which a guest cannot reach. It stays last purely so the pre-existing guest path keeps a value rather than throwing; a link built from it will not work, which is why staffLinkBlockReason() refuses to send at all when the site URL is unset.
The donor half of "I'll name them later". The buyer skipped the guest-details step at checkout, so the seats exist with no names on them; this is where they come back and fill them in. Nothing here writes to Event_Attendee__c: the donor runs as the Guest User, which can never hold Edit on it, so the names are queued as a Guest_Request__c and applied under privileged access by AttendeeNamingHandler.
The donor half of "I need this gift back". Nothing here refunds anything and nothing here can: the request is queued as a Guest_Request__c and parks at Awaiting Review for a person, who issues the refund from the donation record if they agree. See RefundRequestHandler.
Every mutation below is bracketed by beginPortalWrite/endPortalWrite. The donor runs as the Guest User, which can never hold Edit on Recurring_Donation__c, so fflib's trigger-level CRUD assertion has to be waived for the write — see RecurringDonations.suppressTriggerCrudCheck for why that is safe and what actually authorizes the change. The bracket is a try/finally, not a bare assignment: a validation failure inside the service must not leave the waiver on for the rest of the transaction.
The returned mode is the campaign's RESOLVED designation mode, so the portal can hide the control entirely for a campaign that offers no choice ('Assigned' — one fixed fund — or 'None'). It is a display decision only; changeDesignation re-validates the chosen fund against the same config server-side and rejects anything out of scope.
Idempotent by design — a card already flagged Removed__c returns quietly instead of throwing, because the client may retry a request whose response it never saw. Ownership is resolved through selectByDonorSystem (the UNFILTERED donor-scoped read) rather than selectValidByDonorSystem precisely so the already-removed case is distinguishable from the not-yours case. Promoting a replacement default matters beyond cosmetics: PaymentMethodService.saveWalletCard decides a new card's Is_Default__c from whether the donor already has one, so a wallet left with no default silently makes the next saved card default too.
This is a DESERIALIZATION target, not an Aura parameter type — the component sends a JSON string and submitAttendeeNames parses it. An Apex inner class cannot be a parameter of a component-called method: the list arrives the right length with every property null, so a fully filled-in form reads as an empty submission. That is not a validation failure the caller can see, which is why it is written down here rather than left to be rediscovered. attendeeId is a String for the same reason it is untrusted — it is parsed to an Id and matched against the order's own seats before it is used for anything.
Docs note
The single entry point for the Person Account dual-field donor strategy — DonationService, RecurringDonationService, PaymentMethodService, EventPurchaseService, and the donor portal (DonorPortalController/DonorPortalService) all resolve or create a donor through resolveOrCreateDonor rather than branching on IsPersonAccount directly. DonorContext exposes contactId/accountId as computed getters (falling back to the pre-existing Id when the donor already existed) so a caller can read the resolved Id immediately after uow.commitWork() without a second query.
Standard orgs get a HOUSEHOLD Account too, not a bare private Contact: Contact.AccountId and Opportunity.AccountId are both populated, so a gift shows on an Account's related list, in every standard Opportunity report grouped by Account, and in orgs whose validation rules require Contact.AccountId — where an Account-less donor insert would otherwise fail the finalize with an error the donor never sees. It is also what makes SoftCreditService.applyHouseholds() do anything in a standard org. See newHousehold() for why households are never matched by name.
| Method | Returns | |
|---|---|---|
stampStripeCustomer(2 args) |
void |
|
isPersonAccountOrg(0 args) |
Boolean |
|
resolveOrCreateDonor(4 args) |
DonorContext |
|
findLinkedStripeCustomerId(1 arg) |
String |
|
resolveReceiptContactId(1 arg) |
Id |
Event#
Controllers#
EventCheckInController — admin API behind the record-drawer "Check-In" tab. Lists the attendee roster for an event campaign and flips each seat's Event_Attendee__c.Check_In_Status__c (Registered ⇄ Checked-In / No-show) at the door.
Reads via EventAttendeesSelector (USER_MODE, staff-facing); all DML runs through the FFLib Unit of Work. Every @AuraEnabled returns the { success, data/error } envelope via ControllerAction.run().
Scope: check-in only mutates Check_In_Status__c — never the seat's identity, order, or ticket tier. 'Cancelled' (refunded) is NOT a settable target here: a seat is voided by a refund, not by a door scan, so the door can only move a seat between Registered / Checked-In / No-show. A seat already Cancelled is left untouched.
| Method | Returns | |
|---|---|---|
getEventAttendees(1 arg) |
Map<String, Object> |
@AuraEnabled |
setCheckInStatus(2 args) |
Map<String, Object> |
@AuraEnabled |
EventTicketAdminController#
class · public with sharing
event/
EventTicketAdminController — admin API behind the record-drawer "Tickets" tab. Manages per-event ticket tiers (Event_Ticket_Type__c) and their reusable Product2.
Reads via EventTicketTypesSelector; all DML runs through the FFLib Unit of Work. Every @AuraEnabled returns the { success, data/error } envelope via ControllerAction.run().
Scope note (Slice C, Task 1): after the tier's Product2/Event_Ticket_Type__c commit via UoW, saveTicketType/saveTicketTypes call PricebookService.ensureEntriesFor() (direct SOQL/DML, not UoW — PricebookEntry can't be registered against an unsaved Product2) so purchase-time OpportunityLineItems have a PricebookEntry to reference. Price__c on the junction remains the price source of truth — the PricebookEntry.UnitPrice is only a required placeholder.
Docs note
deductibleMode travels with the tiers so the editor can say which of the two split fields is load-bearing in THIS org. Without it the editor showed Fair Market Value and Deductible % side by side with no indication that only one of them is read, and the Deductible % placeholder claimed a derivation from FMV that has never existed (audit F-81).
The authorization check for every read in this controller. Event_Ticket_Type__c queries run SYSTEM_MODE because 2GP cannot grant object permissions on a master-detail detail of a standard master (full reasoning in EventTicketTypesSelector's class header), so the selector layer can no longer refuse anyone — this method is where that refusal moved.
Master-detail access is defined by the master record, so asking whether the running user can see the parent Campaign under USER_MODE is the same question the platform would have asked about the child, and the one the child's ungrantable describe cannot answer.
Writes need this gate just as much as reads, and for a while they did not have it. The rationale that used to sit here — that the Unit of Work commits in user mode, so the platform would enforce master-inherited access on the write itself — is simply false: Application.cls builds its UnitOfWorkFactory from a bare List<SObjectType>, which makes fflib use SimpleDML, and SimpleDML is declared without sharing and commits at AccessLevel.SYSTEM_MODE. Nothing downstream re-checks the caller.
Every entry point that writes a tier therefore calls this method itself, exactly like the read and delete paths. The message is caller-supplied so a failed gate is indistinguishable from a genuine not-found — a distinct "no access" reply would confirm the record exists.
requireCampaignVisible on its own is not enough for an update. buildTierFromMap takes the tier id straight off the client payload and constructs new Event_Ticket_Type__c(Id = ...) without re-reading it, so a caller who legitimately administers campaign A can post A's id alongside a tier id belonging to campaign B and write to B's tier — the campaign gate passes because A really is visible, and the blind registerDirty never consults B.
This method closes that by re-reading every supplied tier and refusing any whose Campaign__c is not the campaign already gated above. Ids that resolve to nothing are refused too, so a deleted or fabricated id cannot be distinguished from one on another event.
Highlight_Label__c is both the badge copy and the on-switch, so "two highlighted tiers" is not something the platform can reject — it just makes the badge meaningless, since three tiers all reading "Most popular" recommend nothing. Both editors (the fundraiser wizard's Tickets step and the drawer's Tickets tab) let an admin highlight a tier without thinking about the others, so the demotion happens here, once, rather than in each editor's client code where a stale list would miss a tier. It is an editing convention, not a data constraint: an API or Data Loader write bypasses this method entirely, and publicEventRegistration renders whatever it is given.
A ticket tier must be able to state its tax-deductible split under the mode the org is actually running. In Percentage mode a tier with no Deductible % used to fall through to the Fair Market Value branch of EventPurchaseService, and a Percentage-mode org has no reason to have filled FMV — both blank printed the WHOLE ticket price on the receipt as tax-deductible. The runtime now records 0% instead of overstating, but that is the backstop; this is the fix. Returns null when the tier is configured, otherwise the admin-facing reason.
Only the ACTIVE mode is gated. Requiring both fields would force every org to maintain a split model it does not use, and an org that later switches modes is caught by the validation rule on the object plus the runtime warning, not by a save it cannot complete. 0 is a legitimate percentage (a wholly non-deductible ticket) and passes; only a missing value fails.
The public tickets page: every ticket on one event order, printable.
WITHOUT SHARING: reached by a guest holding a ticket link. Access is authorized by possession of the unguessable Receipt_Token__c — the same grant the receipt page uses — not by Salesforce sharing, so system context is required to read the order and its attendee rows. The internal Opportunity Id is never accepted from the client: only the token addresses an order.
PII: returns attendee name, tier and check-in status only. Never Email__c — the page doesn't render it, so it must not cross the wire.
Docs note
The tier's Id is what colours the stub — publicEventTickets hashes it into a stable colour. Resolving that here would put a palette in Apex and stop a themed org from overriding it, so the choice is left to publicEventTickets, which owns the colour tokens. The admin-picked Accent_Color__c that used to travel beside it was retired on 2026-08-12.
The street line is assembled here rather than in the LWC because the parts are partly optional and the join rules (comma between street and city, space between state and zip, country only when it differs from the rest of the line) are address formatting, not presentation — and the staff-side ticket component would otherwise have to reimplement them identically.
| Method | Returns | |
|---|---|---|
getTickets(1 arg) |
Map<String, Object> |
@AuraEnabled |
Domains#
EventTicketTypes#
class · public with sharing
extends fflib_SObjectDomain · implements IEventTicketTypes
event/
IEventTicketTypes#
interface · public
extends fflib_ISObjectDomain
event/
Selectors#
EventAttendeesSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IEventAttendeesSelector
event/
Docs note
No class-level ApexDoc by design — the documentation that matters here is per-method, because each query has a DIFFERENT access-mode rationale (USER_MODE staff-facing vs. SYSTEM_MODE batch/guest-context) and a single class summary would blur that distinction. See IEventAttendeesSelector for the caller-facing contract of each method.
selectTicketsByOrderIdsForDonor and selectByCampaignPaged both return Cancelled (refunded) seats rather than filtering them out — Check_In_Status__c flips to 'Cancelled' in place instead of deleting the row — so every caller must branch on that field instead of assuming a returned row is a valid admission.
The TIER record — Event_Ticket_Type__c — is reachable only through the order line. Ticket_Type__c beside it looks like the tier but points at Product2, and is often blank on seats created by the purchase flow, which is why the donor's tickets page showed no tier at all.
Name is taken from here first for that reason; the tier Id exists only on this path.
The Id is what colours the ticket stub: publicEventTickets hashes it to give each tier a distinct stub, so a multi-tier event is colour-separated at the door with no setup at all.
Accent_Color__c, the admin-picked override that used to ride along here, was retired on 2026-08-12 with the design-field cull.
The tier's own blurb — "Includes dinner and a reserved table" — which is what the donor was actually sold. It is admin-authored on the tier record, so it is the one place the ticket can say what the seat entitles someone to without the package inventing copy of its own.
Deliberately a SEPARATE query from selectTicketsByOrderIdsForDonor rather than an Email__c added to it. That method feeds two pages an unauthenticated visitor reaches with nothing but a receipt token, and its field list is a PII boundary pinned by a test (EventAttendeesSelector_Test.test_selectTicketsByOrderIdsForDonor_neverSelectsEmail): widening it would hand every seat's address to anyone holding a forwarded receipt link.
The two callers here are entitled to the address and nobody else is — DonorPortalService, where the donor proved ownership with a magic-link token before the order id was accepted, and AttendeeNamingHandler, which needs to know whether a seat ALREADY has an address so it can add one without overwriting what checkout captured.
EventTicketTypesSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IEventTicketTypesSelector
event/
Docs note
Every query here reasons about Event_Ticket_Type__c's OWD (Master-Detail child of Campaign, Private): selectByCampaign/selectActiveForSaleByCampaign/selectById are USER_MODE for internal staff reads, while the AsGuest and AsSystem variants exist because a guest or a batch/scheduler user has no direct FLS/sharing on this object and would silently get zero rows under USER_MODE rather than an error. selectByIdForUpdate backs the seat-reservation flow (EventPurchaseService, EventRegistrationController) — its FOR UPDATE row lock is the documented exception to the "selectors use newQueryFactory()" rule, since the builder cannot express FOR UPDATE.
EVERY method below is SYSTEM_MODE and builds with newQueryFactory(false, false, true) — object assertion off, FLS assertion off. That is forced by the platform, not a choice: object-level Read on Event_Ticket_Type__c is UNGRANTABLE, because 2GP rejects <objectPermissions> on a master-detail DETAIL object whose master is a standard object (Campaign). No permission set this package can ship turns isAccessible() true.
The knock-on is the part that is easy to get wrong, so it is recorded here: WITHOUT an object-level grant, EVERY field on the object describes as inaccessible too — including Id, and including the 13 fields that DO carry <fieldPermissions> in Fundraising_User. Field permissions are inert without object access. So enforceFLS=true can never pass for anyone but an admin (whose Modify All Data satisfies the object check), and a USER_MODE query does not merely return zero rows — it throws "System.QueryException: sObject type 'Event_Ticket_Type__c' is not supported".
Verified 2026-08-09 against a user holding Fundraising_User: obj=false, Id=false, Price__c=false, USER_MODE threw, SYSTEM_MODE returned the row.
Security therefore cannot live at this layer, and is NOT lost — it moves to the two places the platform actually enforces it:
1. Row access. A master-detail child inherits access from its master record, so the CALLER must confirm the running user can see the parent Campaign under USER_MODE before handing any of these rows back. EventTicketAdminController does this via CampaignsSelector.selectIdsVisibleToUser(); FundraiserDetailController and EventRegistrationController already resolve their Campaign first and throw on empty. A new caller that skips that gate turns these reads into an IDOR.
2. Field exposure. getSObjectFieldList() is a fixed, public-by-nature set (name, price, capacity, sale window, description) already rendered on the public ticket picker — there is no sensitive column here for FLS to protect.
Security.stripInaccessible is deliberately absent throughout for the same reason it is absent from the guest methods: it repeats the entity-level check and throws NoAccessException on this object for exactly the users these queries exist to serve.
There is deliberately NO Security.stripInaccessible here, and it is not an oversight: stripInaccessible starts with the same entity-level check and throws NoAccessException ("No access to entity") on this object for exactly the users the SYSTEM_MODE query exists to serve. FLS cannot be evaluated independently of an object-level grant that cannot exist, so safety comes from the field list instead: getSObjectFieldList() is a fixed, public-by-nature set (name, price, capacity, sale window, description) already rendered on the public ticket picker, and the caller only reaches this method for a campaign that is published to the public site. Same reasoning as selectByIdForUpdate.
IEventAttendeesSelector#
interface · public
extends fflib_ISObjectSelector
event/
Docs note
The SYSTEM_MODE methods here (selectByOpportunityIdsAsSystem, selectTicketsByOrderIdsForDonor) exist only for contexts with no direct FLS/sharing on Event_Attendee__c — batch/scheduler reconciliation and the token-authorized donor portal, respectively — never for a staff-facing read. Picking the wrong one either strands a paid order's repair path (a USER_MODE query in system context silently returns zero rows, not an error) or over-exposes a guest-adjacent read.
IEventTicketTypesSelector#
interface · public
extends fflib_ISObjectSelector
event/
Docs note
selectByIdAsSystem and selectActiveForSaleByCampaignAsGuest exist because Event_Ticket_Type__c is a Master-Detail child of Campaign (OWD Private) — a guest or a batch/scheduler user gets zero rows from the USER_MODE variants, not an error, so picking the wrong method fails silently rather than loudly. See EventTicketTypesSelector for which concrete caller uses each.
IOpportunityLineItemsSelector#
interface · public
extends fflib_ISObjectSelector
event/
IProductsSelector#
interface · public
extends fflib_ISObjectSelector
event/
OpportunityLineItemsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IOpportunityLineItemsSelector
event/
Docs note
Same SYSTEM_MODE rationale as EventAttendeesSelector.selectByOpportunityIdsAsSystem — this is the order-line half of the same batch/scheduler repair read, called alongside it so the reconciliation path sees both the attendee rows and their priced order lines regardless of the running user's FLS.
| Method | Returns | |
|---|---|---|
getSObjectType(0 args) |
Schema.SObjectType |
|
getSObjectFieldList(0 args) |
List<Schema.SObjectField> |
|
selectByOpportunityIdsAsSystem(1 arg) |
List<OpportunityLineItem> |
ProductsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IProductsSelector
event/
ProductsSelector — reads the reusable Product2 "ticket type" catalog (Event_Ticket_Type record type) that backs the wizard/drawer "reuse a saved ticket type" picker.
A Product2 in this record type is a template: its Suggested_Price__c / Fair_Market_Value__c / Attendees_Per_Ticket__c / Deductible_Percentage__c prefill a new per-event Event_Ticket_Type__c junction, which then carries the actual per-event price and capacity.
| Method | Returns | |
|---|---|---|
getSObjectType(0 args) |
Schema.SObjectType |
|
getSObjectFieldList(0 args) |
List<Schema.SObjectField> |
|
selectEventTicketTemplates(0 args) |
List<Product2> |
|
| calls: RecordTypeService.getRecordTypeId() | ||
selectById(1 arg) |
List<Product2> |
Services#
Docs note
Pure/stateless by design (no interface, not Application-registered) so both callers can invoke it as a plain static method without going through Application.Service — the tradeoff is it cannot be mocked out in a unit test; both real callers accept that because the arithmetic here is the thing under test. remaining is intentionally floored at 0 rather than returned negative, so an oversold tier (e.g. from a race that slipped past the row lock in EventTicketTypesSelector.selectByIdForUpdate) reads as sold out rather than as a display bug.
| Method | Returns | |
|---|---|---|
computeAvailability(2 args) |
Map<Id, TierAvailability> |
EventPurchaseService#
class · public with sharing
implements IEventPurchaseService
event/
EventPurchaseService — finalizes a paid Donation_Staging__c (Purchase_Type__c = 'Event_Ticket') into one Opportunity + one OpportunityLineItem per selected tier (+ one for the donation add-on, if any) + one Event_Attendee__c per seat. Mirrors DonationService.finalizeSucceededStaging's shape (idempotency, donor dual-field resolution, two-commit UoW for parent-then-child links) — see that class for the template this was built from.
THE CUSTOMER IS ALREADY CHARGED when this runs, so the two failure modes that matter are (a) recording an order that does not match the capture and (b) stranding a paid order. Hence:
- every line total is asserted against pi.amountReceived BEFORE anything commits (see assertLineTotalMatchesCapture) — Opportunity.Amount is platform-derived from the OLIs and cannot be forced to the captured amount;
- all seats are created in the SAME UoW as the Opportunity, so no committed state can exist with a paid Opportunity and zero attendees;
- the "an Opportunity already exists" path REPAIRS (links the staging, backfills missing seats) rather than returning a no-op, so a half-finished prior run is recoverable.
Docs note
Collaborators worth knowing by name when reading this class: PricebookService (resolves the PricebookEntry every OLI needs, in one bulk call hoisted above the per-tier loop — see that class for why it's direct SOQL/DML rather than UoW-managed); EventAvailabilityService (the same capacity/sale-window math the registration UI runs client-side, re-checked here server-side); and RefundService.releaseForOpportunity, which this interface exposes so a full refund can give sold seats back to their tiers in the SAME UoW as the refund write.
| Method | Returns | |
|---|---|---|
finalizeSucceededEventStaging(2 args) |
Id |
|
releaseReservation(1 arg) |
void |
|
| calls: AppLogger.warn(), DonorResolutionService.isPersonAccountOrg(), PricebookService.ensureEntriesFor(), DonorResolutionService.resolveOrCreateDonor(), RecordTypeService.getRecordTypeId(), PricebookService.eventPricebookId(), SettlementStampHelper.applyTo() | ||
releaseForOpportunity(2 args) |
void |
Contract for finalizing and releasing event ticket purchases once the payment gateway has reported a definitive outcome for a ticket staging record.
Docs note
EventPurchaseService is the sole implementation — see it for the full idempotency and two-commit-UoW design this contract summarizes. finalizeSucceededEventStaging mirrors IDonationService's finalizeSucceededStaging shape so the two staging-finalize paths (donation vs. ticket purchase) stay recognizably parallel to a reader moving between them.
PricebookService — ensures the Standard + "Fundraising Events" PricebookEntry pair exists for a ticket (or donation) Product2, so an OpportunityLineItem can reference it. OLI.UnitPrice is set per-line from the tier price, so the entry UnitPrice is only a required placeholder.
Deliberately uses direct SOQL/DML (NOT the FFLib Unit of Work): PricebookEntry/Pricebook2 are setup objects, not part of the donor/event Domain graph, and must be committed independently (in some cases before the caller's own UoW transaction, e.g. right after a Product2 insert) — they don't belong in a Domain/Selector/UoW layer built around Opportunity/Campaign records.
Docs note
WITHOUT SHARING is required, not a shortcut.
This class is called from PostInstallHandler.seedEventDonationProduct(), which runs as a per-package system user, and in that context a with sharing class cannot resolve Pricebook2 at all — standardPricebookId() dies with "sObject type 'Pricebook2' is not supported" and the packaged Event Donation product ships with no PricebookEntry pair, so the event donation add-on line silently cannot be sold.
Found 2026-08-09 by install-testing into a real subscriber org; it does not reproduce in a dev org, where an admin reads Pricebook2 in any sharing mode.
Sharing was never doing enforcement work here anyway: every query and DML below is already explicitly SYSTEM_MODE / as system, and pricebook plumbing carries no donor data and no record-level privacy interest.
| Method | Returns | |
|---|---|---|
ensureEntriesFor(1 arg) |
Id |
|
ensureEntriesFor(1 arg) |
Map<Id, Id> |
|
| calls: PostInstallHandler.seedEventPricebook() | ||
eventPricebookId(0 args) |
Id |
|
| calls: SettingsService.eventPricebookId() | ||
standardPricebookId(0 args) |
Id |
Fundraising#
Controllers#
EventRegistrationController#
class · public without sharing
fundraising/
Docs note
Ticket-purchase counterpart to the base donation flow — GetEventCampaign delegates to ICampaignService.getEventCampaignConfig (the form payload plus the optional story / documents / updates / FAQ sections, because a ticket page is a whole page rather than an embedded form), and GetEventTickets/EventAvailabilityService.computeAvailability mirror the tier-availability math the guest event page renders. createTicketIntent is the most complex write path in either domain folder: see its own doc block for the lock-ordering and idempotency-retry design.
getEventCampaignConfig, not getCampaignWithQuestions: a ticket page is a whole page, so it carries the same optional story / documents / updates / FAQ sections a donation page does. The plain form payload leaves them out on purpose, for the embedded-form entry points that would pay for them and render nothing.
Guestrequest#
Async (Batch/Queueable/Schedulable)#
GuestRequestQueueable#
class · public without sharing
implements Queueable
guestrequest/
Docs note
Enqueued by GuestRequestService.submit so a visitor's page returns immediately instead of waiting on a handler. It is the fast path, not the reliable one: if it never runs — the queueable limit was already spent, the enqueuing transaction rolled back — the row is simply still Pending and GuestRequestSweepBatch collects it. Nothing here retries, because the sweep already is the retry.
| Method | Returns | |
|---|---|---|
execute(1 arg) |
void |
|
| calls: GuestRequestService.newInstance() |
GuestRequestSweepBatch#
class · public without sharing
implements Database.Batchable<SObject>, Database.Stateful
guestrequest/
Docs note
This is what makes the queue reliable; GuestRequestQueueable only makes it fast. Every way the immediate path can be lost — the queueable limit already spent, the enqueuing transaction rolled back, a handler that failed with attempts still on the clock — ends with a row sitting Pending, and this picks it up.
The grace period exists so the sweep and the Queueable are not racing for the same row seconds after it was submitted. They cannot both apply a request even if they overlap — process() claims the row to Processing before running anything — but a sweep that routinely grabs rows the Queueable is about to handle turns the fast path into dead work for no benefit.
| Method | Returns | |
|---|---|---|
start(1 arg) |
Database.QueryLocator |
|
| calls: GuestRequestsSelector.newInstance() | ||
execute(2 args) |
void |
|
| calls: GuestRequestService.newInstance() | ||
finish(1 arg) |
void |
|
| calls: AppLogger.info() | ||
run(0 args) |
Id |
GuestRequestSweepScheduler#
class · public without sharing
implements Schedulable
guestrequest/
Docs note
Every 20 minutes rather than hourly, because the visitor is watching. A donor who named their guests and whose immediate attempt was lost sees "we are still saving these" until this runs; an hour of that reads as a broken page. Sub-hourly means one CronTrigger per run: System.schedule rejects a minute LIST ('0 9,29,49 * * * ?' throws "Seconds and minutes must be specified as integers"), so the three offsets are scheduled as three jobs, the same way PostInstallHandler already schedules the Finalizer cadence.
| Method | Returns | |
|---|---|---|
cronFor(1 arg) |
String |
|
jobNameFor(1 arg) |
String |
|
execute(1 arg) |
void |
|
| calls: GuestRequestSweepBatch.run() |
Controllers#
GuestRequestController#
class · public with sharing
guestrequest/
GuestRequestController — staff API behind the record-page guest-request panel.
The panel renders on two objects and scopes itself from whichever record it is placed on: an Opportunity lists that one gift's requests, a Campaign lists every request raised against the campaign. Reads go through GuestRequestsSelector; the two writes run through the FFLib Unit of Work. Every @AuraEnabled returns the { success, data/error } envelope via ControllerAction.
SECURITY — read this before adding a method. GuestRequestsSelector is SYSTEM_MODE throughout (it has to be: the guest user and the Automated Process user both hold no access to the object), so it will happily return any request to anyone who knows an id. The gate is requireVisible() below, and it belongs on EVERY entry point. A guest request carries the donor's email address and, on a refund request, the sentence they wrote explaining why they want their money back — a method that skips the gate has built an IDOR that leaks exactly that.
Docs note
Not cacheable, and deliberately so. The list changes the moment a reviewer completes or declines a row, and the same queue is worked from the Guest Requests tab and the fundraiser console at the same time — a cached read shows a colleague's closed request as still open until the page is reloaded, which is how the same refund gets issued twice.
The single-record read behind the Guest Request record page. Same row shape as getRequests so one payload contract serves both surfaces, plus the submitted names on an attendee-naming request — the only place that payload is ever shown to a human.
| Method | Returns | |
|---|---|---|
getRequests(2 args) |
Map<String, Object> |
@AuraEnabled |
getRequest(1 arg) |
Map<String, Object> |
@AuraEnabled |
resolveRequest(4 args) |
Map<String, Object> |
@AuraEnabled |
Handler#
AttendeeNamingHandler#
class · public without sharing
implements IGuestRequestHandler
guestrequest/
Applies an "I'll name them later" submission: fills in the guest names on the seats of one ticket order.
FFLib role: service-layer strategy, resolved by GuestRequestRouter for Request_Type__c = Attendee_Names. Owns its own DML because the write happens under the guest user's async context, where object-level Edit on Event_Attendee__c is not obtainable — the Guest License cannot hold it — so the update has to run as system rather than through a user-mode path.
Docs note
The seat cap is not a governor-limit hedge — an order cannot hold more seats than the checkout allowed. It is there because Payload_JSON__c arrived from an unauthenticated browser, and a payload naming 50,000 seats must be refused rather than parsed.
Deliberately parses the payload rather than reading Event_Attendee__c. Showing the seats as they stand today would answer a different question, and it would need a master-detail read that the caller has no parent gate for. See GuestRequestController.GetRequest.
| Method | Returns | |
|---|---|---|
handle(1 arg) |
[GuestRequestOutcome](#apex-guestrequestoutcome) |
|
| calls: AppLogger.warn(), GuestRequestOutcome.declined(), AppLogger.error(), GuestRequestOutcome.failed(), AppLogger.info(), GuestRequestOutcome.completed() | ||
submittedNames(1 arg) |
List<Map<String, Object>> |
What a handler decided about one guest request: the status the row should end in, plus, when that status is not a success, a sentence safe to show the visitor who raised it.
FFLib role: none — a plain value object passed between GuestRequestService and the handlers.
Docs note
message is read back to the DONOR on the public dashboard, not only to an admin, so a handler must write something a member of the public can act on: no record ids, no class names, no stack traces. The detail an engineer needs goes to AppLogger instead.
Maps a Guest_Request__c.Request_Type__c value to the handler that processes it.
FFLib role: a service-layer factory. Deliberately a plain Apex switch rather than a Custom Metadata registry resolved through Type.forName(): the set of request types is small, changes only when this package ships new code, and a compile-time switch means a typo is a build error instead of a row that sits Pending forever. It also keeps every handler visible to the compiler, so no handler can be deleted without the router failing to compile.
Docs note
Adding a Request_Type__c picklist value is half the work — the other half is a case here. A value with no case is refused at routing time, so the two cannot drift apart unnoticed: the request fails loudly with a message an admin can act on rather than being silently skipped by every sweep.
Callers use resolve(), not handlerFor() — it is the same lookup with the test seam in front of it.
| Method | Returns | |
|---|---|---|
handlerFor(1 arg) |
[IGuestRequestHandler](#apex-iguestrequesthandler) |
|
resolve(1 arg) |
[IGuestRequestHandler](#apex-iguestrequesthandler) |
One request type's processing rules. Implementations are resolved by GuestRequestRouter and run under privileged access, minutes after an unauthenticated visitor submitted the row.
FFLib role: a service-layer strategy — a handler may call selectors and services, and owns its own DML for the records it changes.
The contract every implementation must keep:
- Act only on Subject_Id__c. That id was written by Apex after it validated the visitor's access token. Taking a target id out of Payload_JSON__c instead would be an IDOR with an async delay bolted on.
- Treat the payload as hostile. Bound the sizes accepted, refuse unrecognised values, and truncate anything written to a field.
- Re-check eligibility. The world moved on between submit and processing: the gift may already be refunded, the seat may already be named, the setting may have been switched off.
- Never throw for a foreseeable refusal. Return a declined or failed outcome carrying a donor-safe sentence; an exception is for the genuinely unexpected.
- Be safe to run twice. A row can be re-processed after a rollback, so applying the same request again must not double anything.
RefundRequestHandler#
class · public without sharing
implements IGuestRequestHandler
guestrequest/
Screens a donor's "please refund this gift" ask and parks it for a person.
FFLib role: service-layer strategy, resolved by GuestRequestRouter for Request_Type__c = Refund_Request.
This handler deliberately moves no money. It re-checks that the gift could still be refunded, tells the organisation a request is waiting, tells the donor it was received, and leaves the row at Awaiting Review — a refund is issued by a human from the donation record, via RefundController, which is where the Process_Refunds custom permission is enforced (RefundService itself does not re-check it). No unauthenticated visitor's say-so reaches an automated refund path.
Docs note
The reason the donor typed. Bounded here as well as at submit because Payload_JSON__c arrived from an unauthenticated browser and this class quotes it into an email.
Split out from the send for the same reason ReceiptService.buildDisputeAlert is: Messaging.sendEmail spends the org's daily email allocation even inside a test, so a suite that asserted on Limits.getEmailInvocations() would start failing once the allocation ran out. Asserting the built message is deterministic and says more.
Public and static because the reviewer's side reads it too — RefundController.getDonorRequest shows the same sentence in the refund action that this class puts in the staff email, and one parser means the two can never disagree about what the donor actually wrote.
| Method | Returns | |
|---|---|---|
evaluate(1 arg) |
Eligibility |
|
| calls: SettingsService.refundsEnabled(), SettingsService.donorRefundRequestsEnabled(), RefundService.isRefundableStatus(), SettingsService.refundWindowDays(), AppLogger.error(), GuestRequestOutcome.failed(), GuestRequestOutcome.declined(), GuestRequestOutcome.awaitingReview(), AppLogger.warn(), SettingsService.replyToEmail(), SettingsService.orgDisplayName(), EmailSenderService.applySender() | ||
handle(1 arg) |
[GuestRequestOutcome](#apex-guestrequestoutcome) |
|
| calls: AppLogger.error(), GuestRequestOutcome.failed(), GuestRequestOutcome.declined(), GuestRequestOutcome.awaitingReview() | ||
reasonFrom(1 arg) |
String |
|
| calls: AppLogger.warn() |
Selectors#
GuestRequestsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IGuestRequestsSelector
guestrequest/
Selector for Guest_Request__c — the queue of requests raised from the public site.
FFLib role: Selector. Every read here is SYSTEM_MODE, and that is a deliberate decision rather than convenience: the only two callers are the unauthenticated guest user (who holds no field-level access to this object at all, by design — see Fundraising_GuestDonor) and the Automated Process user running the sweep and purge batches. A USER_MODE read would return nothing for either of them.
Because these queries do not enforce sharing or FLS, the authorization gate lives in the caller: DonorPortalService resolves the visitor's access token to a donor, works out which records that donor owns, and only then asks this selector about those ids. A caller that passes in an id a visitor supplied has built an IDOR — nothing in this class will stop it.
Docs note
The guest's own submission is inserted through Apex, not read back through this selector — a visitor is told the status of their requests by DonorPortalService, which re-validates their token first. Nothing on the public site ever queries this object with a filter the visitor controls.
IGuestRequestsSelector#
interface · public
extends fflib_ISObjectSelector
guestrequest/
Services#
GuestRequestService#
class · public without sharing
implements IGuestRequestService
guestrequest/
The lifecycle of a public request: queue it, claim it, run its handler, close it.
FFLib role: Service. It owns the queue mechanics and knows nothing about what any particular request means — that lives in the IGuestRequestHandler implementations GuestRequestRouter resolves.
without sharing because the two callers have no sharing to inherit that would help: the unauthenticated guest submitting a request, and the Automated Process user sweeping the queue. The check that matters happened before submit() was ever called — see the note on Submission.subjectId.
Docs note
Three attempts, then the row is left Failed and the sweep stops looking at it. The failures worth retrying are transient (a row locked by another transaction, a limit hit under load); a payload the handler cannot read fails identically every time, which is why a handler returns declined for that case and skips the retries entirely.
The typed overload exists because one subject can carry requests of more than one kind: an event order is both a ticket order (Attendee_Names) and a Closed Won gift that shows in the giving history (Refund_Request). Without the filter, the history row would report "Awaiting review" from a naming request the donor made about their guest list, which is true of the order and nonsense next to the gift. Pass null to mean "whatever is newest".
| Method | Returns | |
|---|---|---|
newInstance(0 args) |
[IGuestRequestService](#apex-iguestrequestservice) |
|
submit(1 arg) |
Guest_Request__c |
|
| calls: GuestRequestsSelector.newInstance(), GuestRequestRouter.resolve(), AppLogger.error(), GuestRequestOutcome.failed(), AppLogger.warn() | ||
process(1 arg) |
void |
|
| calls: GuestRequestsSelector.newInstance(), GuestRequestRouter.resolve(), AppLogger.error(), GuestRequestOutcome.failed() | ||
latestBySubject(1 arg) |
Map<Id, Guest_Request__c> |
|
latestBySubject(2 args) |
Map<Id, Guest_Request__c> |
|
| calls: GuestRequestsSelector.newInstance() | ||
resolveAwaitingReview(4 args) |
Integer |
|
| calls: GuestRequestsSelector.newInstance() |
Payment#
Core#
Docs note
This is the payment gateway abstraction the rest of the package codes against — DonationService, RecurringDonationService, RefundService, and the reconciliation batches all depend on IPaymentGateway, never on StripeGateway directly. PaymentGatewayFactory resolves a concrete instance per Payment_Account__c by looking the account's Gateway__c picklist value up in PaymentGatewayRegistry — adding a second gateway means writing a new implementation, adding one line to that registry and one value to the picklist, not touching any caller. Every method returns a GatewayResult DTO rather than a gateway-native shape, so callers and tests never see Stripe's (or any future gateway's) JSON structures. StripeGateway is currently the only implementation.
On the interface rather than as a formula field on Stripe's pk_test_/pk_live_ prefix so a second gateway with a different key format needs no data-model change. Implementations must return 'Unknown' rather than guessing — Mode__c drives an admin-facing "this account takes real money" warning, and a wrong Live is worse than an honest Unknown.
Docs note
mockGateway is the injection point every test in the package uses to stub out real Stripe callouts — a test assigns PaymentGatewayFactory.mockGateway directly (it is @TestVisible) to an fflib-mocks stub of IPaymentGateway, and getByAccount() returns it instead of resolving a real Payment_Account__c, gated by Test.isRunningTest() so production code never picks up a stray mock.
There is deliberately NO getDefault()/"first active account" entry point. One existed until 2026-08-10 and every caller of it was a latent multi-account bug: the orphan sweep only ever recovered payments on the alphabetically-first account, and the refund and event-registration fallbacks could charge or refund against the wrong Stripe account entirely. Callers that have no account context must iterate every active Payment_Account__c (see StripePaymentSweep and RefundReconciliationBatch) rather than guessing at one.
Exists so the jobs that are SCHEDULED AT INSTALL can no-op silently on an org where no gateway has been connected yet, instead of logging a gateway error on every hourly fire — an alarm for a condition that is simply "not configured yet" trains admins to ignore the log. Scheduling those jobs at install is what makes reconciliation work in a subscriber org at all; before, only an unpackaged dev script scheduled them.
This replaced getDefault()'s "first active account" rule on 2026-08-10. The distinction is the whole point: with one connected account there is nothing to choose and the answer is certain, so a legacy Opportunity with no Payment_Account__c stamp still refunds and an event campaign with a blank Payment_Account__c still checks out. With two or more, ANY answer is a guess — and a wrong guess here refunds or charges against the wrong processor account, which fails at the processor at best and moves the wrong org's money at worst. So this returns null and the caller must stop and say so, rather than pick alphabetically.
| Method | Returns | |
|---|---|---|
getByAccount(1 arg) |
[IPaymentGateway](#apex-ipaymentgateway) |
|
| calls: AppLogger.error(), PaymentGatewayRegistry.newGateway() | ||
hasActiveAccount(0 args) |
Boolean |
|
soleActiveAccountId(0 args) |
Id |
PaymentGatewayRegistry — the compile-time catalog of payment gateways this package implements.
One entry per gateway: the picklist value stored on Payment_Account__c.Gateway__c, the label the Settings console shows, the gateway's pinned API version, and the IPaymentGateway class that implements it. PaymentGatewayFactory resolves an account's gateway through here; the Settings console builds its "Gateway" picker from here.
Replaces the Payment_Gateway__mdt custom metadata type (removed 2026-08-10).
Docs note
This registry is deliberately Apex rather than custom metadata. Adding a gateway has always required shipping a new IPaymentGateway implementation in the package, so the old CMDT never made a gateway configurable — it only turned a compile-time class reference into a Type.forName() string lookup that could fail at donation time, and left the type subscriber-writable in an unprotected managed CMDT. Holding the class as a System.Type means a bad reference is a compile error in the packaging org instead of a donor-facing "Payments are temporarily unavailable" in a subscriber org.
Adding a gateway is a three-line change: write the IPaymentGateway class, add an ENTRIES row here, add the matching value to the Payment_Account__c.Gateway__c picklist. PaymentGatewayRegistry_Test asserts the registry and the picklist agree, so the two places cannot drift apart silently.
Stripe's API version string is the one place a Stripe release upgrade lands. It was previously Payment_Gateway__mdt.Stripe.API_Version__c; moving it here means the pinned version ships and upgrades with the code that parses the responses, which is the only thing that can safely change it.
Async (Batch/Queueable/Schedulable)#
AdminCredentialBackfillBatch#
class · public without sharing
implements Database.Batchable<SObject>
payment/
One-time upgrade backfill for Payment_Account__c.Admin_Named_Credential__c.
The field was added 2026-08-11 to split one Stripe credential into a restricted, charge-only key the site guest user holds and a full key only back-office operations name. It is required, for the same reason Named_Credential__c is — a blank resolves the callout endpoint to 'callout:null', which fails when a refund is attempted rather than when the account is saved.
Making a field required does not fill existing rows; it makes them unsavable. An account created before this release would carry a null the admin could not clear, because opening and re-saving the record is exactly what the requirement blocks. This batch fills each one ONCE from the credential it was already using, which lands it in shared mode — supported, reported as amber by the setup guide rather than green, and split by the admin whenever they choose.
Idempotent — the scope is Admin_Named_Credential__c = null, so a re-run after a later upgrade fills only what is still blank and never rewrites a credential an admin deliberately split.
Docs note
No chained pass. Unlike PaymentAccountBackfillBatch there is only one thing to resolve here and only one place to resolve it from — the account's own row.
| Method | Returns | |
|---|---|---|
start(1 arg) |
Database.QueryLocator |
|
execute(2 args) |
void |
|
| calls: AppLogger.info() | ||
finish(1 arg) |
void |
PaymentAccountBackfillBatch#
class · public without sharing
implements Database.Batchable<SObject>, Database.Stateful
payment/
One-time upgrade backfill for Payment_Account__c on Recurring_Donation__c and Payment_Method__c.
Both fields were added 2026-08-07 to stop the recurring renewal batch re-deriving the Stripe account from Campaign.Payment_Account__c on every run — an admin re-pointing a campaign at a second Stripe account was silently redirecting every in-flight schedule's account-A tokens to account B. Rows that existed before the fields did carry no account at all, so the renewal chain falls through to that same campaign rung for them. This batch resolves each one ONCE and freezes it, which is the whole point of the change.
Two passes, chained: SCHEDULES — every schedule with a blank account, resolved from Campaign__r.Payment_Account__c; the wallet row each one points at is stamped with the same account in the same chunk (the card and the schedule charged on it are necessarily the same account). ORPHAN_WALLET — wallet rows no schedule points at, so the first pass could not reach them. Runs ONLY when the org has exactly one Payment_Account__c, where the answer is not a guess. Multi-account orgs leave these blank on purpose: an unstamped card only costs a missed fingerprint de-dup, whereas a wrong stamp would make a real card un-chargeable.
Idempotent — both passes scope on Payment_Account__c = null, so re-running after a later upgrade only fills what is still blank and never rewrites an account a live token was minted under.
| Method | Returns | |
|---|---|---|
start(1 arg) |
Database.QueryLocator |
|
execute(2 args) |
void |
|
finish(1 arg) |
void |
|
| calls: AppLogger.info() |
Selectors#
IPaymentMethodsSelector#
interface · public
extends fflib_ISObjectSelector
payment/
ITransactionLogsSelector#
interface · public
extends fflib_ISObjectSelector
payment/
PaymentMethodsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IPaymentMethodsSelector
payment/
Docs note
Three access levels, three different callers — picking the wrong one silently breaks a donor-facing flow rather than throwing. selectById/selectByContactId/selectDefaultByContactId are USER_MODE for internal Lightning contexts where a logged-in user's own sharing/FLS should apply. selectValidByDonorSystem and selectByDonorSystem are SYSTEM_MODE because their only callers are the token-authorized donor portal and the guest recurring-signup path — neither is a real logged-in Salesforce user, so USER_MODE would return zero rows.
selectByDonorSystem additionally returns ALL rows (not just Is_Valid__c = true) because PaymentMethodService. saveWalletCard needs the full set to dedup by Fingerprint__c and detect an existing default card in one query.
Payment_Account__c sits in the shared field list rather than being selected per-method (unlike Exp_Month__c/Exp_Year__c) because it is identity, not display detail — it belongs with Stripe_Customer_Id__c, and saveWalletCard's fingerprint dedup cannot run without it.
Removed__c is in the shared list for the same reason: PaymentMethodService.saveWalletCard has to see it on a dedup hit to reinstate a card the donor previously removed. Only the two wallet-LISTING methods filter it out (selectValidByDonor, selectValidByDonorSystem) — the dedup method selectByDonorSystem must keep returning removed rows, because Stripe_Payment_Method_Id__c is unique and a hidden removed row would turn a re-add into a DUPLICATE_VALUE failure rather than a reinstatement.
Removed_Date__c is deliberately absent: nothing queries on it, so it stays off every caller's FLS surface.
Exp_Month__c/Exp_Year__c are selected explicitly rather than added to getSObjectFieldList — widening the shared field list would push two more FLS-checked fields onto every other caller of this selector, including ones with no use for an expiry date.
Exp_Month__c/Exp_Year__c are selected explicitly for the same reason as selectValidByDonor above: widening getSObjectFieldList would push two more FLS-checked fields onto callers with no use for an expiry date. Contact__r.Name / Donor_Account__r.Name are parent fields, which getSObjectFieldList structurally cannot carry, so they need explicit selectField too.
newQueryFactory(false, false, true) — SYSTEM_MODE on the query alone is not enough. The bare newQueryFactory() asserts object read at BUILD time, so it throws "Permission to access an Payment_Method__c denied." for the guest user before the SYSTEM_MODE query runs, breaking the recurring signup this method exists to serve. The Guest License holds no read on the wallet by design and must not be granted one — rows are used only internally by saveWalletCard to dedup by Fingerprint__c, and no field reaches the guest. Matches sibling selectValidByDonorSystem.
TransactionLogsSelector#
class · public with sharing
extends fflib_SObjectSelector · implements ITransactionLogsSelector
payment/
Docs note
selectRecentErrors() is the query behind the setup console's health-check "recent errors" panel (SetupController) — it is the only reader of Transaction_Log__c outside of whatever Apex writes the log rows during a payment operation, so a change to Transaction_Log__c's Status__c/Error__c values must stay in sync with what that panel expects.
Services#
IPaymentSetupGuideService — reads back the credential an admin built by hand in Setup and reports, step by step, what is still wrong with it.
The package builds no credential (see PaymentSetupGuideService's header for why), so this is the whole of its involvement in gateway credentials: tell the admin the exact values to enter, then check their work against the org and name the field that is wrong.
PaymentMethodService#
class · public inherited sharing
implements IPaymentMethodService
payment/
Docs note
Payment_Method__c is display/dedup metadata for the donor portal's saved-card list, not the billing-critical record — the Stripe payment method id actually charged for a renewal lives on Recurring_Donation__c.Stripe_Payment_Method_Id__c, stamped directly by the caller. That split matters because saveWalletCard() leaves an existing wallet row untouched on a dedup match rather than refreshing it: Guest User licenses can never be granted object-level Edit (a hard Salesforce platform restriction, not a policy choice), and every caller of this method runs in guest context. The single exception is reinstateIfRemoved() below — see its header for why a re-added card has to be written back and why that one write is safe.
Prefer the gateway overload for any synchronous, DML-free caller — passing a stale snapshot here would persist card metadata that no longer matches Stripe. This overload takes paymentAccountId explicitly because it has no gateway instance to read it off; the caller parked the snapshot at intent time and must park the account with it.
This is the one place saveWalletCard* writes to an EXISTING wallet row; every other match path still returns it untouched, per the class header. It is safe from guest context despite the Guest License never holding object-level Edit, because Payment_Method__c has no trigger (so fflib's domain-layer CRUD re-assertion never runs) and fflib_SObjectUnitOfWork.SimpleDML pins AccessLevel.SYSTEM_MODE. Adding a trigger to this object would break that and must be paired with the suppressTriggerCrudCheck waiver the other guest-written domains use.
| Method | Returns | |
|---|---|---|
newInstance(0 args) |
[IPaymentMethodService](#apex-ipaymentmethodservice) |
|
saveWalletCard(5 args) |
Payment_Method__c |
|
saveWalletCardFromSnapshot(6 args) |
Payment_Method__c |
|
| calls: PaymentMethodsSelector.newInstance() |
PaymentSetupGuideService#
class · public virtual inherited sharing
implements IPaymentSetupGuideService
payment/
PaymentSetupGuideService — grades the credentials an admin built by hand in Setup against what the account's gateway actually needs, one step at a time.
The package creates no credential (owner decision, 2026-08-10: an org may hold several gateway accounts, and a secret must never travel through an @AuraEnabled method), so the Settings → Payments panel is a guide rather than a wizard: it states the exact values, then this service reads back whatever a managed package is allowed to read and names whichever field is wrong. Every value it checks against comes from PaymentGatewayRegistry.CredentialShape, so the instructions the panel prints and the check that grades them can never disagree.
FFLib service layer. Registered as IPaymentSetupGuideService in Application.cls; reached from SetupController.getPaymentSetupGuide.
Docs note
An account names TWO credentials — a restricted, guest-reachable one that may only take a payment (Named_Credential__c) and a full-access one for refunds and back-office work (Admin_Named_Credential__c). Steps 1-6 are the same build performed twice, so each of them renders one sub-row per credential under 'credentials' and takes the WORSE of the two as its own status. Grading only the public one would leave a broken admin credential invisible until the first refund failed, and a green step sitting above a red sub-row is the exact dishonesty this panel exists to avoid.
Naming the SAME credential in both fields is legal and is a configuration an admin may choose deliberately — one key to manage. That is "shared mode" ('shared' on the payload): it is read and graded once and collapses to a single sub-row, because showing the same credential twice would read as two independent things to fix.
It is supported, and it is never rendered green: a shared row that would otherwise pass is graded 'shared' (amber) and says why, because one credential in both roles means the site guest user reaches the same key the back office issues refunds with. Amber is as far as it goes — sharing never blocks readiness, so an org that has everything else right can still take payments on one credential.
WHAT THIS CLASS CAN AND CANNOT SEE (rewritten 2026-08-11; see docs/decision-payment-setup-guide-grading-2026-08-11.md for the evidence). It used to read the chain through ConnectApi.NamedCredentials. That works in an unmanaged scratch org and CANNOT work once the package is installed: "Managed packages can access only the named credentials and external credentials that are included in or created from the package's Apex code" (Apex Reference Guide, ConnectApi.NamedCredentials).
Every credential this panel grades is built by the subscriber's admin by hand, so in a real installation both reads threw and seven of the ten steps reported "could not read". Worse, the old readCredential treated any error mentioning "invalid"/"not found" as credential-not-built-yet, so the panel could state confidently that a working credential "isn't in this org".
So the reads are now plain SOQL, and nothing else. Verified against a live org 2026-08-11: readable — NamedCredential by DeveloperName, CalloutOptionsAllowMergeFieldsInHeader, CalloutOptionsGenerateAuthorizationHeader, CustomHttpHeader rows whose parent is a NAMED credential, SetupEntityAccess for ExternalCredentialParameter grants (with Parent.Name, the granting permission set), PermissionSetAssignment; NOT readable — ExternalCredential and ExternalCredentialParameter (not supported in standard SOQL at all), the principal, the header when it sits on the external credential (CustomHttpHeader.ParentId references only ExternalDataSource and NamedCredential), the callout URL (NamedCredential.Endpoint is the legacy field and comes back null on a modern SecuredEndpoint credential), "Enabled for Callouts", "Allowed Namespaces for Callouts", and WHICH credential a given ExternalCredentialParameter grant belongs to.
Everything in that second list is graded CONFIRM rather than guessed at or reported as an error: an amber card that names the exact screen and the exact value to look at, and says plainly that a package cannot read it. CONFIRM never blocks readiness — it is not work the panel discovered, it is work the panel cannot witness. Step 11's live call is the real gate, and it exercises the whole chain including the parts no read can reach.
The reads sit behind @TestVisible protected virtual seams because setup objects cannot be created by DML — no test can insert a NamedCredential, a CustomHttpHeader or a SetupEntityAccess row, and no test can assign a permission set to a site guest user. A test subclass overrides the seams and hands back rows it builds in memory, which is the only way to exercise the grading against a credential shape the test org does not have.
A developer name may arrive namespace-qualified while the merge formula inside an auth header keeps the bare name, so every developer-name comparison here strips a leading prefix first.
The single place the step→stage grouping lives. A step key missing from this map renders outside every stage, so adding a step means adding it here — deliberately, rather than the renderer guessing a home for it.
Both credentials are resolved from ONE pair of queries rather than one pair each: the named credentials come back in a single IN-bind and their headers in a second, keyed by parent id. Reading them separately cost four queries to answer the same question, and the panel already renders once per account switch.
Grants are org-wide now, not per credential, and that is a real loss of precision rather than a simplification. SetupEntityAccess reports THAT a permission set grants an ExternalCredentialParameter and names the permission set, but SetupEntityId points at an ExternalCredentialParameter row, which standard SOQL cannot query — so no grant can be attributed to a particular credential. The old guest-user detector (a site guest user holding the ADMIN principal, a live refund exposure) depended on that attribution and cannot survive; stepGuestUser now names the permission sets a guest holds and asks the admin to confirm none of them is the admin credential's.
The tally is built by a separate method rather than inline here, because Apex resolves identifiers case-insensitively: this method already declares a local Boolean named shared, and a same-scope reference to the SHARED status constant resolves to that local variable instead of the class field, which fails to compile ("Comparison arguments must be compatible types: String, Boolean"). tallyStatuses has no such local, so SHARED unambiguously means the constant there.
Always CONFIRM once a name exists. ExternalCredential is not a queryable sObject at all — not "empty for a package", not supported — so there is no read that could distinguish built-correctly from never-built, and pretending otherwise is what this rewrite removed. Whether the ACCOUNT names a credential is a fact about our own record, so that half stays a genuine PENDING.
Doubly unreadable, and it was already half so: a stored secret can never be read back by anyone, and since the rewrite the principal holding it can't be seen either. The card names the parameter to look for so the admin is checking a value, not a vibe.
CustomHttpHeader.ParentId references ExternalDataSource and NamedCredential only, so a header is readable when the admin put it on the NAMED credential and invisible when they put it where this guide tells them to, on the external credential. Both work at runtime. So this grades for real when the row is there and asks the admin to confirm when it isn't, rather than reporting a missing header that is very probably present.
The header value is deliberately NOT echoed back in this message, and since the 2026-08-11 rewrite it is never even copied onto the Reading. This branch fires precisely when the admin pasted a literal instead of a merge formula, so the value SOQL just returned is very often the live secret key itself — carrying it any further would render the key in the console DOM for everyone who can reach SetupController (Fundraising_User, not just admins) and drop it into any screenshot or HAR captured while troubleshooting this very step.
Now a real finding rather than a guess. The old ConnectApi read reported this whenever its exception message happened to contain "not found" or "invalid", which in an installed org meant telling admins a working credential did not exist. An empty SOQL result genuinely means no such row; a query that could not run at all lands on readError above instead.
NamedCredential.Endpoint is the LEGACY URL field. A modern credential — the kind this guide has the admin build, the kind that can carry an external credential — leaves it null (verified 2026-08-11 against two hand-built SecuredEndpoint credentials in a live org), and there is no other field carrying the URL. So a blank endpoint is the normal, correct case and the URL simply cannot be graded.
"For managed packages, the subscriber must add the package's namespace to a named credential's list of allowed namespaces to enable callouts." (developer.salesforce.com/docs/platform/named-credentials/guide/nc-package-credentials.html) The credential looks complete without it and every packaged callout is blocked — donations and refunds alike. The field is "Allowed Namespaces for Callouts" on the named credential's edit form (free text, comma separated); once saved, the namespace shows under "Managed Package Access" on the credential's detail page, which is the admin's confirmation. There is no field on NamedCredential carrying it, so it can never be graded from a read — step 11's live call is what proves it.
CalloutOptionsAllowMergeFieldsInHeader and CalloutOptionsGenerateAuthorizationHeader are fields on NamedCredential, so those two are graded for real. "Enabled for Callouts" is not a field on the object at all, so the third row is marked unreadable and the step's best possible verdict is CONFIRM. Both failure modes look identical from the gateway's side — "Invalid API Key provided" — which is exactly why the panel names checkboxes rather than repeating the gateway's message.
On demand rather than automatic, matching stepTestConnection — a live callout on every panel render would spend the org's callout budget re-answering a question that changes only when an admin rotates a key. SetupController.probeRestrictedKey is the button.
Amber, never red, and never green. Sharing is a configuration an admin may choose deliberately — the package supports it — but the consequence is real and is stated rather than softened: the site guest user's principal can issue refunds. Probing here would report a failure for something that was chosen on purpose, so the callout is skipped.
getByAccount() calls init() internally, so this must not call it again — a second init() would simply re-read the same account and re-derive the same two credential names. getByAccount() resolves the gateway through selectActiveById, not the plain selectById this class uses for the shared-credential check above — so a deactivated account, or one whose Gateway__c no longer matches a registered gateway, makes it throw AppException.
That is a finding for this guide to report, not a failure that should escape a probe whose whole contract is that it never throws for a misconfigured org — so it is caught here rather than left to surface the callout-layer's donor-facing message in the setup console.
Every branch that reports a fault here is still a genuine read: a grant that exists, a permission set nobody holds, a permission set only the site guest user holds. What is gone is attribution — SetupEntityAccess names the permission set but its SetupEntityId points at an ExternalCredentialParameter, which standard SOQL cannot query, so no grant can be tied to a named credential. The DONE case therefore becomes CONFIRM and prints the permission set names for the admin to match up, rather than claiming a pairing it did not verify.
"Held by somebody" is not the same question as "held by a member of staff". The branch above only catches permission sets nobody holds at all; a set held solely by the site guest user passes it while leaving every member of staff unable to charge a card — and in shared mode unable to issue a refund either, since that is the same credential.
Count GRANTS, not permission sets. One permission set can legitimately carry the principals of every credential in the org, so sizing g.permissionSetNames here reported a correctly-configured org as incomplete — and contradicted this step's own guidance copy, which tells the admin one set is enough. Distinct SetupEntityAccess.SetupEntityId values are the readable proxy for "how many credential principals are granted at all"; which grant belongs to which credential is still unknowable, hence the CONFIRM below.
The verb agrees with the number of permission sets, and the count comes from grantedPrincipals rather than being hard-coded singular — one set carrying both principals is the shape this step's own guidance recommends, so "Stripe_Credential_Access grant an external credential parameter" was both ungrammatical and an undercount.
The second half used to be a FAILED detector: a site guest user holding the ADMIN principal is a live refund exposure, and an admin who granted the wrong permission set by hand has no other way to find out. It ran on principal ids that ConnectApi returned, and with those gone there is no way to tell the two grants apart — so the check is now stated as the thing the admin must confirm, naming the exact permission sets a guest holds.
Losing the detector does not lose the defence. Layers 1 and 2 — the guest-path guard and the credential split itself — still stop the package reaching a back-office operation from a guest path, so this step is the third layer and is now advisory. The restricted-key probe catches the same exposure from the other side: it proves the key a guest can reach cannot issue a refund.
The prerequisite this step never stated, and the dead end a first-run admin walks into: there IS no site guest user until an Experience Cloud site exists, and every instruction here (Digital Experiences → All Sites → Builder → gear → General → Guest User Profile) presupposes one. Found 2026-08-18 on the 1.0.0.20 fresh-install audit — Site and Network both returned zero rows and the step still reported the guest user as holding the wrong grants, which is a statement about a user that does not exist. Only a POSITIVE "no site" reading changes the message; an unreadable answer falls through to the wording below rather than inventing a cause.
Reported before the "which credential?" question, because it is the harder failure to diagnose: the principal grant is visibly present, the panel used to say the card could be charged, and every public donation died at the callout with an error only Diagnostic Logs ever showed. Site & Domain and Get Started both grade this leg too — all three must agree, so none of them may assert "can charge a card" without it.
This copy is deliberately duplicated in three places that must agree — this step, the two fields' inlineHelpText, and docs-site/docs/getting-started/connecting-stripe.md. An admin reaches whichever one they reach first, and a contradiction between them is worse than the repetition.
No position argument. Every step used to carry its own hardcoded number, repeated once per branch — 25 literals for 11 steps — and the numbering contradicted the dependency graph: 'restrictedKey' sat at position 7 but its probe needs the Payment_Account__c that stepPaymentAccount creates and the principal grant stepPermissionSet asks for, both of which came later in the list — so following the numbers in order could not work. Numbers are assigned by assignPositions() from the order of the list in buildGuide(), so reordering a step is now a one-line move and the two cannot disagree.
Called once, immediately after buildGuide() composes the list. The list order IS the numbering — there is no second place to keep in step with it.
Deliberately carries no done/total rollup. The panel recomputes those from the steps it is actually rendering, because a live probe (the restricted-key check) changes a step's status client-side after this payload was built — a rollup baked in here would be stale the moment it mattered most. Same reasoning as setPayments' _reconcile().
'name' is the value the admin typed into the account field, not a name this service resolved — so it is null on a preview grading, where no account exists to have named anything. A renderer with nothing to print there should fall back to the role.
The row is never green. Sharing is supported and may well be deliberate, but a step that rendered green would tell an admin by silence that a site guest user reaching the refund key is fine — so a verdict that would otherwise be 'done' is downgraded to 'shared' and carries the sentence explaining what that costs. A real finding outranks the downgrade: a shared credential that was never built still reads 'pending', and one whose answer this package cannot read still reads 'confirm', because both outrank SHARED in worstOf.
The step's own oneLine is the WORST row's sentence rather than a summary of both. An admin reading a collapsed step needs the thing that is actually broken, not "one of two credentials has a problem" — the sub-rows are there for which one. Ties go to the first row, which is the public credential: the one that stops donations rather than refunds.
Ordering is failed > pending > confirm > unverifiable > shared > done. Why each rank sits where it does:
- CONFIRM above UNVERIFIABLE — it is the more actionable of the two. The admin can settle a confirm right now on a Setup screen, where an unverifiable step waits on a live call they may not want to spend.
- Both below PENDING — neither is a fault this panel found, and neither counts toward readiness. Only pendingCount and failedCount do.
- SHARED immediately above DONE — so sharing can only ever take a step off green. One credential in both roles is supported, not a fault, so it must never outrank something the admin actually has to act on; a shared credential that was never built still reads 'pending'.
- SHARED clear of FAILED and PENDING — pendingCount is what the payload's readiness flag is computed from. An org that shares one credential and has everything else right is still ready to take payments, and ranking sharing any higher would have silently told it otherwise.
The WORSE wins rather than the more common because a green step sitting above a red sub-row is the exact dishonesty this panel exists to avoid. An unrecognised status ranks as done, so a status added later cannot silently drag every step it touches to red.
A preview grading has no account, so neither credential has a name to print — the row can only say what to build. The public row keeps the original "this is where the whole chain starts" wording because it still is; the admin row exists so a new admin learns from the first screen that there are two credentials to build, not one they will discover is missing when the account form refuses to save.
These lines used to ASSERT "Nothing built yet". They cannot know that: with no payment account there is no credential name to look anything up by, so the grading is not "nothing exists", it is "nothing to look up".
A first-run walkthrough built both external credentials, both principals, both headers and both named credentials, pressed Re-check, was told "Nothing built yet" six times over, and concluded it had done the whole stage wrong (2026-08-17 — the most damaging finding on this panel).
The order the guide asks for is right, and it is the only order that works: the account form cannot save without a named credential to pick. So the fix is to say what is true — this stage starts grading once the account below names your credentials — never to imply the work was not done.
Org-wide, not per credential, and deliberately so: SetupEntityAccess is the only readable evidence that any external credential is granted to anybody, and its SetupEntityId points at an ExternalCredentialParameter row that standard SOQL cannot query. So this counts what it can prove — which permission sets carry a grant, how many distinct principals are granted across all of them, and who holds those sets — and the two grant steps ask the admin to confirm the pairing rather than inventing one.
The package DOES carry this grant, on Fundraising_GuestDonor, and it never arrives in a subscriber org: sf package version create strips standard-object permissions out of a managed permission set and still reports Success (AGENTS.md, confirmed 2026-08-16). So it is a manual step by construction, which is why it is checked here rather than assumed — the same reasoning as guest Campaign read and the principal grant itself.
Dynamic SOQL, not a static query: Network does not exist as a queryable type until Digital Experiences is enabled, so a static reference would refuse to compile in an org that has never turned it on — which is precisely the org this check exists for. An exception here therefore means "not enabled, or not readable by this user"; both are reported as NULL rather than as "no site", because the two cases lead an admin to different screens.
DeveloperName only. There is no field on this object for the callout URL of a modern credential, the credential type, "Enabled for Callouts" or "Allowed Namespaces for Callouts" — Endpoint is the legacy URL field and is null on the credentials this guide has admins build. Everything selected here is everything the platform offers.
CustomHttpHeader.ParentId references ExternalDataSource and NamedCredential only, so this can never return a header that sits on an EXTERNAL credential — which is where this guide tells admins to put it. An empty result is therefore the expected case, not a finding. HeaderFieldValue is selected because the merge formula has to be parsed out of it, and it is discarded immediately: see the Reading class, which stores the parsed credential and parameter names and never the value, because the value is the live secret key whenever the admin pasted a literal instead of a formula.
SetupEntityType 'ExternalCredentialParameter' is the grant an admin creates under "External Credential Principal Access" on a permission set. Parent is the permission set; SetupEntityId is the parameter, and that object is not queryable in standard SOQL, which is why no grant here can be attributed to a particular credential.
The third row carries readable=false: "Enabled for Callouts" is not a field on NamedCredential, so it is rendered with no value and its 'ok' is null rather than false — gradeCalloutOptions only counts a row as wrong when ok is exactly false, so an unreadable box can never be reported as misconfigured.
This used to be one table, built from the public reading alone, while the step's own verdict rows graded BOTH credentials. In shared mode that is right — there is one credential — but in split mode the admin cleared a table that was green about the public credential and never saw the admin one, whose checkboxes carry exactly the same two Salesforce defaults and fail exactly as silently.
Found 2026-08-17 while proving the "Enabled for Callouts" row from a live call, which is what made the roles matter: the probe runs through the public credential and the connection test through the admin one, so a single table had no single answer to carry. Each group now names its own credential and the LWC proves each from the check that runs through it.
role is what the LWC keys its live-call proof off, so it is the machine-readable half and heading the human one. The credential name is carried separately from the heading because a group for a credential that does not exist yet still needs a heading to sit under.
A Reading for a credential that does not exist carries the field defaults (false, false), and printing those as "Yours: Off" invented a reading of a record that was never built (found 2026-08-17 by a first-run walkthrough). Worse, "Generate Authorization Header — must be Off, yours Off" then read as ALREADY CORRECT on a completely unconfigured org. gradeCalloutOptions returns before it reaches this method in that case, so only the reference table at the top of the panel is affected, and it now says so rather than guessing.
Read off this class's own type name rather than hardcoded, because the packaging namespace is not final (see AGENTS.md — pledgivo_test today, pledgivo once that namespace is linked) and a literal here would go stale silently on a screen whose whole job is telling an admin exactly what to type. Null in an unmanaged org, where the class name carries no prefix and no grant is needed; the copy falls back to a generic phrase.
| Method | Returns | |
|---|---|---|
buildGuide(1 arg) |
Map<String, Object> |
|
| calls: PaymentGatewayRegistry.get() | ||
probeRestrictedKey(1 arg) |
Map<String, Object> |
|
| calls: PaymentGatewayFactory.getByAccount() |
Stripe#
StripeBodyBuilder — fluent builder for Stripe application/x-www-form-urlencoded request bodies.
Handles: - Scalar params: put('key', value) - Array params: add('items[]', value) → items[]=value - Nested params: put('items[0][price]', value) or the fluent nest() API - Auto URL-encodes all values
Usage: String body = new StripeBodyBuilder() .put('customer', customerId) .put('amount', amountInCents) .add('payment_method_types[]', 'card') .add('payment_method_types[]', 'us_bank_account') .nest('payment_method_options', 'us_bank_account', 'verification_method', 'automatic') .put('metadata[source]', 'pledgivo') .build();
StripeGateway#
class · public inherited sharing
implements IPaymentGateway
payment/
StripeGateway — implements IPaymentGateway for the Stripe payment platform.
Credentials are loaded from a specific Payment_Account__c record via init(paymentAccountId). This enables multiple Stripe accounts in one org — each campaign selects which account to use, and the factory calls init() with the correct account Id before handing back the gateway instance.
Fields loaded from Payment_Account__c: Named_Credential__c → the RESTRICTED, charge-only credential. Every donor-facing call (customers, payment intents, setup intents, payment methods) runs on it, and it is the credential the site guest user is granted. Admin_Named_Credential__c → the BACK-OFFICE credential. Refunds, the refund/dispute reconcilers, the orphan sweep, off-session renewals and testConnection run on it, and nothing guest-reachable ever names it. Its key may itself be restricted (Refunds + PaymentIntents write; Disputes, Charges, Balance transactions, Customers and Payment methods read) — the setup console documents that scope, and testConnection tolerates a key that cannot read /v1/account. Publishable_Key__c → sent to VF iframe for client-side Stripe.js
Both are org-local, built by hand by the admin in Setup and then selected on the account — this package ships no credential of its own and creates none. Naming the same credential in both fields is supported; the gateway needs no special case for it.
The credential split is backed by a second, independent layer: every back-office operation calls assertBackOffice() first and refuses to run inside a public site request. Routing decides which key an operation uses; the guard decides which caller may run it at all. See assertBackOffice for why both are needed.
This package has no inbound webhook: payment outcomes are confirmed by re-fetching the PaymentIntent from Stripe (see retrievePaymentIntent / the reconciliation scheduler), so no webhook signing secret is stored anywhere.
API version is pinned in PaymentGatewayRegistry — shared across all accounts, and shipped with the code that parses Stripe's responses so the two can never disagree.
Docs note
StripeCalloutException carries two extra classification signals beyond the message that the off-session dunning path depends on: declineCode (Stripe's granular decline_code, e.g. 'stolen_card') lets the retry logic recognize a terminal decline and stop retrying instead of burning the full retry cadence, and isTransient marks a failure as INDETERMINATE — a 5xx or an unparseable/empty error body, where the charge may actually have gone through on Stripe's side. A caller must treat isTransient=true as "re-verify via retrievePaymentIntent before deciding", never as a confirmed decline, or a retry risks double-charging the donor.
Two credentials, one per side of the split. publicCredential is the restricted, charge-only key that the site guest user is granted; adminCredential is the back-office key that can move money back out (restricted or full — see the class header). Every callout names one of them explicitly at its call site, so which side an operation sits on is readable in one line rather than inferred from context.
The BACK-OFFICE set — the operations that name adminCredential — is exactly: refund, listRecentRefunds, listRecentDisputes, listRecentSucceededIntents, chargeOffSession and testConnection. Everything else names publicCredential. chargeOffSession is back-office despite charging a donor's card: it runs from RecurringRenewalBatch against a saved card with nobody present, so it is the one path that moves money unattended and must not be reachable with the key a public page can leak. One exception to "public credential means donor-facing": probePublicKeyScope also names publicCredential, but it is called only from the internal setup console, never from a public page — it exists to READ ABOUT the public key's scope, not to act as the donor.
The pinned Stripe-Version comes from PaymentGatewayRegistry, which ships and upgrades with the response-parsing code in this class — the only thing that can safely change it. It was previously read from Payment_Gateway__mdt.Stripe.API_Version__c on every gateway instantiation, which cost a SOQL query per donation and left the version editable in a subscriber org independently of the code that parses what Stripe sends back.
The raw HTTP status Stripe answered with. Added for probePublicKeyScope, which needs the numeric status even on the permissions-failure path, where Stripe returns a parseable JSON error body built from its own error.code rather than the 'http_<status>' synthetic code rawRequest uses for a non-JSON body — so the status is not always recoverable from the message text. Set unconditionally in rawRequest next to isTransient so every caller gets it, not just the probe.
Exposes the account init() was called with so a caller can record WHICH Stripe account minted a token without threading the id alongside the gateway instance. PaymentMethodService stamps it onto Payment_Method__c.Payment_Account__c — a cus_/pm_ pair is only chargeable on the account that created it, so the wallet row has to remember which one that was.
retrievePaymentIntent reads through the RESTRICTED public credential even though back-office jobs call it too. It is guest-reachable (the donation and event-registration status polls both land here), so it has to work with the key a public page can reach; the reconcilers simply reuse that read rather than needing a second, more powerful one.
Note what that asks of the restricted key: read access to PaymentIntents is not enough on its own, because the request expands latest_charge.balance_transaction and so also reads a Charge and a balance transaction. A key scoped narrower than that breaks the guest status poll and both reconcilers — see the Named_Credential__c field comment for what that silently costs.
Null is the correct outcome far more often than it looks, and it is NOT an error: Stripe posts the balance transaction when the charge settles, so a PaymentIntent read before that — typically a card charge fetched the same instant it succeeded, which on this card-only flow is the common case — legitimately has none. Callers must read null as "not known yet" and leave the field alone rather than writing a zero fee, which would look like a free charge.
Amounts are scaled with the BALANCE TRANSACTION's own currency, not the PaymentIntent's. They differ whenever the charge is settled into a different currency than it was presented in, and it is the settlement currency the fee and net are denominated in — scaling those by the presentment currency would be wrong by up to 100x the moment either side is a zero-decimal currency such as JPY.
Set only by StripeGateway_Test. Site.getSiteId() returns null in a test by default and nothing in Apex can make it return otherwise, so simulating a public site request needs this seam. It is read only when Test.isRunningTest() is true, so it cannot weaken the guard in a subscriber org.
The predicate is "am I serving a public site request", not "is this user a guest", for two reasons: it also catches a LOGGED-IN community user on the same path, and it avoids depending on UserInfo.getUserType() returning 'Guest', which this repo has never been able to confirm from official documentation. The User.UserType FIELD value is proven in three places (PaymentSetupGuideService.readGrants, SetupController, CommunityService); the session-level UserInfo equivalent is not, so the guard does not rest on it. Site.getSiteId() is documented to return null when the current request is not a site request, which is exactly the question asked here.
This is the second of two layers, and it is defence against a FUTURE DEVELOPER rather than against a subscriber admin — which is why it is not redundant with the per-operation credential routing above it. Routing decides WHICH KEY an operation uses, so a leaked public key cannot refund; this guard decides WHICH CALLER may run it at all.
Today no guest-reachable code path reaches adminCredential, but a new guest-granted @AuraEnabled controller that called refund() would route to the admin credential perfectly correctly and succeed, with nothing to notice. The first layer makes the wrong key useless; this one makes the wrong caller fail loudly.
The log line is written BEFORE the throw because a tripped guard is a code defect, and it must be diagnosable from the log rather than only from whatever the caller does with the message. The message names the operation and nothing else — never a credential name or any part of a key.
The guard asks where the CALLER is running, not which credential resolved, so an account that names the same credential in both fields (shared mode, a supported configuration) gets exactly the same answer.
The reverse direction — a back-office operation running on the public credential — is deliberately NOT guarded. It fails loudly at Stripe with an HTTP 403, harms nothing, and the credential routing already prevents it.
The credential is the first parameter rather than instance state because it is the one thing that varies per operation: a donor-facing call names publicCredential, a back-office call names adminCredential. Passing it explicitly at every call site is what makes the split auditable — StripeGateway_Test asserts the exact set of endpoints each side calls, so a call routed to the wrong credential fails a test rather than silently succeeding at Stripe.
testConnection is reached only from the internal setup console, and it probes the ADMIN credential: the account read it performs is the one thing that proves the full key is wired up, and that is the credential an admin most needs reassurance about — a broken restricted key shows up the first time a donation is taken, a broken admin key only at the first refund. It therefore says nothing about the restricted public credential.
The admin credential may legitimately hold a RESTRICTED key (rk_…) rather than the full secret key — Stripe's own guidance is to prefer restricted keys everywhere, and the setup console documents the exact scope this app needs. The account profile read above is NOT in that scope: /v1/account sits behind its own Stripe permission (it exposes KYC data), so a correctly scoped key is answered 403 for it. Failing the button on that would tell an admin their key is broken when it is right, and the only way to make it green would be to widen the key — the opposite of what this app asks for.
So a 403 falls back to the weakest read the admin key must have anyway: listing refunds, which the refund reconciler calls on every run. It moves no money, creates nothing, and proves the credential resolves and the key authenticates. Account name, country and mode are simply absent — the caller already renders a generic success when accountName is null.
Only 403 falls through. A 401 is a bad or revoked key and must still fail loudly.
Deliberately a READ (GET /v1/refunds?limit=1), never a write. It moves no money, creates nothing, and a key that cannot READ refunds cannot issue them either — Stripe's restricted key permissions are per-resource, so read access is the weaker of the two and its absence proves the stronger one is absent too.
On the PUBLIC credential specifically. Probing the admin credential would be meaningless: that key is SUPPOSED to reach refunds, so a 200 there is the correct answer.
The guide calls this on demand behind a button rather than on every panel load — same treatment testConnection gets, and for the same reason: a live callout on every render would spend the org's callout budget to re-answer a question that changes only when an admin rotates a key.
Deliberately NOT guarded by assertBackOffice — it runs on the public credential and is called from the internal setup console, which is exactly the shape assertBackOffice does not exist to block: the guard protects against a guest-reachable caller landing on the admin credential, and this method never touches that credential at all.
rawRequest sets StripeCalloutException.statusCode unconditionally, so this simply reads it back. It does NOT parse the status out of the exception message: for a JSON-parseable error body (exactly Stripe's shape for a permissions failure) the message is built from Stripe's own error.code, not the 'http_<status>' synthetic marker rawRequest only uses for a non-JSON body — a regex over the message would return null for precisely the 403 case this probe exists to detect. See StripeCalloutException.statusCode for the field.
| Method | Returns | |
|---|---|---|
lastRefundsListTruncated(0 args) |
Boolean |
|
lastDisputesListTruncated(0 args) |
Boolean |
|
init(1 arg) |
void |
|
getPublishableKey(0 args) |
String |
|
classifyPublishableKey(1 arg) |
String |
|
getPaymentAccountId(0 args) |
Id |
|
getGatewayName(0 args) |
String |
|
createCustomer(3 args) |
[GatewayResult](#apex-gatewayresult).Customer |
|
createIntent(5 args) |
[GatewayResult](#apex-gatewayresult).Intent |
|
| calls: StripeAmount.toMinor() | ||
chargeOffSession(5 args) |
[GatewayResult](#apex-gatewayresult).Intent |
|
| calls: StripeAmount.toMinor(), StripeAmount.toMajor() | ||
retrievePaymentIntent(1 arg) |
[GatewayResult](#apex-gatewayresult).Intent |
|
| calls: StripeAmount.toMajor() | ||
listRecentSucceededIntents(1 arg) |
List<[GatewayResult](#apex-gatewayresult).Intent> |
|
| calls: StripeAmount.toMajor() | ||
listRecentRefunds(1 arg) |
List<[GatewayResult](#apex-gatewayresult).Refund> |
|
listRecentDisputes(1 arg) |
List<[GatewayResult](#apex-gatewayresult).Dispute> |
|
createSetupIntent(2 args) |
[GatewayResult](#apex-gatewayresult).Intent |
|
attachPaymentMethod(3 args) |
void |
|
| calls: AppLogger.error() | ||
retrievePaymentMethod(1 arg) |
[GatewayResult](#apex-gatewayresult).PaymentMethod |
|
refund(3 args) |
String |
|
testConnection(0 args) |
Map<String, Object> |
|
probePublicKeyScope(0 args) |
Map<String, Object> |
StripePaymentPageController#
class · public with sharing
payment/
Renders the org domain URL for postMessage origin validation in the VF stripe payment page.
Docs note
This tiny controller backs stripePayment.page, the Visualforce page that hosts Stripe.js inside an iframe so no card data ever touches this org's own DOM — a donation LWC talks to it only via window.postMessage, never by injecting Stripe's script directly into the LWC's page.
That indirection is deliberate: the AppExchange Security Review's Source Scanner flags dynamic third-party script injection, so the iframe+postMessage pattern was chosen specifically to stay compliant while still tokenizing card data client-side (the browser only ever sends Apex a paymentMethod.id, never raw card details — see IPaymentGateway/StripeGateway).
getAllowedOrigin() is the other half of that safety: both this page and the LWC validate the postMessage event.origin against the org's own domain before trusting a message, in either direction.
StripePaymentReturnController backs the redirect-return counterpart of this same page for flows that leave the iframe (e.g. 3DS).
| Method | Returns | |
|---|---|---|
getAllowedOrigin(0 args) |
String |
StripePaymentReturnController#
class · public with sharing
payment/
StripeResponse — typed wrappers for Stripe REST API JSON responses.
Field names match Stripe's snake_case JSON keys so JSON.deserialize() maps them directly.
Constraints:
- Apex forbids nested inner classes → Card and UsBankAccount are direct children of StripeResponse (not nested inside PaymentMethod).
- currency is a reserved identifier in Apex → omitted from typed DTOs.
These types are ONLY used inside StripeGateway. Callers receive gateway-agnostic GatewayResult DTOs — StripeResponse never leaks outside the stripe package.
Recurring#
Async (Batch/Queueable/Schedulable)#
RecurringAutoCancelBatch#
class · public without sharing
implements Database.Batchable<SObject>
recurring/
Sweeps recurring donations that have sat Status__c = 'Failed' (Failed_At__c) since before the admin-configured grace period (SettingsService.gracePeriodDays()) and cancels them. Reuses IRecurringDonationService.cancelSubscriptions — a single-UoW bulk cancel already used by the goal-reached auto-cancel path — rather than implementing new cancel logic. Rides the same daily 2am job as the renewal batch (see RecurringDonationScheduler).
| Method | Returns | |
|---|---|---|
start(1 arg) |
Database.QueryLocator |
|
| calls: SettingsService.gracePeriodDays() | ||
execute(2 args) |
void |
|
finish(1 arg) |
void |
RecurringDonationScheduler#
class · public without sharing
implements Schedulable
recurring/
Docs note
Orchestrates all three self-managed-recurring async jobs in a fixed order every run: DonorSummaryRollupBatch (donor-level rollups) first, then RecurringRenewalBatch (the actual off-session charges + dunning), then RecurringAutoCancelBatch (sweeps schedules that exhausted retries past the grace period).
Chained job-to-job via finish() — NOT three independent Database.executeBatch calls — because executeBatch is fire-and-forget-async; calling all three back-to-back here would launch them concurrently instead of sequentially, breaking two invariants this order exists for: rollups must reflect the PREVIOUS day's renewals (not ones this same run is about to charge), and a schedule whose retry just succeeded in RecurringRenewalBatch must never be visible to RecurringAutoCancelBatch's Failed-state locator.
See DonorSummaryRollupBatch.finish() and RecurringRenewalBatch.finish() for the actual chain links.
Presence of the row is the test, not its State. A repeating job's State cycles WAITING → ACQUIRED → EXECUTING and back on every fire (CronTrigger Object Reference, Summer '26 / API 67.0), so a State = 'WAITING' filter reports "not scheduled" for a perfectly healthy job that happens to be mid-run — and System.schedule then throws "already scheduled for execution" on the duplicate name. Same defect as the setup console's job counter, fixed together on 2026-08-17. PostInstallHandler.scheduleIfAbsent has always matched on name alone; this now agrees with it.
| Method | Returns | |
|---|---|---|
execute(1 arg) |
void |
|
| calls: DonorSummaryRollupBatch.finish() | ||
scheduleAll(0 args) |
void |
RecurringRenewalBatch#
class · public without sharing
implements Database.Batchable<SObject>, Database.AllowsCallouts
recurring/
Self-managed recurring renewal driver (Phase B). Daily batch that charges every due Recurring_Donation__c off-session against its saved card, then applies the outcome via RecurringDonationService.processRenewal (success → installment Opportunity + advance schedule; requiresAction → re-auth flag; decline → dunning cadence → Failed).
execute() is TWO-PHASE — ALL Stripe charge callouts first, THEN all DML — so Apex's callout-before-DML rule holds at any scope. The Stripe account each schedule is charged against is resolved from the schedule itself (see resolvePaymentAccounts) and one gateway is built per distinct account ONCE up front — no SOQL in the per-record loop. Keep the scope modest (scheduler uses a small size): each successful renewal creates an Opportunity and fires rollup triggers.
Docs note
This batch owns only the callout + two-phase commit shape. The actual dunning cadence — how many retries, how many days apart, when a decline is terminal vs. soft, when the donor gets a re-auth link — lives entirely in RecurringDonationService.processRenewal (TERMINAL_DECLINE_CODES, INDETERMINATE_DECLINE, the RenewalOutcome result). Read that method for the state machine; read this class only for how/when it gets invoked per due schedule.
| Method | Returns | |
|---|---|---|
start(1 arg) |
Database.QueryLocator |
|
execute(2 args) |
void |
|
| calls: PaymentGatewayFactory.getByAccount() | ||
finish(1 arg) |
void |
Controllers#
RecurringDonationAdminController#
class · public with sharing
recurring/
Controller for the staff-facing "Manage Recurring Gift" panel — the internal counterpart to the donor's self-serve portal. Exposes the mid-life schedule changes a fundraiser may need to make on a donor's behalf (pause, resume, skip one payment, retry a failed charge, change the amount, change the fund, reinstate a stopped gift, switch it to another card the donor has already saved), each gated by the Manage_Recurring_Gifts custom permission.
FFLib role: controller. Holds no business logic — every method delegates to IRecurringDonationService, which owns the rules about which transitions a schedule may make.
Docs note
Cancellation is deliberately NOT here. It already has its own action + LWC (StopRecurringDonationController / lexStopRecurringDonation) because it is terminal and requires a reason; folding it into this panel would have put an irreversible action one click away from six reversible ones.
This permission check is the ONLY authorization gate on these operations, which is a deliberate departure from the refund path's belt-and-braces controller + service check. The same IRecurringDonationService methods back the donor's own portal, where the caller is an unauthenticated guest holding a capability token and by definition holds no custom permission — so asserting Manage_Recurring_Gifts inside the service would break every donor self-serve change. The service is scoped by ownership there and by this gate here.
Not cacheable=true on purpose. The panel re-reads itself after every action, so a cached response would leave a just-paused gift still rendering as Active until a full reload.
Takes a Payment_Method__c record Id, NOT a Stripe pm_ id. The client never names a Stripe object — the wallet row is looked up and its Stripe id read server-side, so a tampered request cannot point a schedule at a card that is not this donor's.
Unlike the write actions this one does NOT refuse a user without the permission — it answers with canManage=false so the panel can render the gift read-only rather than as an error. Nothing here is privileged; it is the same schedule state the record page already shows.
Queues the charge for the next renewal-batch run rather than calling Stripe inline — retryNow only moves Next_Retry_Date__c. Staff should be told "we'll try again", not shown a result that has not happened yet.
The org's min/max gift bounds are enforced by the service, not restated here — the donor form and the donor portal are checked against the same limits, and a second copy of them in a controller is a copy that will eventually disagree.
Reinstates on the card already on the schedule (null second argument = no swap). Staff cannot supply a new card here and must not: keying a donor's card into an internal screen would put a PAN in front of a fundraiser and produce a charge with no donor mandate behind it. Switching to a card the donor already saved is a separate action.
The chosen card is re-checked against THIS schedule's donor before it is used. Without that, a valid Payment_Method__c id belonging to any other donor would be accepted and the next installment would bill a stranger's card.
The can* flags mirror the service's own preconditions so the panel can disable a button instead of letting staff click it and read an error. They are a display convenience ONLY — every service method re-asserts its own rules, so a stale panel cannot be used to push through a transition the schedule does not allow.
Lists only cards the donor has ALREADY saved. There is deliberately no "add a card" path on the staff side — collecting a card requires the donor's browser (Stripe.js) and their mandate; a fundraiser typing one in would breach both. A donor with one card on file therefore sees no switch option here, which is correct: there is nothing to switch to.
Mirrors DonorPortalService.getRecurringFundOptions — the same resolved config the donor's own change-fund picker is built from, so staff and donor are offered exactly the same funds for a given campaign.
StopRecurringDonationController#
class · public with sharing
recurring/
Docs note
This is the internal/admin cancel path — a staff user acting on a known Recurring_Donation__c Id. The donor-portal self-serve cancel is a separate path (DonorPortalService, scoped by the donor's capability token rather than a raw Id a guest could enumerate) and does not go through this controller.
| Method | Returns | |
|---|---|---|
stopRecurringDonation(2 args) |
Map<String, Object> |
@AuraEnabled |
Domains#
IRecurringDonations#
interface · public
extends fflib_ISObjectDomain
recurring/
RecurringDonations#
class · public with sharing
extends fflib_SObjectDomain · implements IRecurringDonations
recurring/
Docs note
onAfterInsert's createHistoryRecords is the ONLY place Recurring_Donation_History__c gets written automatically in this package — pause/resume/cancel/reinstate/renewal (all in RecurringDonationService) mutate Recurring_Donation__c directly and do not append a History row. A reader looking for a full audit trail of status changes will not find one here beyond the initial "Created" event.
This is the only fflib CRUD-security waiver in the package. It is scoped to a single transaction window rather than disabled outright in the constructor precisely so an unrelated caller cannot inherit it.
| Method | Returns | |
|---|---|---|
newInstance(1 arg) |
[IRecurringDonations](#apex-irecurringdonations) |
|
construct(1 arg) |
fflib_SObjectDomain |
|
onBeforeInsert(0 args) |
void |
|
onAfterInsert(0 args) |
void |
|
setDefaults(0 args) |
void |
Selectors#
IRecurringDesignationsSelector#
interface · public
extends fflib_ISObjectSelector
recurring/
IRecurringDonationsSelector#
interface · public
extends fflib_ISObjectSelector
recurring/
RecurringDesignationsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IRecurringDesignationsSelector
recurring/
Docs note
Recurring_Designation__c is the multi-split fund-allocation child of Recurring_Donation__c (mirrors Donation_Designation__c on the one-time side). RecurringDonationService reads these via rd.getSObjects('Recurring_Designations__r') on the parent query rather than calling selectByRecurringDonationIds directly, so each renewal installment can copy the same split onto its own Donation_Designation__c rows.
Live recurring commitment to one fund — the split rows of every schedule still billing, with the parent's frequency and amount so the reader can annualize. USER_MODE (unlike the SYSTEM_MODE renewal read below) because this serves an internal staff card, not guest/batch.
newQueryFactory(false, false, true) — the CRUD/FLS asserts must be waived, not just the query's AccessLevel. The bare factory asserts object read at BUILD time and threw "Permission to access an Recurring_Designation__c denied." for the guest user, which broke every donor-portal amount/fund change before the SYSTEM_MODE query ever ran. The Guest License holds no read on this object by design and must not be granted one: the split rows are read only so RecurringDonationService can re-derive or delete them, and no field on them ever reaches the donor. Same posture as RecurringDonationsSelector.selectByIdSystem.
RecurringDonationsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IRecurringDonationsSelector
recurring/
Docs note
selectDueForRenewalLocator's WHERE clause has two branches: normal due-today schedules (Next_Retry_Date__c = null) and schedules already mid dunning-retry cycle (Next_Retry_Date__c <= today). Next_Retry_Date__c being non-null is what routes a schedule into the retry branch instead of the normal one — RecurringDonationService. processRenewal is what sets and clears it. selectByDonorSystem and selectByStripeCustomerId are the two SYSTEM_MODE reads (see the class comment above) used by the donor portal and the self-managed-signup idempotency check, respectively.
No field from this read is ever returned to the guest — it feeds internal decisions only (status gates, campaign scope, the running installment count). Donor scoping is enforced one layer up by DonorPortalService.requireOwnership, which resolves the id from the token-authorized donor's own rows before the service is called at all. Same posture as selectByStripeCustomerId and selectByDonorSystem.
newQueryFactory(false, false, true) — the CRUD/FLS asserts must be waived, not just the query's AccessLevel. The bare newQueryFactory() asserts object read at BUILD time and threw "Permission to access an Recurring_Donation__c denied." for the guest user, aborting every recurring signup before the SYSTEM_MODE query ever ran. The Guest License holds no read on this object by design and must not be granted one: the result is used solely as an internal boolean (does this Stripe customer already have a schedule?) and no field ever reaches the guest. Same posture as the sibling SYSTEM_MODE read selectByDonorSystem below.
stripePaymentMethodId is skipped entirely when blank rather than bound as null — a null bind in Stripe_Payment_Method_Id__c = :token matches every schedule that has no token at all, which would report unrelated gifts as depending on this card.
The guard has to match on BOTH the Payment_Method__c lookup and the Stripe id because a schedule written before the lookup existed carries only the latter, and the Stripe id is what the renewal batch actually charges. Matching on one alone would let a donor remove a card that is still funding a live gift.
| Method | Returns | |
|---|---|---|
newInstance(0 args) |
[IRecurringDonationsSelector](#apex-irecurringdonationsselector) |
|
getSObjectFieldList(0 args) |
List<Schema.SObjectField> |
|
getSObjectType(0 args) |
Schema.SObjectType |
|
selectById(1 arg) |
List<Recurring_Donation__c> |
|
selectMissingPaymentAccountLocator(0 args) |
Database.QueryLocator |
|
selectByIdSystem(1 arg) |
List<Recurring_Donation__c> |
|
selectByIdWithFls(1 arg) |
List<Recurring_Donation__c> |
|
selectActiveByNextPaymentDate(1 arg) |
List<Recurring_Donation__c> |
|
selectByStripeCustomerId(1 arg) |
List<Recurring_Donation__c> |
|
selectByContactId(1 arg) |
List<Recurring_Donation__c> |
|
selectByDonorAccountId(1 arg) |
List<Recurring_Donation__c> |
|
selectByPaymentMethod(2 args) |
List<Recurring_Donation__c> |
|
selectActiveByCampaignIds(2 args) |
List<Recurring_Donation__c> |
|
selectDueForRenewalLocator(1 arg) |
Database.QueryLocator |
|
| calls: SettingsService.gracePeriodDays() | ||
selectDueForRenewal(1 arg) |
List<Recurring_Donation__c> |
|
getQueryLocatorForFailedPayments(1 arg) |
Database.QueryLocator |
|
selectByDonorSystem(2 args) |
List<Recurring_Donation__c> |
|
selectCardBindingsByDonorSystem(2 args) |
List<Recurring_Donation__c> |
|
selectByCampaignPaged(3 args) |
List<Recurring_Donation__c> |
|
countByCampaign(1 arg) |
Integer |
Services#
RecurringDonationService#
class · public inherited sharing
implements IRecurringDonationService
recurring/
Docs note
Self-managed recurring donations (Phase B) — no Stripe Subscription object anywhere in this class. Signup is split in two: activateRecurringStaging (guest context) records the donor's mandate on Donation_Staging__c and creates nothing, then createScheduleFromStaging builds the Active Recurring_Donation__c in system context from inside DonationService's finalize path, alongside gift #1's Opportunity; RecurringRenewalBatch drives every later charge off-session via IPaymentGateway.chargeOffSession, and processRenewal is the dunning state machine that decides what a charge outcome means for the schedule.
processRenewal's three branches: a successful charge creates the installment Opportunity and advances Next_Payment_Date__c (advanceScheduleDate, which anchors off the schedule's OWN due date rather than today, so a late-running batch doesn't drift the billing cycle); requiresAction sets Re_Auth_Required__c and throttles re-auth emails to once per reAuthThrottleDays; a decline checks TERMINAL_DECLINE_CODES (e.g. stolen_card, expired_card) to fail the schedule immediately versus scheduling a retry via SettingsService.retryIntervalDays()/maxFailedPaymentRetries(). INDETERMINATE_DECLINE is the sentinel used when the gateway callout itself failed (timeout, etc.) rather than returning a real decline code — treated as retryable, never terminal.
The pause/resume/cancel/reinstate methods here mutate Recurring_Donation__c directly and do NOT write a Recurring_Donation_History__c row — see RecurringDonations.onAfterInsert, which writes the schedule's one Created row. The self-service and term methods added 2026-08-06 DO append history (changeAmount, changeDesignation, skipNextPayment, and processRenewal when a capped schedule completes), all through registerHistory below, so a donor-visible change to a live commitment is auditable even though the older status transitions are not.
The stored Recurring_Designation__c rows are re-derived from their percentages rather than left alone. processRenewal already allocates each installment by percentage, so a stale split Amount__c would not misallocate anything — but it WOULD be read by anyone reporting on the schedule's own children, and a $50 split sitting under a $100 gift is simply wrong on its face. A split with no percentage (a fixed-amount split) is left as authored; scaling it would silently reinterpret an amount the donor chose deliberately.
Validation is delegated to ICampaignDesignationService.filterAllowedSplits — the SAME authority the public donation form is checked against, so a fund the campaign will not accept at checkout cannot be reached by editing an existing schedule either. That method SUBSTITUTES the campaign default rather than rejecting, so the returned split is compared back against what was asked for: a substitution means the request was not allowed, and a donor who asked for fund A must never silently end up giving to fund B.
A skipped cycle does NOT count toward Installment_Cap__c: the counter only moves on a successful capture, so a 12-payment pledge with one skip still collects 12 payments (it just finishes a month later). Refused while a retry is pending or re-auth is outstanding, because in both cases the "next payment" is a charge that has already failed — skipping it would quietly write off money the donor still owes rather than skipping a future one.
This method used to create the whole schedule inline, which could never work: the guest user has no Create on Contact or Recurring_Donation__c, and both objects carry fflib domain triggers whose handleAfterInsert re-asserts object CRUD regardless of the DML's AccessLevel — so a SYSTEM_MODE insert still threw "Permission to create an Contact denied". Guest Users can never be granted that Create (platform restriction), so staging-only is the correct shape, and it matches the one-time path exactly.
The caller is responsible for the post-commit follow-up (donor confirmation email and the optional NPSP mirror) because neither can run until the schedule has a real Id.
Setup#
Controllers#
Docs note
Backs the lexEmbedBuilder admin LWC only — it builds the preview/snippet payload for a logged-in admin, distinct from the actual guest-facing embed delivery. The restBase this method returns is consumed client-side to construct calls against EmbedConfigResource, the namespace-qualified Apex REST endpoint that serves the embed config to the external site at runtime and enforces the CORS allowlist via SettingsService.allowedEmbedOrigins(). Both classes independently resolve the namespace prefix because a guest REST resource and an internal @AuraEnabled controller run in different contexts.
The panel's two "Settings → …" links used to be a hardcoded '/lightning/n/Settings' in lexEmbedBuilder.js. A custom tab is namespace-prefixed once packaged, so that URL 404s in every subscriber org — and it is the link the panel offers precisely when the admin has hit the "add your domain to Allowed Embed Origins" wall, i.e. exactly when they cannot afford a dead end. Same reason SetupController hands setGetStarted a settingsTabApiName: the source may never write the prefix, so the server resolves it and the LWC turns it into a URL.
Fixed, not read from the design record. Embed_Button_Label__c was the only embed-specific field on Campaign_Design__c and no packaged theme ever set it, so every org saw this same fallback while the field cost a row on the design layout. The snippet this builds is copy-and-paste HTML — an admin who wants different wording edits the label in the snippet they just pasted.
| Method | Returns | |
|---|---|---|
getEmbedConfig(1 arg) |
Map<String, Object> |
@AuraEnabled |
| calls: SettingsService.isEmbedEnabled() |
PostInstallHandler#
class · global without sharing
implements InstallHandler
setup/
Docs note
Cut from eleven to six on 2026-08-13. Eleven rows differing only in hue read as one design with eleven palettes, which is worse than six that genuinely differ: an admin scrolling a picker of near-identical swatches learns nothing about what each one does to their page. Each survivor now differs from every other in column ratio, container width, corner radius, shadow, density, button style, palette AND both typefaces — and carries its own Theme_Tokens_JSON__c character set on top. RETIRED_DESIGN_MAP below names where the five deleted ones send their campaigns.
Deterministic and logged. This restyles live subscriber pages on upgrade day — an accepted cost of hard-deleting rather than deactivating. Deactivating instead was considered and rejected: an inactive design still holds every campaign that points at it, so the picker would be honest while the pages stayed on styles the package no longer maintains or tests. Each retired name maps to its nearest survivor by palette family, not alphabetically — Ember Gala and Sanctuary already shared the plum family, Lantern and Grove the green, Bloom and Sunrise the warm coral. Slate and Nocturne have no close relative and fall back to the default. Stated in the release notes.
Every seed* step here is idempotent (upsert or "insert only if absent"), so onInstall is safe to re-run on every package upgrade, not just the first install — it only fills blanks and never overwrites an admin's existing configuration. scheduleJobs() schedules the complete pipeline — the Finalizer and ReconciliationHeartbeat families plus every single job, including the two Stripe ones, which are fresh-org safe and no-op until a gateway is connected (see the comment on scheduleJobs itself). expectedJobCount() derives how many cron entries that leaves behind, and SetupController's health check and Get Started step both grade against it rather than against a literal of their own.
Every step is individually guarded and only ever logged, never rethrown. A post-install script that throws makes the whole package version UNINSTALLABLE — the subscriber sees nothing but "The post install script failed." with no stack trace, and the script runs as a per-package system user that does not exist before runtime, so it cannot be traced in advance either.
Seeding is convenience: every step only fills a blank an admin can set by hand afterwards, so degrading one default is always preferable to bricking the install. A failure lands in Log__c under the step's own name — that is the only diagnostic channel available for this context, so keep it, and never "simplify" a guard away.
Upgrade-only step for the 2026-08-07 Payment_Account__c change. Recurring_Donation__c and Payment_Method__c now remember which Stripe account minted the cus_/pm_ pair they carry, because re-deriving it from Campaign.Payment_Account__c at renewal time redirected every in-flight schedule the moment an admin re-pointed the campaign. Rows created before the fields existed are blank, so PaymentAccountBackfillBatch resolves each ONE time from its campaign and freezes it. See the batch's own header for the two passes and what it deliberately leaves blank. AdminCredentialBackfillBatch does the same for Payment_Account__c.Admin_Named_Credential__c, which is required and therefore makes every pre-existing account unsavable until it is filled.
Paired with SettingsService.SUPERSEDED_PORTAL_CONFIRMATION_MESSAGE — delete both together once no installed org can still be carrying the pre-1.0.0-17 seed.
Runs AFTER seedDefaultPageDesign, not before, and the ordering is load-bearing twice over. (1) seedDefaultSettings only switches Auto_Receipt_Enabled__c and Soft_Credit_Automation_Enabled__c on when NO Settings__c row exists yet; this step's ensureDefaultImagesSeeded() creates that row, so running it earlier would silently disable both on every fresh install. (2) buildTheme rewrites both image fields to null on each re-seed like every other packaged-theme field, so the stamp has to come after the designs are written or an upgrade would leave them blank.
The two Stripe jobs used to be deliberately EXCLUDED, on the grounds that both reach PaymentGatewayFactory.getDefault() unconditionally and would throw on an org with no active Payment_Account__c. That left them schedulable only by scripts/apex/schedule-reconciliation-jobs.apex — which lives outside base/ and is therefore never packaged, so in a subscriber org refund and dispute reconciliation simply never ran. Both are now fresh-org safe (RefundReconciliationBatch.start() returns an empty scope when selectAllActive() is empty; StripePaymentSweep.execute() checks PaymentGatewayFactory.hasActiveAccount()), so they are scheduled at install and no-op quietly until an admin connects Stripe. Names and offsets match the dev script's, so scheduleIfAbsent leaves an already-scripted org alone.
The cadences scheduleJobs() runs on, named rather than inlined so expectedJobCount() below can derive the total from the same numbers the loops use. A literal count kept in a second class is a count that drifts: SetupController's health check hardcoded 21 and went on reporting "24/21 jobs are scheduled" — a green step showing failing arithmetic — for the whole release after the three guest-request sweeps were added here.
Derived, never a literal. Every term traces to the loop or list scheduleJobs() actually iterates, so adding a job family without updating this count is not possible: the count IS the families.
present is the set of job names that are scheduled AND fit to keep — existing names minus anything abortOrphanedJobs() just aborted. Reading it rather than re-querying is deliberate: whether an aborted CronTrigger disappears from SOQL within the same transaction is not contractual, and getting that wrong would silently skip exactly the jobs the reclaim exists to fix. Aborting frees the name, so rescheduling it is safe.
Keyed by job name, carrying OwnerId, so both questions scheduleJobs() has to answer — "is this one already scheduled?" and "is its owner still a real person?" — come out of one query instead of one per job plus a re-read.
A CronTrigger row survives its owner: deactivating a user does not stop their scheduled Apex, it just leaves the job running as someone nobody can reach. That is the same failure as the install-time system user, one department reshuffle later, which is why the test is "resolves to an ACTIVE User" and not "is a real Id".
Runs AFTER the upsert above, never before — the survivor it reassigns to has to exist by the time a campaign is pointed at it, and on a fresh-ish org (installed before the cull, upgraded after) Sanctuary and Grove are only created by that same upsert.
Guarded in its own right rather than relying on onInstall's guard: a delete that fails because an admin locked a record must not roll back the six designs the upsert just wrote. An unguarded throw in a post-install script makes the whole package version uninstallable.
A retired design an admin has RENAMED is invisible here and survives untouched, which is the right outcome — a renamed record is the admin's, not the package's.
The packaged catalog. Six designs, governed by one rule: any two rows must differ in MORE than colour. Column ratio, container width, corner radius, shadow, density, button style, body face and heading face are all differentiators, and PostInstallHandler_Test asserts twelve distinct faces across the six rows plus a distinct structural fingerprint per row.
Two constraints on the faces. A face may appear in the OPPOSITE role on another theme — a serif at 15px body and the same serif at 60px display are not the same texture — but no face may repeat in the same role. And two catalog faces are deliberately never used for headings: Instrument Serif ships 400 only and IBM Plex Mono 400/500, so at the 600-700 weights headings render they would be synthetically emboldened into a smeared headline.
character() is the fine dial. The discrete fields above are coarse — six designs that set only those read as six palettes of one page — so each theme also seeds a set of catalog tokens that changes what the page is MADE of: rules instead of cards, a drop cap, a squared corner, a denser story column.
Only keys present in Theme_Token__mdt survive CampaignService.normalizeThemeTokensJson(), so a typo here is silently dropped rather than rendered — the test asserts every key against the catalog for exactly that reason. Values must stay CSS-injection-safe (no ';', '{', '}', 'url(') or the same guard strips them.
EVERY row must carry the four --pf-portal-accent* tokens. They are the only route by which the donor portal learns a theme's colour: publicDonorDashboard declares its whole steady/positive family as var(--pf-portal-accent, #2E5A4B), and pfThemeVars has no field to derive them from, so a row that omits them leaves the portal rendering the mock's moss green over that theme's page — reported 2026-08-14 as a green button and mint panels on the coral Sunrise theme.
accent is the theme's own primary; soft is the panel tint behind the statement header and trust block; line is its hairline; mute is the desaturated tone for inactive pledge ticks. PostInstallHandler_Test asserts all four on all six rows.
EVERY row must likewise carry the status family — --pf-portal-warn/-soft/-ink, --pf-ui-warning-ink, and --pf-ui-error/-bg/-border — for the same reason and with one extra twist. Nine bundles read those seven tokens (dfForm's validation, the receipt and ticket wallets' clay/honey pairs, the portal's caution rows, the tier "only N left" chip, the event registration error box, pfGivingStatement, pfStyles), no design FIELD derives any of them, and nothing set them before 2026-08-14 — so every one of those rendered its own hardcoded fallback, and those fallbacks had DRIFTED: four different reds (#C7362B, #BC3A2B, #c00) and four different tints (#FCE4E4, #FBEDE8, #fdf0ee) for one semantic role.
Seeding them makes the role consistent across the site and admin-overridable per design at the same time.
Warn varies per theme, error does NOT. Caution is decorative-adjacent — it should sit in the theme's own temperature, which is why Marigold's is terracotta (see its map). Error is a safety signal: it must never be mistaken for the brand, and docs/brand-identity.md reserves #C7362B for it and forbids reusing Ember coral, so all six rows seed the identical triplet below rather than a theme-derived red. That is deliberate, not a copy-paste oversight.
The primary is a deepened coral (#C4462F), not the #E0533D of the brand palette. #E0533D contrasts 3.84:1 against white — below the 4.5:1 WCAG AA minimum — and it is used BOTH as a fill under white text (the donate button, the selected tab) and as ink on the near-white page (the raised total, the required-field mark, the FAQ marker), so no choice of foreground rescues it. #C4462F reads 4.92:1 in both directions and stays recognisably the same coral.
Muted #82686F, not #8A7078 — the latter sat exactly on 4.50:1, with no margin for the sub-pixel antialiasing that makes small grey type read lighter than it measures.
Muted #7B6B77, not #857581 — the original read 4.32:1 on this theme's near-white page, under the 4.5:1 WCAG AA minimum for the small type it carries.
Muted #7C7154, not #857A5C — the original read 4.25:1 on this theme's cream page, under the 4.5:1 WCAG AA minimum for the small type it carries.
The only theme whose caution family is terracotta rather than amber. Marigold's own primary IS ochre (#9A6410) on a cream page, so an amber warning would be indistinguishable from the brand — a "ticket almost sold out" chip has to read as a warning, not as a second heading. Terracotta is the theme's declared accent.
The catalog's only 40/60 — the payment box sits on the LEFT. It used to lift itself 64px above the hero (--pf-rail-offset-top: -64px) to sit "in the hero band"; that was measured against the rail's own box, which is 24px taller than the card inside it, so what actually rendered was a card overlapping the trust bar — its top edge landed 3px above the bar's bottom. Grove now takes the shared default of 0 like every other design: both columns start at the same line, which is the whole point of the canonical two-column layout.
Button fill IS the primary on all six survivors. The retired catalog had one theme whose button drifted two hex digits off its primary for no stated reason, which is why ThemeSpec used to carry a separate btn; the field is gone rather than left as a trap that lets a future row silently break the palette's one-accent rule.
Re-seeding rewrites this to false like every other field on a preinstalled style; an admin who wants the prompt clones the theme and enables it on the clone.
The share image is no longer stamped alongside it. That field moved to Settings__c.Social_Share_Image_URL__c on 2026-08-12, where SettingsService .socialShareImageUrl() already falls back to the Default_Social_Share_Image_URL__c cache — so the packaged default reaches the page with no per-design copy to keep in sync. The same move took Privacy_URL__c, Terms_URL__c and Footer_Text__c off the theme: every packaged design carried an identical copy of the same three values, and two of them were placeholder example.org URLs that would have rendered as live footer links the moment the footer band shipped.
These used to hold a hand-picked images.unsplash.com photo per theme, which only rendered because the package shipped an Unsplash CSP trusted site — putting a non-payment third party into the install-time "Approve Third-Party Access" dialog for a decorative default. The package now ships Stripe trusted sites only; the packaged artwork is served from the org's own file.force.com domain, which a guest LWR site allows with no trusted site at all.
An admin who wants stock photography adds their image host under Setup → CSP Trusted Sites themselves; the Get Started panel's "third-party content" step lists the hosts the optional features can use and reports which ones this org trusts.
Each of these is DERIVED from the structural field it refines rather than pinned to a constant, which is a change from the previous catalog: because the stored JSON always beats the shell's mapping of the field, a hard-coded width or shadow here silently made Content_Max_Width__c and Shadow_Style__c inert on every packaged design — the record said "Narrow" and the page rendered 1440px. Derivation keeps the record honest about what it renders.
The scale is the redesign's, not the shell's older one, so it must stay stated here: the shell declares --pf-layout-padding-x on .pub-fundraising, so it is ALWAYS defined and a stylesheet fallback can never apply. pfStyles clamps the gutter back down at the two responsive breakpoints via min(), so phones are unaffected.
Values are CSS-injection-safe (no ';', '{', '}', 'url(') so they pass the CampaignService allowlist guard untouched.
An aspect ratio, not a pixel height — a fixed height crops the hero to a different slice of the same photo at every breakpoint, while a ratio keeps the crop constant and lets the band shrink with the page.
Switching on t.name is seed-time only. Nothing on the guest render path branches on a design record's name, so a cloned design renders identically to the theme it came from — it inherits the resolved token values, not the switch.
Built as a Map and serialized rather than concatenated into a JSON string by hand, which is what the previous catalog did. Two reasons.
String-splicing three fragments made every fragment responsible for its own trailing comma, so adding a key in the wrong place produced malformed JSON that only surfaced at render time. And a character override that repeated a base key emitted a DUPLICATE JSON key — legal to write, resolved by last-wins on read, but impossible to reason about.
With a Map, putAll gives character() a defined, deliberate win over the derived base: that is how Marigold squares its hero and Sanctuary swaps its card shadow for a hairline border.
The lookup is by name, so renaming the pricebook would normally strand the one an earlier version already created and silently insert an empty replacement beside it — with every existing PricebookEntry still hanging off the old record. LEGACY_EVENT_ PRICEBOOK_NAME exists solely to make that rename survivable: if only the old-named pricebook is present, it is renamed in place rather than abandoned, so its entries come along and no duplicate appears. Remove the constant and the adopt branch once no org can still hold a pricebook under the old name.
Docs note
The one non-cacheable read on this controller. Its card pairs this fetch with a setCheckInStatus write and then reloads, so a cacheable=true here would serve the pre-flip status straight back out of the Lightning Data Service cache and the seat would look un-checked-in until a full page reload.
RecordPageService#
class · public with sharing
implements IRecordPageService
setup/
Docs note
The single implementation behind RecordPageController's four cacheable Lightning record-page actions (donor summary, recurring list, recurring history, receipt data). Every method here is written to degrade gracefully rather than error: getDonorSummary swallows a QueryException around the Opportunity.Recurring_Donation__c breakdown query so a subscriber org on an older package version (field not yet present) still gets back totals, just without the one-time/recurring split.
getDesignationPerformance follows the same posture: the recurring-commitment half is wrapped so a fund card still renders its realized giving even if the running user cannot read Recurring_Designation__c.
getPaymentMethodDependents is the one method that deliberately reports MORE than the data model states: it matches dependent schedules on the raw Stripe token as well as the Payment_Method__c lookup, because the renewal batch charges the token and the lookup can be stale (see the selector's comment).
Backs lexDesignationPerformance on the Designation (fund) record page: what this fund has actually realized, which campaigns raised it, and what live recurring schedules still commit to it. "Net raised" is gross split amount minus each split's PRORATED share of its parent gift's refund — the same math DonationDesignationsSelector uses everywhere else, so the card agrees with the campaign-side fund table rather than quietly disagreeing with it. Note this is NOT Designation__c.Total_Raised__c: that rollup is gross and refund-blind.
Backs lexEventAttendeeDetail on the Event Attendee record page — the single-seat view of a record the console's check-in board only ever manages in bulk. It answers the two questions a door volunteer holding one seat cannot answer from the board: what was actually bought (tier, price paid, the order that paid) and who else came in on the same order. The party list is what makes this more than a field layout: a group registration buys four seats in one transaction, and checking one in usually means checking all four in.
Backs lexContentTemplateUsage on the Content_Template__c record page. A template is a SHARED row — one question or FAQ reused across many campaigns — but nothing on the record says so, because the reference is a JSON id array on Campaign rather than a lookup, so there is no related list the platform can build.
Editing the prompt here rewrites it on every live page in the list; that is the fact this card exists to make visible before the edit, not after.
It also corrects a false assumption the Is_Active__c checkbox invites. Is_Active__c is NOT a kill switch: CustomQuestionService/CampaignFaqService resolve a campaign's selection through ContentTemplatesSelector.selectByIds, which does not filter on it, so an inactive template already picked by a campaign keeps rendering to donors.
The flag only hides the row from the admin picker (selectActiveByRecordType). The card says so in as many words.
| Method | Returns | |
|---|---|---|
getDonorSummary(2 args) |
Map<String, Object> |
|
getRecurringDonations(2 args) |
List<Recurring_Donation__c> |
|
getReceiptData(1 arg) |
Map<String, Object> |
|
| calls: ReceiptStatusHelper.resolve(), SettingsService.defaultCurrency(), ReceiptStatusHelper.legalStatement() | ||
getRecurringDonationHistory(1 arg) |
Map<String, Object> |
|
getDesignationPerformance(1 arg) |
Map<String, Object> |
|
getPaymentMethodDependents(1 arg) |
Map<String, Object> |
|
getEventAttendeeDetail(1 arg) |
Map<String, Object> |
|
getContentTemplateUsage(1 arg) |
Map<String, Object> |
|
compare(4 args) |
Integer |
Docs note
Backing controller for the entire internal admin setup console — record type mapping, general settings, payment account CRUD/connection test, page-design CRUD, scheduled-job status, org feature detection, and the health check panel. Nearly every read method here is deliberately NOT @AuraEnabled(cacheable=true): the setup screens re-load imperatively after every save/reset so the admin sees their own change immediately, and a cacheable method served imperatively would instead return Lightning's stale client cache.
SYSTEM_TEMPLATE_NAMES (below) protects the 10 packaged page-design themes seeded by PostInstallHandler, plus five legacy names from before the 2026-07-24 theme rebuild, from admin edit/delete — see isSystemTemplateName() at the page-design CRUD actions. runHealthCheck()'s scheduled-job check and getSetupGuide()'s "background jobs" step share countPipelineJobs(), which counts every cron entry PostInstallHandler.scheduleJobs() creates and grades it against that class's own PostInstallHandler.expectedJobCount() — see expectedPipelineJobs().
getSetupGuide() is the backing read for the Get Started panel (setGetStarted): the ordered, live-checked version of the post-install checklist published at docs-site/docs/getting-started/after-you-install.md and pointed at by the package's postInstallUrl. Keep the two in step — a step added here needs a section there, and vice versa, or the page a subscriber is redirected to stops matching what their org shows them.
The donation-pipeline monitor's two methods (getStuckDonations, retryDonationStagingNow) used to live here and moved to DonationMonitorController on 2026-08-14, with the LWC, when the monitor became its own Lightning tab. They had to leave: this class is granted to Fundraising_Admin only, because it is the sole entry point behind every settings panel, and the monitor is meant for program staff who must never reach the console. Do not add monitor methods back here.
The diagnostic-log read (getRecentLogs) used to live here and moved to DiagnosticLogController on 2026-08-15, with the list itself, when reading Log__c became its own Diagnostic Logs tab. It had to leave for the same reason the donation monitor did: this class is granted to Fundraising_Admin only, because it is the sole entry point behind every settings panel, and troubleshooting staff must be able to read logs without being handed the console. getRecentErrorLogs above stays — it reads Transaction_Log__c (the Stripe callout audit trail) for the Logging panel's own error list, not Log__c.
No 'experience_site' branch. It used to return CreateExperienceSite().doExecute(), whose payload is a CLI command rather than a created site — the callers treat any non-error return as "fixed", so the panel claimed success and nothing happened. Site creation is not automatable; setSite still calls createExperienceSite() to FETCH that command, which is honest, and is the only remaining caller.
The silent fall-through this replaces is what let the site button lie. Both callers (setGetStarted.handleFix, setHealth.handleFix) toast "Applied the recommended fix" on ANY non-error return, so an itemId with no branch — a removed fixer, a typo, a step whose fix was never written — reported success and did nothing. An unknown id is now an error the admin can see, which is the only honest answer when nothing was repaired.
The ordered, live-checked post-install checklist behind the Get Started panel. Each step reports its own status from the org rather than from a stored "done" flag, so an admin who undoes a step (deactivates the last payment account, aborts the cron jobs) sees it reopen. That is deliberate: there is no Setup_Step_Complete__c field anywhere, and there should not be one — a checklist that remembers being ticked lies the moment the org drifts.
No fix button, deliberately. Salesforce exposes no supported Apex or ConnectApi call that creates an Experience Cloud site, so there is nothing a button here could do — CommunityService.createSite() only composes the sf community create command for the admin to run. It used to be wired to the generic Fix handler anyway, which ignored the returned command and toasted "Applied the recommended fix", so the button reported success and created nothing (found 2026-08-17 by a first-run walkthrough).
"Take me there" now carries the whole step: the Site & Domain panel holds the real instructions, the copyable command, and the Setup-UI alternative.
This step is graded on Network.Status — Live vs UnderConstruction — which is ACTIVATION, and activation is not publication. Publishing in Experience Builder pushes the current content live and leaves Status exactly where it was; only Workspaces → Administration → Settings → Activate moves it.
The step used to be titled "Publish your donation site" and told the admin to publish, so following it exactly could never clear it: a first-run walkthrough published successfully, watched the row stay amber quoting "is UnderConstruction", and concluded the check was broken (2026-08-17). Both actions are needed and both are now named, in order, with the one this row actually measures called out last.
One task, one noun. This step used to be titled "Connect your Stripe account", described in terms of a Named Credential, and evidenced as "N active payment account(s)" — three names for the same piece of work, which a first-run walkthrough read as three separate things it had failed to do (2026-08-17). "Payment account" is the noun that survives, because it is the record the admin actually creates on the Payments panel and the one every later check names.
Both this step and "Set up your payment account" send the admin to the same panel, and a first-run walkthrough arrived with no way to tell which of the two it had come for (2026-08-17, finding 7). Each summary now names the step on the Payments panel it corresponds to — by name, never by number, because PaymentSetupGuideService assigns those numbers positionally.
The route to the guest user is spelled out in full because the two routes an admin reaches for first are both dead ends: "Administration → Members" manages which profiles and permission sets may be MEMBERS of the site, and the guest user is not listed under Setup → Users at all. This checklist named both of them until 2026-08-17, when a first-run walkthrough followed them and got nowhere. Route verified against Salesforce Help, "Assign the Permission Sets to Experience Cloud Site Guest Users" (Summer '26); the Site & Domain and Payments panels carry the same wording.
This description states the consequence rather than the mechanism because the previous wording ("the install schedules all of them; re-run this if any were aborted") read as reassurance, and a 2026-08-17 fresh-install run skipped the step on the strength of it, then took a real card payment that no record was ever created for.
The donation form still does not REFUSE a gift while the finalizer is down — blocking a donation to guard against a delay would be the larger outage — so this description carries the consequence. Since 2026-08-18 (F-36) it does raise the alarm: each such gift writes a PIPELINE_STALE Transaction_Log__c row, and the heartbeat read below turns this step red even when all 24 jobs are present but none of them is running.
The label follows the failure, because the two failures need different words. A stale pipeline has every job PRESENT and none of them running (F-67: install-time jobs owned by an unreachable system user), so offering to "schedule the missing jobs" describes work there is none of — a 2026-08-17 walkthrough read exactly that and concluded the button was broken. Same button either way; it schedules what is absent and re-owns what is orphaned (SetupController.FixHealthCheckItem → scheduleJobs(true)).
The unset detail used to read "public pages fall back to the org name". They do not. UiConfigController hands orgName straight from SettingsService.orgDisplayName(), which is null when the field is blank, and every guest consumer degrades to empty text — pfTrustbar, publicThankYou, publicEventTickets and publicDonorDashboard to '', publicCampaignGallery to the literal word "Fundraising". (Their monogram tiles degrade to '?' too, but only in an org with no logo, and the install seeds one.) The only place that really falls back to UserInfo.getOrganizationName() is the INTERNAL receipt print page (RecordPageService.getReceiptData), because a tax document with an empty letterhead is not a receipt. Saying the guest pages do the same told an admin the blank field was harmless when it is what a donor sees.
Every step's "read more" is this URL plus the docsAnchor its buildGuideStep call passes. Those anchors are NOT derived from the headings on that page — MkDocs would slug "## 4. Connect Stripe" as 4-connect-stripe, and for the whole release before 2026-08-17 seven of the eight links pointed at ids that did not exist, landing the admin at the top of a 450-line page with no indication anything had gone wrong.
The page now pins each heading's id with an attr_list { #slug } so the anchor survives renumbering and rewording. Adding a step here means adding a section AND its { #slug } there. Four of them had drifted apart again by 2026-08-18, so the pairing is no longer trusted to reviewers: docs-site/scripts/mkdocs_hooks.py reads the anchors back out of this class and fails the docs build when one is missing from that page. A renamed section now breaks a build instead of quietly breaking a link.
The package ships CSP trusted sites for Stripe and nothing else. That is a deliberate install-experience decision: every packaged CspTrustedSite is listed in the "Approve Third-Party Access" dialog a subscriber sees mid-install, and a fundraising package asking to reach five unfamiliar domains reads as a warning rather than as a payment integration.
The hosts below back OPTIONAL features, so they are the subscriber's call to add — Setup → CSP Trusted Sites — and this step tells them which one unlocks what. Apex cannot create them: CspTrustedSite.SObjectType.getDescribe().isCreateable() is false, and the Apex Metadata API supports only Layout and CustomMetadata records. So this step detects and explains; it can never offer a one-click fix.
The object-access leg was added 2026-08-19 (audit F-76). Until then this row graded the principal grant alone and went green on an org where every public donation still died at the callout with "You don't have read permissions on the User External Credential object" — the Site & Domain panel's step 5 already reported that org as broken, so the console contradicted itself on the same fact, and this row was the one an admin sees first. It is a distinct 'warn' rather than 'fail' because the two grants are separate pieces of work and an admin who has done one has genuinely done half of the step.
Returns the raw Settings__c on purpose — ControllerAction.run() re-keys every SObject in a payload to unprefixed field API names before it reaches the browser, so the eleven set* panels can keep reading data.Org_Logo_URL__c under any namespace. Do not hand-map it here; that was the shape of the original bug's fix and it only ever covered this one payload. See the namespace note on ControllerAction.
Blanking either fee-coverage field is a supported way to fall back to the Stripe US standard (2.9% + $0.30) that SettingsService defaults to — the nulls below are deliberate, not a failed parse.
SavePaymentAccount answers with { id, warning } rather than a bare Id so the console can say something at the moment a shared credential is chosen. A WARNING, never a block. Running one credential in both roles is a configuration an admin may choose deliberately, and blocking it would fight a decision they have just made. The value here is timing: it catches the exposure at the moment it is created rather than on the next guide load, which the admin may never open again.
Coalesced, never assigned straight through. Column_Ratio__c is required with a picklist default, but a default only fires when the field is absent from the DML payload — writing an explicit null instead fails the insert with REQUIRED_FIELD_MISSING. Any caller that does not send the key (an older console build, a scripted save) would otherwise be unable to create a design at all.
persisted=true is set here rather than left to resolveExperienceSite() because this payload is what the panel renders immediately after the save, and the hierarchy custom setting is still cached at this point in the transaction — a re-resolve would report the site as unsaved and re-arm "Use this site".
Nests the Stripe-credential check under 'stripeCredential', and the guest Campaign-access check under 'objectAccess', on the same round trip rather than adding further @AuraEnabled methods — additive so every existing key keeps its meaning, and no permission-set classAccesses change is needed. See CommunityService.checkStripeCredentialAccess() and checkGuestObjectAccess().
persisted is the distinction the console could not previously draw: whether this site came from the admin's SAVED choice (Settings__c.Experience_Network_Id__c) or was merely DISCOVERED by CommunityService's name/prefix convention. Discovery is a convenience, not a decision — nothing is stored, Experience_Site_URL__c is still blank, and every public link the console composes is still dead.
Without the flag the picker pre-selected the discovered site, compared it against the site it was already showing, and disabled "Use this site" permanently: the single click that would persist it could never be made, while the rest of the panel kept reporting "No site URL yet" and step 2 showed a green Done (2026-08-17 naive-admin walkthrough, findings 11 and 12).
The URL fallback is the other half. ConnectApi is the only source that reports a real public base URL — Network has no such field, and its UrlPathPrefix is a path fragment that Setup renders with a suffix donors never type. So a discovered-but-unsaved site borrows its URL from listSites() rather than showing none.
One normalization for every donor-facing URL this controller composes. Both the public links panel and the setup checklist's page table append a path to this value, so a trailing slash left on it produces "…/fundraising//donate" — a 404 on Enhanced LWR.
Attaches the finished public page URLs to a site-status payload so the setup checklist's page table can offer a live "Open" link per page. Composed here rather than in setSite.js deliberately: SettingsService is the single authority for the slash handling on the site URL and the two configurable paths, and a second implementation in JS would drift from it the first time a default changes. siteBaseUrl is passed in rather than always re-read because the hierarchy custom setting is cached within the transaction — right after saveExperienceSite writes a new site URL, a re-read still returns the previous one.
This was a hardcoded 21 until 2026-08-17. The three guest-request sweep jobs were added to scheduleJobs() afterwards and the literal was not, so a correctly-scheduled org reported "24/21 jobs are scheduled" under a green tick — arithmetic that reads as a defect to the admin looking at it. Deriving it from PostInstallHandler.expectedJobCount() means the two cannot drift again.
Counts jobs that are still LIVE, not just jobs sitting in WAITING. CronTrigger.State cycles WAITING → ACQUIRED → EXECUTING and back on every single fire, per the CronTrigger Object Reference (Summer '26 / API 67.0). With twelve finalizer jobs on a five-minute cadence, one of them is very often mid-fire — so the old State = 'WAITING' filter under-counted at random.
A first-run walkthrough pressed Re-check seven times with no action in between and read 24, 24, 23, 24, 24, 24, 24; on the 23 the console dropped the org's score and offered a "Schedule the missing jobs" button for a job that was never missing (2026-08-17, finding 4). A health check that flickers is worse than no health check: it teaches the admin to fix things that are not broken.
The live test is instead — the row exists, it has a next fire time, and it is not in one of the three terminal states. COMPLETE is documented as "fired and is not scheduled to fire again"; DELETED and ERROR speak for themselves. A job removed with System.abortJob() leaves no CronTrigger row at all, which this counts as missing — correctly. Source: https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_crontrigger.htm
THE SECOND HALF OF THE SCHEDULED-JOBS QUESTION (audit F-57), folded into the same row rather than given its own: "24 of 24 scheduled" and "and they can authenticate" are two halves of one answer, and an admin who reads the first and stops has learned nothing.
A named-principal callout authenticates as the RUNNING user, and a scheduled job runs as whoever scheduled it. So every gateway-touching job — the finalizer family, recurring billing, the Stripe payment sweep, refund/dispute reconciliation — fails outright unless its owner holds a permission set that grants an external credential principal. F-67 fixed WHO owns the jobs (a reachable admin instead of the post-install system user); it could not fix whether that admin is entitled, because a package cannot assign onto its own managed permission set. Nothing else in the console notices: the count row reads green, every CronTrigger reads WAITING and on time, and the only trace of a failed run is an ERROR row in Diagnostic Logs.
GREEN IS NOT REACHABLE HERE, deliberately. SetupEntityAccess names the permission set that carries a grant but its SetupEntityId points at an ExternalCredentialParameter, which standard SOQL cannot query — so "this owner holds a grant" is provable and "this owner holds the RIGHT grant" is not. The best case is therefore a sentence and no colour at all (see CONFIRM_REACH) — the same honest-limits framing that PaymentSetupGuideService.stepPermissionSet uses.
Returns null — no clause at all — when there is no active payment account (nothing to reach yet) or when any read fails. A question this row cannot answer must not be able to turn the row red on its own.
THREE SEAMS, not protected virtual methods like PaymentSetupGuideService's, because this controller is static. Same reason though: no test can schedule a job under another user, create a SetupEntityAccess row, or assign a managed permission set, so the only way to exercise the verdicts is to hand the logic the org state a real install would have.
SetupEntityType 'ExternalCredentialParameter' is the grant an admin makes under a permission set's External Credential Principal Access. Parent is the permission set; SetupEntityId is the parameter, and that object is not queryable in standard SOQL — which is the whole reason no grant here can be tied to a particular credential.
CONFIRM is not a colour. It is the best case this check can reach — the owner holds a grant, and no package can prove it is the right one — so it contributes its sentence and leaves the row's status alone. Colouring it amber would leave a correctly configured org permanently warning about something its admin cannot resolve from that screen, which is exactly the loud-and-unactionable alarm F-53 removed one row below.
Worse-of-two, so folding the entitlement clause into the count row can only ever make it more alarming — a green count is dragged to red by a stranded owner, and a red count is never softened by an entitled one. Only the failing verdict is passed through; see CONFIRM_REACH for why the best case deliberately does not colour anything.
One verdict shared by the health-check panel and the Get Started step so the two can never disagree about how bad things are. Returns a status AND its sentence together, because the two are one judgement: a caller that picked its own colour and borrowed this text is how the two rows drifted apart in the first place.
THREE states, not two (audit F-53). SettingsService.isFinalizerPipelineStale() folds "never reported in" into "stale" on purpose — for the guest donation path, a pipeline that has never run is exactly as dead as one that stopped, and that caller only needs the boolean. The console needs the distinction, because the two states differ in what is at RISK, not in what is broken. Every fresh install sits on never-ran with zero gifts taken, so rendering it as "gifts are charged but NOT recorded" made the loudest alarm in the console wrong on literally every install — and an alarm that cries wolf on day one is how the next real one gets ignored. It is still a warning, still carries the Fix button, and still says plainly that the jobs will not run until the admin restarts them: F-67 established that a fresh install schedules them under a system user the org cannot reach, so this is real work, just not yet a loss.
The damage number comes from the staging rows themselves — confirmed gifts with a PaymentIntent that no Opportunity was ever created for — because "gifts are being charged but not recorded" is only actionable with a count attached, and because a count of zero is what separates a fresh install from an org that is losing money.
| Method | Returns | |
|---|---|---|
runHealthCheck(0 args) |
Map<String, Object> |
@AuraEnabled |
fixHealthCheckItem(1 arg) |
Map<String, Object> |
@AuraEnabled |
getSetupGuide(0 args) |
Map<String, Object> |
@AuraEnabled |
getRecordTypeMappings(0 args) |
Map<String, Object> |
@AuraEnabled |
getRecordTypesForObject(1 arg) |
Map<String, Object> |
@AuraEnabled |
saveRecordTypeMapping(3 args) |
Map<String, Object> |
@AuraEnabled |
getSettings(0 args) |
Map<String, Object> |
@AuraEnabled |
getActiveDesignations(0 args) |
Map<String, Object> |
@AuraEnabled |
getPublicLinks(0 args) |
Map<String, Object> |
@AuraEnabled |
saveGeneralSettings(2 args) |
Map<String, Object> |
@AuraEnabled |
uploadOrgLogo(3 args) |
Map<String, Object> |
@AuraEnabled |
removeOrgLogo(0 args) |
Map<String, Object> |
@AuraEnabled |
getScheduledJobStatus(0 args) |
Map<String, Object> |
@AuraEnabled |
runJobNow(1 arg) |
Map<String, Object> |
@AuraEnabled |
abortJobFamily(1 arg) |
Map<String, Object> |
@AuraEnabled |
scheduleJobFamily(2 args) |
Map<String, Object> |
@AuraEnabled |
getNamedCredentials(0 args) |
Map<String, Object> |
@AuraEnabled |
getPaymentAccounts(0 args) |
Map<String, Object> |
@AuraEnabled |
getGatewayCatalog(0 args) |
Map<String, Object> |
@AuraEnabled |
savePaymentAccount(2 args) |
Map<String, Object> |
@AuraEnabled |
deletePaymentAccount(1 arg) |
Map<String, Object> |
@AuraEnabled |
testPaymentAccount(1 arg) |
Map<String, Object> |
@AuraEnabled |
getPaymentSetupGuide(1 arg) |
Map<String, Object> |
@AuraEnabled |
probeRestrictedKey(1 arg) |
Map<String, Object> |
@AuraEnabled |
getPageDesigns(0 args) |
Map<String, Object> |
@AuraEnabled |
savePageDesign(2 args) |
Map<String, Object> |
@AuraEnabled |
setDefaultDesign(1 arg) |
Map<String, Object> |
@AuraEnabled |
deletePageDesign(1 arg) |
Map<String, Object> |
@AuraEnabled |
detectOrgFeatures(0 args) |
Map<String, Object> |
@AuraEnabled |
getRecentErrorLogs(0 args) |
Map<String, Object> |
@AuraEnabled |
resetToDefaults(0 args) |
Map<String, Object> |
@AuraEnabled |
getExperienceSiteStatus(0 args) |
Map<String, Object> |
@AuraEnabled |
createExperienceSite(0 args) |
Map<String, Object> |
@AuraEnabled |
listExperienceSites(0 args) |
Map<String, Object> |
@AuraEnabled |
saveExperienceSite(2 args) |
Map<String, Object> |
@AuraEnabled |
checkGuestPermissionSet(1 arg) |
Map<String, Object> |
@AuraEnabled |
getThemeTokenCatalog(0 args) |
Map<String, Object> |
@AuraEnabled |
save(0 args) |
Map<String, Object> |
|
| calls: PaymentGatewayRegistry.newGateway() |
App-level UI config for LWCs that need it but have no payload of their own to carry it.
WHY THIS EXISTS: currency must come from Settings__c.Default_Currency__c — the value the gateway is actually charged in. Components were instead using @salesforce/i18n/currency (the ORG's currency, which can differ) or a hardcoded '$'. Threading currencyCode through ~18 unrelated Apex payloads would couple every one of them to a formatting concern; one cacheable read is shared by all of them (LDS caches it, so this costs a single round-trip per session).
WITHOUT SHARING is deliberately NOT used: this touches no records. Settings__c.getInstance() is a custom-setting read, which is exempt from CRUD/FLS, so guests resolve it without a grant.
| Method | Returns | |
|---|---|---|
getUiConfig(0 args) |
Map<String, Object> |
@AuraEnabled |
Domains#
CampaignDesigns#
class · public with sharing
extends fflib_SObjectDomain
setup/
Domain behaviour for Campaign_Design__c — keeps Is_Default__c a singleton.
FFLib role: Domain.
Docs note
Every public page that is not a donation page paints itself from the ORG-DEFAULT design record — CampaignController.getOrgDefaultTheme() resolves it through CampaignDesignsSelector.selectDefaultSystemMode(), which is LIMIT 1 with no deterministic ORDER BY.
So a second record carrying Is_Default__c = true does not produce an error, it produces a coin flip: the guest site picks whichever row the database returns first and can change its mind between requests.
That is why the flag is enforced here, in the domain, and not only in SetupController.SetDefaultDesign — the console's "make default" button already demoted its siblings, but a data load, the Salesforce record page, an admin's Data Loader run, or a second packaged seed all bypass it entirely.
Semantics: any record SAVED with the flag newly true demotes every other record. When more than one row in the same batch claims it, LAST IN THE BATCH WINS — arbitrary, but total, which is the point. Nothing auto-promotes when the last default is unchecked: zero defaults is a legitimate state and getOrgDefaultTheme() already falls back to its built-in theme, so silently re-flagging some other record would be a surprise write the admin never asked for.
| Method | Returns | |
|---|---|---|
construct(1 arg) |
fflib_SObjectDomain |
|
onAfterInsert(0 args) |
void |
|
onAfterUpdate(2 args) |
void |
Selectors#
CampaignDesignsSelector#
class · public with sharing
extends fflib_SObjectSelector · implements ICampaignDesignsSelector
setup/
Docs note
The user-mode methods (selectAll/selectById/selectDefault) and the SYSTEM_MODE guest methods below look alike but exist for genuinely different callers — the setup console's page-design editor uses the former, the public donation experience shell uses the latter. The SystemModeQuery inner class exists because Campaign_Design__c has a Private OWD and the guest user has no sharing access to it: AccessLevel.SYSTEM_MODE lifts CRUD/FLS but NOT sharing, so a plain with sharing SYSTEM_MODE query still returned zero rows for guests until this without sharing wrapper was added.
Every row flagged default, for the CampaignDesigns domain. Deliberately WITHOUT the LIMIT 1 that selectDefault() above carries — the domain exists precisely to clean up the case where more than one row holds the flag, so a capped query would hide the very rows it must demote.
SYSTEM_MODE, and there is no USER_MODE twin on purpose: the USER_MODE version this replaced was a landmine with no safe caller.
A trigger runs as whoever performed the DML, and that writer is not always someone who can SEE this object's fields — the post-install script is the case that proved it.
Its system user holds object-level CRUD on Campaign_Design__c but no field-level access, and a USER_MODE query reports a field the running user cannot see as one that does not exist: "No such column 'Accent_Color__c' on entity 'Campaign_Design__c'".
That threw inside onAfterInsert, rolled back PostInstallHandler.seedDefaultPageDesign's whole upsert, and shipped 1.0.0-15 subscriber orgs with ZERO page designs — every public page but the donation page paints from the org-default record, so the site came up unthemed.
SYSTEM_MODE is safe here in a way it would not be on a caller-facing read: these rows are never returned to anyone. The domain reads Ids solely to write Is_Default__c = false on them, so there is no field to leak and no id to confuse — the IDOR concern that governs the SYSTEM_MODE rule elsewhere in this package has no counterpart here.
ICampaignDesignsSelector#
interface · public
extends fflib_ISObjectSelector
setup/
IPaymentAccountsSelector#
interface · public
extends fflib_ISObjectSelector
setup/
PaymentAccountsSelector#
class · public inherited sharing
extends fflib_SObjectSelector · implements IPaymentAccountsSelector
setup/
Docs note
selectByIdWithCredentials is what StripeGateway.init() calls to load Named_Credential__c, Admin_Named_Credential__c and Publishable_Key__c before making any Stripe callout — the gateway picks between the two credentials per operation, so both have to be on the record it loads once. It deliberately skips Security.stripInaccessible because it runs in both admin and guest donation-page context, and stripping Named_Credential__c on a guest profile would resolve the callout endpoint to 'callout:null'. No Stripe signing secret is ever read here or anywhere in this package — the webhook-free architecture confirms payment outcomes by re-fetching the PaymentIntent instead (see IPaymentGateway.retrievePaymentIntent).
UsersSelector#
class · public with sharing
extends fflib_SObjectSelector · implements IUsersSelector
setup/
Selector for the standard User object. Exists so the one place the package reads Users — validating an admin-configured owner Id before assigning work to it — still obeys the "all SOQL lives in a selector" rule rather than inlining a query in a service.
Docs note
SYSTEM_MODE on the one query below is deliberate and narrow. The caller (CompanyMatchService) runs in the Automated Process context of a platform-event subscriber, which holds no FLS grants at all, and the question being asked is a boolean — "is this configured Id still an active user?" — whose answer never reaches a UI. Nothing about a User is exposed; only IsActive is even selected beyond the Id.
Services#
CommunityService#
class · public virtual without sharing
implements ICommunityService
setup/
Wraps ConnectApi.Communities so the site-creation call can be stubbed in tests. All Network SOQL uses dynamic Database.query() with a try/catch because the Network object only exists in orgs with Digital Experiences enabled.
Docs note
selectPreferredNetwork() resolves which Network row to report when a subscriber org has more than one Experience Cloud site (or a renamed/duplicate one) matching by name or URL prefix — it prefers an exact URL-prefix match that is Live, then any URL-prefix match, then a Live name match, then falls back to the first row queried. This tie-break order exists because "Fundraising"/"fundraising" is only a convention this package seeds, not something enforced — an admin can rename or recreate the site, and the setup console's site-status panel should still find the right one rather than surface a false "no site" result.
Object access reaches a guest user through TWO channels and both have to count: the guest user's PROFILE (the usual one — a profile owns a backing PermissionSet whose IsOwnedByProfile is true, and that row shows up in PermissionSetAssignment like any other) and any regular permission set assigned to it.
Querying PermissionSetAssignment first and ObjectPermissions second covers both without a separate profile query — verified against a live org 2026-08-16, where the guest user's assignments were its profile-owned set plus two regular sets. @TestVisible protected virtual for the same reason as resolveGuestUserId(): a Guest-licensed User cannot be created from Apex DML, so a test can only reach both branches by stubbing.
Matching on PermissionSetId alone also covers a grant delivered through a permission set GROUP — it is not the gap it looks like. Verified against a live org 2026-08-11: adding a granting permission set to a group makes the platform materialize a SECOND SetupEntityAccess row whose ParentId is the group's own backing PermissionSet (Type = 'Group'), and a group assignment's PermissionSetAssignment.PermissionSetId points at that same backing permission set. So queryGrantingPermSetIds() returns the group's id too and this join hits. Guest users can be assigned permission set groups, so the case is reachable.
SetupEntityType = 'ExternalCredentialParameter' is the discriminator checkStripeCredentialAccess()'s own header already specifies — SetupEntityId alone is not enough, since an arbitrary stand-in id used for test coverage could otherwise collide with an unrelated SetupEntityAccess row of a different entity type that happens to share a key prefix pattern.
Pulled into its own @TestVisible protected virtual seam, like queryNetworks()/resolveGuestUserId() above, because SetupEntityAccess.SetupEntityType is a system-derived, non-writeable field (confirmed against a live org, 2026-08-11: DML on an explicit SetupEntityType throws "Field is not writeable") — so a test cannot insert a row whose SetupEntityType is 'ExternalCredentialParameter' without a real ExternalCredentialPrincipal id, which is not creatable via Apex DML. Stubbing this seam (see CommunityService_Test's StubStripeCheck) is the only way to test the join logic without asserting on org state.
Shared by checkGuestPermissionSet() and checkStripeCredentialAccess() — both need the same site's guest user, resolved by the exact-name convention "<Site Name> Site Guest User" (see checkGuestPermissionSet()'s header for why an exact match, not LIKE). @TestVisible protected virtual, like queryNetworks()/ queryNetworkById() above, so a test can hand back an arbitrary user Id — a real Guest-licensed User cannot be created from Apex DML, so checkStripeCredentialAccess()'s "assignedToGuest=true" test path has no other way to reach the assignment-count query without asserting on org state.
More than one active Guest user matching the name convention is treated the same as zero — UNRESOLVABLE, not "pick the first one". A Salesforce Site (Visualforce) and an Experience site can share a display name, so an arbitrary row would let steps 4-6 report a false Done off the wrong guest user while real donations keep failing. LIMIT 2 (not 1) is what makes that ambiguity detectable at all — see I1 in the 2026-08-10 site-setup-guide-redesign final review.
Resolved from the org's own payment accounts, never from a credential name this class knows. The package stopped shipping Stripe_API / Stripe_External in 2026-08: the admin builds the External Credential + Named Credential pair by hand in Setup, once per gateway account, and names them whatever they like. A hardcoded lookup would therefore report "no credential" in almost every subscriber org and turn step 6 permanently red.
So walk the same chain the payments panel walks — active Payment_Account__c rows name a Named Credential, and each Named Credential names the External Credential whose principals carry the key. An org with several Stripe accounts has several pairs, and a grant on ANY of them lets that account's donations through, so every principal counts.
This supersedes the reconcile-after-merge note that stood here while SetupController.guestPaymentCredentialStatus() was being written on a sibling branch; the two now differ by intent — that one grades one chosen account, this one asks whether the site's guest user can reach any configured account at all.
The name-resolution walk also removes the reason the old bare-name-then-prefixed-name fallback existed: a name read off a Payment_Account__c row is already whatever the admin typed, in whatever form this org stores it, so there is no namespace form to guess at.
The two ConnectApi reads are split out as overridable seams so the join above is testable at all: its body only runs when getNamedCredential() SUCCEEDS, and no test can make that happen — a test cannot create a Named Credential, and depending on whichever credential this org happens to have is org state a packaged test must never assert on. Without the seams the whole walk (credential → its external credentials → their principals) is permanently unreachable code. They deliberately return plain Apex types rather than the ConnectApi wrappers, so a stub can supply them without constructing a ConnectApi.NamedCredential; see CommunityService_Test.StubCredentialChain.
Returns the developer names of the external credentials a Named Credential points at, or an empty list if the credential was renamed or never built. That is a finding for the payments panel, not a reason to abandon the org's other payment accounts, so it is logged and swallowed rather than thrown.
Only NamedPrincipal-type principals count. An external credential may carry several principals of different types, and a grant on one does NOT enable a callout that runs as another: the Stripe callouts this check exists for run as the credential's named principal ("OrgPrincipal"), so a permission set granting, say, a PerUserPrincipal would have satisfied the old unfiltered lookup and shown step 6 as Done on a site whose donations still fail at payment. Verified against a live org 2026-08-11: the packaged credential's sole principal reports principalName 'OrgPrincipal' / principalType NamedPrincipal, and its id is the SetupEntityId the grant rows carry.
Split out of queryStripePrincipalIds() as a pure static so the filter itself is unit testable: ConnectApi.ExternalCredentialPrincipal cannot be constructed outside a test context (System.UnsupportedOperationException), but inside one it can — so a test can hand this method a hand-built mixed list and assert the filtering, with no live External Credential and no stubbing of the ConnectApi seam.
@TestVisible protected virtual for the same reason as queryStripePrincipalIds() above — a test needs to drive the principal walk without depending on what the running org has configured.
PUBLIC CREDENTIAL ONLY, and deliberately so. Payment_Account__c has carried a second credential since the 2026-08-11 split (Admin_Named_Credential__c, the full key that can issue refunds), and adding it here looks like an obvious completeness fix. It is the opposite.
This set feeds checkStripeCredentialAccess(), whose assignedToGuest flag the Site & Domain panel renders as step 6 — and on that step assignedToGuest = true is the SUCCESS condition, because the site guest user is SUPPOSED to hold the restricted, charge-only credential. Fold the admin credential in and a guest user holding the FULL key — the exact exposure the split exists to prevent — would light that step green.
The consequence is that this check cannot see the guest-holds-admin fault at all. That is covered elsewhere on purpose: PaymentSetupGuideService step 8 detects it and reports it as a failure, and setSite's step 6 copy defers to the Payments panel for it in so many words. If a future caller needs the admin credential's name, give it its own method rather than widening this one.
| Method | Returns | |
|---|---|---|
getSiteStatus(0 args) |
Map<String, Object> |
|
| calls: AppLogger.info() | ||
isDigitalExperiencesEnabled(0 args) |
Boolean |
|
| calls: AppLogger.info() | ||
createSite(2 args) |
Map<String, Object> |
|
getJobStatus(1 arg) |
Map<String, Object> |
|
listSites(0 args) |
List<Map<String, Object>> |
|
| calls: AppLogger.info() | ||
resolveByNetworkId(1 arg) |
Map<String, Object> |
|
| calls: AppLogger.info() | ||
checkGuestPermissionSet(1 arg) |
Map<String, Object> |
|
| calls: AppLogger.info() | ||
checkGuestObjectAccess(1 arg) |
Map<String, Object> |
|
| calls: AppLogger.info() | ||
checkStripeCredentialAccess(1 arg) |
Map<String, Object> |
Stamps the shared sender identity onto every outbound donor email — the From address, the sender display name, and the reply-to — so ReceiptService, RefundRequestHandler and DonorPortalService cannot drift apart on who a donor thinks the message came from.
FFLib role: stateless helper service. It is deliberately static rather than a registered Application.Service binding — it resolves org email configuration, has no domain graph behind it, and is called from inside message-building loops where a factory lookup buys nothing.
Docs note
Apex cannot invent a From address. Messaging.SingleEmailMessage sends from the RUNNING USER unless it is handed the Id of an OrgWideEmailAddress, so Settings__c.From_Email__c is only ever a LOOKUP KEY for one of those records — never the sender itself. Before 2026-08-19 nothing read the field at all and every donor email went out from whichever user or scheduled job happened to trigger it.
Two conditions gate the substitution, and both are the platform's, not ours: - IsVerified: an unverified address cannot be used as a sender. - IsAllowAllProfiles: donor mail is sent from contexts with no ordinary profile — the site guest user, a scheduled batch, an @future. Using an address restricted to selected profiles from one of those fails the send outright, so an address that is not shared with all profiles is treated as unusable and ignored rather than risked. When either fails we fall back to today's behaviour (running user + display name) — a misconfigured From address must never cost a donor their receipt. The Settings console's Health Check reports which of the two is missing.
setOrgWideEmailAddressId and setSenderDisplayName are mutually exclusive — the platform rejects a message that sets a display name on top of an org-wide address ("The object's DisplayName field cannot be set if the setSenderDisplayName field is already set"). When a From address is in effect the name donors see is the one on the OrgWideEmailAddress record, and Settings__c.From_Name__c is intentionally not applied.
Inline SOQL by the same accepted exception as ReconciliationHeartbeat's User lookup — OrgWideEmailAddress is org email configuration, not a donation-domain SObject, and has no selector or Application.Selector binding. SYSTEM_MODE is required, not convenient: the guest user and the Automated Process user have no read on this setup object, and a USER_MODE read would throw exactly where receipts are sent from.
OrgWideEmailAddress cannot be created by a test (setup object, requires a real address verification round-trip), so the query is stubbed rather than seeded.
| Method | Returns | |
|---|---|---|
applySender(1 arg) |
void |
|
| calls: SettingsService.fromName(), SettingsService.replyToEmail(), SettingsService.fromEmail() | ||
resolveSenderId(0 args) |
Id |
|
| calls: SettingsService.fromEmail() | ||
describeSender(0 args) |
Map<String, Object> |
|
| calls: SettingsService.fromEmail() |
Sole authorized reader/writer of the record-type mapping table (compact-encoded across Settings__c's Record_Type_Mapping_Chunk_1__c..4__c fields). Every class that needs package record-type slot data — RecordTypeService, SetupController, PostInstallHandler — must go through this service; nothing else may read or write those chunk fields directly.
Plain value object for one packaged record-type slot — the shape encoded/decoded exclusively by RecordTypeMappingService into the compact delimited string stored (chunked) across Settings__c's Record_Type_Mapping_Chunk_1__c..4__c fields. Every other class that needs slot data goes through that service and works with these objects, never with the raw encoded string or the underlying Settings__c fields.
RecordTypeMappingService#
class · public with sharing
implements IRecordTypeMappingService
setup/
Sole reader/writer of the record-type mapping table. Every slot (object, package record type, and any admin override) is compact-encoded (not JSON — too wide to fit the 255-char field cap) into a single delimited string, which SettingsService chunks across 4 Text(255) fields on Settings__c. This class owns the encoding/decoding of that string; SettingsService owns only the chunk-field storage mechanics. Every other caller works with RecordTypeMapping DTOs and never touches the raw string or the Settings__c fields directly.
Docs note
Field separator (U+00A7 "§") and slot separator (U+00B6 "¶") were chosen because neither is a regex metacharacter — String.split() treats its argument as a regex, so a delimiter like "|" would silently mis-split (alternation with empty branches) unless escaped. Both are also characters an admin is very unlikely to type into a record-type name or label; sanitize() strips them from free-text fields before encoding as a defensive backstop. Written as \uXXXX escapes rather than raw literal glyphs — a raw multi-byte character in a .cls source file risks silent corruption on deploy toolchains whose JVM/shell default encoding isn't UTF-8 (Salesforce Help #000387632).
| Method | Returns | |
|---|---|---|
getAllMappings(0 args) |
List<[RecordTypeMapping](#apex-recordtypemapping)> |
|
| calls: SettingsService.recordTypeMappingsRaw() | ||
getActiveMappings(0 args) |
List<[RecordTypeMapping](#apex-recordtypemapping)> |
|
saveOverride(4 args) |
void |
|
seedMissingDefaults(1 arg) |
void |
Docs note
The RT_ID_CACHE / PLEDGIVO_RT_IDS maps are static, so they live for one transaction only (a fresh Apex context resets them) — loadMappings() calls RecordTypeMappingService at most once per transaction no matter how many Domain classes or controllers call getRecordTypeId()/getPackageRecordTypeIds() during it. clearCache() exists purely for tests that need to simulate a second, independent load. This is the sole sanctioned way to resolve a package record type id or test "is this trigger.new record type ours" — Domain trigger handlers must call getPackageRecordTypeIds(), never hardcode a record type Id or compare DeveloperName directly, because an admin can override the resolved developer name per object (RecordTypeMapping.overrideRTDeveloperName, via RecordTypeMappingService).
| Method | Returns | |
|---|---|---|
getRecordTypeId(2 args) |
Id |
|
getPackageRecordTypeIds(1 arg) |
Set<Id> |
|
isRecordTypeEnabled(1 arg) |
Boolean |
|
getAvailableRecordTypes(1 arg) |
List<RecordTypeInfo> |
|
clearCache(0 args) |
void |
SettingsService#
class · public with sharing
implements ISettingsService
setup/
Docs note
Two distinct API surfaces live in this one class. ISettingsService (getSettings/ saveSettings/detectAndUpdateOrgFeatures) is the instance-based CRUD path used only by SetupController for the admin settings screen. Everything else — the large block of static typed accessors below (logLevel, defaultCurrency, refundsEnabled, retryIntervalDays, donorPortalShowSummary, and so on) — is the canonical, sanctioned way every OTHER class in the package reads a single Settings__c value; callers must never call Settings__c.getInstance() directly or branch on a raw flag. Each accessor has a matching @TestVisible *Override static field so tests can stub one setting without inserting or upserting a Settings__c row.
Two recurring patterns worth knowing before adding a new accessor: (1) capability gates (personAccountEnabled, npspEnabled, npspGauSyncEnabled, npspRecurringSyncEnabled) are always "detected AND admin-enabled" — detection is authoritative and an admin flag can only opt OUT of a capability the org actually supports, never opt into one it doesn't; (2) several Boolean fields are stored inverted as Hide_/Disabled__c (donorPortalShow, refundsEnabled, isEmbedEnabled) specifically so that a Checkbox field added in a later release, which backfills existing Settings__c rows to false, defaults to the "on"/visible behavior with no data migration required.
Reads via getOrgDefaults(), never getInstance(). Settings__c has no profile/user tier — every accessor in this file treats it as an org-wide singleton — but getInstance() still resolves the hierarchy for the RUNNING USER.
When the org-default row exists but the running context doesn't cleanly resolve to it, upserting getInstance()'s result can create a stray row scoped to that user, which then permanently shadows the real org-default for every later getInstance() call by that user — the exact bug already fixed for recordTypeMappingsRaw()/saveRecordTypeMappingsRaw() below, extended here to the shared getSettings()/saveSettings() CRUD path (SetupController's save handlers, and the org-logo methods: ensureDefaultLogoSeeded()/uploadOrgLogo()/removeOrgLogo()).
Reproduced live on pledgivo-rtm-verify-19793: an admin's logo upload landed on a shadow row the guest-facing UiConfigController never reads, so the uploaded logo silently never appeared on donor pages.
ContentVersion/ContentDistribution are file-handling utility objects, not part of any fflib-registered SObject graph — same rationale as the Settings__c direct-upsert exception above (and AppLogger's direct Log__c insert): routing one-off file plumbing through the UnitOfWork would add nothing but ceremony. DML/SOQL here are deliberate.
They ship as packaged static resources and are served from the org's own <org>.file.force.com domain, which a guest LWR site allows in img-src with no CSP Trusted Site. That is the whole reason for the ContentDistribution round trip: a remotely-hosted stock photo would need a packaged CspTrustedSite, and every packaged trusted site shows up in the install-time "Approve Third-Party Access" dialog — which this package deliberately keeps to Stripe only.
WITHOUT SHARING, deliberately and narrowly. Seeding runs from PostInstallHandler, which executes as a per-package system user, and in that context a with sharing class cannot resolve StaticResource at all — the query dies with "sObject type 'StaticResource' is not supported" and the whole install used to abort there. Found 2026-08-09 by install-testing into a real subscriber org; it is invisible in a dev org, where an admin can read the resource in any sharing mode.
This class is the ONLY part of logo handling that runs without sharing, and it only ever touches the package's own packaged resources plus the documents it creates from them. Admin- and donor-supplied images stay on the USER_MODE path in uploadOrgLogo() — do not route them through here.
Describe result is cached by the platform per transaction, so the wizard pays this once.
Enforced in two places, and it needs both. CampaignService masks Allow_Tribute__c to false on every campaign payload it serves, which is what actually hides the section; and DonationService.applyTributeAndMatch refuses to capture tribute fields off the staging options blob, which is what stops a hand-crafted guest submission writing them anyway. The first is the product behaviour, the second is the enforcement — a client-side gate alone would be advisory on a public endpoint.
No separate send-time gate in ReceiptService: with capture blocked the Opportunity has no Tribute_Type__c, so buildTributeNotification's existing gates already return null. An in-flight staging row created while the switch was on is finalized through the same capture path, so flipping the switch off stops its e-card too.
Same field, read for a different purpose: the starting value of a brand-new Campaign's Allow_Tribute__c, applied by FundraiserAdminController.getFundraiserOptions() for the "New fundraiser" wizard (lexFundraisersConsole). Deliberately the same answer as tributeEnabled() — offering the wizard a toggle that defaults to on while the org switch suppresses it everywhere would let an admin create a fundraiser whose tribute section silently never renders.
This is only the donor-facing ask. It grants nothing on its own — a request parks at Awaiting Review for a person, and refundsEnabled()/refundWindowDays() still bound whether the refund itself can be issued afterwards.
Turning this on is only half the job — inline playback also needs a CSP Trusted Site for https://www.youtube-nocookie.com that the admin creates themselves. This package ships no trusted site of its own (see the posture note above createLogoDistribution), so the flag is an intent switch, not a capability check. With it on and no trusted site, the browser blocks the frame; the setup panel therefore states the requirement next to the toggle.
Answers "is the job that turns confirmed gifts into Opportunities actually running?" — a question a CronTrigger count cannot answer, because a job can be scheduled and still abort on every fire. Settings__c.Finalizer_Heartbeat__c is written from INSIDE StagingReconciliationBatch.finish(), so it only advances when a pass genuinely completed. Two callers: the guest donation path (DonationService.createDonorAndIntent, which alarms but never refuses the gift) and the setup console (SetupController's health check and the "Start the background jobs" Get Started step).
The default was 24h through 1.0.0-16 and was cut to 3h. A magic link is a bearer credential sitting in an inbox: it is not one-time-use and it is not device-bound, so its whole security story is how long the window stays open. Three hours is long enough for a donor who steps away from the machine and comes back, and short enough that a forwarded or shoulder-surfed mail is usually already dead. Admins who need longer raise Portal_Token_TTL_Hours__c; nothing hard-codes the number except this line, because every surface that states it to a donor reads it back through this accessor.
Three defaults, not one, because the three cases make mutually exclusive factual claims — see the doc comment on the Receipt_Legal_Text_With_Goods__c field for why the single override this replaced was a correctness bug. These stay textually in step with the LEGAL_* constants in ReceiptStatusHelper, which are the last-resort fallback for an org that clears a seeded field; PostInstallHandler seeds all three from here.
This is the ONLY way to build a donor-facing link from internal (Lightning) context. Site.getBaseUrl() returns blank off-site and URL.getOrgDomainUrl() returns the internal My Domain host, which a guest cannot open — so an internal sender that falls back to the org domain silently emails a dead link. Prefer Site.getBaseUrl() when running inside the site (it is the host the visitor actually arrived on) and this accessor everywhere else.
The address donor email is sent FROM. Consumed only by EmailSenderService, which turns it into an OrgWideEmailAddress id — Apex cannot invent a From address, so a value here that does not match a verified, all-profiles org-wide address has no effect at all.
The three org-wide fallbacks behind the public page's contact band. Each returns null when unset so the band can omit the line entirely rather than print an empty label — a contact block showing "Phone:" with nothing after it reads as broken, which is the opposite of the trust the band exists to build. A campaign's own Contact_*_Override__c wins over these; CampaignService resolves that precedence, not this class.
Reads Support_Phone__c rather than a contact-specific field — an org has one support number, and the payment-failure emails and the public contact band should never be able to disagree about what it is.
These seven describe the ORGANISATION, not a theme. They lived on Campaign_Design__c until 2026-08-12, where a campaign switching from one packaged theme to another would have silently changed which privacy policy its donors were pointed at. Each returns null when unset so the footer can omit the line or the icon entirely — a dead "Terms" link or a social icon that opens nothing is worse for trust than no link at all, which is the whole point of the band they render in. A campaign's own *_Override__c wins over these; CampaignService.resolveIdentity owns that precedence, not this class.
RecordTypeMappingService owns the delimited-encoding format (what the string MEANS); this class only owns the mechanics of splitting/joining that string across the 4 physical Settings__c chunk fields (WHERE it's stored). Replaces the former Record_Type_Mapping__c custom object (deleted 2026-08-03) — Custom Settings fields cap at 255 chars with no Long Text Area option, so the encoded table is chunked here instead of living in a dedicated Long-Text-capable object.
Reads via getOrgDefaults(), never getInstance() — record type mapping config is an org-wide singleton with no profile/user tier, and getInstance()'s hierarchy resolution caches on first call for the rest of the transaction, which previously made this read (when it ran first) silently freeze OTHER getInstance()-based accessors (e.g. eventDeductibleMode()) against later same-transaction Settings__c DML — a real Apex custom-settings platform gotcha, not just a test artifact.
| Method | Returns | |
|---|---|---|
getSettings(0 args) |
Settings__c |
|
saveSettings(1 arg) |
void |
|
detectAndUpdateOrgFeatures(0 args) |
void |
|
ensureDefaultLogoSeeded(0 args) |
void |
|
ensureDefaultImagesSeeded(0 args) |
void |
|
uploadOrgLogo(3 args) |
String |
|
| calls: Preconditions.checkNotBlank() | ||
removeOrgLogo(0 args) |
void |
|
logLevel(0 args) |
String |
|
debugLoggingEnabled(0 args) |
Boolean |
|
defaultCurrency(0 args) |
String |
|
currencyCodeForGateway(0 args) |
String |
|
defaultCampaignType(0 args) |
String |
|
isActiveCampaignType(1 arg) |
Boolean |
|
defaultSuggestedAmounts(0 args) |
String |
|
tributeEnabled(0 args) |
Boolean |
|
defaultAllowTribute(0 args) |
Boolean |
|
defaultAllowCompanyMatch(0 args) |
Boolean |
|
refundsEnabled(0 args) |
Boolean |
|
isEmbedEnabled(0 args) |
Boolean |
|
allowedEmbedOrigins(0 args) |
Set<String> |
|
refundWindowDays(0 args) |
Integer |
|
donorRefundRequestsEnabled(0 args) |
Boolean |
|
inlineVideoEmbedsEnabled(0 args) |
Boolean |
|
dataRetentionDays(0 args) |
Integer |
|
maxFailedPaymentRetries(0 args) |
Integer |
|
retryIntervalDays(0 args) |
Integer |
|
gracePeriodDays(0 args) |
Integer |
|
dunningEmailFrequencyDays(0 args) |
Integer |
|
finalizerHeartbeat(0 args) |
Datetime |
|
isFinalizerPipelineStale(0 args) |
Boolean |
|
receiptNumberPrefix(0 args) |
String |
|
companyMatchTaskOwnerId(0 args) |
Id |
|
companyMatchFollowupDays(0 args) |
Integer |
|
defaultDesignationId(0 args) |
Id |
|
minDonationAmount(0 args) |
Decimal |
|
maxDonationAmount(0 args) |
Decimal |
|
feeCoveragePercent(0 args) |
Decimal |
|
| calls: DonorResolutionService.isPersonAccountOrg(), PostInstallHandler.seedEventPricebook() | ||
feeCoverageFixed(0 args) |
Decimal |
|
portalTokenTtlHours(0 args) |
Integer |
|
emailBrandColor(0 args) |
String |
|
emailAccentColor(0 args) |
String |
|
emailHeadingColor(0 args) |
String |
|
emailBackgroundColor(0 args) |
String |
|
experienceSiteUrl(0 args) |
String |
|
donorPortalPath(0 args) |
String |
|
donationPagePath(0 args) |
String |
|
experienceNetworkId(0 args) |
String |
|
receiptEmailTemplate(0 args) |
String |
|
fromName(0 args) |
String |
|
fromEmail(0 args) |
String |
|
autoReceiptEnabled(0 args) |
Boolean |
|
replyToEmail(0 args) |
String |
|
emailSignature(0 args) |
String |
|
contactEmail(0 args) |
String |
|
contactPhone(0 args) |
String |
|
contactHours(0 args) |
String |
|
orgMailingAddress(0 args) |
String |
|
orgDisplayName(0 args) |
String |
|
receiptFooterText(0 args) |
String |
|
taxReceiptLegalText(0 args) |
String |
|
taxReceiptLegalTextWithGoods(0 args) |
String |
|
taxReceiptLegalTextPartialRefund(0 args) |
String |
|
ein(0 args) |
String |
|
orgLogoUrl(0 args) |
String |
|
termsUrl(0 args) |
String |
|
privacyUrl(0 args) |
String |
|
socialTwitterUrl(0 args) |
String |
|
socialFacebookUrl(0 args) |
String |
|
socialInstagramUrl(0 args) |
String |
|
socialShareImageUrl(0 args) |
String |
|
footerText(0 args) |
String |
|
personAccountEnabled(0 args) |
Boolean |
|
npspEnabled(0 args) |
Boolean |
|
npspGauSyncEnabled(0 args) |
Boolean |
|
npspRecurringSyncEnabled(0 args) |
Boolean |
|
eventPricebookId(0 args) |
String |
|
eventDonationProductId(0 args) |
String |
|
eventSyncCampaignMembers(0 args) |
Boolean |
|
donationSyncCampaignMembers(0 args) |
Boolean |
|
softCreditAutomationEnabled(0 args) |
Boolean |
|
eventDeductibleMode(0 args) |
String |
|
defaultThankYouCtaUrl(0 args) |
String |
|
defaultThankYouCtaLabel(0 args) |
String |
|
isSupersededPortalConfirmationMessage(1 arg) |
Boolean |
|
donorPortalShowSummary(0 args) |
Boolean |
|
donorPortalShowRecurring(0 args) |
Boolean |
|
donorPortalShowHistory(0 args) |
Boolean |
|
donorPortalShowWallet(0 args) |
Boolean |
|
donorPortalRequestTitle(0 args) |
String |
|
donorPortalRequestSubtitle(0 args) |
String |
|
donorPortalConfirmationTitle(0 args) |
String |
|
donorPortalConfirmationMessage(0 args) |
String |
|
donorPortalEmptyRecurringMessage(0 args) |
String |
|
donorPortalEmptyHistoryMessage(0 args) |
String |
|
donorPortalEmptyWalletMessage(0 args) |
String |
|
recordTypeMappingsRaw(0 args) |
String |
|
saveRecordTypeMappingsRaw(1 arg) |
void |
Shared#
Core#
Docs note
Two deliberate gotchas for anyone modifying this class: (1) without sharing + a direct insert (not Application.UnitOfWork) is required because a log call can happen from inside a UoW commit's own catch block — routing through the UoW here would risk a circular/nested commit; (2) the DML-limit guard in write() exists because cacheable @AuraEnabled methods run with a DML limit of 0 — without that guard, logging a caught exception from a cacheable method would itself throw a LimitException and mask the original error the log call was trying to capture.
| Method | Returns | |
|---|---|---|
setCorrelationId(1 arg) |
void |
|
getCorrelationId(0 args) |
String |
|
debug(2 args) |
void |
|
info(2 args) |
void |
|
warn(2 args) |
void |
|
error(3 args) |
void |
|
error(4 args) |
void |
Docs note
The UnitOfWork registration order below is not cosmetic — fflib's UnitOfWork inserts SObjectTypes in list order, so a parent must be registered before any child whose lookup will be resolved via registerRelationship() in the same commit (see the inline comments beside each entry for the specific FK it exists to satisfy). Adding a new object here means finding its true parents in this list, not appending to the end.
IPaymentGateway is deliberately NOT in the Service map below — PaymentGatewayFactory resolves it dynamically per Payment_Account__c record instead of a single static binding, since an org can have more than one active gateway account. Which one a given campaign charges against is Campaign.Payment_Account__c.
Turns the raw query string of a checkout URL into the attribution fields carried by a donation or a recurring gift. Pure utility — no SOQL, no DML, no callouts; it only filters, normalizes and writes onto an in-memory SObject, so the calling service keeps ownership of the Unit of Work.
Usage: Map<String, Object> options = (Map<String, Object>) JSON.deserializeUntyped(staging.Options_JSON__c); AttributionCapture.apply(opp, options.get('attribution')); AttributionCapture.copy(recurringDonation, renewalOpportunity);
Docs note
Two caps guard the LongTextArea and the org's storage: at most MAX_KEYS parameters survive, each value truncated to MAX_VALUE_LENGTH, and the serialized JSON is dropped whole if it would still exceed MAX_JSON_LENGTH. A donor can put an unbounded query string on a public URL, so none of these are optional.
EXCLUDED_KEYS is the functional/PII denylist — everything else on the URL is kept. The first group is how the public pages carry state (slug/amount/email/name/ref/ tickets) and would be noise in a marketing report; the second is Stripe's own redirect params and our receipt/dashboard tokens, which are credentials, not attribution, and must never be persisted onto a reportable record.
The five UTM keys get their own reportable Text(255) columns; everything else that survives the denylist lands in URL_Parameters__c as JSON. Keys here are matched case-insensitively, so ?UTM_Source=Email fills UTM_Source__c just like ?utm_source=email.
| Method | Returns | |
|---|---|---|
apply(2 args) |
void |
|
| calls: AppLogger.warn() | ||
copy(2 args) |
void |
|
| calls: AppLogger.warn() | ||
normalize(1 arg) |
Map<String, String> |
ControllerAction — base class for all LWC controller inner action classes.
Usage:
public class MyController {
@AuraEnabled
public static Map<String, Object> doSomething(String param) {
return new DoSomethingAction(param).run();
}
private class DoSomethingAction extends ControllerAction {
private String param;
DoSomethingAction(String param) { this.param = param; }
protected override void doValidate() {
if (String.isBlank(param)) addError('param is required');
}
protected override Object doExecute() {
return MyService.doWork(param);
}
}
}
Docs note
This is what produces the { success, data/error } response envelope every @AuraEnabled method in this package must return (see AGENTS.md). A controller method that builds its response map by hand instead of extending this class is the one place that contract can silently drift.
Every payload leaving an @AuraEnabled method is walked here: any SObject in it becomes a plain Map keyed by field API names, with the packaging namespace removed.
Why it exists. An SObject handed to the client serializes under its runtime API names, so the same Campaign arrives as {"Tagline__c": ...} in an unmanaged dev org and as {"pledgivo__Tagline__c": ...} once the package carries a namespace — while every LWC in this repo reads the unprefixed name (source never carries the prefix; see the Namespace Rule in AGENTS.md). Verified 2026-08-09 in the pledgivo_test org:
JSON.serialize(new Campaign(Header_Image_URL__c='x'))
→ {"attributes":{...},"pledgivo_test__Header_Image_URL__c":"x"}
The failure is silent — no error, every read is simply undefined — and it degrades into data loss wherever a panel builds its save payload from what it just read, which is what the settings console does: blank reads were saved back over real values.
Why here rather than per payload. run() is the single choke point every controller already passes through to build the { success, data } envelope, so one implementation covers ~30 existing payloads and every future one. Hand-mapping each payload instead leaves the next new controller free to reintroduce the bug silently.
Two things this does not do:
- An Apex wrapper class holding an SObject in an
@AuraEnabledfield is serialized by the platform, not by this walk — so a wrapper must expose primitives, not records. - In an unmanaged org the prefix is empty, and this becomes a shape-only pass (SObject → Map).
The branch order below is load-bearing — do not reorder. A list of SObjects reports instanceof SObject == true in Apex, so testing SObject first swallows every list and then fails the cast with "Invalid conversion from runtime type List<Campaign> to SObject", breaking every selectAll()-style controller in the package. List<SObject> must be tested first. The generic List<Object> branch then covers List<Map<String, Object>> and List<String> alike, since each element is re-walked.
A payload holding no SObject is returned as-is, by identity, rather than rebuilt into an equivalent structure. That is not an optimization; it keeps the walk's runtime-type footprint down to the conversion it actually has to make:
- Without the guard, the generic
List<Object>branch rebuilds every list, so aList<String>, aList<RecordTypeMapping>or aList<GivingStatementService.StatementYear>comes back typedList<Object>. - The client cannot tell (identical JSON), but any Apex caller assigning the result to its declared type throws "Invalid conversion from runtime type List<ANY>".
- The regression was real: 8 tests across four controllers failed in the 2026-08-10 package build, and non-test Apex calling a controller directly would have hit it too.
With the guard, the only types this method changes are the ones it must: an SObject becomes a Map, a List<SObject> becomes a List<Object> of Maps, and any list or map containing one of those is rebuilt to carry the converted children.
Answers "is there an SObject anywhere in this payload" — i.e. does stripNamespace have any work to do at all. It short-circuits on the first record found, so the common primitive-only payload costs one shallow pass and is then handed back untouched. The first test deliberately covers BOTH a record and a list of records: per the measured table above, List<Campaign> instanceof SObject is true, so no separate List<SObject> case is needed here (unlike in stripNamespace, where the two must be handled apart because only one of them is castable).
Only POPULATED fields are emitted — the same set the platform would have serialized — so this neither adds nulls the client did not have before nor hides a queried field. Parent lookups (Campaign__r) and subquery children come back as SObject / List<SObject> and are walked recursively, so a nested record is stripped exactly like a top-level one.
Derived from a describe at runtime, never hardcoded: correct with no edit in an unmanaged scratch org (empty prefix), under the temporary pledgivo_test namespace, and under pledgivo after the swap. A namespaced object name carries two "" (ns__Settings__c); a bare one carries a single "".
Object-level delete permission has to be asserted HERE, by hand, because nothing downstream does it: fflib's UnitOfWork commits at AccessLevel.SYSTEM_MODE, and a config object with no trigger has no Domain layer to re-assert CRUD either. So an action that registerDeleted()s a record deletes it for any user who can reach the controller class, regardless of what the permission sets say. Both packaged deletes hit that: the three non-admin permission sets all carry allowDelete=false on Payment_Account__c and Campaign_Design__c, and neither was enforced before this guard. Read/field access is a different matter and stays with the selectors — this is only the delete gate.
| Method | Returns | |
|---|---|---|
run(0 args) |
Map<String, Object> |
|
| calls: AppLogger.error() |
DiagnosticLogController — backs the lexDiagnosticLogs tab (Diagnostic Logs).
FFLib role: controller. Reads Log__c through ILogsSelector and writes nothing at all; the only other values it returns are the two Settings__c reads that decide whether the list can be trusted (the persisted log level, and how long entries survive before the retention purge).
Docs note
Split out of SetupController on 2026-08-15, in the same change that moved the "Recent diagnostic logs" list out of the Settings-console Logging panel and onto its own Lightning tab — the same treatment, and for the same reason, as DonationMonitorController the day before.
Reading logs is troubleshooting: it happens when something has already gone wrong, repeatedly, and often by staff who have no business changing the org's settings. It could not stay on SetupController. That class is the single Apex entry point behind all 16 settings panels and is granted to Fundraising_Admin ONLY (its absence from Fundraising_User carries a "must not be added back" note saying exactly that), so reaching a log viewer through it would have meant handing out the whole console with it.
The surface here is one read, with no caller-supplied filter, field list, or record id — every filter on the page is applied client-side over the rows already returned. So the new grant widens access to nothing beyond Log__c itself, which the permission sets that get this tab (Fundraising_Admin, Fundraising_ReadOnly) already read today.
The panel it left behind (setLogging) keeps the log-level, debug-echo and retention SETTINGS plus the Transaction_Log__c error list — those are configuration, not diagnosis.
Returns { rows, rowLimit, logLevel, retentionDays } rather than a bare list, because an empty log list is ambiguous on its own and the two settings are what disambiguate it. logLevel is the threshold AppLogger writes at: at OFF nothing is being recorded at all, and at the ERROR default the INFO/DEBUG entry an admin is hunting for was never written — so "no logs" means "logging is off", not "nothing went wrong". Without it the page would report a silent org as a healthy one.
retentionDays is the other half: RetentionPurgeBatch deletes by CreatedDate, so a search for last quarter's incident can come back empty because the entries were purged, not because they never existed. The page states the window instead of letting the admin guess.
| Method | Returns | |
|---|---|---|
getRecentLogs(0 args) |
Map<String, Object> |
@AuraEnabled |
GuestAction#
class · public abstract inherited sharing
extends ControllerAction
shared/
GuestAction — base for every controller action an unauthenticated visitor can reach.
ControllerAction's default hands the raw platform message back to the caller, which is the right default for an internal Lightning screen staffed by an admin: it makes a failure diagnosable without opening the log. On a public page it is an information leak. An uncaught fflib CRUD failure prints an internal object API name, a record id and an Apex stack trace straight onto the screen of whoever loaded the URL — no login required, and search engines and support-ticket screenshots carry it further.
Extend this instead of ControllerAction whenever the action is reachable from a lightningCommunity__* bundle. AppException — the package's own, donor-worded validation ("Enter an amount greater than zero.") — is passed through unchanged, because that text exists precisely to be read by a donor. Everything else (DmlException, QueryException, fflib's DomainException, a null dereference) collapses to one neutral sentence. ControllerAction.run() has already written the real message and stack trace to AppLogger before this is called, so nothing is lost to whoever has to debug it.
Validation errors added with addError() are NOT affected — they never travel this path, and "Amount is required" still reaches the donor verbatim.
Docs note
The guest counterpart to ControllerAction. Which controllers must use it is decided by the Fundraising_GuestDonor permission set: every class listed in its <classAccesses> is, by definition, callable by an unauthenticated visitor. UiConfigController is the one member of that list which does NOT extend this — see the rationale comment on the class itself. The passthrough is only as safe as the AppException messages behind it: an AppException raised on a guest-reachable path must be written for a donor and must not embed a record id or an internal API name, because it is printed verbatim.
Guard clause utilities (Guava-style). Use in service/domain/selector methods to fail fast with a meaningful message instead of a cryptic NullPointerException.
Usage: Preconditions.checkNotNull(contactId, 'contactId is required'); Preconditions.checkArgument(amount > 0, 'amount must be positive, got: {0}', new List<Object>{ amount }); Preconditions.checkNotBlank(email, 'email is required');
| Method | Returns | |
|---|---|---|
checkNotNull(2 args) |
Object |
|
checkNotBlank(2 args) |
String |
|
checkArgument(2 args) |
void |
|
checkArgument(3 args) |
void |
|
checkState(2 args) |
void |
|
checkNotEmpty(2 args) |
void |
QrCodeEncoder — encodes a short string as a QR symbol and emits it as inline SVG.
Shared utility, not an FFLib layer: a pure function with no SObject, SOQL or DML surface. Byte mode, error-correction level M, versions 1-6 selected automatically.
Docs note
Written from scratch rather than pulled from a library because the package's CSP trusts no script CDN, and because a QR has to be generated somewhere the print sheet and the receipt email can both reach — which rules out encoding it in the browser.
Output is monochrome SVG path data with the quiet zone baked into the viewBox.
It carries no theme token and no colour of its own on purpose: a themed QR is a QR that fails at the door, and a page that tightens padding would otherwise eat the quiet zone the scanner needs.
Versions stop at 6 so no version-information block is required — a ticket reference is about a dozen characters, and version 6 at ECC M holds 108. Above capacity this throws rather than truncating: a truncated symbol still scans, just to the wrong ticket.
Apex exposes no byte array, so UTF-8 bytes come out of a hex round-trip through Blob. Encoding the string's characters directly would corrupt any payload outside ASCII. The nibbles are looked up by hand because Integer.valueOf has no radix overload.
| Method | Returns | |
|---|---|---|
toSvg(1 arg) |
String |
|
toSvgPath(1 arg) |
String |
|
moduleCount(1 arg) |
Integer |
Composable SOQL WHERE clause builder.
Works with Database.queryWithBinds() (available API 57+) so binding variables are passed as a Map — no Apex scope variable tricks needed.
Usage in a selector method:
QueryCondition cond = QueryCondition.of('Status = :status', 'status', 'Active') .andWhere(QueryCondition.of('CampaignId = :cid', 'cid', campaignId));
String soql = newQueryFactory().setCondition(cond.getClause()).toSOQL(); return (List<Opportunity>) Database.queryWithBinds( soql, cond.getBindings(), AccessLevel.USER_MODE );
Concrete condition inner classes (recommended for reuse):
private class ActiveCondition extends QueryCondition { ActiveCondition() { super('Status__c = :status', new Map<String,Object>{ 'status' => 'Active' }); } }
private class ByCampaignCondition extends QueryCondition { ByCampaignCondition(Set<Id> ids) { super('CampaignId IN :cids', new Map<String,Object>{ 'cids' => ids }); } }
// Compose: QueryCondition cond = new ActiveCondition().andWhere(new ByCampaignCondition(campaignIds));
Cryptographically-random identifiers for the webhook-free donation flow.
- uuid() → a random 36-char UUID-shaped id (correlation id embedded in Stripe metadata).
- opaque(n) → an n-byte random hex string (unguessable staging capability token).
Uses Crypto.generateAesKey (CSPRNG) — never Math.random (predictable) — so the values cannot be guessed or enumerated by a guest.
| Method | Returns | |
|---|---|---|
uuid(0 args) |
String |
|
opaque(1 arg) |
String |
Docs note
Settings__c is a Hierarchy Custom Setting — createSettings() below calls Settings__c.getOrgDefaults() + upsert, never new Settings__c() + insert, or the org-default row silently fails to apply to every context that reads settings without an explicit Id. The Event_* factory helpers were merged in from the former P2PTestDataFactory on 2026-07-13 — there is no separate test factory for them anymore.
The routed thank-you page: one read covering both the donation and the event confirmation.
WITHOUT SHARING: reached by a guest immediately after payment, holding either the short-lived staging access token or the unguessable Receipt_Token__c. Authorisation is possession of a token — never Salesforce sharing, and never an Id supplied by the client.
PII: returns the donor's first name, their email (so the page can confirm where the receipt went), and per seat the attendee display name, tier and check-in status. Never the attendee's Email__c — the page doesn't render it, so it must not cross the wire.
Docs note
This exists as its own controller rather than two client calls to ReceiptController and EventTicketsController because the page needs one round trip, and because the per-seat QR codes are generated server-side by QrCodeEncoder — the LWC never encodes anything. The page is a real Experience Cloud route (/thank-you) rather than a step inside the donation form specifically so a conversion produces its own pageview for analytics.
Two payload builders, ONE key set. giftSummary reads the finalized Opportunity; pendingSummary reads the Donation_Staging__c row the donor's own checkout wrote, and returns the same keys with the finalizer-only ones null. That symmetry is deliberate: the finalizer runs on a five-minute cron, so a donor arriving on ?access= usually lands on the pending payload, and the page must render a real confirmation from it rather than a holding message. The null keys are what the page replaces with one quiet placeholder line.
The page footer, resolved exactly as the donation page's band resolves it — same two CampaignService methods, same campaign-over-org fallback, same showContact gate. A donor who lands here has just come from that page; a footer that named a different support address or dropped the privacy link would read as a different site.
| Method | Returns | |
|---|---|---|
getThankYou(2 args) |
Map<String, Object> |
@AuraEnabled |
Async (Batch/Queueable/Schedulable)#
RetentionPurgeBatch#
class · public without sharing
implements Database.Batchable<SObject>, Database.Stateful
shared/
Docs note
Adding a value here needs a matching case in start() AND a Database.executeBatch line in RetentionPurgeScheduler — a target nothing schedules is a table that still grows forever.
| Method | Returns | |
|---|---|---|
start(1 arg) |
Database.QueryLocator |
|
| calls: SettingsService.dataRetentionDays(), AppLogger.info() | ||
execute(2 args) |
void |
|
finish(1 arg) |
void |
|
| calls: AppLogger.info() |
RetentionPurgeScheduler#
class · public without sharing
implements Schedulable
shared/
Selectors#
LogsSelector#
class · public with sharing
extends fflib_SObjectSelector · implements ILogsSelector
shared/
Docs note
Asymmetric with AppLogger by design: AppLogger inserts Log__c directly (without sharing, no UoW — see that class) so a log write never fails because of the calling user's access, but reads here go through the normal fflib selector (USER_MODE) because reading logs back — e.g. DiagnosticLogController behind the Diagnostic Logs tab — is a staff-facing action that should respect the running admin's FLS/sharing like any other read.
That asymmetry is why the tab is granted only to permission sets holding viewAllRecords on Log__c: the object is Private and AppLogger owns each row as whoever ran the transaction, so a user without it sees only their own sessions — a partial list that reads as a clean org.