Skip to content

Data model — commerce & wallet

Models: Entitlement, GumroadSale, WalletAudit, LLMUsageLog — paid access, verbatim Gumroad webhook persistence, the append-only wallet ledger, and per-request LLM cost accounting.

erDiagram
    USER ||--o{ ENTITLEMENT : holds
    GUMROADSALE |o--o{ ENTITLEMENT : "funded (provenance)"
    USER ||--o{ WALLETAUDIT : "wallet mutations"
    USER ||--o{ LLMUSAGELOG : "LLM calls"
    JOURNALENTRY |o--o{ LLMUSAGELOG : "call context"

Entitlement (backend/src/models/entitlement.py)

The "may access paid content" ledger: one row per grant of one access kind (today only course_access), with provenance and lifecycle. A dedicated table rather than a boolean on User because entitlements have their own lifecycle and more kinds are anticipated (entitlement.py:1-8).

Field Type Constraints / column Default Purpose
id int \| None primary key None Grant id (entitlement.py:78)
user_id int FK user.id, ondelete="CASCADE", index Grantee (entitlement.py:79)
kind str max_length=32, CHECK ck_entitlement_kind_valid "course_access" EntitlementKind value; new kinds extend the enum and the derived CHECK follows (entitlement.py:27-41,80)
product_id str \| None None The Gumroad SKU that funded the grant; manual grants omit it (entitlement.py:81-82)
source_sale_id int \| None FK gumroadsale.id, nullable, no ondelete cascade None Provenance, not a dependency — deleting a sale row must never silently revoke access (entitlement.py:83-88)
granted_at datetime DateTime(timezone=True), not null datetime.now(UTC) Grant instant (entitlement.py:89-92)
revoked_at datetime \| None nullable None Revocation instant (entitlement.py:93)
entitlement_metadata dict[str, object] JSON, not null, DB column named metadata {} Extensibility bag; SQLModel reserves the attribute name metadata, hence the attribute/column split (entitlement.py:60-63,96-99)

At most one active entitlement per (user_id, kind) — partial unique index ix_entitlement_user_kind_active WHERE revoked_at IS NULL, so revoke-then-regrant always works while revoked history accumulates (entitlement.py:55-58,66-76). Migration: a6b7c8d9e0f1_add_entitlement (backend/migrations/versions/). Rules in domain/entitlements.

GumroadSale (backend/src/models/gumroad_sale.py)

Verbatim persistence of Gumroad ping webhooks: one row per ping, keyed by Gumroad's sale_id so webhook replays collapse onto the existing row. Typed columns cover fields current features read; raw_payload keeps the posted form intact (Gumroad sends booleans as the strings "true"/"false", and those strings are preserved) so later features can re-derive anything without asking Gumroad to resend history (gumroad_sale.py:1-7,41-42).

Field Type Constraints / column Default Purpose
id int \| None primary key None Row id (gumroad_sale.py:33)
gumroad_sale_id str unique, index Gumroad's sale_id — the webhook idempotency key (gumroad_sale.py:34-35)
product_id str SKU sold (gumroad_sale.py:36)
email str Buyer email (gumroad_sale.py:37)
resource_name str Ping type; only "sale" (SALE_RESOURCE_NAME, gumroad_sale.py:24-27) carries entitlement/wallet side effects (gumroad_sale.py:38)
is_recurring_charge bool False Subscription renewal flag (gumroad_sale.py:39)
refunded bool False Refund flag from the ping (gumroad_sale.py:40)
raw_payload dict[str, str] JSON, not null {} The posted form exactly as received (gumroad_sale.py:43-46)
created_at datetime DateTime(timezone=True), not null datetime.now(UTC) Receipt instant (gumroad_sale.py:47-50)
token_pack_credited_at datetime \| None nullable None Exactly-once wallet-credit guard — see below (gumroad_sale.py:51-58)
token_pack_credited_user_id int \| None FK user.id, ondelete="SET NULL", index, nullable None Which account received the pack; SET NULL so the financial trail survives account deletion (gumroad_sale.py:59-69)
revocation_processed_at datetime \| None nullable None Exactly-once reversal guard shared by refund/dispute/cancellation events (gumroad_sale.py:70-78)

The two guard columns are the load-bearing concurrency design (backend/src/models/gumroad_sale.py:51-54,70-74):

    # The token-pack claim guard. NULL means "no wallet credit has been taken
    # for this sale yet"; a guarded UPDATE stamps it, so only one writer can
    # ever move the credits. It deliberately outlives the crediting account:
    # a deleted-then-re-registered email must not re-mint the same pack.
    ...
    # The reversal claim guard, shared by every event that unwinds a purchase.
    # NULL means "nothing has reversed this sale yet"; a guarded UPDATE stamps
    # it, so the refund and the cancellation of the same subscription cannot
    # both revoke.

Migrations: d0e1f2a3b4c6_add_gumroad_sale, b8c9d0e1f2a3_gumroad_sale_token_pack_credit (backend/migrations/versions/).

WalletAudit (backend/src/models/wallet_audit.py)

Append-only audit log for every wallet mutation (BUG-BM-011): one row per offering_balance / monthly_messages_used change. Intentionally not exposed via the API — a forensic surface read by ops via direct SQL (wallet_audit.py:1-6). Append-only is enforced at the application layer (services.wallet only ever inserts); operators wanting DB-level defence-in-depth should REVOKE UPDATE, DELETE from the application role in deployment (the role name is environment-specific, so it is not embedded in the migration) (wallet_audit.py:8-16).

Field Type Constraints / column Default Purpose
id int \| None primary key None Row id (wallet_audit.py:117)
user_id int FK user.id, index, ondelete="CASCADE" Wallet owner (wallet_audit.py:118)
actor_user_id int \| None FK user.id, index, ondelete="SET NULL", nullable None Who initiated the change; SET NULL so an admin's deletion does not destroy audit rows for actions on other users' wallets (wallet_audit.py:119-131)
bucket str String(64), not null, index monthly or offering (BUCKET_MONTHLY/BUCKET_OFFERING, wallet_audit.py:70-75,132-134)
reason str String(64), not null, index Symbolic reason token — see table below (wallet_audit.py:135-137)
delta Decimal Numeric(18,6), not null Signed change (wallet_audit.py:138-140)
balance_before Decimal Numeric(18,6), not null Balance before (wallet_audit.py:141-143)
balance_after Decimal Numeric(18,6), not null Balance after (wallet_audit.py:144-146)
created_at datetime not null, index, server_default=func.now() datetime.now(UTC) Defence-in-depth default so a raw ops INSERT omitting it lands cleanly (wallet_audit.py:147-159)

Reason tokens are module constants so the service layer references symbolic names, and analytics can group by reason (wallet_audit.py:26-32):

Constant Value Meaning
REASON_SPEND_MONTHLY spend_monthly Free-bucket message spend (wallet_audit.py:33)
REASON_SPEND_OFFERING spend_offering Paid-bucket message spend (wallet_audit.py:34)
REASON_ADMIN_GRANT admin_grant Admin granted credits to another user (actor_user_id != user_id) (wallet_audit.py:35-40)
REASON_SELF_GRANT self_grant Courtesy self top-up (wallet_audit.py:41)
REASON_MONTHLY_RESET monthly_reset First-of-month rollover zeroing monthly_messages_used; recorded so reconciliation holds (wallet_audit.py:42-50)
REASON_GUMROAD_PURCHASE gumroad_purchase Paid token pack credited to the offering bucket (wallet_audit.py:51-58)
REASON_GUMROAD_REFUND gumroad_refund Claw-back; deliberately unclamped, so the balance can go below zero — "spending the credits first must not make the refund cheaper" (wallet_audit.py:59-68)

The sign convention makes reconciliation a single SQL query (backend/src/models/wallet_audit.py:99-112):

    * ``BUCKET_MONTHLY`` rows use a *count-up* convention.  A
      ``REASON_SPEND_MONTHLY`` row records ``delta = +1`` (the
      counter rose by one).  A ``REASON_MONTHLY_RESET`` row records
      ``delta = -before`` (the counter dropped from ``before`` to 0).
      ``SUM(delta WHERE bucket='monthly')`` over a calendar month
      therefore yields zero -- spends and the rollover net out ...
    * ``BUCKET_OFFERING`` rows use a *credit-balance* convention.  A
      grant records ``delta = +amount``; a spend records ``delta = -1``.
      ``SUM(delta WHERE bucket='offering')`` is the user's current
      offering balance from the audit log alone ...

Migrations: e1f2a3b4c5d6_decimal_cost_and_wallet_audit, f3a4b5c6d7e8_add_user_fk_ondelete (backend/migrations/versions/).

LLMUsageLog (backend/src/models/llm_usage_log.py)

Per-request LLM cost + token accounting: one row per successful LLM call (journal resonance / essay generation), append-only, doubling as an audit log for cost investigations; aggregates are computed on read by the admin stats endpoint (llm_usage_log.py:1-8).

Field Type Constraints / column Default Purpose
id int \| None primary key None Row id (llm_usage_log.py:58)
user_id int FK user.id, index, ondelete="CASCADE" Caller (llm_usage_log.py:59)
timestamp datetime not null, index datetime.now(UTC) Call instant (llm_usage_log.py:60-63)
provider str max_length=32, index LLM provider (llm_usage_log.py:64)
model str max_length=128, index Model id (llm_usage_log.py:65)
prompt_tokens int ge=0 0 Input tokens (llm_usage_log.py:66)
completion_tokens int ge=0 0 Output tokens (llm_usage_log.py:67)
total_tokens int ge=0 0 Total tokens (llm_usage_log.py:68)
estimated_cost_usd Decimal \| None Numeric(12,6), nullable None (DEFAULT_COST) Derived from tokens via services.llm_pricing; stored so historical rows survive pricing-table updates (llm_usage_log.py:39-42,69-72)
journal_entry_id int \| None FK journalentry.id, index None The entry the call was about; None for stateless calls (e.g. transcription) (llm_usage_log.py:50-55,73)

estimated_cost_usd is Decimal (BUG-ADMIN-004 / BUG-BM-008) so aggregate sums are exact; None means "unknown model — pricing table missed it" and is logged as a warning rather than silently averaged in as $0, which the previous float default did (llm_usage_log.py:44-48). Migration: e1f2a3b4c5d6_decimal_cost_and_wallet_audit (backend/migrations/versions/).


Grounded in adepthood@fbc529d, 2026-07-31.