Skip to content

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 (ABSORB vs CHARGE_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 policyWhop invoice request
ABSORBcharge_buyer_fee: false
CHARGE_BUYERcharge_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 journal

createInvoiceIntent 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 hardcodes canSendInvoice: 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.feeQuoteEvidence is 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 supports charge_buyer_fee and paginated payments.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_fees as 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.tsx assumes a quoted processing fee and total.
  • PublicCampaignInvoiceScreen.tsx independently 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 feeHandling in 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 feeHandling to charge_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.listFees and 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, and amount_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.md and affected docs/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-funding

Run 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 --noEmit

Cover 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 ​

  1. 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.
  2. Create equivalent small invoices through the canonical service for separate test campaigns: one ABSORB, one CHARGE_BUYER. Record the persisted policy, provider request flag, invoice ID and unchanged base/plan price.
  3. 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.
  4. 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.
  5. Run canonical reconciliation, including a repeated delivery, and record receipt, allocation, journal linkage, and any real funding/readiness hold separately.
  6. 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.