Appearance
Status note (2026-09-25): Dated plan and decision evidence. Use current backend money sources and tests for implementation; provider fee and settlement outcomes require separate verified evidence.
Campaign invoice fee policy — plan for review
TL;DR: design record for how campaign-invoice fees work (
ABSORBvsCHARGE_BUYER). Explains the accounting semantics; implementation status and sandbox evidence live in the results doc and current backend tests.
Status: implemented with corrected ABSORB accounting semantics; live sandbox testing deferred by the owner pending sandbox credentials. See campaign-invoice-fee-policy-results.md for implementation and validation results.
Audited on 2026-09-15 against backend origin/dev at 7d0bd05dba9043df2da0ae5102896836881c8646 and frontend origin/dev at 2b2b2a0484da203a9d8dc927a7d7a32541b7216b.
Outcome
Campaign invoice fees are configurable. BloxClips can either absorb actual provider fees or instruct Whop to add its buyer-fee surcharge at checkout. The surcharge offsets provider cost but does not guarantee full fee reimbursement.
| BloxClips policy | Whop invoice request |
|---|---|
ABSORB | charge_buyer_fee: false |
CHARGE_BUYER | charge_buyer_fee: true |
The campaign budget, invoice base price, and plan initial price remain identical in both modes. Whop calculates any buyer fee. No percentage, gross-up, estimated fee, or custom fee line item will be introduced.
Audit findings (before implementation)
The current canonical path is:
text
POST /api/admin/campaigns/:id/invoices
→ sendCampaignInvoice → createInvoiceIntent → CampaignInvoice
→ createWhopCampaignInvoice → client.invoices.create
→ provider retrieval / webhook reconciliation
→ CampaignFundingReceipt → CampaignFundingAllocation
→ postInvoiceAllocation → payout funding journalcreateInvoiceIntent is the canonical CampaignInvoice intent function. It is distinct from the retired duplicate Whop funding writer, which remains retired.
Backend findings:
src/api/routes/admin.ts: invoice creation accepts no fee policy; state hardcodescanSendInvoice: false.src/utils/campaignFunding/service.ts: every new send requires a trusted quote resolver whose default always returns null. Creation/recovery/reconciliation expect two quoted line items and reject buyer fees.src/utils/campaignFunding/domain.ts: the request fingerprint and immutable invoice snapshot currently encode the quote.CampaignInvoice.feeQuoteEvidenceis protected against updates by the database snapshot trigger.src/utils/campaignFunding/whopProvider.ts: the canonical adapter sends a campaign line and a processing-fee line, setting the plan price to their quoted total. The pinned invoice SDK is 1.0.14; it already supportscharge_buyer_feeand paginatedpayments.listFees({ id }).src/api/routes/campaignInvoicePublic.ts: public payment links require a complete unexpired quote, independently of admin creation availability.- Reconciliation records the provider's
amount_after_feesas the receipt. It allocates the full campaign budget only if that net receipt covers it, then bridges the allocation into payout accounting.
Frontend findings:
- The current UI is
CampaignInvoiceDialog.tsx, with initial and top-up forms, persisted retry payloads, and shared invoice API/types. CampaignInvoiceFeeBreakdown.tsxassumes a quoted processing fee and total.PublicCampaignInvoiceScreen.tsxindependently disables payment when quoted totals are absent. Updating only the creation dialog would leave client payment blocked.
Provider references: Create invoice, Retrieve payment, List fees. Payment total is documented as creator-visible and excludes buyer fees; it must not be labeled as the customer's total charge.
Proposed implementation
1. Required product policy and immutable persistence
- Add
InvoiceFeeHandling = 'ABSORB' | 'CHARGE_BUYER'to the campaign funding domain. - Require
feeHandlingin new invoice API requests. Reject missing/invalid values and fee-related numeric overrides, including provider booleans supplied through the product API. - Include the policy in the canonical request fingerprint and explicit existing-request comparison. A changed policy with the same request ID returns a conflict before provider I/O.
- Persist a versioned policy snapshot in existing
CampaignInvoice.feeQuoteEvidence, for example{ version: 1, feeHandling: 'ABSORB' }. Keep the existing column name for this small change and document its broadened configuration-evidence use. - Return the persisted choice in admin and public invoice responses. Do not infer historical policy from a new default. Old quote snapshots remain historical evidence; a missing policy is represented as unknown unless explicitly established by their stored configuration.
- No migration is expected: the JSON snapshot already exists and is immutable. Leave actual fee/total/net values unknown until authoritative provider evidence exists; do not store zero as a substitute for unknown.
2. Provider-native creation and recovery
- Remove the upfront quote resolver requirement from new canonical sends.
- At the Whop adapter boundary only, map
feeHandlingtocharge_buyer_fee. - Send one campaign-budget line item, with the same value as
plan.initial_price. Remove the manually added processing-fee line. - Use Whop's normal payment-method configuration instead of requiring quote-supplied settings. Keep fixed USD pricing and existing provider scope/version controls.
- Validate returned invoice identity, currency, base price, and selected fee flag against the persisted request.
- Preserve durable intent/attempt creation, idempotency keys, recovery markers, timeout handling, and recovery without resend. Keep historical invoice validation separate from the new snapshot format.
3. API availability and UI
- Derive sending availability from real Whop configuration and applicable campaign lifecycle/collectible-invoice guards. Preserve staff permissions and top-up eligibility.
- Add an explicit, initially unselected fee choice to both creation forms:
- BloxClips absorbs provider fees: “The client pays the campaign budget amount. BloxClips bears the actual provider fees.”
- Pass Whop buyer fee to client: “Whop adds its buyer fee on top of the campaign budget. It may not cover every provider fee.”
- Include the selected policy in the existing persisted retry payload and lock it while a request is unresolved. Do not assign a new default to a restored old payload missing the policy.
- Show the campaign base price and policy. For provider-controlled fees/totals that are not yet known, direct the client to Whop checkout without a numerical estimate.
- Remove quote prerequisites from both the public API payment-link projection and public page. Preserve token expiry, invoice status, processing, and reconciliation guards.
4. Actual payment evidence and existing funding rules
- Extend canonical payment retrieval with paginated
payments.listFeesand retain the returned fee types, amounts, currencies, and identifiers in existing reconciliation evidence. - Capture the provider fields for customer charge and settlement separately from creator-visible
total, campaign price, andamount_after_fees. Verify their meaning against the pinned API response before selecting fields. - Change quote-specific amount validation so a provider-added buyer fee does not cause a false mismatch. Keep identity, currency, settled-payment, refund/dispute, and duplicate-payment checks.
- A failed fee lookup must remain distinguishable from an empty fee list; retain/report the evidence gap and retry it without resending an invoice or fabricating fees.
- Keep actual net receipts authoritative. Record purchased entitlement separately and use the existing provider-cost mechanism for BloxClips-borne costs. Do not infer net settlement from the selected policy.
Approved accounting correction: The campaign receives its full purchased budget under both policies. Provider net remains truthful in the receipt. The existing funding entry's providerCostAmount explicitly records the BloxClips-borne shortfall, with immutable receipt evidence and a checked cash-plus-cost conservation identity. Launch and source-funding guards recognize that explicit contribution. Actual payout liquidity remains independently checked.
5. Documentation
- Update backend
docs/whop-integration.mdand affecteddocs/payout-e2e-readiness/pages, plus relevant workspace invoice/funding handoffs. - Replace the unresolved invoice fee-percentage/quote blocker with the confirmed policy and mapping.
- Explain that exact fees are provider-controlled and observed after payment. Keep invoice sending, payment verification, funding sufficiency, and payout dispatch as distinct readiness results.
Expected files
Backend implementation: src/utils/campaignFunding/{domain,service,whopProvider}.ts, src/api/routes/{admin,campaignInvoicePublic}.ts.
Backend tests: the corresponding provider/domain/service tests, adminCampaignInvoice.test.ts, campaignInvoicePublic.test.ts, and affected canonical webhook/funding fixtures. No payout journal or transfer-dispatch redesign is planned.
Frontend: features/admin/campaign-management/types/campaignInvoices.ts; CampaignInvoiceDialog.tsx, CampaignInvoiceFeeBreakdown.tsx, and PublicCampaignInvoiceScreen.tsx under surfaces/content-rewards/screens/; tests/campaign-invoice-{api,dialog}.test.tsx. The existing API wrapper already serializes the payload and may need no implementation change.
Automated validation after approval
Backend:
sh
npm run build
npm test -- src/utils/campaignFunding/whopProvider.test.ts
npm run test:campaign-fundingRun the funding suite with CAMPAIGN_FUNDING_TEST_DATABASE_URL pointing to a fresh migrated disposable local database. Use the repository runner's credential isolation. Add coverage for both mappings, immutable persistence, invalid/missing policy and numeric overrides, unchanged base price, same-request policy conflicts, concurrent retries, recovery, actual fee evidence, public payment links, both reconciliation modes, and net shortfall behavior. Run existing relevant correction/bridge tests if reconciliation changes touch their contracts.
Frontend:
sh
node --import tsx --test tests/campaign-invoice-api.test.tsx tests/campaign-invoice-dialog.test.tsx
npx tsc --noEmitCover both choices, payload propagation, required selection, locked retry/restored state, public payment without a quote, and absence of guessed fee amounts. Inspect the scoped diff/source for fee arithmetic, percentage constants, gross-ups, and added fee lines. Record exact command results; no automated validation has been performed for this plan.
Sandbox execution after implementation passes
- Verify the sandbox host, credential/company scope, invoice/payment/fee permissions, test payer, and disposable application database. Do not treat the currently present key as a verified sandbox key.
- Create equivalent small invoices through the canonical service for separate test campaigns: one
ABSORB, oneCHARGE_BUYER. Record the persisted policy, provider request flag, invoice ID and unchanged base/plan price. - Complete Whop sandbox checkout when available; record the authoritative checkout total and payment ID. Do not substitute manually marking an invoice paid for a settling payment.
- Retrieve each payment and all fee pages. Record customer charge, explicitly identified buyer fee if exposed, actual fee records, settlement amount/currency, and net receipt. Report unavailable fields explicitly.
- Run canonical reconciliation, including a repeated delivery, and record receipt, allocation, journal linkage, and any real funding/readiness hold separately.
- Locate the earlier $10 payment using sandbox payment evidence and context. Retrieve its fees and settlement evidence; name the actual fee types explaining $0.87 only if supported. Do not attribute an arbitrary $10 payment or infer a rate. If it cannot be uniquely identified, request its payment ID.
Readiness evidence and limits of this audit
The leased backend .env has an invoice key and company ID, but WHOP_ENVIRONMENT is unset; the transfer key and accounting/transfer activation flags are also unset. No explicit integration-test database was configured in this shell. These are local configuration observations, not authenticated provider or running-service checks.
No sandbox invoice, payment, fee lookup, or live DB readiness check was executed during this audit. The previous $10 payment was not identified in the inspected documentation. Older readiness documents contain broader payout blockers that must be rechecked before reporting them as current blockers.
Workspace handoff at the plan-review checkpoint (historical)
The preliminary backend edit was fully reverted; both repositories have no tracked content changes from this task. Task branches codex/invoice-fee-handling were created from the audited origin/dev revisions. The backend lease was returned.
The pre-existing frontend lease is handed off at /home/kirbysmashyeet/.treehouse/BloxClips-frontend-207dad/1/BloxClips-frontend (lease f22a1b389fbf603cca9611be6c00696a). Its pre-existing untracked AGENTS.md and CLAUDE.md remain untouched; they prevent a clean lease return. No primary checkout was modified.