diff --git a/backend/package-lock.json b/backend/package-lock.json index c2c86ad..31f56c8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -27,6 +27,7 @@ "opossum": "^8.0.0", "pg": "^8.13.1", "resend": "^3.0.0", + "stripe": "^20.2.0", "tar": "^7.4.3", "winston": "^3.17.0", "zod": "^3.24.1" @@ -1960,7 +1961,7 @@ "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -2899,7 +2900,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6258,7 +6258,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6906,10 +6905,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7319,7 +7317,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7339,7 +7336,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7356,7 +7352,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7375,7 +7370,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7635,6 +7629,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.2.0.tgz", + "integrity": "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA==", + "license": "MIT", + "dependencies": { + "qs": "^6.14.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strtok3": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index 35ff18b..69725ba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,45 +18,46 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "pg": "^8.13.1", - "ioredis": "^5.4.2", - "@fastify/multipart": "^9.0.1", - "axios": "^1.7.9", - "opossum": "^8.0.0", - "winston": "^3.17.0", - "zod": "^3.24.1", - "js-yaml": "^4.1.0", - "fastify": "^5.2.0", + "@fastify/autoload": "^6.0.1", "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", "@fastify/jwt": "^10.0.0", + "@fastify/multipart": "^9.0.1", "@fastify/type-provider-typebox": "^6.1.0", "@sinclair/typebox": "^0.34.0", - "fastify-plugin": "^5.0.1", - "@fastify/autoload": "^6.0.1", - "get-jwks": "^11.0.3", - "file-type": "^16.5.4", - "resend": "^3.0.0", - "node-cron": "^3.0.3", "auth0": "^4.12.0", - "tar": "^7.4.3" + "axios": "^1.7.9", + "fastify": "^5.2.0", + "fastify-plugin": "^5.0.1", + "file-type": "^16.5.4", + "get-jwks": "^11.0.3", + "ioredis": "^5.4.2", + "js-yaml": "^4.1.0", + "node-cron": "^3.0.3", + "opossum": "^8.0.0", + "pg": "^8.13.1", + "resend": "^3.0.0", + "stripe": "^20.2.0", + "tar": "^7.4.3", + "winston": "^3.17.0", + "zod": "^3.24.1" }, "devDependencies": { - "@types/node": "^22.0.0", - "@types/pg": "^8.10.9", - "@types/js-yaml": "^4.0.9", - "@types/node-cron": "^3.0.11", - "typescript": "^5.7.2", - "ts-node": "^10.9.1", - "nodemon": "^3.1.9", - "jest": "^29.7.0", - "@types/jest": "^29.5.10", - "ts-jest": "^29.1.1", - "supertest": "^7.1.4", - "@types/supertest": "^6.0.3", - "@types/opossum": "^8.0.0", - "eslint": "^9.17.0", "@eslint/js": "^9.17.0", + "@types/jest": "^29.5.10", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.0.0", + "@types/node-cron": "^3.0.11", + "@types/opossum": "^8.0.0", + "@types/pg": "^8.10.9", + "@types/supertest": "^6.0.3", + "eslint": "^9.17.0", + "jest": "^29.7.0", + "nodemon": "^3.1.9", + "supertest": "^7.1.4", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "typescript": "^5.7.2", "typescript-eslint": "^8.18.1" } } diff --git a/backend/src/features/subscriptions/README.md b/backend/src/features/subscriptions/README.md new file mode 100644 index 0000000..5a79612 --- /dev/null +++ b/backend/src/features/subscriptions/README.md @@ -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 diff --git a/backend/src/features/subscriptions/data/subscriptions.repository.ts b/backend/src/features/subscriptions/data/subscriptions.repository.ts new file mode 100644 index 0000000..8a49de5 --- /dev/null +++ b/backend/src/features/subscriptions/data/subscriptions.repository.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/backend/src/features/subscriptions/domain/subscriptions.types.ts b/backend/src/features/subscriptions/domain/subscriptions.types.ts new file mode 100644 index 0000000..2bcab80 --- /dev/null +++ b/backend/src/features/subscriptions/domain/subscriptions.types.ts @@ -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; + 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; +} + +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; +} diff --git a/backend/src/features/subscriptions/external/stripe/stripe.client.ts b/backend/src/features/subscriptions/external/stripe/stripe.client.ts new file mode 100644 index 0000000..58809d2 --- /dev/null +++ b/backend/src/features/subscriptions/external/stripe/stripe.client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/backend/src/features/subscriptions/external/stripe/stripe.types.ts b/backend/src/features/subscriptions/external/stripe/stripe.types.ts new file mode 100644 index 0000000..d26bc1d --- /dev/null +++ b/backend/src/features/subscriptions/external/stripe/stripe.types.ts @@ -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; +} + +// 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; +} + +// 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; +} + +// 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; +} + +// Stripe Error +export interface StripeError { + type: string; + code?: string; + message: string; + param?: string; +} diff --git a/backend/src/features/subscriptions/index.ts b/backend/src/features/subscriptions/index.ts new file mode 100644 index 0000000..fd40cf6 --- /dev/null +++ b/backend/src/features/subscriptions/index.ts @@ -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'; diff --git a/backend/src/features/subscriptions/migrations/001_subscriptions_tables.sql b/backend/src/features/subscriptions/migrations/001_subscriptions_tables.sql new file mode 100644 index 0000000..3107f77 --- /dev/null +++ b/backend/src/features/subscriptions/migrations/001_subscriptions_tables.sql @@ -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();