Accrue.Entitlements.Resolver.LocalMap (accrue v1.2.0)

Copy Markdown View Source

Default entitlement resolver — derives a billable's entitlements from local subscription state only, with zero processor calls.

Resolution path:

  1. Read-only accrue_customers lookup by (owner_type, owner_id) — a clone of the private Accrue.Billing.fetch_customer/2, NEVER the effectful get-or-create customer path (which would hit the processor on a miss). A nil/wrong-shape billable resolves to no customer.
  2. Entitling-subscription read via Accrue.Billing.Query.entitling/1 (active/trialing, not paused, not ended; the database twin of Accrue.Billing.Subscription.entitling?/1; never raw .status) joined to its items, selecting {price_id, quantity}. Using the entitlement-grade fragment closes the paused fail-open gap: a status: :active row with a non-nil pause_collection no longer grants entitlement.
  3. Fold each active item through the price_id -> plan reverse index built from Accrue.Config.entitlements/0, accumulating:
    • active_plans — the SET of ALL active plan atoms (membership source of truth for has_active_plan?/2),
    • features — the UNION of every active plan's features,
    • quantities — merged quota_key => min(cap, quantity),
    • grace_plans — the SUBSET of active_plans admitted via the past-due grace window (empty unless past_due_grace is enabled).

Past-due grace overlay (ENT-09): when Accrue.Config.past_due_grace/0 is :none (default) the base fetch stays Query.entitling/1 with the lean {price_id, quantity} select — zero query/compute change. When grace is enabled the fetch widens to Query.entitling_with_grace_candidates/1 (adds :past_due only, never :unpaid), and each :past_due candidate (per Subscription.dunning_sweepable?/1) is kept only if PastDueGrace.within_grace?/2 is true for its past_due_since; kept rows are tagged into grace_plans. A grace grant is an affirmative, resolved, configured decision — never a fail-open.

An active item whose price_id is unmapped is dropped under the default :deny (and its plan is NOT added to active_plans); under :raise the resolver raises so the context's try/rescue collapses it to fail-closed.

:plan carries a single representative active plan (the last folded one, or nil) for display only — membership decisions use active_plans.