mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
📝 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
This commit is contained in:
parent
d86e9031bb
commit
0e8f6f134e
4 changed files with 270 additions and 463 deletions
|
|
@ -83,8 +83,6 @@ The generator reads `.env.development` and creates app-specific `.env` files wit
|
|||
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key | - |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook secret | - |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins | - |
|
||||
| `CREDITS_SIGNUP_BONUS` | Credits on signup | `150` |
|
||||
| `CREDITS_DAILY_FREE` | Daily free credits | `5` |
|
||||
| `RATE_LIMIT_TTL` | Rate limit window (seconds) | `60` |
|
||||
| `RATE_LIMIT_MAX` | Max requests per window | `100` |
|
||||
|
||||
|
|
|
|||
|
|
@ -9,38 +9,27 @@ Get the authentication system running in 5 minutes!
|
|||
- Docker & Docker Compose
|
||||
- OpenSSL (for key generation)
|
||||
|
||||
## Step 1: Generate JWT Keys (2 minutes)
|
||||
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
chmod +x scripts/generate-keys.sh
|
||||
./scripts/generate-keys.sh
|
||||
```
|
||||
|
||||
This will create `private.pem` and `public.pem` and show you the formatted keys for .env
|
||||
|
||||
## Step 2: Configure Environment (1 minute)
|
||||
## Step 1: Configure Environment (1 minute)
|
||||
|
||||
```bash
|
||||
# Copy the example
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env and add:
|
||||
# 1. JWT keys from Step 1
|
||||
# 2. Change default passwords
|
||||
# 3. Add Stripe test keys (optional for now)
|
||||
# 1. Change default passwords
|
||||
# 2. Add Stripe test keys (optional for now)
|
||||
```
|
||||
|
||||
**Minimum required changes in .env:**
|
||||
|
||||
```env
|
||||
POSTGRES_PASSWORD=your-secure-password-here
|
||||
REDIS_PASSWORD=your-redis-password-here
|
||||
JWT_PRIVATE_KEY="your-private-key-here"
|
||||
JWT_PUBLIC_KEY="your-public-key-here"
|
||||
DATABASE_URL=postgresql://mana:mana@localhost:5432/mana_auth
|
||||
REDIS_HOST=localhost
|
||||
```
|
||||
|
||||
## Step 3: Start Infrastructure (30 seconds)
|
||||
Note: JWT keys are auto-generated by Better Auth (EdDSA algorithm) and stored in the database.
|
||||
|
||||
## Step 2: Start Infrastructure (30 seconds)
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
|
|
@ -50,7 +39,7 @@ docker-compose up postgres redis -d
|
|||
docker-compose ps
|
||||
```
|
||||
|
||||
## Step 4: Run Migrations (10 seconds)
|
||||
## Step 3: Run Migrations (10 seconds)
|
||||
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
|
|
@ -64,7 +53,7 @@ Running migrations...
|
|||
Migrations completed successfully
|
||||
```
|
||||
|
||||
## Step 5: Start the Service (10 seconds)
|
||||
## Step 4: Start the Service (10 seconds)
|
||||
|
||||
```bash
|
||||
pnpm start:dev
|
||||
|
|
@ -143,15 +132,15 @@ Expected response:
|
|||
```json
|
||||
{
|
||||
"balance": 0,
|
||||
"freeCreditsRemaining": 150,
|
||||
"totalEarned": 0,
|
||||
"totalSpent": 0,
|
||||
"dailyFreeCredits": 5
|
||||
"totalSpent": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Some Credits
|
||||
|
||||
First, you'll need to add credits via Stripe or a gift code. Then:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/v1/credits/use \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
|
|
@ -175,14 +164,13 @@ Expected response:
|
|||
"type": "usage",
|
||||
"status": "completed",
|
||||
"amount": -10,
|
||||
"balanceBefore": 150,
|
||||
"balanceAfter": 140,
|
||||
"balanceBefore": 100,
|
||||
"balanceAfter": 90,
|
||||
"appId": "test",
|
||||
"description": "Test credit usage"
|
||||
},
|
||||
"newBalance": {
|
||||
"balance": 0,
|
||||
"freeCreditsRemaining": 140,
|
||||
"balance": 90,
|
||||
"totalSpent": 10
|
||||
}
|
||||
}
|
||||
|
|
@ -224,18 +212,18 @@ docker-compose ps # Check if postgres is healthy
|
|||
docker-compose logs postgres # Check logs
|
||||
```
|
||||
|
||||
### "JWT key not found" error
|
||||
### "JWKS not found" error
|
||||
|
||||
**Problem:** JWT keys not set in .env
|
||||
**Problem:** Better Auth hasn't initialized JWT keys yet
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Run the key generator again
|
||||
./scripts/generate-keys.sh
|
||||
# Make sure the database is running and migrations have been applied
|
||||
pnpm db:push
|
||||
|
||||
# Copy the keys to .env
|
||||
# Make sure they're properly escaped (with \n for newlines)
|
||||
# The JWKS keys are auto-generated on first request
|
||||
# Try making a login request to initialize them
|
||||
```
|
||||
|
||||
### Migrations fail
|
||||
|
|
@ -328,8 +316,6 @@ pnpm db:studio
|
|||
### Required
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `JWT_PRIVATE_KEY` - RS256 private key (PEM format)
|
||||
- `JWT_PUBLIC_KEY` - RS256 public key (PEM format)
|
||||
|
||||
### Optional (have defaults)
|
||||
|
||||
|
|
@ -337,8 +323,7 @@ pnpm db:studio
|
|||
- `NODE_ENV` - Environment (default: development)
|
||||
- `REDIS_HOST` - Redis host (default: localhost)
|
||||
- `CORS_ORIGINS` - Allowed origins (default: localhost:3000,localhost:8081)
|
||||
- `CREDITS_SIGNUP_BONUS` - Signup credits (default: 150)
|
||||
- `CREDITS_DAILY_FREE` - Daily free credits (default: 5)
|
||||
- `BASE_URL` - Base URL for JWKS (default: http://localhost:3001)
|
||||
|
||||
### For Production
|
||||
|
||||
|
|
|
|||
|
|
@ -4,19 +4,18 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
|
||||
## Features
|
||||
|
||||
- **JWT-based Authentication** (RS256 algorithm)
|
||||
- **JWT-based Authentication** (EdDSA algorithm via Better Auth)
|
||||
- User registration and login
|
||||
- Refresh token rotation
|
||||
- Multi-session management
|
||||
- Device tracking
|
||||
- JWKS endpoint for token verification
|
||||
|
||||
- **Credit System**
|
||||
- User balance management
|
||||
- Transaction ledger with double-entry bookkeeping
|
||||
- Transaction ledger (purchase, usage, refund, gift)
|
||||
- Optimistic locking for concurrency
|
||||
- Daily free credits
|
||||
- Signup bonus (150 credits)
|
||||
- Idempotency for credit operations
|
||||
- Gift code system with auto-redemption on registration
|
||||
|
||||
- **Security**
|
||||
- Row-Level Security (RLS) on PostgreSQL
|
||||
|
|
@ -145,7 +144,7 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
|
||||
- Get current credit balance
|
||||
- Requires: Bearer token
|
||||
- Returns: `{ balance, freeCreditsRemaining, totalEarned, totalSpent }`
|
||||
- Returns: `{ balance, totalEarned, totalSpent }`
|
||||
|
||||
**POST** `/api/v1/credits/use`
|
||||
|
||||
|
|
@ -199,13 +198,10 @@ See `.env.example` for all available configuration options.
|
|||
Key variables:
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `JWT_PUBLIC_KEY` - RS256 public key (PEM format)
|
||||
- `JWT_PRIVATE_KEY` - RS256 private key (PEM format)
|
||||
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` - Redis configuration
|
||||
- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` - Stripe integration
|
||||
- `CORS_ORIGINS` - Allowed origins for CORS
|
||||
- `CREDITS_SIGNUP_BONUS` - Free credits on signup (default: 150)
|
||||
- `CREDITS_DAILY_FREE` - Daily free credits (default: 5)
|
||||
- `BASE_URL` - Base URL for JWKS endpoint (e.g., http://localhost:3001)
|
||||
|
||||
## Development
|
||||
|
||||
|
|
@ -250,16 +246,15 @@ pnpm format
|
|||
|
||||
### Credit System
|
||||
|
||||
- **Signup Bonus**: 150 free credits on registration
|
||||
- **Daily Free Credits**: 5 credits added every 24 hours
|
||||
- **Paid Credits**: Purchased via Stripe (100 mana = €1)
|
||||
- **Usage Priority**: Free credits used first, then paid credits
|
||||
- **Gift Codes**: Can be created and redeemed, auto-redeem on registration if pending
|
||||
- **Transaction Types**: purchase, usage, refund, gift
|
||||
- **Idempotency**: Duplicate requests with same key are detected and ignored
|
||||
- **Concurrency**: Optimistic locking prevents race conditions
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **JWT Keys**: Generate strong RS256 keys and keep private key secure
|
||||
1. **JWT Keys**: Better Auth auto-generates EdDSA keys stored in `auth.jwks` table
|
||||
2. **Database**: Use strong passwords and enable SSL in production
|
||||
3. **Redis**: Always set a password for Redis
|
||||
4. **CORS**: Only allow trusted origins
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
The Mana Core authentication service uses PostgreSQL with two main schemas:
|
||||
|
||||
- `auth` - User authentication, sessions, and organization management
|
||||
- `credits` - Credit system for B2C and B2B customers
|
||||
- `credits` - Credit system for users
|
||||
|
||||
## Schema Diagrams
|
||||
|
||||
|
|
@ -14,11 +14,9 @@ The Mana Core authentication service uses PostgreSQL with two main schemas:
|
|||
```
|
||||
auth.users (UUID)
|
||||
├── auth.sessions (user sessions)
|
||||
├── auth.passwords (hashed passwords)
|
||||
├── auth.accounts (OAuth providers)
|
||||
├── auth.verification_tokens (email verification, password reset)
|
||||
├── auth.two_factor_auth (2FA settings)
|
||||
├── auth.security_events (audit log)
|
||||
├── 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) ───────┤
|
||||
│
|
||||
|
|
@ -29,31 +27,74 @@ auth.organizations (TEXT) ←─────────────────
|
|||
|
||||
```
|
||||
credits.balances (user credit balances)
|
||||
├── credits.transactions (all credit movements) ──┐
|
||||
├── credits.purchases (credit purchases) │
|
||||
├── credits.usage_stats (analytics) │
|
||||
└── credits.packages (pricing tiers) │
|
||||
│
|
||||
credits.organization_balances ←───────────────────┤
|
||||
├── credits.credit_allocations (org→employee) │
|
||||
└── auth.organizations (TEXT) ────────────────────┘
|
||||
├── credits.transactions (all credit movements)
|
||||
├── credits.purchases (credit purchases via Stripe)
|
||||
├── credits.packages (pricing tiers)
|
||||
└── credits.gift_codes (gift codes for sharing credits)
|
||||
```
|
||||
|
||||
## Better Auth Organization Plugin
|
||||
## Core Tables
|
||||
|
||||
### Core Tables
|
||||
### auth.users
|
||||
|
||||
#### auth.organizations
|
||||
|
||||
Stores organization/company information for B2B customers.
|
||||
Main user table managed by Better Auth.
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.organizations (
|
||||
id TEXT PRIMARY KEY, -- Better Auth uses nanoid/ULID
|
||||
name TEXT NOT NULL, -- Organization name
|
||||
slug TEXT UNIQUE, -- URL-friendly identifier
|
||||
logo TEXT, -- Logo URL
|
||||
metadata JSONB, -- Additional custom data
|
||||
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.
|
||||
|
||||
```sql
|
||||
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).
|
||||
|
||||
```sql
|
||||
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.
|
||||
|
||||
```sql
|
||||
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()
|
||||
);
|
||||
|
|
@ -61,406 +102,194 @@ CREATE TABLE auth.organizations (
|
|||
|
||||
**Key Design Decisions:**
|
||||
|
||||
- Uses TEXT for IDs (Better Auth requirement - nanoid/ULID format)
|
||||
- Slug is unique and URL-friendly for organization pages
|
||||
- Metadata field allows flexible custom attributes
|
||||
- `balance`: Current available credits
|
||||
- `total_earned`: Lifetime credits received (purchases + gifts)
|
||||
- `total_spent`: Lifetime credits spent
|
||||
- `version`: Enables optimistic locking to prevent race conditions
|
||||
|
||||
#### auth.members
|
||||
### credits.transactions
|
||||
|
||||
Links users to organizations with roles (owner, admin, member).
|
||||
Immutable ledger of all credit movements.
|
||||
|
||||
```sql
|
||||
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.
|
||||
|
||||
```sql
|
||||
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.
|
||||
|
||||
```sql
|
||||
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.
|
||||
|
||||
```sql
|
||||
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
|
||||
|
||||
```sql
|
||||
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.
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.members (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT REFERENCES auth.organizations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL, -- References auth.users.id (UUID cast to TEXT)
|
||||
role TEXT NOT NULL, -- 'owner', 'admin', 'member', or custom
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL, -- 'owner', 'admin', 'member'
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX members_organization_id_idx ON auth.members(organization_id);
|
||||
CREATE INDEX members_user_id_idx ON auth.members(user_id);
|
||||
CREATE INDEX members_organization_user_idx ON auth.members(organization_id, user_id);
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
## Optimistic Locking
|
||||
|
||||
- Composite index on (organization_id, user_id) for fast membership checks
|
||||
- user_id is TEXT to match Better Auth expectations (actual data is UUID cast to TEXT)
|
||||
- ON DELETE CASCADE ensures members are removed when org is deleted
|
||||
|
||||
#### auth.invitations
|
||||
|
||||
Tracks pending, accepted, and rejected organization invitations.
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT REFERENCES auth.organizations(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL, -- Email of invitee
|
||||
role TEXT NOT NULL, -- Role they'll have if accepted
|
||||
status TEXT NOT NULL, -- 'pending', 'accepted', 'rejected', 'canceled'
|
||||
expires_at TIMESTAMPTZ NOT NULL, -- Invitation expiry
|
||||
inviter_id TEXT REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX invitations_organization_id_idx ON auth.invitations(organization_id);
|
||||
CREATE INDEX invitations_email_idx ON auth.invitations(email);
|
||||
CREATE INDEX invitations_status_idx ON auth.invitations(status);
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
|
||||
- Index on email for quick lookup of pending invitations
|
||||
- Index on status for filtering active invitations
|
||||
- ON DELETE SET NULL for inviter (keeps history even if inviter deleted)
|
||||
- expires_at allows automatic expiry of old invitations
|
||||
|
||||
## Organization Credit Management
|
||||
|
||||
### credits.organization_balances
|
||||
|
||||
Tracks credit pools for B2B organizations.
|
||||
|
||||
```sql
|
||||
CREATE TABLE credits.organization_balances (
|
||||
organization_id TEXT PRIMARY KEY REFERENCES auth.organizations(id) ON DELETE CASCADE,
|
||||
balance INTEGER DEFAULT 0 NOT NULL, -- Current available credits
|
||||
allocated_credits INTEGER DEFAULT 0 NOT NULL, -- Sum of credits allocated to employees
|
||||
available_credits INTEGER DEFAULT 0 NOT NULL, -- balance - allocated_credits
|
||||
total_purchased INTEGER DEFAULT 0 NOT NULL, -- Total credits ever purchased
|
||||
total_allocated INTEGER DEFAULT 0 NOT NULL, -- Total ever allocated (includes deallocated)
|
||||
version INTEGER DEFAULT 0 NOT NULL, -- For optimistic locking
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
|
||||
- `balance`: Organization's total purchased credits
|
||||
- `allocated_credits`: Sum of credits allocated to employees (not yet spent)
|
||||
- `available_credits`: Credits owner can still allocate (calculated: balance - allocated_credits)
|
||||
- `total_purchased`: Historical tracking of all purchases
|
||||
- `total_allocated`: Historical tracking (includes deallocations)
|
||||
- `version`: Enables optimistic locking to prevent race conditions
|
||||
|
||||
**Credit Flow:**
|
||||
|
||||
1. Owner purchases credits → `balance` increases
|
||||
2. Owner allocates to employee → `allocated_credits` increases, `available_credits` decreases
|
||||
3. Employee spends credits → employee's `credits.balances.balance` decreases
|
||||
4. Owner deallocates from employee → `allocated_credits` decreases, `available_credits` increases
|
||||
|
||||
### credits.credit_allocations
|
||||
|
||||
Immutable audit trail of all credit allocations.
|
||||
|
||||
```sql
|
||||
CREATE TABLE credits.credit_allocations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id TEXT REFERENCES auth.organizations(id) ON DELETE CASCADE,
|
||||
employee_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
amount INTEGER NOT NULL, -- Positive = allocation, negative = deallocation
|
||||
allocated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
reason TEXT, -- Optional explanation
|
||||
balance_before INTEGER NOT NULL, -- Employee balance before
|
||||
balance_after INTEGER NOT NULL, -- Employee balance after
|
||||
metadata JSONB, -- Additional context
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX credit_allocations_organization_id_idx ON credits.credit_allocations(organization_id);
|
||||
CREATE INDEX credit_allocations_employee_id_idx ON credits.credit_allocations(employee_id);
|
||||
CREATE INDEX credit_allocations_allocated_by_idx ON credits.credit_allocations(allocated_by);
|
||||
CREATE INDEX credit_allocations_created_at_idx ON credits.credit_allocations(created_at);
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
|
||||
- **Immutable**: No updates or deletes allowed (audit trail)
|
||||
- `amount` can be positive (allocation) or negative (deallocation/adjustment)
|
||||
- `balance_before`/`balance_after` track exact state changes
|
||||
- `allocated_by` tracks who made the change
|
||||
- `reason` field for transparency and accountability
|
||||
|
||||
### credits.transactions (Updated)
|
||||
|
||||
Extended to support B2B transactions.
|
||||
|
||||
```sql
|
||||
-- Added column:
|
||||
organization_id TEXT REFERENCES auth.organizations(id) ON DELETE SET NULL
|
||||
|
||||
-- Added index:
|
||||
CREATE INDEX transactions_organization_id_idx ON credits.transactions(organization_id);
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
|
||||
- `organization_id` is **nullable** (NULL for B2C users, set for B2B employees)
|
||||
- ON DELETE SET NULL preserves transaction history even if org deleted
|
||||
- Enables organization-wide usage analytics and reporting
|
||||
|
||||
## ID Type Compatibility
|
||||
|
||||
### The UUID vs TEXT Challenge
|
||||
|
||||
**Problem:**
|
||||
|
||||
- Better Auth uses TEXT IDs (nanoid/ULID format like "abc123xyz")
|
||||
- Our existing system uses UUID for user IDs
|
||||
- PostgreSQL doesn't allow direct foreign keys between UUID and TEXT
|
||||
|
||||
**Solution:**
|
||||
We use TEXT for organization-related tables and cast UUIDs to TEXT when needed:
|
||||
|
||||
```sql
|
||||
-- members.user_id is TEXT (stores UUID cast to TEXT)
|
||||
ALTER TABLE auth.members
|
||||
ADD CONSTRAINT members_user_id_users_id_fk
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
|
||||
-- This works because PostgreSQL can implicitly cast UUID to TEXT
|
||||
```
|
||||
|
||||
**In Application Code:**
|
||||
The `credits.balances` table uses a `version` column for optimistic locking:
|
||||
|
||||
```typescript
|
||||
// When inserting a member
|
||||
await db.insert(members).values({
|
||||
id: nanoid(),
|
||||
organization_id: 'org_abc123',
|
||||
user_id: userId.toString(), // Convert UUID to TEXT
|
||||
role: 'member',
|
||||
});
|
||||
// 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)
|
||||
)
|
||||
);
|
||||
|
||||
// When querying
|
||||
const member = await db.query.members.findFirst({
|
||||
where: eq(members.userId, userId.toString()),
|
||||
});
|
||||
```
|
||||
|
||||
## Row Level Security (RLS) Policies
|
||||
|
||||
### Helper Functions
|
||||
|
||||
```sql
|
||||
-- Get user's role in organization
|
||||
auth.user_organization_role(org_id TEXT) → TEXT
|
||||
|
||||
-- Check membership
|
||||
auth.is_organization_member(org_id TEXT) → BOOLEAN
|
||||
auth.is_organization_owner_or_admin(org_id TEXT) → BOOLEAN
|
||||
auth.is_organization_owner(org_id TEXT) → BOOLEAN
|
||||
```
|
||||
|
||||
### Key Policies
|
||||
|
||||
**Organizations:**
|
||||
|
||||
- Members can view their organizations
|
||||
- Any user can create organizations (Better Auth adds them as owner)
|
||||
- Only owners can update/delete organizations
|
||||
|
||||
**Members:**
|
||||
|
||||
- Members can view other members in their orgs
|
||||
- Owners/admins can add/remove/update members
|
||||
- Members can remove themselves
|
||||
|
||||
**Invitations:**
|
||||
|
||||
- Members can view org invitations
|
||||
- Invitees can view invitations sent to them
|
||||
- Owners/admins can create/manage invitations
|
||||
- Inviters and invitees can delete invitations
|
||||
|
||||
**Organization Balances:**
|
||||
|
||||
- Members can view org balance
|
||||
- Only owners can modify balances
|
||||
|
||||
**Credit Allocations:**
|
||||
|
||||
- Employees can view allocations to them
|
||||
- Owners/admins can view all org allocations
|
||||
- Only owners can create allocations
|
||||
- **No updates/deletes** (immutable audit trail)
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Running Migrations
|
||||
|
||||
```bash
|
||||
# Generate migration from schema changes
|
||||
pnpm run migration:generate
|
||||
|
||||
# Run migrations
|
||||
pnpm run migration:run
|
||||
|
||||
# Or manually via SQL
|
||||
psql $DATABASE_URL -f src/db/migrations/0001_better_auth_organizations.sql
|
||||
```
|
||||
|
||||
### Migration Files
|
||||
|
||||
**Up Migration:** `0001_better_auth_organizations.sql`
|
||||
|
||||
- Creates organization tables
|
||||
- Creates credit management tables
|
||||
- Adds foreign keys and indexes
|
||||
- Sets up triggers
|
||||
|
||||
**Down Migration:** `0001_better_auth_organizations_down.sql`
|
||||
|
||||
- Reverses all changes
|
||||
- Safe rollback path
|
||||
|
||||
**RLS Policies:** `postgres/init/03-organization-rls.sql`
|
||||
|
||||
- Applied automatically in Docker
|
||||
- Can be run manually: `psql $DATABASE_URL -f postgres/init/03-organization-rls.sql`
|
||||
|
||||
## Data Migration Considerations
|
||||
|
||||
### Existing Data
|
||||
|
||||
If you have existing users and credit data:
|
||||
|
||||
1. **Users**: No changes needed (remain B2C users)
|
||||
2. **Balances**: No changes needed (personal balances)
|
||||
3. **Transactions**: `organization_id` defaults to NULL (B2C)
|
||||
|
||||
### New Organizations
|
||||
|
||||
When creating a B2B organization:
|
||||
|
||||
```sql
|
||||
-- 1. Create organization (Better Auth handles this)
|
||||
INSERT INTO auth.organizations (id, name, slug)
|
||||
VALUES ('org_abc123', 'Acme Corp', 'acme-corp');
|
||||
|
||||
-- 2. Add owner as member (Better Auth handles this)
|
||||
INSERT INTO auth.members (id, organization_id, user_id, role)
|
||||
VALUES ('mem_xyz789', 'org_abc123', '<owner_uuid>', 'owner');
|
||||
|
||||
-- 3. Create organization credit balance
|
||||
INSERT INTO credits.organization_balances (organization_id)
|
||||
VALUES ('org_abc123');
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Indexes
|
||||
|
||||
All critical query paths are indexed:
|
||||
|
||||
- Organization lookups by slug
|
||||
- Member lookups by user_id and organization_id
|
||||
- Invitation lookups by email and status
|
||||
- Credit allocation history by organization and employee
|
||||
|
||||
### Optimistic Locking
|
||||
|
||||
Both `credits.balances` and `credits.organization_balances` use a `version` column for optimistic locking:
|
||||
|
||||
```typescript
|
||||
// Prevent race conditions when allocating credits
|
||||
await db
|
||||
.update(organizationBalances)
|
||||
.set({
|
||||
allocated_credits: sql`allocated_credits + ${amount}`,
|
||||
version: sql`version + 1`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(organizationBalances.organizationId, orgId),
|
||||
eq(organizationBalances.version, currentVersion)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Schema Relationships
|
||||
|
||||
```
|
||||
B2C User Flow:
|
||||
auth.users → credits.balances → credits.transactions
|
||||
|
||||
B2B Owner Flow:
|
||||
auth.users → auth.members → auth.organizations → credits.organization_balances
|
||||
|
||||
B2B Employee Flow:
|
||||
auth.users → auth.members → auth.organizations
|
||||
↓
|
||||
credits.balances ← credits.credit_allocations → credits.organization_balances
|
||||
↓
|
||||
credits.transactions (with organization_id)
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
|
||||
1. **Usage Quotas**: Add limits per employee/organization
|
||||
2. **Credit Expiry**: Time-based credit expiration for organizations
|
||||
3. **Tiered Pricing**: Different rates for B2C vs B2B
|
||||
4. **Sub-organizations**: Support for department-level credit pools
|
||||
5. **Approval Workflows**: Multi-step approval for large allocations
|
||||
|
||||
### Schema Extensions
|
||||
|
||||
```sql
|
||||
-- Example: Usage quotas
|
||||
ALTER TABLE credits.credit_allocations
|
||||
ADD COLUMN quota_limit INTEGER,
|
||||
ADD COLUMN quota_period TEXT; -- 'daily', 'weekly', 'monthly'
|
||||
|
||||
-- Example: Credit expiry
|
||||
ALTER TABLE credits.organization_balances
|
||||
ADD COLUMN credits_expire_at TIMESTAMPTZ;
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Foreign Key Errors (UUID vs TEXT):**
|
||||
|
||||
```sql
|
||||
-- Check if casting is needed
|
||||
SELECT user_id::uuid FROM auth.members WHERE user_id ~ '^[0-9a-f-]{36}$';
|
||||
```
|
||||
|
||||
**RLS Policy Blocking Queries:**
|
||||
|
||||
```sql
|
||||
-- Temporarily disable RLS for debugging (development only!)
|
||||
ALTER TABLE auth.organizations DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Check what policies apply
|
||||
SELECT * FROM pg_policies WHERE tablename = 'organizations';
|
||||
```
|
||||
|
||||
**Optimistic Lock Failures:**
|
||||
|
||||
```typescript
|
||||
// Retry logic for version conflicts
|
||||
const maxRetries = 3;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await allocateCredits(orgId, employeeId, amount);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (i === maxRetries - 1) throw err;
|
||||
await sleep(100 * Math.pow(2, i)); // Exponential backoff
|
||||
}
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error('Concurrent modification or insufficient balance');
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
## Idempotency
|
||||
|
||||
- [Better Auth Organization Plugin](https://www.better-auth.com/docs/plugins/organization)
|
||||
- [Drizzle ORM Documentation](https://orm.drizzle.team/)
|
||||
- [PostgreSQL Row Level Security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
|
||||
The `idempotency_key` column in `credits.transactions` prevents duplicate operations:
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```bash
|
||||
# Push schema to database (development)
|
||||
pnpm db:push
|
||||
|
||||
# Open Drizzle Studio to view/edit data
|
||||
pnpm db:studio
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue