feat: add subscriptions feature capsule - M1 database schema and Stripe client (refs #55)
- Create 4 new tables: subscriptions, subscription_events, donations, tier_vehicle_selections - Add StripeClient wrapper with createCustomer, createSubscription, cancelSubscription, updatePaymentMethod, createPaymentIntent, constructWebhookEvent methods - Implement SubscriptionsRepository with full CRUD and mapRow case conversion - Add domain types for all subscription entities - Install stripe npm package v20.2.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
79
backend/src/features/subscriptions/README.md
Normal file
79
backend/src/features/subscriptions/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Subscriptions Feature
|
||||
|
||||
Stripe integration for subscription management, donations, and tier-based vehicle limits.
|
||||
|
||||
## Milestone 1: Core Infrastructure (COMPLETE)
|
||||
|
||||
### Database Schema
|
||||
- `subscriptions` - User subscription records with Stripe integration
|
||||
- `subscription_events` - Webhook event logging for idempotency
|
||||
- `donations` - One-time payment tracking
|
||||
- `tier_vehicle_selections` - User vehicle selections during tier downgrades
|
||||
|
||||
### Type Definitions
|
||||
- **Domain Types** (`domain/subscriptions.types.ts`): Core business entities and request/response types
|
||||
- **Stripe Types** (`external/stripe/stripe.types.ts`): Simplified Stripe API response types
|
||||
|
||||
### Data Access
|
||||
- **Repository** (`data/subscriptions.repository.ts`): Database operations with proper snake_case to camelCase mapping
|
||||
- Subscription CRUD operations
|
||||
- Event logging with idempotency checks
|
||||
- Donation tracking
|
||||
- Vehicle selection management
|
||||
|
||||
### External Integration
|
||||
- **Stripe Client** (`external/stripe/stripe.client.ts`): Stripe API wrapper with error handling
|
||||
- Customer creation
|
||||
- Subscription lifecycle management
|
||||
- Payment intent creation for donations
|
||||
- Webhook event verification
|
||||
- Payment method updates
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required for Stripe integration:
|
||||
- `STRIPE_SECRET_KEY` - Stripe API secret key
|
||||
- `STRIPE_WEBHOOK_SECRET` - Stripe webhook signing secret
|
||||
|
||||
## Subscription Tiers
|
||||
|
||||
Defined in `user_profiles.subscription_tier` enum:
|
||||
- `free` - Limited features (default)
|
||||
- `pro` - Enhanced features
|
||||
- `enterprise` - Full features
|
||||
|
||||
## Billing Cycles
|
||||
|
||||
- `monthly` - Monthly billing
|
||||
- `yearly` - Annual billing
|
||||
|
||||
## Subscription Status
|
||||
|
||||
- `active` - Subscription is active
|
||||
- `past_due` - Payment failed, in grace period
|
||||
- `canceled` - Subscription canceled
|
||||
- `unpaid` - Payment failed, grace period expired
|
||||
|
||||
## Next Steps (Future Milestones)
|
||||
|
||||
- M2: Subscription service layer with business logic
|
||||
- M3: API endpoints for subscription management
|
||||
- M4: Webhook handlers for Stripe events
|
||||
- M5: Frontend integration and subscription UI
|
||||
- M6: Testing and documentation
|
||||
|
||||
## Database Migration
|
||||
|
||||
Run migrations:
|
||||
```bash
|
||||
npm run migrate:feature subscriptions
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- All database columns use snake_case
|
||||
- All TypeScript properties use camelCase
|
||||
- Repository `mapRow()` methods handle case conversion
|
||||
- Prepared statements used for all queries (no string concatenation)
|
||||
- Comprehensive error logging with structured logger
|
||||
- Webhook idempotency via `stripe_event_id` unique constraint
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* @ai-summary Data access layer for subscriptions
|
||||
* @ai-context All database operations for subscriptions, events, donations, vehicle selections
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
Subscription,
|
||||
SubscriptionEvent,
|
||||
Donation,
|
||||
TierVehicleSelection,
|
||||
CreateSubscriptionRequest,
|
||||
UpdateSubscriptionData,
|
||||
CreateSubscriptionEventRequest,
|
||||
CreateDonationRequest,
|
||||
UpdateDonationData,
|
||||
CreateTierVehicleSelectionRequest,
|
||||
} from '../domain/subscriptions.types';
|
||||
|
||||
export class SubscriptionsRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
// ========== Subscriptions ==========
|
||||
|
||||
/**
|
||||
* Create a new subscription
|
||||
*/
|
||||
async create(data: CreateSubscriptionRequest & { stripeCustomerId: string }): Promise<Subscription> {
|
||||
const query = `
|
||||
INSERT INTO subscriptions (
|
||||
user_id, stripe_customer_id, tier, billing_cycle
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.stripeCustomerId,
|
||||
data.tier,
|
||||
data.billingCycle,
|
||||
];
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
logger.info('Subscription created', { subscriptionId: result.rows[0].id, userId: data.userId });
|
||||
return this.mapSubscriptionRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create subscription', {
|
||||
userId: data.userId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find subscription by user ID
|
||||
*/
|
||||
async findByUserId(userId: string): Promise<Subscription | null> {
|
||||
const query = `
|
||||
SELECT * FROM subscriptions
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapSubscriptionRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to find subscription by user ID', {
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find subscription by Stripe customer ID
|
||||
*/
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<Subscription | null> {
|
||||
const query = `
|
||||
SELECT * FROM subscriptions
|
||||
WHERE stripe_customer_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [stripeCustomerId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapSubscriptionRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to find subscription by Stripe customer ID', {
|
||||
stripeCustomerId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find subscription by Stripe subscription ID
|
||||
*/
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<Subscription | null> {
|
||||
const query = `
|
||||
SELECT * FROM subscriptions
|
||||
WHERE stripe_subscription_id = $1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [stripeSubscriptionId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapSubscriptionRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to find subscription by Stripe subscription ID', {
|
||||
stripeSubscriptionId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a subscription
|
||||
*/
|
||||
async update(id: string, data: UpdateSubscriptionData): Promise<Subscription | null> {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (data.stripeSubscriptionId !== undefined) {
|
||||
fields.push(`stripe_subscription_id = $${paramCount++}`);
|
||||
values.push(data.stripeSubscriptionId);
|
||||
}
|
||||
if (data.tier !== undefined) {
|
||||
fields.push(`tier = $${paramCount++}`);
|
||||
values.push(data.tier);
|
||||
}
|
||||
if (data.billingCycle !== undefined) {
|
||||
fields.push(`billing_cycle = $${paramCount++}`);
|
||||
values.push(data.billingCycle);
|
||||
}
|
||||
if (data.status !== undefined) {
|
||||
fields.push(`status = $${paramCount++}`);
|
||||
values.push(data.status);
|
||||
}
|
||||
if (data.currentPeriodStart !== undefined) {
|
||||
fields.push(`current_period_start = $${paramCount++}`);
|
||||
values.push(data.currentPeriodStart);
|
||||
}
|
||||
if (data.currentPeriodEnd !== undefined) {
|
||||
fields.push(`current_period_end = $${paramCount++}`);
|
||||
values.push(data.currentPeriodEnd);
|
||||
}
|
||||
if (data.gracePeriodEnd !== undefined) {
|
||||
fields.push(`grace_period_end = $${paramCount++}`);
|
||||
values.push(data.gracePeriodEnd);
|
||||
}
|
||||
if (data.cancelAtPeriodEnd !== undefined) {
|
||||
fields.push(`cancel_at_period_end = $${paramCount++}`);
|
||||
values.push(data.cancelAtPeriodEnd);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
logger.warn('No fields to update for subscription', { id });
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const query = `
|
||||
UPDATE subscriptions
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramCount}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('Subscription updated', { subscriptionId: id });
|
||||
return this.mapSubscriptionRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to update subscription', {
|
||||
subscriptionId: id,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find subscription by ID
|
||||
*/
|
||||
async findById(id: string): Promise<Subscription | null> {
|
||||
const query = 'SELECT * FROM subscriptions WHERE id = $1';
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapSubscriptionRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to find subscription by ID', {
|
||||
subscriptionId: id,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Subscription Events ==========
|
||||
|
||||
/**
|
||||
* Create a subscription event
|
||||
*/
|
||||
async createEvent(data: CreateSubscriptionEventRequest): Promise<SubscriptionEvent> {
|
||||
const query = `
|
||||
INSERT INTO subscription_events (
|
||||
subscription_id, stripe_event_id, event_type, payload
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
data.subscriptionId,
|
||||
data.stripeEventId,
|
||||
data.eventType,
|
||||
JSON.stringify(data.payload),
|
||||
];
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
logger.info('Subscription event created', {
|
||||
eventId: result.rows[0].id,
|
||||
stripeEventId: data.stripeEventId,
|
||||
eventType: data.eventType,
|
||||
});
|
||||
return this.mapEventRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create subscription event', {
|
||||
stripeEventId: data.stripeEventId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find event by Stripe event ID (for idempotency)
|
||||
*/
|
||||
async findEventByStripeId(stripeEventId: string): Promise<SubscriptionEvent | null> {
|
||||
const query = `
|
||||
SELECT * FROM subscription_events
|
||||
WHERE stripe_event_id = $1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [stripeEventId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapEventRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to find subscription event by Stripe ID', {
|
||||
stripeEventId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Donations ==========
|
||||
|
||||
/**
|
||||
* Create a donation
|
||||
*/
|
||||
async createDonation(data: CreateDonationRequest & { stripePaymentIntentId: string }): Promise<Donation> {
|
||||
const query = `
|
||||
INSERT INTO donations (
|
||||
user_id, stripe_payment_intent_id, amount_cents, currency
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.stripePaymentIntentId,
|
||||
data.amountCents,
|
||||
data.currency || 'usd',
|
||||
];
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
logger.info('Donation created', {
|
||||
donationId: result.rows[0].id,
|
||||
userId: data.userId,
|
||||
amountCents: data.amountCents,
|
||||
});
|
||||
return this.mapDonationRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create donation', {
|
||||
userId: data.userId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find donations by user ID
|
||||
*/
|
||||
async findDonationsByUserId(userId: string): Promise<Donation[]> {
|
||||
const query = `
|
||||
SELECT * FROM donations
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
return result.rows.map(row => this.mapDonationRow(row));
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to find donations by user ID', {
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find donation by Stripe payment intent ID
|
||||
*/
|
||||
async findDonationByPaymentIntentId(stripePaymentIntentId: string): Promise<Donation | null> {
|
||||
const query = `
|
||||
SELECT * FROM donations
|
||||
WHERE stripe_payment_intent_id = $1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [stripePaymentIntentId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapDonationRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to find donation by payment intent ID', {
|
||||
stripePaymentIntentId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a donation
|
||||
*/
|
||||
async updateDonation(id: string, data: UpdateDonationData): Promise<Donation | null> {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (data.status !== undefined) {
|
||||
fields.push(`status = $${paramCount++}`);
|
||||
values.push(data.status);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
logger.warn('No fields to update for donation', { id });
|
||||
return this.findDonationById(id);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const query = `
|
||||
UPDATE donations
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramCount}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('Donation updated', { donationId: id });
|
||||
return this.mapDonationRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to update donation', {
|
||||
donationId: id,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find donation by ID
|
||||
*/
|
||||
async findDonationById(id: string): Promise<Donation | null> {
|
||||
const query = 'SELECT * FROM donations WHERE id = $1';
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapDonationRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to find donation by ID', {
|
||||
donationId: id,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Tier Vehicle Selections ==========
|
||||
|
||||
/**
|
||||
* Create a tier vehicle selection
|
||||
*/
|
||||
async createVehicleSelection(data: CreateTierVehicleSelectionRequest): Promise<TierVehicleSelection> {
|
||||
const query = `
|
||||
INSERT INTO tier_vehicle_selections (
|
||||
user_id, vehicle_id
|
||||
)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [data.userId, data.vehicleId];
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, values);
|
||||
logger.info('Tier vehicle selection created', {
|
||||
selectionId: result.rows[0].id,
|
||||
userId: data.userId,
|
||||
vehicleId: data.vehicleId,
|
||||
});
|
||||
return this.mapVehicleSelectionRow(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create tier vehicle selection', {
|
||||
userId: data.userId,
|
||||
vehicleId: data.vehicleId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find vehicle selections by user ID
|
||||
*/
|
||||
async findVehicleSelectionsByUserId(userId: string): Promise<TierVehicleSelection[]> {
|
||||
const query = `
|
||||
SELECT * FROM tier_vehicle_selections
|
||||
WHERE user_id = $1
|
||||
ORDER BY selected_at DESC
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
return result.rows.map(row => this.mapVehicleSelectionRow(row));
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to find vehicle selections by user ID', {
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all vehicle selections for a user
|
||||
*/
|
||||
async deleteVehicleSelectionsByUserId(userId: string): Promise<void> {
|
||||
const query = `
|
||||
DELETE FROM tier_vehicle_selections
|
||||
WHERE user_id = $1
|
||||
`;
|
||||
|
||||
try {
|
||||
await this.pool.query(query, [userId]);
|
||||
logger.info('Vehicle selections deleted', { userId });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to delete vehicle selections', {
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Private Mapping Methods ==========
|
||||
|
||||
private mapSubscriptionRow(row: any): Subscription {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
stripeCustomerId: row.stripe_customer_id,
|
||||
stripeSubscriptionId: row.stripe_subscription_id || undefined,
|
||||
tier: row.tier,
|
||||
billingCycle: row.billing_cycle || undefined,
|
||||
status: row.status,
|
||||
currentPeriodStart: row.current_period_start || undefined,
|
||||
currentPeriodEnd: row.current_period_end || undefined,
|
||||
gracePeriodEnd: row.grace_period_end || undefined,
|
||||
cancelAtPeriodEnd: row.cancel_at_period_end,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private mapEventRow(row: any): SubscriptionEvent {
|
||||
return {
|
||||
id: row.id,
|
||||
subscriptionId: row.subscription_id,
|
||||
stripeEventId: row.stripe_event_id,
|
||||
eventType: row.event_type,
|
||||
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private mapDonationRow(row: any): Donation {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
stripePaymentIntentId: row.stripe_payment_intent_id,
|
||||
amountCents: row.amount_cents,
|
||||
currency: row.currency,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private mapVehicleSelectionRow(row: any): TierVehicleSelection {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
selectedAt: row.selected_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
133
backend/src/features/subscriptions/domain/subscriptions.types.ts
Normal file
133
backend/src/features/subscriptions/domain/subscriptions.types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for subscriptions feature
|
||||
* @ai-context Core business types for Stripe subscription management
|
||||
*/
|
||||
|
||||
// Subscription tier types (matches DB enum)
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
// Subscription status types (matches DB enum)
|
||||
export type SubscriptionStatus = 'active' | 'past_due' | 'canceled' | 'unpaid';
|
||||
|
||||
// Billing cycle types (matches DB enum)
|
||||
export type BillingCycle = 'monthly' | 'yearly';
|
||||
|
||||
// Donation status types (matches DB enum)
|
||||
export type DonationStatus = 'pending' | 'succeeded' | 'failed' | 'canceled';
|
||||
|
||||
// Main subscription entity
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId?: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
status: SubscriptionStatus;
|
||||
currentPeriodStart?: Date;
|
||||
currentPeriodEnd?: Date;
|
||||
gracePeriodEnd?: Date;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Subscription event entity (webhook event logging)
|
||||
export interface SubscriptionEvent {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
stripeEventId: string;
|
||||
eventType: string;
|
||||
payload: Record<string, any>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Donation entity (one-time payments)
|
||||
export interface Donation {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripePaymentIntentId: string;
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
status: DonationStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Tier vehicle selection entity (tracks which vehicles user selected to keep during downgrade)
|
||||
export interface TierVehicleSelection {
|
||||
id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
selectedAt: Date;
|
||||
}
|
||||
|
||||
// Request/Response types
|
||||
|
||||
export interface CreateSubscriptionRequest {
|
||||
userId: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle: BillingCycle;
|
||||
paymentMethodId?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId?: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
status: SubscriptionStatus;
|
||||
currentPeriodStart?: string;
|
||||
currentPeriodEnd?: string;
|
||||
gracePeriodEnd?: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DonationResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripePaymentIntentId: string;
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
status: DonationStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateDonationRequest {
|
||||
userId: string;
|
||||
amountCents: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionEventRequest {
|
||||
subscriptionId: string;
|
||||
stripeEventId: string;
|
||||
eventType: string;
|
||||
payload: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CreateTierVehicleSelectionRequest {
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
// Service layer types
|
||||
export interface UpdateSubscriptionData {
|
||||
stripeSubscriptionId?: string;
|
||||
tier?: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
status?: SubscriptionStatus;
|
||||
currentPeriodStart?: Date;
|
||||
currentPeriodEnd?: Date;
|
||||
gracePeriodEnd?: Date;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDonationData {
|
||||
status?: DonationStatus;
|
||||
}
|
||||
326
backend/src/features/subscriptions/external/stripe/stripe.client.ts
vendored
Normal file
326
backend/src/features/subscriptions/external/stripe/stripe.client.ts
vendored
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @ai-summary Stripe API client wrapper
|
||||
* @ai-context Handles all Stripe API interactions with proper error handling
|
||||
*/
|
||||
|
||||
import Stripe from 'stripe';
|
||||
import { logger } from '../../../../core/logging/logger';
|
||||
import {
|
||||
StripeCustomer,
|
||||
StripeSubscription,
|
||||
StripePaymentIntent,
|
||||
StripeWebhookEvent,
|
||||
} from './stripe.types';
|
||||
|
||||
export class StripeClient {
|
||||
private stripe: Stripe;
|
||||
|
||||
constructor() {
|
||||
const apiKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('STRIPE_SECRET_KEY environment variable is required');
|
||||
}
|
||||
|
||||
this.stripe = new Stripe(apiKey, {
|
||||
apiVersion: '2025-12-15.clover',
|
||||
typescript: true,
|
||||
});
|
||||
|
||||
logger.info('Stripe client initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Stripe customer
|
||||
*/
|
||||
async createCustomer(email: string, name?: string): Promise<StripeCustomer> {
|
||||
try {
|
||||
logger.info('Creating Stripe customer', { email, name });
|
||||
|
||||
const customer = await this.stripe.customers.create({
|
||||
email,
|
||||
name,
|
||||
metadata: {
|
||||
source: 'motovaultpro',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Stripe customer created', { customerId: customer.id });
|
||||
|
||||
return {
|
||||
id: customer.id,
|
||||
email: customer.email || email,
|
||||
name: customer.name || undefined,
|
||||
created: customer.created,
|
||||
metadata: customer.metadata,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create Stripe customer', {
|
||||
email,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subscription for a customer
|
||||
*/
|
||||
async createSubscription(
|
||||
customerId: string,
|
||||
priceId: string,
|
||||
paymentMethodId?: string
|
||||
): Promise<StripeSubscription> {
|
||||
try {
|
||||
logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId });
|
||||
|
||||
const subscriptionParams: Stripe.SubscriptionCreateParams = {
|
||||
customer: customerId,
|
||||
items: [{ price: priceId }],
|
||||
payment_behavior: 'default_incomplete',
|
||||
payment_settings: {
|
||||
save_default_payment_method: 'on_subscription',
|
||||
},
|
||||
expand: ['latest_invoice.payment_intent'],
|
||||
};
|
||||
|
||||
if (paymentMethodId) {
|
||||
subscriptionParams.default_payment_method = paymentMethodId;
|
||||
}
|
||||
|
||||
const subscription = await this.stripe.subscriptions.create(subscriptionParams);
|
||||
|
||||
logger.info('Stripe subscription created', { subscriptionId: subscription.id });
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customer: subscription.customer as string,
|
||||
status: subscription.status as StripeSubscription['status'],
|
||||
items: subscription.items,
|
||||
currentPeriodStart: (subscription as any).current_period_start || 0,
|
||||
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at || undefined,
|
||||
created: subscription.created,
|
||||
metadata: subscription.metadata,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create Stripe subscription', {
|
||||
customerId,
|
||||
priceId,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a subscription
|
||||
*/
|
||||
async cancelSubscription(
|
||||
subscriptionId: string,
|
||||
cancelAtPeriodEnd: boolean = false
|
||||
): Promise<StripeSubscription> {
|
||||
try {
|
||||
logger.info('Canceling Stripe subscription', { subscriptionId, cancelAtPeriodEnd });
|
||||
|
||||
let subscription: Stripe.Subscription;
|
||||
|
||||
if (cancelAtPeriodEnd) {
|
||||
// Cancel at period end (schedule cancellation)
|
||||
subscription = await this.stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
logger.info('Stripe subscription scheduled for cancellation', { subscriptionId });
|
||||
} else {
|
||||
// Cancel immediately
|
||||
subscription = await this.stripe.subscriptions.cancel(subscriptionId);
|
||||
logger.info('Stripe subscription canceled immediately', { subscriptionId });
|
||||
}
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customer: subscription.customer as string,
|
||||
status: subscription.status as StripeSubscription['status'],
|
||||
items: subscription.items,
|
||||
currentPeriodStart: (subscription as any).current_period_start || 0,
|
||||
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at || undefined,
|
||||
created: subscription.created,
|
||||
metadata: subscription.metadata,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to cancel Stripe subscription', {
|
||||
subscriptionId,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the payment method for a customer
|
||||
*/
|
||||
async updatePaymentMethod(customerId: string, paymentMethodId: string): Promise<void> {
|
||||
try {
|
||||
logger.info('Updating Stripe payment method', { customerId, paymentMethodId });
|
||||
|
||||
// Attach payment method to customer
|
||||
await this.stripe.paymentMethods.attach(paymentMethodId, {
|
||||
customer: customerId,
|
||||
});
|
||||
|
||||
// Set as default payment method
|
||||
await this.stripe.customers.update(customerId, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentMethodId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Stripe payment method updated', { customerId, paymentMethodId });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to update Stripe payment method', {
|
||||
customerId,
|
||||
paymentMethodId,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payment intent for one-time donations
|
||||
*/
|
||||
async createPaymentIntent(amount: number, currency: string = 'usd'): Promise<StripePaymentIntent> {
|
||||
try {
|
||||
logger.info('Creating Stripe payment intent', { amount, currency });
|
||||
|
||||
const paymentIntent = await this.stripe.paymentIntents.create({
|
||||
amount,
|
||||
currency,
|
||||
metadata: {
|
||||
source: 'motovaultpro',
|
||||
type: 'donation',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Stripe payment intent created', { paymentIntentId: paymentIntent.id });
|
||||
|
||||
return {
|
||||
id: paymentIntent.id,
|
||||
amount: paymentIntent.amount,
|
||||
currency: paymentIntent.currency,
|
||||
status: paymentIntent.status,
|
||||
customer: paymentIntent.customer as string | undefined,
|
||||
payment_method: paymentIntent.payment_method as string | undefined,
|
||||
created: paymentIntent.created,
|
||||
metadata: paymentIntent.metadata,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create Stripe payment intent', {
|
||||
amount,
|
||||
currency,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct and verify a webhook event from Stripe
|
||||
*/
|
||||
constructWebhookEvent(payload: Buffer, signature: string): StripeWebhookEvent {
|
||||
try {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
throw new Error('STRIPE_WEBHOOK_SECRET environment variable is required');
|
||||
}
|
||||
|
||||
const event = this.stripe.webhooks.constructEvent(
|
||||
payload,
|
||||
signature,
|
||||
webhookSecret
|
||||
);
|
||||
|
||||
logger.info('Stripe webhook event verified', { eventId: event.id, type: event.type });
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
created: event.created,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to verify Stripe webhook event', {
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a subscription by ID
|
||||
*/
|
||||
async getSubscription(subscriptionId: string): Promise<StripeSubscription> {
|
||||
try {
|
||||
logger.info('Retrieving Stripe subscription', { subscriptionId });
|
||||
|
||||
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customer: subscription.customer as string,
|
||||
status: subscription.status as StripeSubscription['status'],
|
||||
items: subscription.items,
|
||||
currentPeriodStart: (subscription as any).current_period_start || 0,
|
||||
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at || undefined,
|
||||
created: subscription.created,
|
||||
metadata: subscription.metadata,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to retrieve Stripe subscription', {
|
||||
subscriptionId,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a customer by ID
|
||||
*/
|
||||
async getCustomer(customerId: string): Promise<StripeCustomer> {
|
||||
try {
|
||||
logger.info('Retrieving Stripe customer', { customerId });
|
||||
|
||||
const customer = await this.stripe.customers.retrieve(customerId);
|
||||
|
||||
if (customer.deleted) {
|
||||
throw new Error('Customer has been deleted');
|
||||
}
|
||||
|
||||
return {
|
||||
id: customer.id,
|
||||
email: customer.email || '',
|
||||
name: customer.name || undefined,
|
||||
created: customer.created,
|
||||
metadata: customer.metadata,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to retrieve Stripe customer', {
|
||||
customerId,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
backend/src/features/subscriptions/external/stripe/stripe.types.ts
vendored
Normal file
82
backend/src/features/subscriptions/external/stripe/stripe.types.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for Stripe API responses
|
||||
* @ai-context Simplified types for the Stripe API responses we care about
|
||||
*/
|
||||
|
||||
// Stripe Customer
|
||||
export interface StripeCustomer {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
created: number;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Stripe Subscription
|
||||
export interface StripeSubscription {
|
||||
id: string;
|
||||
customer: string;
|
||||
status: 'incomplete' | 'incomplete_expired' | 'trialing' | 'active' | 'past_due' | 'canceled' | 'unpaid' | 'paused';
|
||||
items: unknown;
|
||||
currentPeriodStart: number;
|
||||
currentPeriodEnd: number;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
canceledAt?: number;
|
||||
created: number;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Stripe Payment Method
|
||||
export interface StripePaymentMethod {
|
||||
id: string;
|
||||
type: string;
|
||||
card?: {
|
||||
brand: string;
|
||||
last4: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Stripe Payment Intent (for donations)
|
||||
export interface StripePaymentIntent {
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'processing' | 'requires_capture' | 'canceled' | 'succeeded';
|
||||
customer?: string;
|
||||
payment_method?: string;
|
||||
created: number;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Stripe Webhook Event
|
||||
export interface StripeWebhookEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
object: any;
|
||||
};
|
||||
created: number;
|
||||
}
|
||||
|
||||
// Stripe Price (for subscription plans)
|
||||
export interface StripePrice {
|
||||
id: string;
|
||||
product: string;
|
||||
unit_amount: number;
|
||||
currency: string;
|
||||
recurring?: {
|
||||
interval: 'day' | 'week' | 'month' | 'year';
|
||||
interval_count: number;
|
||||
};
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Stripe Error
|
||||
export interface StripeError {
|
||||
type: string;
|
||||
code?: string;
|
||||
message: string;
|
||||
param?: string;
|
||||
}
|
||||
41
backend/src/features/subscriptions/index.ts
Normal file
41
backend/src/features/subscriptions/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @ai-summary Public API for subscriptions feature capsule
|
||||
* @ai-context Export all public types, classes, and utilities
|
||||
*/
|
||||
|
||||
// Domain types
|
||||
export type {
|
||||
Subscription,
|
||||
SubscriptionEvent,
|
||||
Donation,
|
||||
TierVehicleSelection,
|
||||
SubscriptionTier,
|
||||
SubscriptionStatus,
|
||||
BillingCycle,
|
||||
DonationStatus,
|
||||
CreateSubscriptionRequest,
|
||||
SubscriptionResponse,
|
||||
DonationResponse,
|
||||
CreateDonationRequest,
|
||||
CreateSubscriptionEventRequest,
|
||||
CreateTierVehicleSelectionRequest,
|
||||
UpdateSubscriptionData,
|
||||
UpdateDonationData,
|
||||
} from './domain/subscriptions.types';
|
||||
|
||||
// Stripe types
|
||||
export type {
|
||||
StripeCustomer,
|
||||
StripeSubscription,
|
||||
StripePaymentMethod,
|
||||
StripePaymentIntent,
|
||||
StripeWebhookEvent,
|
||||
StripePrice,
|
||||
StripeError,
|
||||
} from './external/stripe/stripe.types';
|
||||
|
||||
// Stripe client
|
||||
export { StripeClient } from './external/stripe/stripe.client';
|
||||
|
||||
// Repository
|
||||
export { SubscriptionsRepository } from './data/subscriptions.repository';
|
||||
@@ -0,0 +1,106 @@
|
||||
-- Migration: Subscriptions tables for Stripe integration
|
||||
-- Creates: subscriptions, subscription_events, donations, tier_vehicle_selections
|
||||
|
||||
-- Enable uuid-ossp extension if not already enabled
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Create subscription status enum
|
||||
CREATE TYPE subscription_status AS ENUM ('active', 'past_due', 'canceled', 'unpaid');
|
||||
|
||||
-- Create billing cycle enum
|
||||
CREATE TYPE billing_cycle AS ENUM ('monthly', 'yearly');
|
||||
|
||||
-- Create donation status enum
|
||||
CREATE TYPE donation_status AS ENUM ('pending', 'succeeded', 'failed', 'canceled');
|
||||
|
||||
-- Create updated_at trigger function if not exists
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Main subscriptions table
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
stripe_customer_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
stripe_subscription_id VARCHAR(255) UNIQUE,
|
||||
tier subscription_tier NOT NULL DEFAULT 'free',
|
||||
billing_cycle billing_cycle,
|
||||
status subscription_status NOT NULL DEFAULT 'active',
|
||||
current_period_start TIMESTAMP WITH TIME ZONE,
|
||||
current_period_end TIMESTAMP WITH TIME ZONE,
|
||||
grace_period_end TIMESTAMP WITH TIME ZONE,
|
||||
cancel_at_period_end BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_subscriptions_user_id FOREIGN KEY (user_id) REFERENCES user_profiles(auth0_sub) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Subscription events table (webhook event logging)
|
||||
CREATE TABLE IF NOT EXISTS subscription_events (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
subscription_id UUID NOT NULL,
|
||||
stripe_event_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_subscription_events_subscription_id FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Donations table (one-time payments)
|
||||
CREATE TABLE IF NOT EXISTS donations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
stripe_payment_intent_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'usd',
|
||||
status donation_status NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_donations_user_id FOREIGN KEY (user_id) REFERENCES user_profiles(auth0_sub) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Tier vehicle selections table (tracks which vehicles user selected to keep during downgrade)
|
||||
CREATE TABLE IF NOT EXISTS tier_vehicle_selections (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
vehicle_id UUID NOT NULL,
|
||||
selected_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_tier_vehicle_selections_user_id FOREIGN KEY (user_id) REFERENCES user_profiles(auth0_sub) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tier_vehicle_selections_vehicle_id FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
|
||||
CREATE INDEX idx_subscriptions_stripe_customer_id ON subscriptions(stripe_customer_id);
|
||||
CREATE INDEX idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id);
|
||||
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
||||
CREATE INDEX idx_subscriptions_tier ON subscriptions(tier);
|
||||
|
||||
CREATE INDEX idx_subscription_events_subscription_id ON subscription_events(subscription_id);
|
||||
CREATE INDEX idx_subscription_events_stripe_event_id ON subscription_events(stripe_event_id);
|
||||
CREATE INDEX idx_subscription_events_event_type ON subscription_events(event_type);
|
||||
CREATE INDEX idx_subscription_events_created_at ON subscription_events(created_at);
|
||||
|
||||
CREATE INDEX idx_donations_user_id ON donations(user_id);
|
||||
CREATE INDEX idx_donations_stripe_payment_intent_id ON donations(stripe_payment_intent_id);
|
||||
CREATE INDEX idx_donations_status ON donations(status);
|
||||
CREATE INDEX idx_donations_created_at ON donations(created_at);
|
||||
|
||||
CREATE INDEX idx_tier_vehicle_selections_user_id ON tier_vehicle_selections(user_id);
|
||||
CREATE INDEX idx_tier_vehicle_selections_vehicle_id ON tier_vehicle_selections(vehicle_id);
|
||||
|
||||
-- Add updated_at triggers
|
||||
CREATE TRIGGER update_subscriptions_updated_at
|
||||
BEFORE UPDATE ON subscriptions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_donations_updated_at
|
||||
BEFORE UPDATE ON donations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
Reference in New Issue
Block a user