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 (including the entitlements.url pagination
handle) for the deferred lattice_stripe >= 1.2 paginated reconcile.
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 inlines at most 10 entitlements; entitlements.has_more maps to
the typed truncated column (queryable / partially-indexed) so operators
can find known-incomplete caches. Because the cache is observational-only,
truncation can never affect a gate decision.
Summary
Functions
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.
Types
@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() }
Functions
@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.