📝 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:
Till-JS 2026-02-16 12:03:35 +01:00
parent d86e9031bb
commit 0e8f6f134e
4 changed files with 270 additions and 463 deletions

View file

@ -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` |

View file

@ -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

View file

@ -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

View file

@ -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
```