# Ecommerce and billing facts

Money events are tier 1. They feed revenue reports, ad-platform conversions and finance reconciliation, so the occurrence key and the source of truth matter more here than anywhere else in the plan.

## Contents

- Source of truth for orders and refunds
- Occurrence keys for billing webhooks
- Duplicate guards
- Mapping to GA4 ecommerce events
- Coverage against the ledger
- Acceptance cases

## Source of truth for orders and refunds

- Anchor `order_completed` (or your purchase name) and every refund to the order or billing system that commits them. A thank-you page view is not a purchase.
- If GA4 needs browser attribution for the purchase, send a companion from the page after the order system confirms, carrying the order ID as `transaction_id` and the backend `event_id` as a parameter. Its `message_id` differs from the backend event's (different name and emitter), so a CDP route does not drop it.
- Keep the same `transaction_id` on every retry of one order. Never send an empty `transaction_id`: GA4 treats every purchase with an empty value as the same transaction.
- A partial refund is its own occurrence. Two partial refunds on one order are two refund events, each keyed on its own refund record, each carrying the original order's `transaction_id`.

## Occurrence keys for billing webhooks

Derive `event_id` from the business fact, then hash the key into a UUIDv5 so it fits every destination dedupe field. Stripe is the example; apply the same idea to any billing provider.

- One-time transitions (an invoice paid, a specific refund created): the object ID plus the transition, for example the invoice ID plus `invoice.paid`, or the refund ID.
- Update-type events (`customer.subscription.updated`, `invoice.updated`): the object ID alone repeats on every genuine change. Key on the object ID plus the new business state that the event records, for example subscription ID plus new status plus price or quantity plus current period start. Emit only when that state differs from the last state stored in your own database.
- Never derive `event_id` from object ID plus event type for update-type events. Every later change to the same subscription would get the first change's `event_id` and be discarded as a duplicate.

## Duplicate guards

Stripe documents three behaviours, checked 2026-10-17: an endpoint can receive the same event more than once, delivery order is not guaranteed, and occasionally two separate Event objects are generated for one change.

- First guard: log the provider event IDs you have processed and skip any already logged. Stripe recommends this for repeated deliveries.
- Second guard, one-time transitions only: Stripe suggests the `data.object` ID plus `event.type` to spot the rare case of two Event objects for one change. Use it there; do not use it for update-type events.
- Update-type events: compare the new state with the stored state. Two Event objects describing one change produce one state transition, so one canonical event.
- Ordering: do not use the event's `created` time to decide order or whether you processed it. Fetch the current object from the provider when the handler needs the latest state.

## Mapping to GA4 ecommerce events

- `purchase` requires `transaction_id`, `value`, `currency` and `items`. `refund` requires `transaction_id`, and `currency` whenever `value` is set.
- Google recommends `items` with `item_id` and `quantity` on `refund` to get item-level refund metrics. For a partial refund, send only the refunded items and the refunded value.
- `transaction_id` deduplicates purchases for the same user on web streams only, not on app streams. Prevent in-app duplicates at the source.
- Server-side refunds through the Measurement Protocol must arrive within its backdating window (72 hours, checked 2026-10-17). Older refunds stay in the warehouse and GA4 shows partial refund totals; write that down.

## Coverage against the ledger

- Daily, compare count and sum by currency between the canonical warehouse events and the order or billing ledger, for the same window and exclusions. Expect equality after the refresh window.
- List ledger records with no event and events with no ledger record. A list beats a percentage: each row is a bug to fix.
- A ratio near 100 between event revenue and ledger revenue means minor and major units are mixed.
- Compare GA4 and ad-platform purchase totals with the ledger separately. They are expected to be lower (consent, blockers, attribution windows); the gap should be stable.

## Acceptance cases

Add to the standard catalogue in `acceptance-tests.md` for any money contract:

- Two partial refunds on one order: two refund events with different `event_id` values, both carrying the order's `transaction_id`.
- One change delivered as two Event objects: one canonical event.
- The same webhook delivered twice: one canonical event; the second delivery is skipped by the processed-ID log.
- `customer.subscription.updated` twice with two genuine changes (plan upgrade, then quantity change): two canonical events.
- `customer.subscription.updated` with no tracked state change (metadata only): no canonical event.
- Out-of-order delivery (`invoice.paid` before `customer.subscription.created`): final stored state correct, one event per transition.
- Order retried by the client after a timeout: one order, one purchase event, one `transaction_id`.
