- Remove per-module projection files (README, architecture, companion, interfaces, model) under design/<module>/ - Add design/documents/ with numbered design docs, references, policies, and manifest - Update design/README.md to describe the directory as a mirror of membcons-db's normative surfaces - Record Decisions 140-141 in companion and glossary; update data-model.md schema organization
40 KiB
Data Model Reference
Foundational Concepts
This data model separates three concerns that are often conflated:
- Identity: Who is this human, and how do they authenticate?
- Organization & Access: What containers exist, and who can access them?
- Billing & Value: Who pays, and what value have they contributed?
These concerns have different lifecycles, different data requirements, and different stakeholders. A person's legal name for tax forms is not the same as their login email. An organization's access control is not the same as its billing arrangement. Separating these concerns allows each to evolve independently.
Layer 1: Identity
Identity answers: "Who is this human, and how do they authenticate?"
users
A user is an authentication identity. It represents a login credential managed by the identity provider (Keycloak). This table is a local cache of authentication data, synchronized from the identity provider.
A user is not a person. A user is a way for a person to authenticate. This distinction matters because:
- A person exists in the business domain even before they create a login (e.g., when invited)
- Authentication data (email, username) may differ from legal/business data
- If the identity provider changes, user records change but person records persist
| Field | Type | Purpose |
|---|---|---|
user_id |
BIGSERIAL | Primary key |
oidc_subject |
VARCHAR(255) | Unique identifier from identity provider. Immutable reference that survives email changes. |
oidc_issuer |
VARCHAR(255) | Which identity provider issued this identity. Supports multiple IdPs. |
email |
VARCHAR(255) | Email used for authentication. May differ from billing email. |
email_verified |
BOOLEAN | Whether the identity provider has verified this email. |
username |
VARCHAR(100) | Display username from identity provider. |
display_name |
VARCHAR(255) | Human-readable name for UI display. |
avatar_url |
VARCHAR(500) | Profile image URL. |
locale |
VARCHAR(10) | Preferred language/locale. |
timezone |
VARCHAR(50) | Preferred timezone for date display. |
last_login_at |
TIMESTAMPTZ | Most recent authentication timestamp. |
last_login_ip |
INET | IP address of most recent login. For security auditing. |
status |
VARCHAR(20) | Account state: active, suspended, deleted. |
created_at |
TIMESTAMPTZ | When this auth identity was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Relationships:
users(1) → (0..1)persons: A user may be linked to a person. The relationship is optional on the persons side because a person can exist before they sign up (invited but not yet registered).
persons
A person is a human being as a business and legal entity. It holds information needed for invoices, contracts, tax forms, and cooperative membership.
A person is not a user. A person is a human who participates in business relationships. This distinction matters because:
- Business data (legal name, tax ID, mailing address) doesn't belong in the authentication system
- A person may be invited to an organization before they create a login
- Cooperative membership, equity ownership, and patronage belong to the person, not to their login credentials
- Legal and tax requirements (like 1099-PATR forms) require data the auth system doesn't have
| Field | Type | Purpose |
|---|---|---|
person_id |
BIGSERIAL | Primary key |
user_id |
BIGINT | Link to authentication identity. NULL if invited but not yet signed up. |
invite_email |
VARCHAR(255) | Email address for pending invitation. Used when person exists before user. |
invite_token |
VARCHAR(255) | Secure token for accepting invitation. |
invite_expires_at |
TIMESTAMPTZ | When the invitation expires. |
invited_by_person_id |
BIGINT | Who sent the invitation. For audit trail. |
legal_first_name |
VARCHAR(100) | Legal first name as it appears on official documents. |
legal_last_name |
VARCHAR(100) | Legal last name as it appears on official documents. |
phone |
VARCHAR(50) | Contact phone number. |
address_line1 |
VARCHAR(255) | Street address line 1. Required for invoices and tax forms. |
address_line2 |
VARCHAR(255) | Street address line 2. |
city |
VARCHAR(100) | City. |
state_province |
VARCHAR(100) | State or province. |
postal_code |
VARCHAR(20) | Postal/ZIP code. |
country_code |
VARCHAR(2) | ISO 3166-1 alpha-2 country code. |
tax_id_type |
VARCHAR(20) | Type of tax identifier: ssn, ein, itin, vat, gst, other. |
tax_id_last4 |
VARCHAR(4) | Last 4 digits of tax ID. Full ID stored in secure vault. |
tax_id_verified |
BOOLEAN | Whether tax ID has been verified. |
tax_id_verified_at |
TIMESTAMPTZ | When tax ID was verified. |
status |
VARCHAR(20) | State: pending (invited), active, inactive, merged (duplicate resolved). |
created_at |
TIMESTAMPTZ | When this person record was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
- A person must have either a
user_id(signed up) or aninvite_email(invited). A person cannot exist without some form of identity.
Relationships:
persons(0..1) → (1)users: A person may be linked to a user for authentication.persons(1) → (0..1)organizations: A person may own a personal organization.persons(1) → (0..*)org_members: A person may be a member of multiple organizations.persons(1) → (0..*)workspaces: A person may have created workspaces.persons(1) → (0..*)role_assignments: A person may have workspace-level role assignments.
Layer 2: Organization & Access
Organization & Access answers: "What containers exist, and who can access them with what permissions?"
organizations
An organization is the top-level container for all resources, members, and billing relationships. Every person operates within the context of at least one organization.
There are no solo users in this model. Every person has a personal organization created automatically. This design choice means:
- The data model is uniform whether someone is solo or on a team
- Solo users can invite collaborators without migration
- All resources, billing, and access control work the same way regardless of organization size
- Organizations can be personal (one owner), team (multiple members), or enterprise (formal business)
| Field | Type | Purpose |
|---|---|---|
org_id |
BIGSERIAL | Primary key |
name |
VARCHAR(255) | Display name of the organization. |
slug |
VARCHAR(100) | URL-safe identifier. Globally unique. Used in URLs and API references. |
org_type |
VARCHAR(20) | Classification: personal (auto-created for individuals), team (collaborative), enterprise (formal business). |
owner_person_id |
BIGINT | For personal organizations, the person who owns it. Required when org_type = personal. |
legal_name |
VARCHAR(255) | Registered legal name of the business entity. |
entity_type |
VARCHAR(50) | Legal structure: llc, corporation, nonprofit, cooperative, sole_prop. |
tax_id |
VARCHAR(100) | Business tax identifier (EIN for US companies). |
website |
VARCHAR(255) | Organization's website. |
settings |
JSONB | Organization-level configuration and preferences. |
status |
VARCHAR(20) | State: active, suspended, deleted. |
created_at |
TIMESTAMPTZ | When this organization was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
- Personal organizations must have an
owner_person_id. Team and enterprise organizations have membership but no single owner. slugis globally unique to enable clean URLs.
Relationships:
organizations(0..1) → (1)persons: Personal organizations have an owner person.organizations(1) → (0..*)org_members: Organizations have members.organizations(1) → (0..*)workspaces: Organizations contain workspaces.organizations(1) → (0..*)billing_accounts: Organizations have billing accounts.organizations(1) → (0..*)roles: Organizations may define custom roles.
roles
A role defines a named set of permissions. Roles determine what actions a person can perform within an organization or workspace.
Roles are either system-defined (built-in, applicable to all organizations) or organization-defined (custom roles for a specific organization). System roles provide consistent semantics across the platform. Custom roles allow organizations to tailor access control to their needs.
| Field | Type | Purpose |
|---|---|---|
role_id |
BIGSERIAL | Primary key |
org_id |
BIGINT | If set, this is a custom role for this organization. If NULL, this is a system role. |
role_name |
VARCHAR(100) | Machine-readable identifier. Unique within scope (system or per-org). |
display_name |
VARCHAR(255) | Human-readable name for UI. |
description |
TEXT | Explanation of what this role is for. |
is_system |
BOOLEAN | Whether this is a built-in system role. System roles cannot be deleted. |
permissions |
TEXT[] | Array of permission strings this role grants. Format: resource:action (e.g., billing:manage, workspace:delete). |
parent_role_id |
BIGINT | Optional parent role for permission inheritance. |
created_at |
TIMESTAMPTZ | When this role was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
System roles:
owner: Full access including organization deletion and ownership transfer.admin: Full access except organization deletion and ownership transfer.member: Can access workspaces and manage resources.billing: Can only access billing information.viewer: Read-only access.
Relationships:
roles(0..*) → (0..1)organizations: Custom roles belong to an organization. System roles have no organization.roles(1) → (0..*)org_members: Roles are assigned to organization members.roles(1) → (0..*)role_assignments: Roles are assigned at workspace level.roles(0..1) → (0..1)roles: Roles may inherit from a parent role.
org_members
An org_member represents the membership of a person in an organization with a specific role. This is the primary mechanism for organization-level access control.
Membership is distinct from ownership. An organization may have many members with different roles. Personal organizations have exactly one member (the owner). Team organizations may have many members with varying permissions.
| Field | Type | Purpose |
|---|---|---|
org_member_id |
BIGSERIAL | Primary key |
org_id |
BIGINT | The organization. |
person_id |
BIGINT | The person who is a member. |
role_id |
BIGINT | The role this person has in this organization. |
invited_by_person_id |
BIGINT | Who invited this person. For audit trail. |
invited_at |
TIMESTAMPTZ | When the invitation was sent. |
accepted_at |
TIMESTAMPTZ | When the invitation was accepted. |
status |
VARCHAR(20) | Membership state: pending (invited), active, suspended, removed. |
created_at |
TIMESTAMPTZ | When this membership was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
- A person can only be a member of an organization once. The combination of
org_idandperson_idis unique.
Relationships:
org_members(0..*) → (1)organizations: Memberships belong to an organization.org_members(0..*) → (1)persons: Memberships belong to a person.org_members(0..*) → (1)roles: Memberships have a role.
workspaces
A workspace is a container for resources within an organization. Workspaces provide isolation, organization, and optionally different access controls within a single organization.
Workspaces serve several purposes:
- Isolation: Resources in one workspace are separate from another (e.g., production vs. staging).
- Organization: Logical grouping of related resources (e.g., by project, client, or team).
- Access control: Permissions can be granted at workspace level, not just organization level.
- Quota allocation: Organization-wide quotas can be subdivided across workspaces.
| Field | Type | Purpose |
|---|---|---|
workspace_id |
BIGSERIAL | Primary key |
org_id |
BIGINT | The organization this workspace belongs to. |
name |
VARCHAR(255) | Display name of the workspace. |
slug |
VARCHAR(100) | URL-safe identifier. Unique within the organization. |
description |
TEXT | Explanation of what this workspace is for. |
environment |
VARCHAR(20) | Classification: development, staging, production. Informational. |
settings |
JSONB | Workspace-level configuration. |
created_by_person_id |
BIGINT | Who created this workspace. |
status |
VARCHAR(20) | State: active, archived, deleted. |
created_at |
TIMESTAMPTZ | When this workspace was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
slugis unique within an organization. The combination oforg_idandslugis unique.
Relationships:
workspaces(0..*) → (1)organizations: Workspaces belong to an organization.workspaces(0..*) → (0..1)persons: Workspaces track who created them.workspaces(1) → (0..*)role_assignments: Workspaces may have person-specific role assignments.workspaces(1) → (0..*)quotas: Workspaces may have allocated quotas.workspaces(1) → (0..*)patronage_events: Usage may be attributed to a workspace.
role_assignments
A role_assignment grants a role to a person for a specific scope (currently workspace-level). This enables finer-grained access control than organization-level membership.
Organization membership (via org_members) grants baseline access. Role assignments allow exceptions and refinements:
- A person might be a viewer at org level but an admin for a specific workspace.
- A contractor might have access to only one workspace, not the whole organization.
- Temporary access can be granted with an expiration date.
| Field | Type | Purpose |
|---|---|---|
assignment_id |
BIGSERIAL | Primary key |
person_id |
BIGINT | The person receiving the role. |
role_id |
BIGINT | The role being granted. |
scope_type |
VARCHAR(20) | What kind of resource this applies to. Currently only workspace. |
scope_id |
BIGINT | The ID of the resource (e.g., workspace_id). |
granted_by_person_id |
BIGINT | Who granted this role. For audit trail. |
granted_at |
TIMESTAMPTZ | When this role was granted. |
expires_at |
TIMESTAMPTZ | When this role expires. NULL means no expiration. |
status |
VARCHAR(20) | State: active, revoked, expired. |
created_at |
TIMESTAMPTZ | When this assignment was created. |
Constraints:
- A person can only have one assignment of a specific role for a specific scope. The combination of
person_id,role_id,scope_type, andscope_idis unique.
Relationships:
role_assignments(0..*) → (1)persons: Assignments belong to a person.role_assignments(0..*) → (1)roles: Assignments grant a role.role_assignments(0..*) → (1)workspaces: Assignments are scoped to a workspace (whenscope_type=workspace).
Layer 3: Billing & Value
Billing & Value answers: "Who pays, what do they pay for, and what value have they contributed?"
billing_accounts
A billing_account is the entity that pays. It holds payment methods, receives invoices, and accumulates patronage. A billing account is separate from the organization that owns it.
This separation matters because:
- An organization may have multiple billing accounts (e.g., for different cost centers, clients, or funding sources).
- Billing contacts and addresses may differ from organization contacts.
- The payer (billing account) is the patron for cooperative purposes, which may differ from organizational structure.
- Future flexibility: billing accounts could potentially span organizations (enterprise billing) or be shared.
| Field | Type | Purpose |
|---|---|---|
billing_account_id |
BIGSERIAL | Primary key |
org_id |
BIGINT | The organization that owns this billing account. |
name |
VARCHAR(255) | Display name (e.g., "Primary", "Marketing Budget", "Client: Acme"). |
is_default |
BOOLEAN | Whether this is the default billing account for the organization. Each org has exactly one default. |
billing_email |
VARCHAR(255) | Where to send invoices. May differ from user or org email. |
billing_name |
VARCHAR(255) | Name to appear on invoices. |
billing_address_line1 |
VARCHAR(255) | Billing street address. |
billing_address_line2 |
VARCHAR(255) | Billing address line 2. |
billing_city |
VARCHAR(100) | Billing city. |
billing_state |
VARCHAR(100) | Billing state/province. |
billing_postal_code |
VARCHAR(20) | Billing postal code. |
billing_country |
VARCHAR(2) | Billing country (ISO 3166-1 alpha-2). |
tax_id |
VARCHAR(100) | Tax identifier for this billing entity (for invoices, receipts). |
tax_exempt |
BOOLEAN | Whether this billing account is tax exempt. |
tax_exempt_reason |
VARCHAR(255) | Reason for tax exemption if applicable. |
currency |
VARCHAR(3) | Preferred currency for billing (ISO 4217). |
stripe_customer_id |
VARCHAR(255) | Reference to Stripe customer object. |
default_payment_method_id |
VARCHAR(255) | Reference to default payment method in Stripe. |
settings |
JSONB | Billing preferences and configuration. |
status |
VARCHAR(20) | State: active, suspended, closed. |
created_at |
TIMESTAMPTZ | When this billing account was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
- Each organization has exactly one default billing account (
is_default= TRUE). stripe_customer_idis unique when set.
Relationships:
billing_accounts(0..*) → (1)organizations: Billing accounts belong to an organization.billing_accounts(1) → (0..*)subscriptions: Billing accounts have subscriptions.billing_accounts(1) → (0..*)invoices: Billing accounts receive invoices.billing_accounts(1) → (0..*)payments: Billing accounts make payments.billing_accounts(1) → (0..1)patrons: A billing account is a patron (for cooperative tracking).billing_accounts(1) → (0..*)entitlements: Billing accounts have entitlements.billing_accounts(1) → (0..*)quotas: Billing accounts have quota pools.
products
A product represents something you sell. Products define what features or capabilities a subscription grants, independent of pricing.
Products are separated from prices because:
- A single product may have multiple prices (monthly vs. annual, different tiers, different currencies).
- Product features define capabilities; prices define cost.
- Products can be deactivated while preserving historical price records.
| Field | Type | Purpose |
|---|---|---|
product_id |
BIGSERIAL | Primary key |
name |
VARCHAR(255) | Display name (e.g., "Pro Plan", "API Access"). |
description |
TEXT | Marketing/explanatory description. |
product_type |
VARCHAR(50) | Classification: service (subscription), addon (additional feature), usage (metered), one_time (single purchase). |
features |
JSONB | Array of feature keys this product grants (e.g., ["sites", "custom_domains", "api_access"]). |
stripe_product_id |
VARCHAR(255) | Reference to Stripe product object. |
metadata |
JSONB | Additional product attributes. |
is_active |
BOOLEAN | Whether this product can be purchased. |
is_public |
BOOLEAN | Whether this product appears on public pricing pages. |
created_at |
TIMESTAMPTZ | When this product was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Relationships:
products(1) → (0..*)prices: Products have one or more prices.products(1) → (0..*)patronage_events: Patronage can be attributed to a product.
prices
A price defines how much a product costs and what billing terms apply. A price also specifies what quotas (numeric limits) are included.
Prices capture the commercial terms of a subscription:
- Amount and currency
- Billing frequency (monthly, annual)
- Trial periods
- Included quotas (how many sites, how much storage, etc.)
| Field | Type | Purpose |
|---|---|---|
price_id |
BIGSERIAL | Primary key |
product_id |
BIGINT | The product this price is for. |
nickname |
VARCHAR(255) | Display name (e.g., "Monthly", "Annual", "Starter"). |
currency |
VARCHAR(3) | Currency code (ISO 4217). |
unit_amount |
INTEGER | Price in smallest currency unit (e.g., cents for USD). |
billing_scheme |
VARCHAR(20) | How pricing works: per_unit (simple) or tiered (volume-based). |
recurring_interval |
VARCHAR(20) | Billing frequency: day, week, month, year, or NULL for one-time. |
recurring_interval_count |
INTEGER | Number of intervals between billings (e.g., 1 for monthly, 3 for quarterly). |
trial_period_days |
INTEGER | Number of days in trial period. NULL for no trial. |
usage_type |
VARCHAR(20) | For usage-based pricing: metered (pay per use) or licensed (pay per seat). |
included_quotas |
JSONB | Quotas this price grants (e.g., {"sites_limit": 5, "storage_gb": 10}). |
stripe_price_id |
VARCHAR(255) | Reference to Stripe price object. |
metadata |
JSONB | Additional price attributes. |
is_active |
BOOLEAN | Whether this price can be used for new subscriptions. |
created_at |
TIMESTAMPTZ | When this price was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Relationships:
prices(0..*) → (1)products: Prices belong to a product.prices(1) → (0..*)subscriptions: Prices are used by subscriptions.prices(1) → (0..*)subscription_changes: Price changes are recorded.
subscriptions
A subscription represents an active billing relationship between a billing account and a price. Subscriptions grant entitlements and quotas for as long as they remain active.
Subscriptions are the link between payment and access:
- An active subscription grants the features and quotas defined by its product/price.
- Subscription status determines whether access is granted.
- Subscriptions track billing periods, trials, and cancellation state.
| Field | Type | Purpose |
|---|---|---|
subscription_id |
BIGSERIAL | Primary key |
billing_account_id |
BIGINT | The billing account that is subscribed. |
price_id |
BIGINT | The price (and thus product) subscribed to. |
quantity |
INTEGER | Number of units (for per-seat pricing). |
current_period_start |
TIMESTAMPTZ | Start of current billing period. |
current_period_end |
TIMESTAMPTZ | End of current billing period. |
trial_start |
TIMESTAMPTZ | When trial started, if applicable. |
trial_end |
TIMESTAMPTZ | When trial ends, if applicable. |
cancel_at_period_end |
BOOLEAN | Whether to cancel at end of current period. |
cancel_at |
TIMESTAMPTZ | Scheduled cancellation time. |
canceled_at |
TIMESTAMPTZ | When cancellation was requested. |
cancellation_reason |
TEXT | Why the subscription was canceled. |
ended_at |
TIMESTAMPTZ | When the subscription actually ended. |
status |
VARCHAR(20) | State: trialing, active, past_due, canceled, unpaid, paused, incomplete. |
stripe_subscription_id |
VARCHAR(255) | Reference to Stripe subscription object. |
metadata |
JSONB | Additional subscription attributes. |
created_at |
TIMESTAMPTZ | When this subscription was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Relationships:
subscriptions(0..*) → (1)billing_accounts: Subscriptions belong to a billing account.subscriptions(0..*) → (1)prices: Subscriptions are for a specific price.subscriptions(1) → (0..*)subscription_changes: Changes to subscriptions are tracked.subscriptions(1) → (0..*)invoices: Subscriptions generate invoices.subscriptions(1) → (0..*)entitlements: Subscriptions grant entitlements.subscriptions(1) → (0..*)quotas: Subscriptions grant quotas.
subscription_changes
A subscription_change records a modification to a subscription. This provides an audit trail of subscription lifecycle events.
Tracking changes separately from current state allows:
- Historical analysis of subscription behavior
- Understanding churn and upgrade/downgrade patterns
- Debugging billing issues
- Audit compliance
| Field | Type | Purpose |
|---|---|---|
change_id |
BIGSERIAL | Primary key |
subscription_id |
BIGINT | The subscription that changed. |
change_type |
VARCHAR(50) | What kind of change: created, upgraded, downgraded, quantity_changed, renewed, trial_started, trial_ended, paused, resumed, canceled, reactivated, ended. |
previous_price_id |
BIGINT | Price before the change. |
previous_quantity |
INTEGER | Quantity before the change. |
previous_status |
VARCHAR(20) | Status before the change. |
new_price_id |
BIGINT | Price after the change. |
new_quantity |
INTEGER | Quantity after the change. |
new_status |
VARCHAR(20) | Status after the change. |
changed_by_person_id |
BIGINT | Who made the change (NULL if system/automated). |
reason |
TEXT | Explanation for the change. |
stripe_event_id |
VARCHAR(255) | Stripe event that triggered this change, if applicable. |
effective_at |
TIMESTAMPTZ | When the change took/takes effect. |
created_at |
TIMESTAMPTZ | When this record was created. |
Relationships:
subscription_changes(0..*) → (1)subscriptions: Changes belong to a subscription.subscription_changes(0..*) → (0..1)prices: Changes may reference previous and new prices.subscription_changes(0..*) → (0..1)persons: Changes may be attributed to a person.
invoices
An invoice is a billing document requesting payment from a billing account. Invoices may be generated from subscriptions or created manually.
Invoices capture the billing snapshot at a point in time:
- Billing details are copied (not referenced) because they may change later
- Amounts are calculated and frozen
- Status tracks the payment lifecycle
| Field | Type | Purpose |
|---|---|---|
invoice_id |
BIGSERIAL | Primary key |
invoice_number |
VARCHAR(50) | Human-readable unique identifier (e.g., "INV-2024-0001"). |
billing_account_id |
BIGINT | The billing account being invoiced. |
subscription_id |
BIGINT | The subscription this invoice is for, if applicable. |
billing_name |
VARCHAR(255) | Snapshot of billing name at invoice time. |
billing_email |
VARCHAR(255) | Snapshot of billing email at invoice time. |
billing_address |
JSONB | Snapshot of full billing address at invoice time. |
currency |
VARCHAR(3) | Currency for this invoice. |
subtotal |
INTEGER | Amount before discounts and tax (smallest currency unit). |
discount_amount |
INTEGER | Total discounts applied. |
tax_amount |
INTEGER | Tax amount. |
total |
INTEGER | Final amount due (subtotal - discounts + tax). |
amount_paid |
INTEGER | Amount paid so far. |
amount_due |
INTEGER | Remaining balance (total - amount_paid). |
invoice_date |
DATE | Date the invoice was issued. |
due_date |
DATE | Date payment is due. |
period_start |
DATE | Start of billing period this invoice covers. |
period_end |
DATE | End of billing period this invoice covers. |
status |
VARCHAR(20) | State: draft, open, paid, void, uncollectible, refunded. |
paid_at |
TIMESTAMPTZ | When the invoice was fully paid. |
stripe_invoice_id |
VARCHAR(255) | Reference to Stripe invoice object. |
invoice_pdf_url |
VARCHAR(500) | URL to downloadable PDF. |
hosted_invoice_url |
VARCHAR(500) | URL to hosted invoice page. |
memo |
TEXT | Notes or memo on the invoice. |
metadata |
JSONB | Additional invoice attributes. |
created_at |
TIMESTAMPTZ | When this invoice was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Relationships:
invoices(0..*) → (1)billing_accounts: Invoices are sent to a billing account.invoices(0..*) → (0..1)subscriptions: Invoices may be for a subscription.invoices(1) → (0..*)payments: Invoices may have payments applied.
payments
A payment records money received from a billing account. Payments may be applied to invoices or exist as account credits.
Payments track the actual money received:
- Amount and currency
- Payment method used
- Processor fees (important for net patronage calculation)
- Success or failure state
| Field | Type | Purpose |
|---|---|---|
payment_id |
BIGSERIAL | Primary key |
billing_account_id |
BIGINT | The billing account that paid. |
invoice_id |
BIGINT | The invoice this payment is for, if applicable. |
currency |
VARCHAR(3) | Currency of the payment. |
amount |
INTEGER | Gross amount received (smallest currency unit). |
processor_fee |
INTEGER | Fee charged by payment processor. Used to calculate net patronage. |
payment_method_type |
VARCHAR(50) | Type of payment method: card, bank_transfer, crypto, etc. |
payment_method_details |
JSONB | Details about the payment method (e.g., {"brand": "visa", "last4": "4242"}). |
status |
VARCHAR(20) | State: pending, processing, succeeded, failed, refunded, partially_refunded. |
failure_code |
VARCHAR(100) | Error code if payment failed. |
failure_message |
TEXT | Human-readable failure explanation. |
paid_at |
TIMESTAMPTZ | When the payment was completed. |
stripe_payment_intent_id |
VARCHAR(255) | Reference to Stripe PaymentIntent. |
stripe_charge_id |
VARCHAR(255) | Reference to Stripe Charge. |
metadata |
JSONB | Additional payment attributes. |
created_at |
TIMESTAMPTZ | When this payment was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Relationships:
payments(0..*) → (1)billing_accounts: Payments come from a billing account.payments(0..*) → (0..1)invoices: Payments may be applied to an invoice.payments(1) → (0..*)patronage_events: Successful payments create patronage events.
patrons
A patron is a billing account viewed through the lens of cooperative value contribution. Every billing account that has made a payment is a patron.
The patron entity exists to:
- Assign a human-readable patron number for statements and tax forms
- Track lifetime patronage totals for quick access
- Serve as the anchor point for patronage events
- Eventually link to cooperative membership (in later phases)
| Field | Type | Purpose |
|---|---|---|
patron_id |
BIGSERIAL | Primary key |
billing_account_id |
BIGINT | The billing account this patron represents. One-to-one relationship. |
patron_number |
VARCHAR(50) | Human-readable identifier (e.g., "P-2024-0001"). Used on statements and tax forms. |
first_patronage_date |
DATE | Date of first patronage event. |
lifetime_gross |
INTEGER | Total gross patronage ever (smallest currency unit). Denormalized for quick access. |
lifetime_net |
INTEGER | Total net patronage ever (after fees). Denormalized for quick access. |
status |
VARCHAR(20) | State: active, inactive. |
created_at |
TIMESTAMPTZ | When this patron record was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Relationships:
patrons(1) → (1)billing_accounts: A patron is a billing account. One-to-one.patrons(1) → (0..*)patronage_events: Patrons generate patronage events.
patronage_events
A patronage_event records a single contribution of value from a patron. Each successful payment creates a patronage event. Refunds create negative patronage events.
Patronage events are the source of truth for cooperative value calculation:
- Gross amount is what the patron paid
- Net amount is what the cooperative retained after payment processor fees
- Fiscal period allows annual patronage calculations
- Product and workspace attribution enable detailed analysis
| Field | Type | Purpose |
|---|---|---|
event_id |
BIGSERIAL | Primary key |
patron_id |
BIGINT | The patron who generated this event. |
fiscal_year |
INTEGER | Fiscal year for this event. Used for annual patronage calculations. |
fiscal_quarter |
INTEGER | Fiscal quarter (1-4). |
fiscal_month |
INTEGER | Fiscal month (1-12). |
event_type |
VARCHAR(50) | Classification: subscription_payment, usage_charge, one_time_purchase, service_fee, refund, adjustment, credit. |
source_type |
VARCHAR(50) | What generated this event: payment, invoice, credit, manual. |
source_id |
BIGINT | ID of the source record (e.g., payment_id). |
product_id |
BIGINT | Product this patronage is attributed to, if applicable. |
workspace_id |
BIGINT | Workspace this patronage is attributed to, if applicable. |
currency |
VARCHAR(3) | Currency of the amounts. |
gross_amount |
INTEGER | What the patron paid (smallest currency unit). |
fees_amount |
INTEGER | Payment processor fees. |
net_amount |
INTEGER | What the cooperative retained (gross - fees). |
event_date |
DATE | Date of the event. |
event_timestamp |
TIMESTAMPTZ | Exact time of the event. |
description |
TEXT | Human-readable description. |
metadata |
JSONB | Additional event attributes. |
created_at |
TIMESTAMPTZ | When this record was created. |
Relationships:
patronage_events(0..*) → (1)patrons: Events belong to a patron.patronage_events(0..*) → (0..1)products: Events may be attributed to a product.patronage_events(0..*) → (0..1)workspaces: Events may be attributed to a workspace.patronage_events(0..*) → (0..1)payments: Events are typically sourced from payments.
Layer 4: Entitlements & Quotas
Entitlements & Quotas answers: "What capabilities does this billing account have, and what are the limits?"
entitlements
An entitlement represents a capability that a billing account has. Entitlements are typically granted by subscriptions but can also be granted manually (trials, promotions, legacy access).
Entitlements are binary (enabled/disabled) capabilities:
- "Can create sites" (yes/no)
- "Can use custom domains" (yes/no)
- "Can access API" (yes/no)
For numeric limits, see quotas.
| Field | Type | Purpose |
|---|---|---|
entitlement_id |
BIGSERIAL | Primary key |
billing_account_id |
BIGINT | The billing account that has this entitlement. |
feature_key |
VARCHAR(100) | Machine-readable feature identifier (e.g., sites, custom_domains, api_access). |
is_enabled |
BOOLEAN | Whether this entitlement is currently active. |
source_type |
VARCHAR(50) | What granted this entitlement: subscription, trial, grant, legacy, promotion. |
source_id |
BIGINT | ID of the source (e.g., subscription_id). |
valid_from |
TIMESTAMPTZ | When this entitlement became active. |
valid_until |
TIMESTAMPTZ | When this entitlement expires. NULL means valid until source ends. |
metadata |
JSONB | Additional entitlement attributes. |
created_at |
TIMESTAMPTZ | When this entitlement was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
- An entitlement is unique per billing account, feature, source type, and source ID. This allows multiple sources to grant the same feature (e.g., subscription + promotional grant).
Relationships:
entitlements(0..*) → (1)billing_accounts: Entitlements belong to a billing account.entitlements(0..*) → (0..1)subscriptions: Entitlements may be granted by a subscription.
quotas
A quota represents a numeric limit on resource usage. Quotas can exist at the billing account level (the "pool") or workspace level (an "allocation" from the pool).
Quotas enable:
- Limiting resource creation (max 5 sites)
- Limiting storage (max 10 GB)
- Limiting API usage (max 10,000 calls per month)
- Subdividing organization-wide limits across workspaces
| Field | Type | Purpose |
|---|---|---|
quota_id |
BIGSERIAL | Primary key |
billing_account_id |
BIGINT | If set, this is a billing account-level quota (the pool). |
workspace_id |
BIGINT | If set, this is a workspace-level quota (an allocation). |
quota_key |
VARCHAR(100) | Machine-readable identifier (e.g., sites_limit, storage_bytes, api_calls_monthly). |
quota_limit |
BIGINT | The limit. -1 means unlimited. |
current_usage |
BIGINT | Current usage against this quota. |
reset_period |
VARCHAR(20) | For periodic quotas: daily, monthly, yearly, or NULL for non-resetting. |
current_period_start |
TIMESTAMPTZ | Start of current reset period. |
current_period_end |
TIMESTAMPTZ | End of current reset period. |
last_reset_at |
TIMESTAMPTZ | When usage was last reset. |
source_type |
VARCHAR(50) | What granted this quota: subscription, grant, legacy, allocation. |
source_id |
BIGINT | ID of the source. |
created_at |
TIMESTAMPTZ | When this quota was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
- A quota must have either
billing_account_id(account-level) orworkspace_id(workspace-level), not both. - A quota key is unique within its scope.
Relationships:
quotas(0..*) → (0..1)billing_accounts: Account-level quotas belong to a billing account.quotas(0..*) → (0..1)workspaces: Workspace-level quotas belong to a workspace.quotas(0..*) → (0..1)subscriptions: Quotas may be granted by a subscription.
Audit
audit_logs
An audit_log records actions taken within the system. It provides an immutable trail of who did what, when, and to what.
Audit logs serve:
- Security investigations
- Compliance requirements
- Debugging and support
- Understanding user behavior
| Field | Type | Purpose |
|---|---|---|
log_id |
BIGSERIAL | Primary key |
timestamp |
TIMESTAMPTZ | When the action occurred. |
actor_type |
VARCHAR(20) | What kind of actor: user, system, api_key, webhook. |
actor_user_id |
BIGINT | The user who performed the action, if applicable. |
actor_person_id |
BIGINT | The person who performed the action, if applicable. |
actor_ip |
INET | IP address of the actor. |
actor_user_agent |
TEXT | Browser/client user agent string. |
entity_type |
VARCHAR(50) | What kind of entity was affected (e.g., user, org, workspace, subscription). |
entity_id |
BIGINT | ID of the affected entity. |
org_id |
BIGINT | Organization context, for filtering. |
action |
VARCHAR(50) | What happened (e.g., create, update, delete, login). |
changes |
JSONB | Details of what changed (e.g., {"name": {"old": "A", "new": "B"}}). |
metadata |
JSONB | Additional context. |
request_id |
VARCHAR(100) | Request correlation ID for log tracing. |
status |
VARCHAR(20) | Outcome: success, failure, partial. |
Relationships:
audit_logs(0..*) → (0..1)users: Logs may reference an acting user.audit_logs(0..*) → (0..1)organizations: Logs may be scoped to an organization.
Index of Tables
| # | Table | Layer | Purpose |
|---|---|---|---|
| 1 | users |
Identity | Authentication identity |
| 2 | persons |
Identity | Human/business identity |
| 3 | organizations |
Organization | Top-level container |
| 4 | roles |
Organization | Permission definitions |
| 5 | org_members |
Organization | Organization membership |
| 6 | workspaces |
Organization | Resource containers |
| 7 | role_assignments |
Organization | Workspace-level permissions |
| 8 | billing_accounts |
Billing | Payment entity |
| 9 | products |
Billing | What you sell |
| 10 | prices |
Billing | How much things cost |
| 11 | subscriptions |
Billing | Active billing relationships |
| 12 | subscription_changes |
Billing | Subscription audit trail |
| 13 | invoices |
Billing | Billing documents |
| 14 | payments |
Billing | Money received |
| 15 | patrons |
Billing | Cooperative value identity |
| 16 | patronage_events |
Billing | Cooperative value transactions |
| 17 | entitlements |
Entitlements | Binary capabilities |
| 18 | quotas |
Entitlements | Numeric limits |
| 19 | audit_logs |
Audit | Action history |