Skip to content

feat(funding): receipt reconciliation fields — fx, fees/net, refundOf, via, settledAt; transactionId privacy guidance#233

Open
s-adamantine wants to merge 2 commits into
mainfrom
feature/funding-receipt-fx-refunds
Open

feat(funding): receipt reconciliation fields — fx, fees/net, refundOf, via, settledAt; transactionId privacy guidance#233
s-adamantine wants to merge 2 commits into
mainfrom
feature/funding-receipt-fx-refunds

Conversation

@s-adamantine

@s-adamantine s-adamantine commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

Brings org.hypercerts.funding.receipt up to parity with what every major payment model considers essential, fixing the reconciliation gaps that surfaced while integrating it downstream (Ma Earth publishing platform-authored receipts for fiat + crypto donations — first records on dev). Companion to #232 (crypto-vs-fiat classification); the two are independent and can land in either order.

All new fields are optional — existing receipts remain valid. Changeset: minor.

Field survey behind this (Stripe BalanceTransaction, Wise transfers, PayPal capture breakdowns, ISO 20022 pacs.008):

Concept Stripe Wise PayPal ISO 20022 Receipt before this PR
Gross amount amount sourceValue gross_amount InstructedAmount amount
Fees, itemized fee, fee_details[] quote fee paypal_fee, platform_fees[] ChargeBearer + charges
Net received net targetValue net_amount InterbankSettlementAmount
FX rate exchange_rate rate exchange_rate charges rate
Settlement time available_on status timeline settlement date
Intermediary on_behalf_of (Wise itself) platform-fee payees intermediary agents ❌ (implied by author)
Refund linkage refunds[] pacs.004

1. fees[] + netAmount — the gross/fee/net triple

Problem. The most universal invariant in payments is missing: a donor attests "I sent $100", the project attests "I received $96.80" — two honest receipts for one payment that never reconcile and never cluster on amount equality. It also hides fee flows that funding-transparency platforms want public (platform commission, processor fee, donor-covered fees).

Solution. amount stays the gross the sender paid; optional netAmount is what the recipient actually received; optional fees[] itemizes the difference (#fee: type with knownValues [processor, platform, network, fx, tax, other], label, required amount, all in the receipt's currency):

"amount": "100.00",
"netAmount": "91.80",
"fees": [
  { "type": "platform",  "label": "Ma Earth platform fee",  "amount": "5.00" },
  { "type": "processor", "label": "Stripe processing fee", "amount": "3.20" }
]

Deliberately no ISO-style charge-bearer field: a receipt is retrospective, so the amounts already encode who bore what — a sender covering fees simply raises the gross above the intended contribution.

2. fx — foreign-exchange context

Problem. Receipts for the same payment may be denominated differently by different parties (donor paid €100 or 50 CELO; platform records USD-normalized; recipient books local currency). Cross-receipt math never reconciles, amount+currency clustering can't match, and a partial refund computed later at the market rate doesn't match the original proportions.

Solution. Optional fx → new #fxInfo, stamped at payment time: required originalAmount/originalCurrency, optional rate (1 unit of original currency in the receipt's currency; recorded explicitly to remove rounding ambiguity), rateSource, rateTimestamp. Writers of related receipts copy the block verbatim.

3. refundOf — immutable refund representation

Problem. Receipts are immutable in practice — attestations (signatures, per the ATProto Attestation Spec) bind to record content, so editing breaks them and deleting dangles strongRefs. How do partial/whole refunds get represented?

Solution. A refund is a new receipt carrying refundOf → the original: refunded amount in the original denomination (copy the original's fx so partial refunds compute at the original rate, without forex drift), from/to mirrored, one receipt per partial refund; consumers net refund receipts against the referenced receipt. Chargebacks/reversals fit the same shape. Rejected alternatives: status/amountRefunded mutation (breaks attestation CIDs); a separate refund lexicon (a refund is a payment — reuse keeps rails, parties, attestations, indexing uniform).

4. via — the intermediary as a party

Problem. The platform/servicer/broker that actually moved the funds is only implied by who authored the receipt.

Solution. Optional via, same union as from/to (DID / free text / strongRef), naming the facilitator in the record itself. Mirrors Stripe on_behalf_of, ISO intermediary agents, and the broker role in three-party attestation models (attested.network); lets consumers group receipts by facilitator regardless of author.

5. settledAt

Settlement time distinct from occurredAt — bank rails settle days later (Stripe available_on, ISO settlement date; block timestamp for onchain).

6. transactionId privacy guidance (description only)

Receipts publish payment identifiers into public repos, and identifier classes carry different risk: opaque processor IDs are dereferenceable only by the processor/account holder; bank references may embed account details; an onchain tx hash publicly reveals the sending wallet — which matters specifically when from was omitted to keep a donor anonymous (cf. Stripe's transaction-ID guidance).

Deliberately excluded

  • status/lifecycle — a receipt attests a settled fact; pending payments shouldn't get receipts.
  • Payment-method detail (card brand/last4, account numbers) — PII in public repos, no verification value.
  • Purpose/category codes (ISO CHAR etc.) — regulatory plumbing; for + the workscope ecosystem carry semantics.
  • Token contract/decimals — the tx hash dereferences to full on-chain truth.
  • Recurring/scheduled commitments — a different record type someday; a receipt is one payment.
  • Free-form metadata — untyped bags kill interop.

Notes for reviewers

  • npm run check green (validate + gen + typecheck + build + 182 tests); SCHEMAS.md regenerated; minor changeset included.
  • Open question worth a maintainer opinion: should refund receipts also point for at the funded activity (keeping them visible in activity funding views for transparent netting), or omit for so activity views show gross funding? The description currently leaves this to writers.

Three additions to org.hypercerts.funding.receipt from downstream
integration (Ma Earth platform receipts for fiat + crypto donations):

- fx (#fxInfo object: originalAmount/originalCurrency required;
  rate/rateSource/rateTimestamp optional): stamps the payment's
  original denomination + conversion rate at payment time. Receipts
  for one payment written by different parties in different
  currencies otherwise never reconcile, and amount/currency-based
  clustering cannot match them; related receipts copy the block
  verbatim.
- refundOf (strongRef): represents partial/whole refunds as NEW
  receipts netted against the original by consumers — receipts are
  immutable once attested, so edits are not an option. Copying the
  original's fx lets partial refunds compute at the original rate
  without forex drift.
- transactionId description gains privacy guidance for public repos:
  prefer opaque processor ids / internal references, avoid bank
  references embedding account details, and an onchain tx hash
  publicly reveals the sending wallet (significant when from was
  omitted for donor anonymity).

Non-breaking: both fields optional; existing receipts remain valid.
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@s-adamantine, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 620c90ef-f6b2-4a20-b767-d058a2044033

📥 Commits

Reviewing files that changed from the base of the PR and between 1e60c7f and 1b7b6a3.

📒 Files selected for processing (3)
  • .changeset/funding-receipt-fx-refunds-txid-privacy.md
  • SCHEMAS.md
  • lexicons/org/hypercerts/funding/receipt.json
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/funding-receipt-fx-refunds

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1b7b6a3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hypercerts-org/lexicon Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Extend the receipt with the reconciliation fields every major payment
model carries (Stripe BalanceTransaction, Wise transfers, PayPal
capture breakdowns, ISO 20022 pacs.008):

- netAmount + fees[] (#fee: type knownValues [processor, platform,
  network, fx, tax, other], label, amount): the universal
  gross/fee/net triple. amount stays the gross the sender paid;
  netAmount is what the recipient received. Without both, a sender's
  gross receipt and a recipient's net receipt for the same payment
  never reconcile or cluster. No charge-bearer field: receipts are
  retrospective, the amounts encode who bore what.
- via (same party union as from/to): names the facilitating
  intermediary (platform / payment servicer / broker) in the record
  instead of implying it from receipt authorship.
- settledAt: settlement time distinct from occurredAt (bank rails
  settle days later; Stripe available_on / ISO settlement date).

All optional; existing receipts remain valid.
@s-adamantine s-adamantine changed the title feat(funding): fx context + refundOf on receipts; transactionId privacy guidance feat(funding): receipt reconciliation fields — fx, fees/net, refundOf, via, settledAt; transactionId privacy guidance Jul 7, 2026
},
"originalCurrency": {
"type": "string",
"description": "Currency the payment was actually made in (e.g. EUR, CELO, USDC).",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets use ETH as an example instead of CELO

}
}
},
"fee": {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fee could be paid in a different currency, e.g. gas for a USDC transaction on Ethereum uses ETH, cross-border fees, multi-hop conversions with hosts partners.

In most cases the fees are in the recipient currency though. So, we might want to put fxInfo into the fees array (is that nesting allowed?)

"ref": "#fxInfo",
"description": "Optional foreign-exchange context. Stamp at payment time whenever amount/currency is not the denomination the payment was actually made in (e.g. a platform recording a USD-normalized amount for a EUR card payment or an on-chain transfer). Receipts for the same payment written by other parties should copy this block verbatim from the receipt they reference, so consumers comparing or aggregating receipts reconcile deterministically instead of re-deriving rates that drift over time."
},
"paymentRail": {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we have paymentRail = payment_processor, then we have the challenge that paymentNetwork could be Stripe and Visa. I think we have two options (claude argues for option 2 for clarity, option 1 is nice because it is one field less).

Option 1: Naming the payment orocessor in paymentRails

paymentRail: The mechanism that moved the funds. Values are either a mechanism class (bank_transfer, onchain, cash, check) or, when a commercial processor executed the payment, the named processor itself (stripe, paypal, wise, square) — naming the processor implies the payment_processor class. Use paymentNetwork for the settlement scheme the payment rode over. knownValues is an open, advisory vocabulary.

paymentNetwork: The settlement scheme or network the payment rode over (e.g. visa, mastercard, sepa, ach, ethereum, arbitrum). Distinct from paymentRail (the mechanism or processor that executed it). Omit when the rail has no distinct scheme, such as a wallet-balance transfer or cash. knownValues is an open, advisory vocabulary.

via: Intermediary(ies) that facilitated the payment — the platform or entity the funder interacted with (crowdfunding site, fiscal host, broker), named rather than merely implied by the record author. Ordered by flow where meaningful. Distinct from paymentRail (the mechanism or processor that executed the transfer) and paymentNetwork (the settlement scheme). A single facilitator is a one-element array.

Option 2: Adding another field called paymentProcessor

paymentRail: The class of mechanism that moved the funds (bank_transfer, onchain, cash, check, payment_processor): a category, never a specific provider. Use paymentProcessor to name the specific service and paymentNetwork to name the settlement scheme. knownValues is an open, advisory vocabulary.

paymentProcessor: The named commercial service whose product executed the payment (e.g. stripe, paypal, wise, square). Distinct from paymentRail (the mechanism class) and paymentNetwork (the settlement scheme). Omit when there is no processor layer, such as cash or a direct bank transfer. knownValues is an open, advisory vocabulary.

paymentNetwork: "The settlement scheme or network the payment rode over (e.g. visa, mastercard, sepa, ach, ethereum, arbitrum). Distinct from paymentProcessor (the service that executed it). Omit when the rail has no distinct scheme, such as a wallet-balance transfer or cash. knownValues is an open, advisory vocabulary.

via: Intermediary(ies) that facilitated the payment — the platform or entity the funder interacted with (crowdfunding site, fiscal host, broker), named rather than merely implied by the record author. Ordered by flow where meaningful. Distinct from paymentProcessor (backend service that executed the transfer) and paymentNetwork (the settlement scheme). A single facilitator is a one-element array.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I like these options tbh

"com.atproto.repo.strongRef"
]
},
"via": {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be an array, e.g. if the payment went via Ma Earth and a host partner?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When it goes through a host partner, we actually don't represent it fully because it would actually be two payments, but including that seems overkill

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants