# `Accrue.Billing.EntitlementSummary`
[🔗](https://github.com/szTheory/accrue/blob/accrue-v1.5.1/lib/accrue/billing/entitlement_summary.ex#L1)

Ecto schema for `accrue_entitlement_summaries` (ENT-10).

An **advisory, observational-only** local cache of Stripe's
`entitlements.active_entitlement_summary` object, written when a host
explicitly enables `config :accrue, :entitlements, stripe_native_sync: :advisory`.

This schema stores a thin local projection of the summary payload — one
row per customer (keyed on `customer_id` — there is no processor-side
identifier field, because the summary object has **no top-level `id`**;
its identity is the customer it belongs to). Stripe is canonical for
entitlement state;
Accrue persists only the typed columns operators read on, plus a `data`
jsonb with the full payload. A client-backed pull refresh exhaustively
streams the customer's active entitlements before persisting its advisory
snapshot. Webhook summary snapshots can contain only the first reported
entitlements; `entitlements.has_more` records that known incompleteness.

## Observational-only (D-01)

The gate path (`Accrue.entitled?/2`, `has_active_plan?/2`,
`Accrue.Entitlements.Resolver`, `LocalMap`) NEVER reads this table.
Local plan->feature mapping stays canonical; this cache is recorded,
ledgered, telemetered, and exposed via a read-only seam, but never
consulted to decide a grant.

## Force-style write path

There is no user write path — Stripe is the sole source of these rows.
`force_changeset/2` skips status allowlists (there is no status) and
carries `optimistic_lock(:lock_version)` + `unique_constraint(:customer_id)`
(the one-row-per-customer upsert target) + `foreign_key_constraint(:customer_id)`.
The monotonic skip-stale guard (Plan 02) keys off the
`last_stripe_event_ts` / `last_stripe_event_id` watermark columns.

## Truncation honesty (D-07)

Stripe webhook summaries inline at most 10 entitlements; `entitlements.has_more`
maps to the typed `truncated` column (queryable / partially-indexed) so
operators can find known-incomplete advisory snapshots. Both the exhaustive
pull and potentially incomplete webhook paths are observational-only and
never affect local access or a gate decision.

# `t`

```elixir
@type t() :: %Accrue.Billing.EntitlementSummary{
  __meta__: term(),
  customer: term(),
  customer_id: term(),
  data: term(),
  entitlement_count: term(),
  id: term(),
  inserted_at: term(),
  last_stripe_event_id: term(),
  last_stripe_event_ts: term(),
  livemode: term(),
  lock_version: term(),
  processor: term(),
  stripe_customer_id: term(),
  synced_at: term(),
  truncated: term(),
  updated_at: term()
}
```

# `force_changeset`

```elixir
@spec force_changeset(
  %Accrue.Billing.EntitlementSummary{
    __meta__: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    entitlement_count: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    livemode: term(),
    lock_version: term(),
    processor: term(),
    stripe_customer_id: term(),
    synced_at: term(),
    truncated: term(),
    updated_at: term()
  }
  | Ecto.Changeset.t(),
  map()
) :: Ecto.Changeset.t()
```

Webhook-path changeset. Stripe is canonical for entitlement-summary
state; this path skips any status allowlist (there is none) so
out-of-order events can settle arbitrary state without validation
failing. Carries the one-per-customer unique constraint and the
customer foreign-key constraint.

ADV-01: `optimistic_lock/1` is intentionally absent. The DB-level
`ON CONFLICT DO UPDATE WHERE` in `upsert_entitlement_summary/2` is the
sole concurrency guard; mixing OCC with an upsert `on_conflict_where`
causes Ecto to suppress the WHERE clause, silently bypassing the
monotonicity guard under concurrent delivery. The `lock_version` column
stays in the schema (no migration required) but is excluded from
`@cast_fields` so the upsert's `{:replace_all_except, [...]}` does not
overwrite it.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
