managarten/services/mana-core-auth/docs/DATABASE_SCHEMA.md
Till-JS 0e8f6f134e 📝 docs(credits): update documentation for simplified credit system
- Remove free credits and signup bonus references
- Remove B2B organization credits documentation
- Update API responses (no freeCreditsRemaining, dailyFreeCredits)
- Update environment variables (remove CREDITS_SIGNUP_BONUS, CREDITS_DAILY_FREE)
- Update JWT info to reflect EdDSA via Better Auth
- Simplify DATABASE_SCHEMA.md to B2C-only flow
2026-02-16 12:03:35 +01:00

7.4 KiB

Database Schema Documentation

Overview

The Mana Core authentication service uses PostgreSQL with two main schemas:

  • auth - User authentication, sessions, and organization management
  • credits - Credit system for users

Schema Diagrams

Authentication Schema (auth)

auth.users (UUID)
├── auth.sessions (user sessions)
├── auth.accounts (OAuth providers + credentials)
├── auth.verifications (email verification, password reset)
├── auth.jwks (EdDSA keys for JWT signing)
├── auth.members (organization membership) ──┐
└── auth.invitations (org invitations) ───────┤
                                              │
auth.organizations (TEXT) ←───────────────────┘

Credits Schema (credits)

credits.balances (user credit balances)
├── credits.transactions (all credit movements)
├── credits.purchases (credit purchases via Stripe)
├── credits.packages (pricing tiers)
└── credits.gift_codes (gift codes for sharing credits)

Core Tables

auth.users

Main user table managed by Better Auth.

CREATE TABLE auth.users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT UNIQUE NOT NULL,
  name TEXT,
  email_verified BOOLEAN DEFAULT false,
  image TEXT,
  role TEXT DEFAULT 'user',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

auth.sessions

Active user sessions.

CREATE TABLE auth.sessions (
  id TEXT PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  token TEXT UNIQUE NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  ip_address TEXT,
  user_agent TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

auth.jwks

EdDSA keys for JWT signing (managed by Better Auth).

CREATE TABLE auth.jwks (
  id TEXT PRIMARY KEY,
  public_key TEXT NOT NULL,
  private_key TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Credit Tables

credits.balances

User credit balances with optimistic locking.

CREATE TABLE credits.balances (
  user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  balance INTEGER DEFAULT 0 NOT NULL,
  total_earned INTEGER DEFAULT 0 NOT NULL,
  total_spent INTEGER DEFAULT 0 NOT NULL,
  version INTEGER DEFAULT 0 NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

Key Design Decisions:

  • balance: Current available credits
  • total_earned: Lifetime credits received (purchases + gifts)
  • total_spent: Lifetime credits spent
  • version: Enables optimistic locking to prevent race conditions

credits.transactions

Immutable ledger of all credit movements.

CREATE TABLE credits.transactions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  type TEXT NOT NULL,           -- 'purchase', 'usage', 'refund', 'gift'
  status TEXT NOT NULL,         -- 'pending', 'completed', 'failed'
  amount INTEGER NOT NULL,      -- Positive for credits in, negative for out
  balance_before INTEGER NOT NULL,
  balance_after INTEGER NOT NULL,
  app_id TEXT,                  -- Which app used credits
  description TEXT,
  idempotency_key TEXT UNIQUE,  -- Prevent duplicate transactions
  metadata JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX transactions_user_id_idx ON credits.transactions(user_id);
CREATE INDEX transactions_created_at_idx ON credits.transactions(created_at);
CREATE INDEX transactions_app_id_idx ON credits.transactions(app_id);

Transaction Types:

Type Description
purchase Credits bought via Stripe
usage Credits spent in an app
refund Credits returned (e.g., failed operation)
gift Credits received via gift code

credits.packages

Available credit packages for purchase.

CREATE TABLE credits.packages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  credits INTEGER NOT NULL,
  price_euro_cents INTEGER NOT NULL,
  stripe_price_id TEXT,
  active BOOLEAN DEFAULT true,
  sort_order INTEGER DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

credits.purchases

Purchase history linked to Stripe.

CREATE TABLE credits.purchases (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  package_id UUID REFERENCES credits.packages(id),
  credits INTEGER NOT NULL,
  price_euro_cents INTEGER NOT NULL,
  stripe_payment_intent_id TEXT,
  stripe_checkout_session_id TEXT,
  status TEXT NOT NULL,         -- 'pending', 'completed', 'failed', 'refunded'
  created_at TIMESTAMPTZ DEFAULT NOW()
);

credits.gift_codes

Gift codes for sharing credits.

CREATE TABLE credits.gift_codes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code TEXT UNIQUE NOT NULL,
  credits INTEGER NOT NULL,
  created_by UUID REFERENCES auth.users(id),
  redeemed_by UUID REFERENCES auth.users(id),
  target_email TEXT,            -- If set, only this email can redeem
  expires_at TIMESTAMPTZ,
  redeemed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Key Features:

  • target_email: Pre-assign gift to specific email (auto-redeems on registration)
  • expires_at: Optional expiration date
  • redeemed_by + redeemed_at: Track redemption

Organization Tables (for Auth only)

Organizations are used for team management, not credits.

auth.organizations

CREATE TABLE auth.organizations (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  slug TEXT UNIQUE,
  logo TEXT,
  metadata JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

auth.members

Links users to organizations with roles.

CREATE TABLE auth.members (
  id TEXT PRIMARY KEY,
  organization_id TEXT REFERENCES auth.organizations(id) ON DELETE CASCADE,
  user_id TEXT NOT NULL,
  role TEXT NOT NULL,           -- 'owner', 'admin', 'member'
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Optimistic Locking

The credits.balances table uses a version column for optimistic locking:

// Prevent race conditions when using credits
const result = await db
  .update(balances)
  .set({
    balance: sql`balance - ${amount}`,
    totalSpent: sql`total_spent + ${amount}`,
    version: sql`version + 1`,
  })
  .where(
    and(
      eq(balances.userId, userId),
      eq(balances.version, currentVersion),
      gte(balances.balance, amount)
    )
  );

if (result.rowCount === 0) {
  throw new Error('Concurrent modification or insufficient balance');
}

Idempotency

The idempotency_key column in credits.transactions prevents duplicate operations:

// Check if transaction already exists
const existing = await db.query.transactions.findFirst({
  where: eq(transactions.idempotencyKey, idempotencyKey)
});

if (existing) {
  return existing; // Return existing transaction, don't create duplicate
}

Schema Files

All database tables are defined in TypeScript using Drizzle ORM:

src/db/schema/
├── auth.schema.ts         # Users, sessions, accounts, jwks
├── organizations.schema.ts # Organizations, members, invitations
├── credits.schema.ts      # Balances, transactions, packages, gifts
└── index.ts               # Export all schemas

Commands

# Push schema to database (development)
pnpm db:push

# Open Drizzle Studio to view/edit data
pnpm db:studio