From 88b820b1c37e4ceff8f68cfe574899d936b348b4 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:04:11 -0600 Subject: [PATCH 01/17] feat: add subscriptions feature capsule - M1 database schema and Stripe client (refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/package-lock.json | 36 +- backend/package.json | 61 +- backend/src/features/subscriptions/README.md | 79 +++ .../data/subscriptions.repository.ts | 581 ++++++++++++++++++ .../domain/subscriptions.types.ts | 133 ++++ .../external/stripe/stripe.client.ts | 326 ++++++++++ .../external/stripe/stripe.types.ts | 82 +++ backend/src/features/subscriptions/index.ts | 41 ++ .../migrations/001_subscriptions_tables.sql | 106 ++++ 9 files changed, 1404 insertions(+), 41 deletions(-) create mode 100644 backend/src/features/subscriptions/README.md create mode 100644 backend/src/features/subscriptions/data/subscriptions.repository.ts create mode 100644 backend/src/features/subscriptions/domain/subscriptions.types.ts create mode 100644 backend/src/features/subscriptions/external/stripe/stripe.client.ts create mode 100644 backend/src/features/subscriptions/external/stripe/stripe.types.ts create mode 100644 backend/src/features/subscriptions/index.ts create mode 100644 backend/src/features/subscriptions/migrations/001_subscriptions_tables.sql 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(); -- 2.49.1 From 7a0c09b83fbd46a0984adddb33f009bf78e40ebd Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:10:20 -0600 Subject: [PATCH 02/17] feat: add subscriptions service layer and webhook endpoint - M2 (refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement SubscriptionsService with getSubscription, createSubscription, upgradeSubscription, cancelSubscription, reactivateSubscription - Add handleWebhookEvent for Stripe webhook processing with idempotency - Handle 5 webhook events: subscription.created/updated/deleted, invoice.payment_succeeded/failed - Auto-sync tier changes to user_profiles.subscription_tier - Add public webhook endpoint POST /api/webhooks/stripe (signature verified) - Implement 30-day grace period on payment failure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/features/subscriptions/README.md | 36 +- .../subscriptions/api/webhooks.controller.ts | 62 ++ .../subscriptions/api/webhooks.routes.ts | 24 + .../domain/subscriptions.service.ts | 622 ++++++++++++++++++ backend/src/features/subscriptions/index.ts | 6 + 5 files changed, 745 insertions(+), 5 deletions(-) create mode 100644 backend/src/features/subscriptions/api/webhooks.controller.ts create mode 100644 backend/src/features/subscriptions/api/webhooks.routes.ts create mode 100644 backend/src/features/subscriptions/domain/subscriptions.service.ts diff --git a/backend/src/features/subscriptions/README.md b/backend/src/features/subscriptions/README.md index 5a79612..2a09f61 100644 --- a/backend/src/features/subscriptions/README.md +++ b/backend/src/features/subscriptions/README.md @@ -54,13 +54,39 @@ Defined in `user_profiles.subscription_tier` enum: - `canceled` - Subscription canceled - `unpaid` - Payment failed, grace period expired +## Milestone 2: Service Layer + Webhook Endpoint (COMPLETE) + +### Service Layer +- **SubscriptionsService** (`domain/subscriptions.service.ts`): Business logic for subscription management + - Get current subscription for user + - Create new subscription (Stripe customer + free tier record) + - Upgrade subscription (create Stripe subscription with payment method) + - Cancel subscription (schedule for end of period) + - Reactivate subscription (remove pending cancellation) + - Handle Stripe webhook events with idempotency + +### Webhook Processing +- **Webhook Events Handled**: + - `customer.subscription.created` - Update subscription record with Stripe subscription ID + - `customer.subscription.updated` - Update status, tier, period dates + - `customer.subscription.deleted` - Mark as canceled, downgrade to free tier + - `invoice.payment_succeeded` - Clear grace period, mark active + - `invoice.payment_failed` - Set 30-day grace period + +### API Endpoints +- **WebhooksController** (`api/webhooks.controller.ts`): Webhook event handler +- **Routes** (`api/webhooks.routes.ts`): PUBLIC endpoint with rawBody support + - POST /api/webhooks/stripe - Stripe webhook receiver (no JWT auth, signature verified) + +### Integration +- Syncs subscription tier changes to `user_profiles.subscription_tier` via UserProfileRepository +- Uses environment variables for Stripe price IDs (PRO/ENTERPRISE, MONTHLY/YEARLY) + ## 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 +- M3: API endpoints for subscription management (user-facing CRUD) +- M4: Frontend integration and subscription UI +- M5: Testing and documentation ## Database Migration diff --git a/backend/src/features/subscriptions/api/webhooks.controller.ts b/backend/src/features/subscriptions/api/webhooks.controller.ts new file mode 100644 index 0000000..edd15ba --- /dev/null +++ b/backend/src/features/subscriptions/api/webhooks.controller.ts @@ -0,0 +1,62 @@ +/** + * @ai-summary Webhook controller for Stripe events + * @ai-context Handles incoming Stripe webhook events with signature verification + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { logger } from '../../../core/logging/logger'; +import { SubscriptionsService } from '../domain/subscriptions.service'; +import { SubscriptionsRepository } from '../data/subscriptions.repository'; +import { StripeClient } from '../external/stripe/stripe.client'; +import { pool } from '../../../core/config/database'; + +export class WebhooksController { + private service: SubscriptionsService; + + constructor() { + const repository = new SubscriptionsRepository(pool); + const stripeClient = new StripeClient(); + this.service = new SubscriptionsService(repository, stripeClient, pool); + } + + /** + * Handle Stripe webhook events + * POST /api/webhooks/stripe + */ + async handleStripeWebhook(request: FastifyRequest, reply: FastifyReply): Promise { + try { + // Get raw body from request (must be enabled via config: { rawBody: true }) + const rawBody = (request as any).rawBody; + if (!rawBody) { + logger.error('Missing raw body in webhook request'); + return reply.status(400).send({ error: 'Missing raw body' }); + } + + // Get Stripe signature from headers + const signature = request.headers['stripe-signature']; + if (!signature || typeof signature !== 'string') { + logger.error('Missing or invalid Stripe signature'); + return reply.status(400).send({ error: 'Missing Stripe signature' }); + } + + // Process the webhook event + await this.service.handleWebhookEvent(rawBody, signature); + + // Return 200 to acknowledge receipt + return reply.status(200).send({ received: true }); + } catch (error: any) { + logger.error('Webhook handler error', { + error: error.message, + stack: error.stack, + }); + + // Return 400 for signature verification failures + if (error.message.includes('signature') || error.message.includes('verify')) { + return reply.status(400).send({ error: 'Invalid signature' }); + } + + // Return 500 for other errors + return reply.status(500).send({ error: 'Webhook processing failed' }); + } + } +} diff --git a/backend/src/features/subscriptions/api/webhooks.routes.ts b/backend/src/features/subscriptions/api/webhooks.routes.ts new file mode 100644 index 0000000..4a24c1b --- /dev/null +++ b/backend/src/features/subscriptions/api/webhooks.routes.ts @@ -0,0 +1,24 @@ +/** + * @ai-summary Webhook routes for Stripe events + * @ai-context PUBLIC endpoint - no JWT auth, authenticated via Stripe signature + */ + +import { FastifyPluginAsync } from 'fastify'; +import { WebhooksController } from './webhooks.controller'; + +export const webhooksRoutes: FastifyPluginAsync = async (fastify) => { + const controller = new WebhooksController(); + + // POST /api/webhooks/stripe - PUBLIC endpoint (no JWT auth) + // Stripe authenticates via webhook signature verification + // IMPORTANT: rawBody MUST be enabled for signature verification to work + fastify.post( + '/webhooks/stripe', + { + config: { + rawBody: true, // Enable raw body for Stripe signature verification + }, + }, + controller.handleStripeWebhook.bind(controller) + ); +}; diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts new file mode 100644 index 0000000..2729e86 --- /dev/null +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -0,0 +1,622 @@ +/** + * @ai-summary Subscription business logic and webhook handling + * @ai-context Manages subscription lifecycle, Stripe integration, and tier syncing + */ + +import { Pool } from 'pg'; +import { logger } from '../../../core/logging/logger'; +import { SubscriptionsRepository } from '../data/subscriptions.repository'; +import { StripeClient } from '../external/stripe/stripe.client'; +import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { + Subscription, + SubscriptionResponse, + SubscriptionTier, + BillingCycle, + SubscriptionStatus, + UpdateSubscriptionData, +} from './subscriptions.types'; + +interface StripeWebhookEvent { + id: string; + type: string; + data: { + object: any; + }; +} + +export class SubscriptionsService { + private userProfileRepository: UserProfileRepository; + + constructor( + private repository: SubscriptionsRepository, + private stripeClient: StripeClient, + pool: Pool + ) { + this.userProfileRepository = new UserProfileRepository(pool); + } + + /** + * Get current subscription for user + */ + async getSubscription(userId: string): Promise { + try { + const subscription = await this.repository.findByUserId(userId); + + if (!subscription) { + return null; + } + + return this.mapToResponse(subscription); + } catch (error: any) { + logger.error('Failed to get subscription', { + userId, + error: error.message, + }); + throw error; + } + } + + /** + * Create new subscription (Stripe customer + initial free tier record) + */ + async createSubscription(userId: string, email: string): Promise { + try { + logger.info('Creating subscription', { userId, email }); + + // Check if user already has a subscription + const existing = await this.repository.findByUserId(userId); + if (existing) { + logger.warn('User already has a subscription', { userId, subscriptionId: existing.id }); + return existing; + } + + // Create Stripe customer + const stripeCustomer = await this.stripeClient.createCustomer(email); + + // Create subscription record with free tier + const subscription = await this.repository.create({ + userId, + stripeCustomerId: stripeCustomer.id, + tier: 'free', + billingCycle: 'monthly', + }); + + logger.info('Subscription created', { + subscriptionId: subscription.id, + userId, + stripeCustomerId: stripeCustomer.id, + }); + + return subscription; + } catch (error: any) { + logger.error('Failed to create subscription', { + userId, + email, + error: error.message, + }); + throw error; + } + } + + /** + * Upgrade from current tier to new tier + */ + async upgradeSubscription( + userId: string, + newTier: 'pro' | 'enterprise', + billingCycle: 'monthly' | 'yearly', + paymentMethodId: string + ): Promise { + try { + logger.info('Upgrading subscription', { userId, newTier, billingCycle }); + + // Get current subscription + const currentSubscription = await this.repository.findByUserId(userId); + if (!currentSubscription) { + throw new Error('No subscription found for user'); + } + + // Determine price ID from environment variables + const priceId = this.getPriceId(newTier, billingCycle); + + // Create or update Stripe subscription + const stripeSubscription = await this.stripeClient.createSubscription( + currentSubscription.stripeCustomerId, + priceId, + paymentMethodId + ); + + // Update subscription record + const updateData: UpdateSubscriptionData = { + stripeSubscriptionId: stripeSubscription.id, + tier: newTier, + billingCycle, + status: this.mapStripeStatus(stripeSubscription.status), + currentPeriodStart: new Date(stripeSubscription.currentPeriodStart * 1000), + currentPeriodEnd: new Date(stripeSubscription.currentPeriodEnd * 1000), + cancelAtPeriodEnd: false, + }; + + const updatedSubscription = await this.repository.update( + currentSubscription.id, + updateData + ); + + if (!updatedSubscription) { + throw new Error('Failed to update subscription'); + } + + // Sync tier to user profile + await this.syncTierToUserProfile(userId, newTier); + + logger.info('Subscription upgraded', { + subscriptionId: updatedSubscription.id, + userId, + newTier, + billingCycle, + }); + + return updatedSubscription; + } catch (error: any) { + logger.error('Failed to upgrade subscription', { + userId, + newTier, + billingCycle, + error: error.message, + }); + throw error; + } + } + + /** + * Cancel subscription (schedules for end of period) + */ + async cancelSubscription(userId: string): Promise { + try { + logger.info('Canceling subscription', { userId }); + + // Get current subscription + const currentSubscription = await this.repository.findByUserId(userId); + if (!currentSubscription) { + throw new Error('No subscription found for user'); + } + + if (!currentSubscription.stripeSubscriptionId) { + throw new Error('No active Stripe subscription to cancel'); + } + + // Cancel at period end in Stripe + await this.stripeClient.cancelSubscription( + currentSubscription.stripeSubscriptionId, + true + ); + + // Update subscription record + const updatedSubscription = await this.repository.update(currentSubscription.id, { + cancelAtPeriodEnd: true, + }); + + if (!updatedSubscription) { + throw new Error('Failed to update subscription'); + } + + logger.info('Subscription canceled', { + subscriptionId: updatedSubscription.id, + userId, + }); + + return updatedSubscription; + } catch (error: any) { + logger.error('Failed to cancel subscription', { + userId, + error: error.message, + }); + throw error; + } + } + + /** + * Reactivate a pending cancellation + */ + async reactivateSubscription(userId: string): Promise { + try { + logger.info('Reactivating subscription', { userId }); + + // Get current subscription + const currentSubscription = await this.repository.findByUserId(userId); + if (!currentSubscription) { + throw new Error('No subscription found for user'); + } + + if (!currentSubscription.stripeSubscriptionId) { + throw new Error('No active Stripe subscription to reactivate'); + } + + if (!currentSubscription.cancelAtPeriodEnd) { + logger.warn('Subscription is not pending cancellation', { + subscriptionId: currentSubscription.id, + userId, + }); + return currentSubscription; + } + + // Reactivate in Stripe (remove cancel_at_period_end flag) + await this.stripeClient.cancelSubscription( + currentSubscription.stripeSubscriptionId, + false + ); + + // Update subscription record + const updatedSubscription = await this.repository.update(currentSubscription.id, { + cancelAtPeriodEnd: false, + }); + + if (!updatedSubscription) { + throw new Error('Failed to update subscription'); + } + + logger.info('Subscription reactivated', { + subscriptionId: updatedSubscription.id, + userId, + }); + + return updatedSubscription; + } catch (error: any) { + logger.error('Failed to reactivate subscription', { + userId, + error: error.message, + }); + throw error; + } + } + + /** + * Handle incoming Stripe webhook event + */ + async handleWebhookEvent(payload: Buffer, signature: string): Promise { + try { + // Construct and verify webhook event + const event = this.stripeClient.constructWebhookEvent( + payload, + signature + ) as StripeWebhookEvent; + + logger.info('Processing webhook event', { + eventId: event.id, + eventType: event.type, + }); + + // Check idempotency - skip if we've already processed this event + const existingEvent = await this.repository.findEventByStripeId(event.id); + if (existingEvent) { + logger.info('Event already processed, skipping', { eventId: event.id }); + return; + } + + // Process based on event type + switch (event.type) { + case 'customer.subscription.created': + await this.handleSubscriptionCreated(event); + break; + case 'customer.subscription.updated': + await this.handleSubscriptionUpdated(event); + break; + case 'customer.subscription.deleted': + await this.handleSubscriptionDeleted(event); + break; + case 'invoice.payment_succeeded': + await this.handlePaymentSucceeded(event); + break; + case 'invoice.payment_failed': + await this.handlePaymentFailed(event); + break; + default: + logger.info('Unhandled webhook event type', { eventType: event.type }); + } + + logger.info('Webhook event processed', { eventId: event.id, eventType: event.type }); + } catch (error: any) { + logger.error('Failed to handle webhook event', { + error: error.message, + }); + throw error; + } + } + + // ========== Private Helper Methods ========== + + /** + * Handle customer.subscription.created webhook + */ + private async handleSubscriptionCreated(event: StripeWebhookEvent): Promise { + const stripeSubscription = event.data.object; + + // Find subscription by Stripe customer ID + const subscription = await this.repository.findByStripeCustomerId( + stripeSubscription.customer + ); + + if (!subscription) { + logger.warn('Subscription not found for Stripe customer', { + stripeCustomerId: stripeSubscription.customer, + }); + return; + } + + // Update subscription with Stripe subscription ID + await this.repository.update(subscription.id, { + stripeSubscriptionId: stripeSubscription.id, + status: this.mapStripeStatus(stripeSubscription.status), + currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), + currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), + }); + + // Log event + await this.repository.createEvent({ + subscriptionId: subscription.id, + stripeEventId: event.id, + eventType: event.type, + payload: event.data.object, + }); + } + + /** + * Handle customer.subscription.updated webhook + */ + private async handleSubscriptionUpdated(event: StripeWebhookEvent): Promise { + const stripeSubscription = event.data.object; + + // Find subscription by Stripe subscription ID + const subscription = await this.repository.findByStripeSubscriptionId( + stripeSubscription.id + ); + + if (!subscription) { + logger.warn('Subscription not found for Stripe subscription', { + stripeSubscriptionId: stripeSubscription.id, + }); + return; + } + + // Determine tier from price metadata or plan + const tier = this.determineTierFromStripeSubscription(stripeSubscription); + + // Update subscription + const updateData: UpdateSubscriptionData = { + status: this.mapStripeStatus(stripeSubscription.status), + tier, + currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), + currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false, + }; + + await this.repository.update(subscription.id, updateData); + + // Sync tier to user profile + await this.syncTierToUserProfile(subscription.userId, tier); + + // Log event + await this.repository.createEvent({ + subscriptionId: subscription.id, + stripeEventId: event.id, + eventType: event.type, + payload: event.data.object, + }); + } + + /** + * Handle customer.subscription.deleted webhook + */ + private async handleSubscriptionDeleted(event: StripeWebhookEvent): Promise { + const stripeSubscription = event.data.object; + + // Find subscription by Stripe subscription ID + const subscription = await this.repository.findByStripeSubscriptionId( + stripeSubscription.id + ); + + if (!subscription) { + logger.warn('Subscription not found for Stripe subscription', { + stripeSubscriptionId: stripeSubscription.id, + }); + return; + } + + // Update subscription to canceled + await this.repository.update(subscription.id, { + status: 'canceled', + }); + + // Downgrade tier to free + await this.syncTierToUserProfile(subscription.userId, 'free'); + + // Log event + await this.repository.createEvent({ + subscriptionId: subscription.id, + stripeEventId: event.id, + eventType: event.type, + payload: event.data.object, + }); + } + + /** + * Handle invoice.payment_succeeded webhook + */ + private async handlePaymentSucceeded(event: StripeWebhookEvent): Promise { + const invoice = event.data.object; + + // Find subscription by Stripe subscription ID + const subscription = await this.repository.findByStripeSubscriptionId( + invoice.subscription + ); + + if (!subscription) { + logger.warn('Subscription not found for Stripe subscription', { + stripeSubscriptionId: invoice.subscription, + }); + return; + } + + // Clear grace period and mark as active + await this.repository.update(subscription.id, { + status: 'active', + gracePeriodEnd: undefined, + }); + + // Log event + await this.repository.createEvent({ + subscriptionId: subscription.id, + stripeEventId: event.id, + eventType: event.type, + payload: event.data.object, + }); + } + + /** + * Handle invoice.payment_failed webhook + */ + private async handlePaymentFailed(event: StripeWebhookEvent): Promise { + const invoice = event.data.object; + + // Find subscription by Stripe subscription ID + const subscription = await this.repository.findByStripeSubscriptionId( + invoice.subscription + ); + + if (!subscription) { + logger.warn('Subscription not found for Stripe subscription', { + stripeSubscriptionId: invoice.subscription, + }); + return; + } + + // Set grace period (30 days from now) + const gracePeriodEnd = new Date(); + gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 30); + + await this.repository.update(subscription.id, { + status: 'past_due', + gracePeriodEnd, + }); + + // Log event + await this.repository.createEvent({ + subscriptionId: subscription.id, + stripeEventId: event.id, + eventType: event.type, + payload: event.data.object, + }); + } + + /** + * Sync subscription tier to user_profiles table + */ + private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise { + try { + await this.userProfileRepository.updateSubscriptionTier(userId, tier); + logger.info('Subscription tier synced to user profile', { userId, tier }); + } catch (error: any) { + logger.error('Failed to sync tier to user profile', { + userId, + tier, + error: error.message, + }); + // Don't throw - we don't want to fail the subscription operation if sync fails + } + } + + /** + * Get Stripe price ID from environment variables + */ + private getPriceId(tier: 'pro' | 'enterprise', billingCycle: BillingCycle): string { + const envVarMap: Record = { + 'pro-monthly': 'STRIPE_PRO_MONTHLY_PRICE_ID', + 'pro-yearly': 'STRIPE_PRO_YEARLY_PRICE_ID', + 'enterprise-monthly': 'STRIPE_ENTERPRISE_MONTHLY_PRICE_ID', + 'enterprise-yearly': 'STRIPE_ENTERPRISE_YEARLY_PRICE_ID', + }; + + const envVar = envVarMap[`${tier}-${billingCycle}`]; + const priceId = process.env[envVar]; + + if (!priceId) { + throw new Error(`Missing environment variable: ${envVar}`); + } + + return priceId; + } + + /** + * Map Stripe subscription status to our status type + */ + private mapStripeStatus(stripeStatus: string): SubscriptionStatus { + switch (stripeStatus) { + case 'active': + case 'trialing': + return 'active'; + case 'past_due': + return 'past_due'; + case 'canceled': + case 'incomplete_expired': + return 'canceled'; + case 'unpaid': + return 'unpaid'; + default: + logger.warn('Unknown Stripe status, defaulting to canceled', { stripeStatus }); + return 'canceled'; + } + } + + /** + * Determine tier from Stripe subscription object + */ + private determineTierFromStripeSubscription(stripeSubscription: any): SubscriptionTier { + // Try to extract tier from price metadata or plan + const priceId = stripeSubscription.items?.data?.[0]?.price?.id; + + if (!priceId) { + logger.warn('Could not determine tier from Stripe subscription, defaulting to free'); + return 'free'; + } + + // Check environment variables to match price ID to tier + if ( + priceId === process.env.STRIPE_PRO_MONTHLY_PRICE_ID || + priceId === process.env.STRIPE_PRO_YEARLY_PRICE_ID + ) { + return 'pro'; + } + + if ( + priceId === process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || + priceId === process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID + ) { + return 'enterprise'; + } + + logger.warn('Unknown price ID, defaulting to free', { priceId }); + return 'free'; + } + + /** + * Map subscription entity to response DTO + */ + private mapToResponse(subscription: Subscription): SubscriptionResponse { + return { + id: subscription.id, + userId: subscription.userId, + stripeCustomerId: subscription.stripeCustomerId, + stripeSubscriptionId: subscription.stripeSubscriptionId, + tier: subscription.tier, + billingCycle: subscription.billingCycle, + status: subscription.status, + currentPeriodStart: subscription.currentPeriodStart?.toISOString(), + currentPeriodEnd: subscription.currentPeriodEnd?.toISOString(), + gracePeriodEnd: subscription.gracePeriodEnd?.toISOString(), + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + createdAt: subscription.createdAt.toISOString(), + updatedAt: subscription.updatedAt.toISOString(), + }; + } +} diff --git a/backend/src/features/subscriptions/index.ts b/backend/src/features/subscriptions/index.ts index fd40cf6..92a24b8 100644 --- a/backend/src/features/subscriptions/index.ts +++ b/backend/src/features/subscriptions/index.ts @@ -39,3 +39,9 @@ export { StripeClient } from './external/stripe/stripe.client'; // Repository export { SubscriptionsRepository } from './data/subscriptions.repository'; + +// Service +export { SubscriptionsService } from './domain/subscriptions.service'; + +// Routes +export { webhooksRoutes } from './api/webhooks.routes'; -- 2.49.1 From e7461a4836754bfb16e656b611c2fa4bfdbe2f08 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:16:58 -0600 Subject: [PATCH 03/17] feat: add subscription API endpoints and grace period job - M3 (refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Endpoints (all authenticated): - GET /api/subscriptions - current subscription status - POST /api/subscriptions/checkout - create Stripe subscription - POST /api/subscriptions/cancel - schedule cancellation at period end - POST /api/subscriptions/reactivate - cancel pending cancellation - PUT /api/subscriptions/payment-method - update payment method - GET /api/subscriptions/invoices - billing history Grace Period Job: - Daily cron at 2:30 AM to check expired grace periods - Downgrades to free tier when 30-day grace period expires - Syncs tier to user_profiles.subscription_tier Email Templates: - payment_failed_immediate (first failure) - payment_failed_7day (7 days before grace ends) - payment_failed_1day (1 day before grace ends) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/core/scheduler/index.ts | 26 +- .../006_payment_email_templates.sql | 239 +++++++++++++++++ .../api/subscriptions.controller.ts | 249 ++++++++++++++++++ .../subscriptions/api/subscriptions.routes.ts | 51 ++++ .../domain/subscriptions.service.ts | 19 ++ .../external/stripe/stripe.client.ts | 28 ++ backend/src/features/subscriptions/index.ts | 1 + .../subscriptions/jobs/grace-period.job.ts | 132 ++++++++++ 8 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 backend/src/features/notifications/migrations/006_payment_email_templates.sql create mode 100644 backend/src/features/subscriptions/api/subscriptions.controller.ts create mode 100644 backend/src/features/subscriptions/api/subscriptions.routes.ts create mode 100644 backend/src/features/subscriptions/jobs/grace-period.job.ts diff --git a/backend/src/core/scheduler/index.ts b/backend/src/core/scheduler/index.ts index c6dc63c..2012809 100644 --- a/backend/src/core/scheduler/index.ts +++ b/backend/src/core/scheduler/index.ts @@ -19,6 +19,10 @@ import { processAuditLogCleanup, setAuditLogCleanupJobPool, } from '../../features/audit-log/jobs/cleanup.job'; +import { + processGracePeriodExpirations, + setGracePeriodJobPool, +} from '../../features/subscriptions/jobs/grace-period.job'; import { pool } from '../config/database'; let schedulerInitialized = false; @@ -38,6 +42,9 @@ export function initializeScheduler(): void { // Initialize audit log cleanup job pool setAuditLogCleanupJobPool(pool); + // Initialize grace period job pool + setGracePeriodJobPool(pool); + // Daily notification processing at 8 AM cron.schedule('0 8 * * *', async () => { logger.info('Running scheduled notification job'); @@ -67,6 +74,23 @@ export function initializeScheduler(): void { } }); + // Grace period expiration check at 2:30 AM daily + cron.schedule('30 2 * * *', async () => { + logger.info('Running grace period expiration job'); + try { + const result = await processGracePeriodExpirations(); + logger.info('Grace period job completed', { + processed: result.processed, + downgraded: result.downgraded, + errors: result.errors.length, + }); + } catch (error) { + logger.error('Grace period job failed', { + error: error instanceof Error ? error.message : String(error) + }); + } + }); + // Check for scheduled backups every minute cron.schedule('* * * * *', async () => { logger.debug('Checking for scheduled backups'); @@ -120,7 +144,7 @@ export function initializeScheduler(): void { }); schedulerInitialized = true; - logger.info('Cron scheduler initialized - notification (8 AM), account purge (2 AM), audit log cleanup (3 AM), backup check (every min), retention cleanup (4 AM)'); + logger.info('Cron scheduler initialized - notification (8 AM), account purge (2 AM), grace period (2:30 AM), audit log cleanup (3 AM), backup check (every min), retention cleanup (4 AM)'); } export function isSchedulerInitialized(): boolean { diff --git a/backend/src/features/notifications/migrations/006_payment_email_templates.sql b/backend/src/features/notifications/migrations/006_payment_email_templates.sql new file mode 100644 index 0000000..4966d42 --- /dev/null +++ b/backend/src/features/notifications/migrations/006_payment_email_templates.sql @@ -0,0 +1,239 @@ +/** + * Migration: Add payment failure email templates + * @ai-summary Adds email templates for payment failures during grace period + * @ai-context Three templates: immediate, 7-day warning, 1-day warning + */ + +-- Extend template_key CHECK constraint to include payment failure templates +ALTER TABLE email_templates +DROP CONSTRAINT IF EXISTS email_templates_template_key_check; + +ALTER TABLE email_templates +ADD CONSTRAINT email_templates_template_key_check +CHECK (template_key IN ( + 'maintenance_due_soon', 'maintenance_overdue', + 'document_expiring', 'document_expired', + 'payment_failed_immediate', 'payment_failed_7day', 'payment_failed_1day' +)); + +-- Insert payment failure email templates +INSERT INTO email_templates (template_key, name, description, subject, body, variables, html_body) VALUES + ( + 'payment_failed_immediate', + 'Payment Failed - Immediate Notice', + 'Sent immediately when a subscription payment fails', + 'MotoVaultPro: Payment Failed - Action Required', + 'Hi {{userName}}, + +We were unable to process your payment for your {{tier}} subscription. + +Your subscription will remain active for 30 days while we attempt to collect payment. After 30 days, your subscription will be downgraded to the free tier. + +Please update your payment method to avoid interruption of service. + +Amount Due: ${{amount}} +Next Retry: {{retryDate}} + +Best regards, +MotoVaultPro Team', + '["userName", "tier", "amount", "retryDate"]', + ' + + + + + Payment Failed + + + + + + +
+ + + + + + + + + + +
+

Payment Failed

+
+

Hi {{userName}},

+

We were unable to process your payment for your {{tier}} subscription.

+

Your subscription will remain active for 30 days while we attempt to collect payment. After 30 days, your subscription will be downgraded to the free tier.

+

Please update your payment method to avoid interruption of service.

+ + + + +
+

Amount Due: ${{amount}}

+

Next Retry: {{retryDate}}

+
+ +
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ), + ( + 'payment_failed_7day', + 'Payment Failed - 7 Days Left', + 'Sent 7 days before grace period ends', + 'MotoVaultPro: Urgent - 7 Days Until Downgrade', + 'Hi {{userName}}, + +This is an urgent reminder that your {{tier}} subscription payment is still outstanding. + +Your subscription will be downgraded to the free tier in 7 days if payment is not received. + +Amount Due: ${{amount}} +Grace Period Ends: {{gracePeriodEnd}} + +Please update your payment method immediately to avoid losing access to premium features. + +Best regards, +MotoVaultPro Team', + '["userName", "tier", "amount", "gracePeriodEnd"]', + ' + + + + + Payment Reminder - 7 Days Left + + + + + + +
+ + + + + + + + + + +
+

Urgent: 7 Days Until Downgrade

+
+

Hi {{userName}},

+

This is an urgent reminder that your {{tier}} subscription payment is still outstanding.

+
+

Your subscription will be downgraded in 7 days

+

If payment is not received by {{gracePeriodEnd}}, you will lose access to premium features.

+
+ + + + +
+

Amount Due: ${{amount}}

+

Grace Period Ends: {{gracePeriodEnd}}

+
+ +
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ), + ( + 'payment_failed_1day', + 'Payment Failed - Final Notice', + 'Sent 1 day before grace period ends', + 'MotoVaultPro: FINAL NOTICE - Downgrade Tomorrow', + 'Hi {{userName}}, + +FINAL NOTICE: Your {{tier}} subscription will be downgraded to the free tier tomorrow if payment is not received. + +Amount Due: ${{amount}} +Grace Period Ends: {{gracePeriodEnd}} + +This is your last chance to update your payment method and keep your premium features. + +After downgrade: +- Access to premium features will be lost +- Data remains safe but with reduced vehicle limits +- You can resubscribe at any time + +Please update your payment method now to avoid interruption. + +Best regards, +MotoVaultPro Team', + '["userName", "tier", "amount", "gracePeriodEnd"]', + ' + + + + + Final Notice - Downgrade Tomorrow + + + + + + +
+ + + + + + + + + + +
+

FINAL NOTICE

+

Downgrade Tomorrow

+
+

Hi {{userName}},

+
+

FINAL NOTICE

+

Your {{tier}} subscription will be downgraded to the free tier tomorrow if payment is not received.

+
+ + + + +
+

Amount Due: ${{amount}}

+

Grace Period Ends: {{gracePeriodEnd}}

+
+

This is your last chance to update your payment method and keep your premium features.

+
+

After downgrade:

+
    +
  • Access to premium features will be lost
  • +
  • Data remains safe but with reduced vehicle limits
  • +
  • You can resubscribe at any time
  • +
+
+ +
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ); diff --git a/backend/src/features/subscriptions/api/subscriptions.controller.ts b/backend/src/features/subscriptions/api/subscriptions.controller.ts new file mode 100644 index 0000000..7d4e03e --- /dev/null +++ b/backend/src/features/subscriptions/api/subscriptions.controller.ts @@ -0,0 +1,249 @@ +/** + * @ai-summary Subscriptions API controller + * @ai-context Handles subscription management API requests + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { logger } from '../../../core/logging/logger'; +import { SubscriptionsService } from '../domain/subscriptions.service'; +import { SubscriptionsRepository } from '../data/subscriptions.repository'; +import { StripeClient } from '../external/stripe/stripe.client'; +import { pool } from '../../../core/config/database'; + +export class SubscriptionsController { + private service: SubscriptionsService; + + constructor() { + const repository = new SubscriptionsRepository(pool); + const stripeClient = new StripeClient(); + this.service = new SubscriptionsService(repository, stripeClient, pool); + } + + /** + * GET /api/subscriptions - Get current subscription + */ + async getSubscription(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const userId = (request as any).user.sub; + + const subscription = await this.service.getSubscription(userId); + + if (!subscription) { + reply.status(404).send({ + error: 'Subscription not found', + message: 'No subscription exists for this user', + }); + return; + } + + reply.status(200).send(subscription); + } catch (error: any) { + logger.error('Failed to get subscription', { + userId: (request as any).user?.sub, + error: error.message, + }); + reply.status(500).send({ + error: 'Failed to get subscription', + message: error.message, + }); + } + } + + /** + * POST /api/subscriptions/checkout - Create Stripe checkout session + */ + async createCheckout( + request: FastifyRequest<{ + Body: { + tier: 'pro' | 'enterprise'; + billingCycle: 'monthly' | 'yearly'; + paymentMethodId?: string; + }; + }>, + reply: FastifyReply + ): Promise { + try { + const userId = (request as any).user.sub; + const email = (request as any).user.email; + const { tier, billingCycle, paymentMethodId } = request.body; + + // Validate inputs + if (!tier || !billingCycle) { + reply.status(400).send({ + error: 'Missing required fields', + message: 'tier and billingCycle are required', + }); + return; + } + + if (!['pro', 'enterprise'].includes(tier)) { + reply.status(400).send({ + error: 'Invalid tier', + message: 'tier must be "pro" or "enterprise"', + }); + return; + } + + if (!['monthly', 'yearly'].includes(billingCycle)) { + reply.status(400).send({ + error: 'Invalid billing cycle', + message: 'billingCycle must be "monthly" or "yearly"', + }); + return; + } + + // Create or get existing subscription + let subscription = await this.service.getSubscription(userId); + if (!subscription) { + await this.service.createSubscription(userId, email); + subscription = await this.service.getSubscription(userId); + } + + if (!subscription) { + reply.status(500).send({ + error: 'Failed to create subscription', + message: 'Could not initialize subscription', + }); + return; + } + + // Upgrade subscription + const updatedSubscription = await this.service.upgradeSubscription( + userId, + tier, + billingCycle, + paymentMethodId || '' + ); + + reply.status(200).send(updatedSubscription); + } catch (error: any) { + logger.error('Failed to create checkout', { + userId: (request as any).user?.sub, + error: error.message, + }); + reply.status(500).send({ + error: 'Failed to create checkout', + message: error.message, + }); + } + } + + /** + * POST /api/subscriptions/cancel - Schedule cancellation + */ + async cancelSubscription(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const userId = (request as any).user.sub; + + const subscription = await this.service.cancelSubscription(userId); + + reply.status(200).send(subscription); + } catch (error: any) { + logger.error('Failed to cancel subscription', { + userId: (request as any).user?.sub, + error: error.message, + }); + reply.status(500).send({ + error: 'Failed to cancel subscription', + message: error.message, + }); + } + } + + /** + * POST /api/subscriptions/reactivate - Cancel pending cancellation + */ + async reactivateSubscription(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const userId = (request as any).user.sub; + + const subscription = await this.service.reactivateSubscription(userId); + + reply.status(200).send(subscription); + } catch (error: any) { + logger.error('Failed to reactivate subscription', { + userId: (request as any).user?.sub, + error: error.message, + }); + reply.status(500).send({ + error: 'Failed to reactivate subscription', + message: error.message, + }); + } + } + + /** + * PUT /api/subscriptions/payment-method - Update payment method + */ + async updatePaymentMethod( + request: FastifyRequest<{ + Body: { + paymentMethodId: string; + }; + }>, + reply: FastifyReply + ): Promise { + try { + const userId = (request as any).user.sub; + const { paymentMethodId } = request.body; + + // Validate input + if (!paymentMethodId) { + reply.status(400).send({ + error: 'Missing required field', + message: 'paymentMethodId is required', + }); + return; + } + + // Get subscription + const subscription = await this.service.getSubscription(userId); + if (!subscription) { + reply.status(404).send({ + error: 'Subscription not found', + message: 'No subscription exists for this user', + }); + return; + } + + // Update payment method via Stripe + const stripeClient = new StripeClient(); + await stripeClient.updatePaymentMethod(subscription.stripeCustomerId, paymentMethodId); + + reply.status(200).send({ + message: 'Payment method updated successfully', + }); + } catch (error: any) { + logger.error('Failed to update payment method', { + userId: (request as any).user?.sub, + error: error.message, + }); + reply.status(500).send({ + error: 'Failed to update payment method', + message: error.message, + }); + } + } + + /** + * GET /api/subscriptions/invoices - Get billing history + */ + async getInvoices(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const userId = (request as any).user.sub; + + const invoices = await this.service.getInvoices(userId); + + reply.status(200).send(invoices); + } catch (error: any) { + logger.error('Failed to get invoices', { + userId: (request as any).user?.sub, + error: error.message, + }); + reply.status(500).send({ + error: 'Failed to get invoices', + message: error.message, + }); + } + } +} diff --git a/backend/src/features/subscriptions/api/subscriptions.routes.ts b/backend/src/features/subscriptions/api/subscriptions.routes.ts new file mode 100644 index 0000000..25e4789 --- /dev/null +++ b/backend/src/features/subscriptions/api/subscriptions.routes.ts @@ -0,0 +1,51 @@ +/** + * @ai-summary Fastify routes for subscriptions API + * @ai-context Route definitions with authentication for subscription management + */ + +import { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import { FastifyPluginAsync } from 'fastify'; +import { SubscriptionsController } from './subscriptions.controller'; + +export const subscriptionsRoutes: FastifyPluginAsync = async ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions +) => { + const subscriptionsController = new SubscriptionsController(); + + // GET /api/subscriptions - Get current subscription + fastify.get('/subscriptions', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.getSubscription.bind(subscriptionsController) + }); + + // POST /api/subscriptions/checkout - Create Stripe checkout session + fastify.post('/subscriptions/checkout', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.createCheckout.bind(subscriptionsController) + }); + + // POST /api/subscriptions/cancel - Schedule cancellation + fastify.post('/subscriptions/cancel', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.cancelSubscription.bind(subscriptionsController) + }); + + // POST /api/subscriptions/reactivate - Cancel pending cancellation + fastify.post('/subscriptions/reactivate', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.reactivateSubscription.bind(subscriptionsController) + }); + + // PUT /api/subscriptions/payment-method - Update payment method + fastify.put('/subscriptions/payment-method', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.updatePaymentMethod.bind(subscriptionsController) + }); + + // GET /api/subscriptions/invoices - Get billing history + fastify.get('/subscriptions/invoices', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.getInvoices.bind(subscriptionsController) + }); +}; diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index 2729e86..14f26e4 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -599,6 +599,25 @@ export class SubscriptionsService { return 'free'; } + /** + * Get invoices for a user's subscription + */ + async getInvoices(userId: string): Promise { + try { + const subscription = await this.repository.findByUserId(userId); + if (!subscription?.stripeCustomerId) { + return []; + } + return this.stripeClient.listInvoices(subscription.stripeCustomerId); + } catch (error: any) { + logger.error('Failed to get invoices', { + userId, + error: error.message, + }); + throw error; + } + } + /** * Map subscription entity to response DTO */ diff --git a/backend/src/features/subscriptions/external/stripe/stripe.client.ts b/backend/src/features/subscriptions/external/stripe/stripe.client.ts index 58809d2..1547c4a 100644 --- a/backend/src/features/subscriptions/external/stripe/stripe.client.ts +++ b/backend/src/features/subscriptions/external/stripe/stripe.client.ts @@ -323,4 +323,32 @@ export class StripeClient { throw error; } } + + /** + * List invoices for a customer + */ + async listInvoices(customerId: string): Promise { + try { + logger.info('Listing Stripe invoices', { customerId }); + + const invoices = await this.stripe.invoices.list({ + customer: customerId, + limit: 20, + }); + + logger.info('Stripe invoices retrieved', { + customerId, + count: invoices.data.length + }); + + return invoices.data; + } catch (error: any) { + logger.error('Failed to list Stripe invoices', { + customerId, + error: error.message, + code: error.code, + }); + throw error; + } + } } diff --git a/backend/src/features/subscriptions/index.ts b/backend/src/features/subscriptions/index.ts index 92a24b8..949f5a8 100644 --- a/backend/src/features/subscriptions/index.ts +++ b/backend/src/features/subscriptions/index.ts @@ -45,3 +45,4 @@ export { SubscriptionsService } from './domain/subscriptions.service'; // Routes export { webhooksRoutes } from './api/webhooks.routes'; +export { subscriptionsRoutes } from './api/subscriptions.routes'; diff --git a/backend/src/features/subscriptions/jobs/grace-period.job.ts b/backend/src/features/subscriptions/jobs/grace-period.job.ts new file mode 100644 index 0000000..8e8553b --- /dev/null +++ b/backend/src/features/subscriptions/jobs/grace-period.job.ts @@ -0,0 +1,132 @@ +/** + * @ai-summary Grace period expiration job + * @ai-context Processes expired grace periods and downgrades subscriptions to free tier + */ + +import { Pool } from 'pg'; +import { logger } from '../../../core/logging/logger'; + +let jobPool: Pool | null = null; + +export function setGracePeriodJobPool(pool: Pool): void { + jobPool = pool; +} + +interface GracePeriodResult { + processed: number; + downgraded: number; + errors: string[]; +} + +/** + * Process grace period expirations + * Finds subscriptions with expired grace periods and downgrades them to free tier + */ +export async function processGracePeriodExpirations(): Promise { + if (!jobPool) { + throw new Error('Grace period job pool not initialized'); + } + + const result: GracePeriodResult = { + processed: 0, + downgraded: 0, + errors: [], + }; + + const client = await jobPool.connect(); + + try { + // Find subscriptions with expired grace periods + const query = ` + SELECT id, user_id, tier, stripe_subscription_id + FROM subscriptions + WHERE status = 'past_due' + AND grace_period_end < NOW() + ORDER BY grace_period_end ASC + `; + + const queryResult = await client.query(query); + const expiredSubscriptions = queryResult.rows; + + result.processed = expiredSubscriptions.length; + + logger.info('Processing expired grace periods', { + count: expiredSubscriptions.length, + }); + + // Process each expired subscription + for (const subscription of expiredSubscriptions) { + try { + // Start transaction for this subscription + await client.query('BEGIN'); + + // Update subscription to free tier and unpaid status + const updateQuery = ` + UPDATE subscriptions + SET + tier = 'free', + status = 'unpaid', + stripe_subscription_id = NULL, + billing_cycle = NULL, + current_period_start = NULL, + current_period_end = NULL, + grace_period_end = NULL, + cancel_at_period_end = false, + updated_at = NOW() + WHERE id = $1 + `; + + await client.query(updateQuery, [subscription.id]); + + // Sync tier to user_profiles table + const syncQuery = ` + UPDATE user_profiles + SET + subscription_tier = 'free', + updated_at = NOW() + WHERE user_id = $1 + `; + + await client.query(syncQuery, [subscription.user_id]); + + // Commit transaction + await client.query('COMMIT'); + + result.downgraded++; + + logger.info('Grace period expired - downgraded to free', { + subscriptionId: subscription.id, + userId: subscription.user_id, + previousTier: subscription.tier, + }); + } catch (error: any) { + // Rollback transaction on error + await client.query('ROLLBACK'); + + const errorMsg = `Failed to downgrade subscription ${subscription.id}: ${error.message}`; + result.errors.push(errorMsg); + + logger.error('Failed to process grace period expiration', { + subscriptionId: subscription.id, + userId: subscription.user_id, + error: error.message, + }); + } + } + + logger.info('Grace period expiration job completed', { + processed: result.processed, + downgraded: result.downgraded, + errors: result.errors.length, + }); + } catch (error: any) { + logger.error('Grace period job failed', { + error: error.message, + }); + throw error; + } finally { + client.release(); + } + + return result; +} -- 2.49.1 From 94d1c677bc90138f258570895752a1c9be541314 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:37:10 -0600 Subject: [PATCH 04/17] feat: add frontend subscription page - M4 (refs #55) --- frontend/.env.example | 5 +- frontend/package-lock.json | 41 +- frontend/package.json | 3 + frontend/src/App.tsx | 30 ++ frontend/src/core/store/navigation.ts | 2 +- frontend/src/features/subscription/CLAUDE.md | 33 ++ frontend/src/features/subscription/README.md | 111 ++++++ .../subscription/api/subscription.api.ts | 11 + .../components/BillingHistory.tsx | 100 +++++ .../components/PaymentMethodForm.tsx | 119 ++++++ .../subscription/components/TierCard.tsx | 101 +++++ .../features/subscription/constants/plans.ts | 28 ++ .../subscription/hooks/useSubscription.ts | 76 ++++ frontend/src/features/subscription/index.ts | 5 + .../mobile/SubscriptionMobileScreen.tsx | 365 ++++++++++++++++++ .../subscription/pages/SubscriptionPage.tsx | 249 ++++++++++++ .../subscription/types/subscription.types.ts | 38 ++ 17 files changed, 1312 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/subscription/CLAUDE.md create mode 100644 frontend/src/features/subscription/README.md create mode 100644 frontend/src/features/subscription/api/subscription.api.ts create mode 100644 frontend/src/features/subscription/components/BillingHistory.tsx create mode 100644 frontend/src/features/subscription/components/PaymentMethodForm.tsx create mode 100644 frontend/src/features/subscription/components/TierCard.tsx create mode 100644 frontend/src/features/subscription/constants/plans.ts create mode 100644 frontend/src/features/subscription/hooks/useSubscription.ts create mode 100644 frontend/src/features/subscription/index.ts create mode 100644 frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx create mode 100644 frontend/src/features/subscription/pages/SubscriptionPage.tsx create mode 100644 frontend/src/features/subscription/types/subscription.types.ts diff --git a/frontend/.env.example b/frontend/.env.example index 2f003a0..e49213d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -7,4 +7,7 @@ VITE_AUTH0_AUDIENCE=https://your-api-audience VITE_API_BASE_URL=http://localhost:3001/api # Google Maps (for future stations feature) -VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key \ No newline at end of file +VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key + +# Stripe Configuration +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index efff927..0a1da84 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,9 +17,12 @@ "@mui/material": "^6.3.0", "@mui/x-data-grid": "^7.23.0", "@mui/x-date-pickers": "^7.23.0", + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.6.1", "@tanstack/react-query": "^5.84.1", "axios": "^1.7.9", "clsx": "^2.0.0", + "date-fns": "^4.1.0", "dayjs": "^1.11.13", "framer-motion": "^12.0.0", "react": "^19.0.0", @@ -613,7 +616,6 @@ "node_modules/@emotion/is-prop-valid": { "version": "1.4.0", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -2667,6 +2669,30 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz", + "integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=8.0.0 <9.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.6.1.tgz", + "integrity": "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.16" + } + }, "node_modules/@tanstack/query-core": { "version": "5.90.12", "license": "MIT", @@ -4054,6 +4080,17 @@ "node": ">=12" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.19", "license": "MIT", @@ -8384,7 +8421,6 @@ "node_modules/use-sync-external-store": { "version": "1.6.0", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -8697,7 +8733,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/frontend/package.json b/frontend/package.json index 95218a2..7cf8dd4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,9 +21,12 @@ "@mui/material": "^6.3.0", "@mui/x-data-grid": "^7.23.0", "@mui/x-date-pickers": "^7.23.0", + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.6.1", "@tanstack/react-query": "^5.84.1", "axios": "^1.7.9", "clsx": "^2.0.0", + "date-fns": "^4.1.0", "dayjs": "^1.11.13", "framer-motion": "^12.0.0", "react": "^19.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f1e918a..4e534b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,10 @@ const CallbackMobileScreen = lazy(() => import('./features/auth/mobile/CallbackM const OnboardingPage = lazy(() => import('./features/onboarding/pages/OnboardingPage').then(m => ({ default: m.OnboardingPage }))); const OnboardingMobileScreen = lazy(() => import('./features/onboarding/mobile/OnboardingMobileScreen').then(m => ({ default: m.OnboardingMobileScreen }))); +// Subscription pages (lazy-loaded) +const SubscriptionPage = lazy(() => import('./features/subscription/pages/SubscriptionPage').then(m => ({ default: m.SubscriptionPage }))); +const SubscriptionMobileScreen = lazy(() => import('./features/subscription/mobile/SubscriptionMobileScreen').then(m => ({ default: m.SubscriptionMobileScreen }))); + import { HomePage } from './pages/HomePage'; import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation'; import { QuickAction } from './shared-minimal/components/mobile/quickActions'; @@ -743,6 +747,31 @@ function App() { )} + {activeScreen === "Subscription" && ( + + + + +
+
+ Loading subscription... +
+
+
+ + }> + +
+
+
+ )} {activeScreen === "Documents" && ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/core/store/navigation.ts b/frontend/src/core/store/navigation.ts index a639870..fb507a0 100644 --- a/frontend/src/core/store/navigation.ts +++ b/frontend/src/core/store/navigation.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { safeStorage } from '../utils/safe-storage'; -export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup' | 'AdminLogs'; +export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'Subscription' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup' | 'AdminLogs'; export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit'; interface NavigationHistory { diff --git a/frontend/src/features/subscription/CLAUDE.md b/frontend/src/features/subscription/CLAUDE.md new file mode 100644 index 0000000..d33772f --- /dev/null +++ b/frontend/src/features/subscription/CLAUDE.md @@ -0,0 +1,33 @@ +# frontend/src/features/subscription/ + +Subscription and billing management feature with Stripe integration. + +## Files + +| File | What | When to read | +| ---- | ---- | ------------ | +| `README.md` | Feature overview and API integration | Understanding subscription flow | + +## Subdirectories + +| Directory | What | When to read | +| --------- | ---- | ------------ | +| `types/` | TypeScript types for subscription data | Working with subscription types | +| `api/` | Subscription API client calls | API integration | +| `hooks/` | React hooks for subscription data | Using subscription state | +| `components/` | Reusable subscription UI components | Building subscription UI | +| `pages/` | Desktop subscription page | Desktop implementation | +| `mobile/` | Mobile subscription screen | Mobile implementation | +| `constants/` | Subscription plan configurations | Plan pricing and features | + +## Key Patterns + +- Desktop: MUI components with sx props +- Mobile: Tailwind classes with GlassCard +- Stripe Elements for payment methods +- React Query for data fetching +- Toast notifications for user feedback + +## Environment Variables + +- `VITE_STRIPE_PUBLISHABLE_KEY` - Required for Stripe Elements initialization diff --git a/frontend/src/features/subscription/README.md b/frontend/src/features/subscription/README.md new file mode 100644 index 0000000..473ef5e --- /dev/null +++ b/frontend/src/features/subscription/README.md @@ -0,0 +1,111 @@ +# Subscription Feature + +Frontend UI for subscription management with Stripe integration. + +## Overview + +Provides subscription tier management, payment method updates, and billing history viewing. + +## Components + +### TierCard +Displays subscription plan with: +- Plan name and pricing (monthly/yearly) +- Feature list +- Current plan indicator +- Upgrade/downgrade button + +### PaymentMethodForm +Stripe Elements integration for: +- Credit card input with validation +- Payment method creation +- Error handling + +### BillingHistory +Invoice list with: +- Date, amount, status +- PDF download links +- MUI Table component + +## Pages + +### SubscriptionPage (Desktop) +- Current plan card with status +- Three-column tier cards layout +- Payment method section +- Billing history table +- Material-UI components + +### SubscriptionMobileScreen (Mobile) +- Stacked card layout +- Touch-friendly buttons (44px min) +- Tailwind styling +- GlassCard components + +## API Integration + +All endpoints are in `/subscriptions`: +- GET `/subscriptions` - Current subscription +- POST `/subscriptions/checkout` - Upgrade subscription +- POST `/subscriptions/cancel` - Cancel subscription +- POST `/subscriptions/reactivate` - Reactivate subscription +- PUT `/subscriptions/payment-method` - Update payment method +- GET `/subscriptions/invoices` - Invoice history + +## Hooks + +- `useSubscription()` - Fetch current subscription +- `useCheckout()` - Upgrade subscription +- `useCancelSubscription()` - Cancel subscription +- `useReactivateSubscription()` - Reactivate subscription +- `useInvoices()` - Fetch invoice history + +## Environment Setup + +Required environment variable: +```bash +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_... +``` + +## Subscription Tiers + +### Free +- 2 vehicles +- Basic tracking +- Standard reports +- Price: $0 + +### Pro +- Up to 5 vehicles +- VIN decoding +- OCR functionality +- API access +- Price: $1.99/month or $19.99/year + +### Enterprise +- Unlimited vehicles +- All Pro features +- Priority support +- Price: $4.99/month or $49.99/year + +## Mobile Navigation + +Add subscription screen to settings navigation: +```typescript +navigateToScreen('Subscription') +``` + +## Desktop Routing + +Route: `/garage/settings/subscription` + +## Testing + +Test subscription flow: +1. View current plan +2. Toggle monthly/yearly billing +3. Select upgrade tier +4. Enter payment method +5. Complete checkout +6. Verify subscription update +7. View billing history diff --git a/frontend/src/features/subscription/api/subscription.api.ts b/frontend/src/features/subscription/api/subscription.api.ts new file mode 100644 index 0000000..35c7cc4 --- /dev/null +++ b/frontend/src/features/subscription/api/subscription.api.ts @@ -0,0 +1,11 @@ +import { apiClient } from '../../../core/api/client'; +import type { CheckoutRequest, PaymentMethodUpdateRequest } from '../types/subscription.types'; + +export const subscriptionApi = { + getSubscription: () => apiClient.get('/subscriptions'), + checkout: (data: CheckoutRequest) => apiClient.post('/subscriptions/checkout', data), + cancel: () => apiClient.post('/subscriptions/cancel'), + reactivate: () => apiClient.post('/subscriptions/reactivate'), + updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data), + getInvoices: () => apiClient.get('/subscriptions/invoices'), +}; diff --git a/frontend/src/features/subscription/components/BillingHistory.tsx b/frontend/src/features/subscription/components/BillingHistory.tsx new file mode 100644 index 0000000..080b69e --- /dev/null +++ b/frontend/src/features/subscription/components/BillingHistory.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Typography, + Chip, + IconButton, + Box, +} from '@mui/material'; +import DownloadIcon from '@mui/icons-material/Download'; +import { format } from 'date-fns'; + +interface Invoice { + id: string; + date: string; + amount: number; + status: 'paid' | 'pending' | 'failed'; + pdfUrl?: string; +} + +interface BillingHistoryProps { + invoices: Invoice[]; +} + +export const BillingHistory: React.FC = ({ invoices }) => { + if (!invoices || invoices.length === 0) { + return ( + + + No billing history available + + + ); + } + + const getStatusColor = (status: Invoice['status']) => { + switch (status) { + case 'paid': + return 'success'; + case 'pending': + return 'warning'; + case 'failed': + return 'error'; + default: + return 'default'; + } + }; + + return ( + + + + + Date + Amount + Status + Actions + + + + {invoices.map((invoice) => ( + + + {format(new Date(invoice.date), 'MMM dd, yyyy')} + + + ${(invoice.amount / 100).toFixed(2)} + + + + + + {invoice.pdfUrl && ( + + + + )} + + + ))} + +
+
+ ); +}; diff --git a/frontend/src/features/subscription/components/PaymentMethodForm.tsx b/frontend/src/features/subscription/components/PaymentMethodForm.tsx new file mode 100644 index 0000000..4922ef1 --- /dev/null +++ b/frontend/src/features/subscription/components/PaymentMethodForm.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { Box, Button, Typography, Alert, CircularProgress } from '@mui/material'; +import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import type { StripeCardElementChangeEvent } from '@stripe/stripe-js'; + +interface PaymentMethodFormProps { + onSubmit: (paymentMethodId: string) => void; + isLoading?: boolean; +} + +const CARD_ELEMENT_OPTIONS = { + style: { + base: { + fontSize: '16px', + color: '#424770', + '::placeholder': { + color: '#aab7c4', + }, + }, + invalid: { + color: '#9e2146', + }, + }, +}; + +export const PaymentMethodForm: React.FC = ({ + onSubmit, + isLoading = false, +}) => { + const stripe = useStripe(); + const elements = useElements(); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(false); + const [cardComplete, setCardComplete] = useState(false); + + const handleCardChange = (event: StripeCardElementChangeEvent) => { + setError(event.error?.message || null); + setCardComplete(event.complete); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + return; + } + + const cardElement = elements.getElement(CardElement); + if (!cardElement) { + return; + } + + setProcessing(true); + setError(null); + + try { + const { error: createError, paymentMethod } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement, + }); + + if (createError) { + setError(createError.message || 'Failed to create payment method'); + setProcessing(false); + return; + } + + if (paymentMethod) { + onSubmit(paymentMethod.id); + } + } catch { + setError('An unexpected error occurred'); + setProcessing(false); + } + }; + + return ( +
+ + + Card Details + + + + + + + {error && ( + + {error} + + )} + + +
+ ); +}; diff --git a/frontend/src/features/subscription/components/TierCard.tsx b/frontend/src/features/subscription/components/TierCard.tsx new file mode 100644 index 0000000..4e871de --- /dev/null +++ b/frontend/src/features/subscription/components/TierCard.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Card, CardContent, Typography, Button, Box, Chip, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import type { SubscriptionPlan, BillingCycle } from '../types/subscription.types'; + +interface TierCardProps { + plan: SubscriptionPlan; + billingCycle: BillingCycle; + currentTier?: string; + isLoading?: boolean; + onUpgrade: () => void; +} + +export const TierCard: React.FC = ({ + plan, + billingCycle, + currentTier, + isLoading = false, + onUpgrade, +}) => { + const isCurrent = currentTier === plan.tier; + const price = billingCycle === 'monthly' ? plan.monthlyPrice : plan.yearlyPrice; + const priceLabel = billingCycle === 'monthly' ? '/month' : '/year'; + + return ( + + {isCurrent && ( + + )} + + + + {plan.name} + + + + + ${price.toFixed(2)} + + + {priceLabel} + + + + + {plan.features.map((feature, index) => ( + + + + + + + ))} + + + + + {isCurrent ? ( + + ) : ( + + )} + + + ); +}; diff --git a/frontend/src/features/subscription/constants/plans.ts b/frontend/src/features/subscription/constants/plans.ts new file mode 100644 index 0000000..1fb632a --- /dev/null +++ b/frontend/src/features/subscription/constants/plans.ts @@ -0,0 +1,28 @@ +import type { SubscriptionPlan } from '../types/subscription.types'; + +export const PLANS: SubscriptionPlan[] = [ + { + tier: 'free', + name: 'Free', + monthlyPrice: 0, + yearlyPrice: 0, + vehicleLimit: 2, + features: ['2 vehicles', 'Basic tracking', 'Standard reports'], + }, + { + tier: 'pro', + name: 'Pro', + monthlyPrice: 1.99, + yearlyPrice: 19.99, + vehicleLimit: 5, + features: ['Up to 5 vehicles', 'VIN decoding', 'OCR functionality', 'API access'], + }, + { + tier: 'enterprise', + name: 'Enterprise', + monthlyPrice: 4.99, + yearlyPrice: 49.99, + vehicleLimit: 'unlimited', + features: ['Unlimited vehicles', 'All Pro features', 'Priority support'], + }, +]; diff --git a/frontend/src/features/subscription/hooks/useSubscription.ts b/frontend/src/features/subscription/hooks/useSubscription.ts new file mode 100644 index 0000000..943a500 --- /dev/null +++ b/frontend/src/features/subscription/hooks/useSubscription.ts @@ -0,0 +1,76 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAuth0 } from '@auth0/auth0-react'; +import { subscriptionApi } from '../api/subscription.api'; +import toast from 'react-hot-toast'; + +export const useSubscription = () => { + const { isAuthenticated, isLoading } = useAuth0(); + + return useQuery({ + queryKey: ['subscription'], + queryFn: () => subscriptionApi.getSubscription(), + enabled: isAuthenticated && !isLoading, + staleTime: 5 * 60 * 1000, + retry: (failureCount, error: unknown) => { + const err = error as { response?: { status?: number } }; + if (err?.response?.status === 401 && failureCount < 3) return true; + return false; + }, + }); +}; + +export const useCheckout = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: subscriptionApi.checkout, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['user-profile'] }); + toast.success('Subscription upgraded successfully'); + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || 'Failed to upgrade subscription'); + }, + }); +}; + +export const useCancelSubscription = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: subscriptionApi.cancel, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + toast.success('Subscription scheduled for cancellation'); + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || 'Failed to cancel subscription'); + }, + }); +}; + +export const useReactivateSubscription = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: subscriptionApi.reactivate, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + toast.success('Subscription reactivated'); + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || 'Failed to reactivate subscription'); + }, + }); +}; + +export const useInvoices = () => { + const { isAuthenticated, isLoading } = useAuth0(); + return useQuery({ + queryKey: ['invoices'], + queryFn: () => subscriptionApi.getInvoices(), + enabled: isAuthenticated && !isLoading, + staleTime: 5 * 60 * 1000, + }); +}; diff --git a/frontend/src/features/subscription/index.ts b/frontend/src/features/subscription/index.ts new file mode 100644 index 0000000..bb481a0 --- /dev/null +++ b/frontend/src/features/subscription/index.ts @@ -0,0 +1,5 @@ +export { SubscriptionPage } from './pages/SubscriptionPage'; +export { SubscriptionMobileScreen } from './mobile/SubscriptionMobileScreen'; +export { useSubscription, useCheckout, useCancelSubscription, useReactivateSubscription, useInvoices } from './hooks/useSubscription'; +export { PLANS } from './constants/plans'; +export type { Subscription, SubscriptionPlan, SubscriptionTier, BillingCycle, SubscriptionStatus } from './types/subscription.types'; diff --git a/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx b/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx new file mode 100644 index 0000000..c622685 --- /dev/null +++ b/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx @@ -0,0 +1,365 @@ +import React, { useState } from 'react'; +import { Elements } from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { format } from 'date-fns'; +import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; +import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer'; +import { PaymentMethodForm } from '../components/PaymentMethodForm'; +import { + useSubscription, + useCheckout, + useCancelSubscription, + useReactivateSubscription, + useInvoices, +} from '../hooks/useSubscription'; +import { PLANS } from '../constants/plans'; +import type { BillingCycle, SubscriptionTier, SubscriptionPlan } from '../types/subscription.types'; + +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ''); + +interface MobileTierCardProps { + plan: SubscriptionPlan; + billingCycle: BillingCycle; + currentTier?: string; + isLoading?: boolean; + onUpgrade: () => void; +} + +const MobileTierCard: React.FC = ({ + plan, + billingCycle, + currentTier, + isLoading = false, + onUpgrade, +}) => { + const isCurrent = currentTier === plan.tier; + const price = billingCycle === 'monthly' ? plan.monthlyPrice : plan.yearlyPrice; + const priceLabel = billingCycle === 'monthly' ? '/month' : '/year'; + + return ( + + {isCurrent && ( +
+ + Current Plan + +
+ )} + +

+ {plan.name} +

+ +
+
+ ${price.toFixed(2)} +
+
+ {priceLabel} +
+
+ +
    + {plan.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+ + {isCurrent ? ( + + ) : ( + + )} +
+ ); +}; + +interface MobileModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +const MobileModal: React.FC = ({ isOpen, onClose, title, children }) => { + if (!isOpen) return null; + + return ( +
+
+

{title}

+ {children} +
+ +
+
+
+ ); +}; + +export const SubscriptionMobileScreen: React.FC = () => { + const [billingCycle, setBillingCycle] = useState('monthly'); + const [selectedTier, setSelectedTier] = useState(null); + const [showPaymentDialog, setShowPaymentDialog] = useState(false); + + const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscription(); + const { data: invoicesData, isLoading: isLoadingInvoices } = useInvoices(); + const checkoutMutation = useCheckout(); + const cancelMutation = useCancelSubscription(); + const reactivateMutation = useReactivateSubscription(); + + const subscription = subscriptionData?.data; + const invoices = invoicesData?.data || []; + + const handleUpgradeClick = (tier: SubscriptionTier) => { + setSelectedTier(tier); + setShowPaymentDialog(true); + }; + + const handlePaymentSubmit = (paymentMethodId: string) => { + if (!selectedTier) return; + + checkoutMutation.mutate( + { + tier: selectedTier, + billingCycle, + paymentMethodId, + }, + { + onSuccess: () => { + setShowPaymentDialog(false); + setSelectedTier(null); + }, + } + ); + }; + + const handleCancel = () => { + if (window.confirm('Are you sure you want to cancel your subscription? Your plan will remain active until the end of the current billing period.')) { + cancelMutation.mutate(); + } + }; + + const handleReactivate = () => { + reactivateMutation.mutate(); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'active': + return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'; + case 'past_due': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'; + case 'canceled': + return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'; + } + }; + + if (isLoadingSubscription) { + return ( + +
+
+
+
+ ); + } + + return ( + +
+

+ Subscription +

+ + {subscription && ( + +
+ + {subscription.tier.toUpperCase()} + + + {subscription.status} + +
+ +

+ Current Plan: {PLANS.find((p) => p.tier === subscription.tier)?.name || subscription.tier} +

+ + {subscription.currentPeriodEnd && ( +

+ Next billing date: {format(new Date(subscription.currentPeriodEnd), 'MMM dd, yyyy')} +

+ )} + + {subscription.cancelAtPeriodEnd && ( +
+

+ Your subscription will be canceled at the end of the current billing period. +

+
+ )} + +
+ {subscription.cancelAtPeriodEnd ? ( + + ) : subscription.tier !== 'free' ? ( + + ) : null} +
+
+ )} + + +
+

+ Available Plans +

+ +
+ + +
+
+ +
+ {PLANS.map((plan) => ( + handleUpgradeClick(plan.tier)} + /> + ))} +
+
+ + +

+ Billing History +

+ + {isLoadingInvoices ? ( +
+
+
+ ) : invoices.length === 0 ? ( +

+ No billing history available +

+ ) : ( +
+ {invoices.map((invoice: { id: string; date: string; amount: number; status: string; pdfUrl?: string }) => ( +
+
+
+ {format(new Date(invoice.date), 'MMM dd, yyyy')} +
+
+ ${(invoice.amount / 100).toFixed(2)} +
+
+
+ + {invoice.status} + + {invoice.pdfUrl && ( + + ↓ + + )} +
+
+ ))} +
+ )} +
+
+ + !checkoutMutation.isPending && setShowPaymentDialog(false)} + title={`Upgrade to ${selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}`} + > + + + + +
+ ); +}; diff --git a/frontend/src/features/subscription/pages/SubscriptionPage.tsx b/frontend/src/features/subscription/pages/SubscriptionPage.tsx new file mode 100644 index 0000000..3e6242b --- /dev/null +++ b/frontend/src/features/subscription/pages/SubscriptionPage.tsx @@ -0,0 +1,249 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Grid, + Button, + Chip, + CircularProgress, + ToggleButtonGroup, + ToggleButton, + Alert, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from '@mui/material'; +import { Elements } from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { format } from 'date-fns'; +import { Card } from '../../../shared-minimal/components/Card'; +import { TierCard } from '../components/TierCard'; +import { PaymentMethodForm } from '../components/PaymentMethodForm'; +import { BillingHistory } from '../components/BillingHistory'; +import { + useSubscription, + useCheckout, + useCancelSubscription, + useReactivateSubscription, + useInvoices, +} from '../hooks/useSubscription'; +import { PLANS } from '../constants/plans'; +import type { BillingCycle, SubscriptionTier } from '../types/subscription.types'; + +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ''); + +export const SubscriptionPage: React.FC = () => { + const [billingCycle, setBillingCycle] = useState('monthly'); + const [selectedTier, setSelectedTier] = useState(null); + const [showPaymentDialog, setShowPaymentDialog] = useState(false); + + const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscription(); + const { data: invoicesData, isLoading: isLoadingInvoices } = useInvoices(); + const checkoutMutation = useCheckout(); + const cancelMutation = useCancelSubscription(); + const reactivateMutation = useReactivateSubscription(); + + const subscription = subscriptionData?.data; + const invoices = invoicesData?.data || []; + + const handleBillingCycleChange = (_: React.MouseEvent, newCycle: BillingCycle | null) => { + if (newCycle) { + setBillingCycle(newCycle); + } + }; + + const handleUpgradeClick = (tier: SubscriptionTier) => { + setSelectedTier(tier); + setShowPaymentDialog(true); + }; + + const handlePaymentSubmit = (paymentMethodId: string) => { + if (!selectedTier) return; + + checkoutMutation.mutate( + { + tier: selectedTier, + billingCycle, + paymentMethodId, + }, + { + onSuccess: () => { + setShowPaymentDialog(false); + setSelectedTier(null); + }, + } + ); + }; + + const handleCancel = () => { + if (window.confirm('Are you sure you want to cancel your subscription? Your plan will remain active until the end of the current billing period.')) { + cancelMutation.mutate(); + } + }; + + const handleReactivate = () => { + reactivateMutation.mutate(); + }; + + if (isLoadingSubscription) { + return ( + + + + ); + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'active': + return 'success'; + case 'past_due': + return 'warning'; + case 'canceled': + return 'error'; + default: + return 'default'; + } + }; + + return ( + + + Subscription + + + {subscription && ( + + + + + + + + + + Current Plan: {PLANS.find((p) => p.tier === subscription.tier)?.name || subscription.tier} + + + {subscription.currentPeriodEnd && ( + + Next billing date: {format(new Date(subscription.currentPeriodEnd), 'MMM dd, yyyy')} + + )} + + {subscription.cancelAtPeriodEnd && ( + + Your subscription will be canceled at the end of the current billing period. + + )} + + + + {subscription.cancelAtPeriodEnd ? ( + + ) : subscription.tier !== 'free' ? ( + + ) : null} + + + + )} + + + + + Available Plans + + + + Monthly + Yearly + + + + + {PLANS.map((plan) => ( + + handleUpgradeClick(plan.tier)} + /> + + ))} + + + + + + Billing History + + + {isLoadingInvoices ? ( + + + + ) : ( + + )} + + + !checkoutMutation.isPending && setShowPaymentDialog(false)} + maxWidth="sm" + fullWidth + > + + Upgrade to {selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name} + + + + + + + + + + + + ); +}; diff --git a/frontend/src/features/subscription/types/subscription.types.ts b/frontend/src/features/subscription/types/subscription.types.ts new file mode 100644 index 0000000..a7d9af3 --- /dev/null +++ b/frontend/src/features/subscription/types/subscription.types.ts @@ -0,0 +1,38 @@ +export type SubscriptionTier = 'free' | 'pro' | 'enterprise'; +export type BillingCycle = 'monthly' | 'yearly'; +export type SubscriptionStatus = 'active' | 'past_due' | 'canceled' | 'unpaid'; + +export interface Subscription { + 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 SubscriptionPlan { + tier: SubscriptionTier; + name: string; + monthlyPrice: number; + yearlyPrice: number; + features: string[]; + vehicleLimit: number | 'unlimited'; +} + +export interface CheckoutRequest { + tier: SubscriptionTier; + billingCycle: BillingCycle; + paymentMethodId: string; +} + +export interface PaymentMethodUpdateRequest { + paymentMethodId: string; +} -- 2.49.1 From 6c1a100eb9ea289704c2776ef51c5b3fc7e8ffff Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:44:45 -0600 Subject: [PATCH 05/17] feat: add vehicle selection and downgrade flow - M5 (refs #55) --- .../api/subscriptions.controller.ts | 61 ++++++++ .../subscriptions/api/subscriptions.routes.ts | 6 + .../domain/subscriptions.service.ts | 89 ++++++++++++ .../vehicles/domain/vehicles.service.ts | 70 ++++++++- .../vehicles/domain/vehicles.types.ts | 5 + .../subscription/api/subscription.api.ts | 3 +- .../subscription/components/DowngradeFlow.tsx | 67 +++++++++ .../components/VehicleSelectionDialog.tsx | 135 ++++++++++++++++++ .../subscription/hooks/useSubscription.ts | 17 +++ .../subscription/pages/SubscriptionPage.tsx | 58 +++++++- .../subscription/types/subscription.types.ts | 5 + 11 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 frontend/src/features/subscription/components/DowngradeFlow.tsx create mode 100644 frontend/src/features/subscription/components/VehicleSelectionDialog.tsx diff --git a/backend/src/features/subscriptions/api/subscriptions.controller.ts b/backend/src/features/subscriptions/api/subscriptions.controller.ts index 7d4e03e..719eba3 100644 --- a/backend/src/features/subscriptions/api/subscriptions.controller.ts +++ b/backend/src/features/subscriptions/api/subscriptions.controller.ts @@ -246,4 +246,65 @@ export class SubscriptionsController { }); } } + + /** + * POST /api/subscriptions/downgrade - Downgrade subscription with vehicle selection + */ + async downgrade( + request: FastifyRequest<{ + Body: { + targetTier: 'free' | 'pro'; + vehicleIdsToKeep: string[]; + }; + }>, + reply: FastifyReply + ): Promise { + try { + const userId = (request as any).user.sub; + const { targetTier, vehicleIdsToKeep } = request.body; + + // Validate inputs + if (!targetTier || !vehicleIdsToKeep) { + reply.status(400).send({ + error: 'Missing required fields', + message: 'targetTier and vehicleIdsToKeep are required', + }); + return; + } + + if (!['free', 'pro'].includes(targetTier)) { + reply.status(400).send({ + error: 'Invalid tier', + message: 'targetTier must be "free" or "pro"', + }); + return; + } + + if (!Array.isArray(vehicleIdsToKeep)) { + reply.status(400).send({ + error: 'Invalid vehicle selection', + message: 'vehicleIdsToKeep must be an array', + }); + return; + } + + // Downgrade subscription + const updatedSubscription = await this.service.downgradeSubscription( + userId, + targetTier, + vehicleIdsToKeep + ); + + reply.status(200).send(updatedSubscription); + } catch (error: any) { + logger.error('Failed to downgrade subscription', { + userId: (request as any).user?.sub, + error: error.message, + }); + reply.status(500).send({ + error: 'Failed to downgrade subscription', + message: error.message, + }); + } + } } diff --git a/backend/src/features/subscriptions/api/subscriptions.routes.ts b/backend/src/features/subscriptions/api/subscriptions.routes.ts index 25e4789..cca8439 100644 --- a/backend/src/features/subscriptions/api/subscriptions.routes.ts +++ b/backend/src/features/subscriptions/api/subscriptions.routes.ts @@ -48,4 +48,10 @@ export const subscriptionsRoutes: FastifyPluginAsync = async ( preHandler: [fastify.authenticate], handler: subscriptionsController.getInvoices.bind(subscriptionsController) }); + + // POST /api/subscriptions/downgrade - Downgrade subscription with vehicle selection + fastify.post('/subscriptions/downgrade', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.downgrade.bind(subscriptionsController) + }); }; diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index 14f26e4..f422dd4 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -271,6 +271,95 @@ export class SubscriptionsService { } } + /** + * Downgrade subscription to a lower tier with vehicle selection + */ + async downgradeSubscription( + userId: string, + targetTier: SubscriptionTier, + vehicleIdsToKeep: string[] + ): Promise { + try { + logger.info('Downgrading subscription', { userId, targetTier, vehicleCount: vehicleIdsToKeep.length }); + + // Get current subscription + const currentSubscription = await this.repository.findByUserId(userId); + if (!currentSubscription) { + throw new Error('No subscription found for user'); + } + + // Define tier limits + const tierLimits: Record = { + free: 2, + pro: 5, + enterprise: null, // unlimited + }; + + const targetLimit = tierLimits[targetTier]; + + // Validate vehicle selection count + if (targetLimit !== null && vehicleIdsToKeep.length > targetLimit) { + throw new Error(`Vehicle selection exceeds tier limit. ${targetTier} tier allows ${targetLimit} vehicles, but ${vehicleIdsToKeep.length} were selected.`); + } + + // Cancel current Stripe subscription if exists (downgrading from paid tier) + if (currentSubscription.stripeSubscriptionId) { + await this.stripeClient.cancelSubscription( + currentSubscription.stripeSubscriptionId, + false // Cancel immediately, not at period end + ); + } + + // Clear previous vehicle selections + await this.repository.deleteVehicleSelectionsByUserId(userId); + + // Save new vehicle selections + for (const vehicleId of vehicleIdsToKeep) { + await this.repository.createVehicleSelection({ + userId, + vehicleId, + }); + } + + // Update subscription tier + const updateData: UpdateSubscriptionData = { + tier: targetTier, + status: 'active', + stripeSubscriptionId: undefined, + billingCycle: undefined, + cancelAtPeriodEnd: false, + }; + + const updatedSubscription = await this.repository.update( + currentSubscription.id, + updateData + ); + + if (!updatedSubscription) { + throw new Error('Failed to update subscription'); + } + + // Sync tier to user profile + await this.syncTierToUserProfile(userId, targetTier); + + logger.info('Subscription downgraded', { + subscriptionId: updatedSubscription.id, + userId, + targetTier, + vehicleCount: vehicleIdsToKeep.length, + }); + + return updatedSubscription; + } catch (error: any) { + logger.error('Failed to downgrade subscription', { + userId, + targetTier, + error: error.message, + }); + throw error; + } + } + /** * Handle incoming Stripe webhook event */ diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index 4ab2b6f..e4a7f74 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -203,23 +203,85 @@ export class VehiclesService { async getUserVehicles(userId: string): Promise { const cacheKey = `${this.cachePrefix}:user:${userId}`; - + // Check cache const cached = await cacheService.get(cacheKey); if (cached) { logger.debug('Vehicle list cache hit', { userId }); return cached; } - + // Get from database const vehicles = await this.repository.findByUserId(userId); const response = vehicles.map((v: Vehicle) => this.toResponse(v)); - + // Cache result await cacheService.set(cacheKey, response, this.listCacheTTL); - + return response; } + + /** + * Get user vehicles with tier-gated status + * Returns vehicles with tierStatus: 'active' | 'locked' + */ + async getUserVehiclesWithTierStatus(userId: string): Promise> { + // Get user's subscription tier + const userProfile = await this.userProfileRepository.getByAuth0Sub(userId); + if (!userProfile) { + throw new Error('User profile not found'); + } + const userTier = userProfile.subscriptionTier; + + // Get all vehicles + const vehicles = await this.repository.findByUserId(userId); + + // Define tier limits + const tierLimits: Record = { + free: 2, + pro: 5, + enterprise: null, // unlimited + }; + + const tierLimit = tierLimits[userTier]; + + // If tier has unlimited vehicles, all are active + if (tierLimit === null) { + return vehicles.map((v: Vehicle) => ({ + ...this.toResponse(v), + tierStatus: 'active' as const, + })); + } + + // If vehicle count is within tier limit, all are active + if (vehicles.length <= tierLimit) { + return vehicles.map((v: Vehicle) => ({ + ...this.toResponse(v), + tierStatus: 'active' as const, + })); + } + + // Vehicle count exceeds tier limit - check for tier_vehicle_selections + // Get vehicle selections from subscriptions repository + const { SubscriptionsRepository } = await import('../../subscriptions/data/subscriptions.repository'); + const subscriptionsRepo = new SubscriptionsRepository(this.pool); + const selections = await subscriptionsRepo.findVehicleSelectionsByUserId(userId); + const selectedVehicleIds = new Set(selections.map(s => s.vehicleId)); + + // If no selections exist, return all as active (selections only exist after downgrade) + if (selections.length === 0) { + return vehicles.map((v: Vehicle) => ({ + ...this.toResponse(v), + tierStatus: 'active' as const, + })); + } + + // Mark vehicles as active or locked based on selections + return vehicles.map((v: Vehicle) => ({ + ...this.toResponse(v), + tierStatus: selectedVehicleIds.has(v.id) ? ('active' as const) : ('locked' as const), + })); + } async getVehicle(id: string, userId: string): Promise { const vehicle = await this.repository.findById(id); diff --git a/backend/src/features/vehicles/domain/vehicles.types.ts b/backend/src/features/vehicles/domain/vehicles.types.ts index 024d54c..e1380de 100644 --- a/backend/src/features/vehicles/domain/vehicles.types.ts +++ b/backend/src/features/vehicles/domain/vehicles.types.ts @@ -195,6 +195,11 @@ export interface VehicleParams { id: string; } +// Vehicle with tier status (for tier-gated access) +export interface VehicleWithTierStatus extends Vehicle { + tierStatus: 'active' | 'locked'; +} + // TCO (Total Cost of Ownership) response export interface TCOResponse { vehicleId: string; diff --git a/frontend/src/features/subscription/api/subscription.api.ts b/frontend/src/features/subscription/api/subscription.api.ts index 35c7cc4..61df5ed 100644 --- a/frontend/src/features/subscription/api/subscription.api.ts +++ b/frontend/src/features/subscription/api/subscription.api.ts @@ -1,5 +1,5 @@ import { apiClient } from '../../../core/api/client'; -import type { CheckoutRequest, PaymentMethodUpdateRequest } from '../types/subscription.types'; +import type { CheckoutRequest, PaymentMethodUpdateRequest, DowngradeRequest } from '../types/subscription.types'; export const subscriptionApi = { getSubscription: () => apiClient.get('/subscriptions'), @@ -8,4 +8,5 @@ export const subscriptionApi = { reactivate: () => apiClient.post('/subscriptions/reactivate'), updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data), getInvoices: () => apiClient.get('/subscriptions/invoices'), + downgrade: (data: DowngradeRequest) => apiClient.post('/subscriptions/downgrade', data), }; diff --git a/frontend/src/features/subscription/components/DowngradeFlow.tsx b/frontend/src/features/subscription/components/DowngradeFlow.tsx new file mode 100644 index 0000000..15dc62f --- /dev/null +++ b/frontend/src/features/subscription/components/DowngradeFlow.tsx @@ -0,0 +1,67 @@ +import { useState, useEffect } from 'react'; +import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import { VehicleSelectionDialog } from './VehicleSelectionDialog'; +import type { SubscriptionTier } from '../types/subscription.types'; + +interface DowngradeFlowProps { + targetTier: SubscriptionTier; + onComplete: (vehicleIdsToKeep: string[]) => void; + onCancel: () => void; +} + +const TIER_LIMITS: Record = { + free: 2, + pro: 5, + enterprise: null, // unlimited +}; + +export const DowngradeFlow = ({ + targetTier, + onComplete, + onCancel, +}: DowngradeFlowProps) => { + const { data: vehicles } = useVehicles(); + const [showVehicleSelection, setShowVehicleSelection] = useState(false); + + useEffect(() => { + // Check if vehicle selection is needed + const targetLimit = TIER_LIMITS[targetTier]; + const vehicleCount = vehicles?.length || 0; + + if (targetLimit !== null && vehicleCount > targetLimit) { + // Vehicle count exceeds target tier limit - show selection dialog + setShowVehicleSelection(true); + } else { + // No selection needed - directly downgrade with all vehicles + const allVehicleIds = vehicles?.map((v: any) => v.id) || []; + onComplete(allVehicleIds); + } + }, [vehicles, targetTier, onComplete]); + + const handleVehicleSelectionConfirm = (selectedVehicleIds: string[]) => { + setShowVehicleSelection(false); + onComplete(selectedVehicleIds); + }; + + const handleVehicleSelectionCancel = () => { + setShowVehicleSelection(false); + onCancel(); + }; + + if (!showVehicleSelection) { + return null; + } + + const targetLimit = TIER_LIMITS[targetTier]; + + return ( + + ); +}; diff --git a/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx b/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx new file mode 100644 index 0000000..14ba3cf --- /dev/null +++ b/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx @@ -0,0 +1,135 @@ +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + FormGroup, + FormControlLabel, + Checkbox, + Typography, + Alert, + Box, +} from '@mui/material'; +import type { SubscriptionTier } from '../types/subscription.types'; + +interface Vehicle { + id: string; + make?: string; + model?: string; + year?: number; + nickname?: string; +} + +interface VehicleSelectionDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (selectedVehicleIds: string[]) => void; + vehicles: Vehicle[]; + maxSelections: number; + targetTier: SubscriptionTier; +} + +export const VehicleSelectionDialog = ({ + open, + onClose, + onConfirm, + vehicles, + maxSelections, + targetTier, +}: VehicleSelectionDialogProps) => { + const [selectedVehicleIds, setSelectedVehicleIds] = useState([]); + + // Pre-select first N vehicles when dialog opens + useEffect(() => { + if (open && vehicles.length > 0) { + const initialSelection = vehicles.slice(0, maxSelections).map((v) => v.id); + setSelectedVehicleIds(initialSelection); + } + }, [open, vehicles, maxSelections]); + + const handleToggle = (vehicleId: string) => { + setSelectedVehicleIds((prev) => { + if (prev.includes(vehicleId)) { + return prev.filter((id) => id !== vehicleId); + } else { + // Only add if under the limit + if (prev.length < maxSelections) { + return [...prev, vehicleId]; + } + return prev; + } + }); + }; + + const handleConfirm = () => { + onConfirm(selectedVehicleIds); + }; + + const getVehicleLabel = (vehicle: Vehicle): string => { + if (vehicle.nickname) { + return vehicle.nickname; + } + const parts = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean); + return parts.join(' ') || 'Unknown Vehicle'; + }; + + const canConfirm = selectedVehicleIds.length > 0 && selectedVehicleIds.length <= maxSelections; + + return ( + + Select Vehicles to Keep + + + You are downgrading to the {targetTier} tier, which allows {maxSelections} vehicle + {maxSelections > 1 ? 's' : ''}. Select which vehicles you want to keep active. Unselected + vehicles will be hidden but not deleted, and you can unlock them by upgrading later. + + + + + Selected {selectedVehicleIds.length} of {maxSelections} allowed + + + + + {vehicles.map((vehicle) => ( + handleToggle(vehicle.id)} + disabled={ + !selectedVehicleIds.includes(vehicle.id) && + selectedVehicleIds.length >= maxSelections + } + /> + } + label={getVehicleLabel(vehicle)} + /> + ))} + + + {selectedVehicleIds.length === 0 && ( + + You must select at least one vehicle. + + )} + + {selectedVehicleIds.length > maxSelections && ( + + You can only select up to {maxSelections} vehicle{maxSelections > 1 ? 's' : ''}. + + )} + + + + + + + ); +}; diff --git a/frontend/src/features/subscription/hooks/useSubscription.ts b/frontend/src/features/subscription/hooks/useSubscription.ts index 943a500..225ed4a 100644 --- a/frontend/src/features/subscription/hooks/useSubscription.ts +++ b/frontend/src/features/subscription/hooks/useSubscription.ts @@ -74,3 +74,20 @@ export const useInvoices = () => { staleTime: 5 * 60 * 1000, }); }; + +export const useDowngrade = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: subscriptionApi.downgrade, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['vehicles'] }); + queryClient.invalidateQueries({ queryKey: ['user-profile'] }); + toast.success('Subscription downgraded successfully'); + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { message?: string } } }; + toast.error(err.response?.data?.message || 'Downgrade failed'); + }, + }); +}; diff --git a/frontend/src/features/subscription/pages/SubscriptionPage.tsx b/frontend/src/features/subscription/pages/SubscriptionPage.tsx index 3e6242b..788d327 100644 --- a/frontend/src/features/subscription/pages/SubscriptionPage.tsx +++ b/frontend/src/features/subscription/pages/SubscriptionPage.tsx @@ -21,12 +21,14 @@ import { Card } from '../../../shared-minimal/components/Card'; import { TierCard } from '../components/TierCard'; import { PaymentMethodForm } from '../components/PaymentMethodForm'; import { BillingHistory } from '../components/BillingHistory'; +import { DowngradeFlow } from '../components/DowngradeFlow'; import { useSubscription, useCheckout, useCancelSubscription, useReactivateSubscription, useInvoices, + useDowngrade, } from '../hooks/useSubscription'; import { PLANS } from '../constants/plans'; import type { BillingCycle, SubscriptionTier } from '../types/subscription.types'; @@ -37,12 +39,15 @@ export const SubscriptionPage: React.FC = () => { const [billingCycle, setBillingCycle] = useState('monthly'); const [selectedTier, setSelectedTier] = useState(null); const [showPaymentDialog, setShowPaymentDialog] = useState(false); + const [showDowngradeFlow, setShowDowngradeFlow] = useState(false); + const [downgradeTargetTier, setDowngradeTargetTier] = useState(null); const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscription(); const { data: invoicesData, isLoading: isLoadingInvoices } = useInvoices(); const checkoutMutation = useCheckout(); const cancelMutation = useCancelSubscription(); const reactivateMutation = useReactivateSubscription(); + const downgradeMutation = useDowngrade(); const subscription = subscriptionData?.data; const invoices = invoicesData?.data || []; @@ -53,9 +58,50 @@ export const SubscriptionPage: React.FC = () => { } }; + const getTierRank = (tier: SubscriptionTier): number => { + const ranks = { free: 0, pro: 1, enterprise: 2 }; + return ranks[tier]; + }; + const handleUpgradeClick = (tier: SubscriptionTier) => { - setSelectedTier(tier); - setShowPaymentDialog(true); + const currentTier = subscription?.tier || 'free'; + const isDowngrade = getTierRank(tier) < getTierRank(currentTier); + + if (isDowngrade) { + // Trigger downgrade flow + setDowngradeTargetTier(tier); + setShowDowngradeFlow(true); + } else { + // Trigger upgrade flow (show payment dialog) + setSelectedTier(tier); + setShowPaymentDialog(true); + } + }; + + const handleDowngradeComplete = (vehicleIdsToKeep: string[]) => { + if (!downgradeTargetTier) return; + + downgradeMutation.mutate( + { + targetTier: downgradeTargetTier, + vehicleIdsToKeep, + }, + { + onSuccess: () => { + setShowDowngradeFlow(false); + setDowngradeTargetTier(null); + }, + onError: () => { + setShowDowngradeFlow(false); + setDowngradeTargetTier(null); + }, + } + ); + }; + + const handleDowngradeCancel = () => { + setShowDowngradeFlow(false); + setDowngradeTargetTier(null); }; const handlePaymentSubmit = (paymentMethodId: string) => { @@ -244,6 +290,14 @@ export const SubscriptionPage: React.FC = () => { + + {showDowngradeFlow && downgradeTargetTier && ( + + )} ); }; diff --git a/frontend/src/features/subscription/types/subscription.types.ts b/frontend/src/features/subscription/types/subscription.types.ts index a7d9af3..9f6398d 100644 --- a/frontend/src/features/subscription/types/subscription.types.ts +++ b/frontend/src/features/subscription/types/subscription.types.ts @@ -36,3 +36,8 @@ export interface CheckoutRequest { export interface PaymentMethodUpdateRequest { paymentMethodId: string; } + +export interface DowngradeRequest { + targetTier: SubscriptionTier; + vehicleIdsToKeep: string[]; +} -- 2.49.1 From 56da99de36ea4a0b44b702681effad90ea2cba8e Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:51:20 -0600 Subject: [PATCH 06/17] feat: add donations feature with one-time payments - M6 (refs #55) --- backend/src/app.ts | 8 +- .../subscriptions/api/donations.controller.ts | 81 ++++++ .../subscriptions/api/donations.routes.ts | 26 ++ .../subscriptions/domain/donations.service.ts | 150 +++++++++++ .../domain/subscriptions.service.ts | 40 +++ .../external/stripe/stripe.client.ts | 1 + .../external/stripe/stripe.types.ts | 1 + backend/src/features/subscriptions/index.ts | 4 +- .../subscription/api/subscription.api.ts | 2 + .../components/DonationSection.tsx | 246 ++++++++++++++++++ .../components/DonationSectionMobile.tsx | 224 ++++++++++++++++ .../subscription/hooks/useSubscription.ts | 24 ++ .../mobile/SubscriptionMobileScreen.tsx | 5 + .../subscription/pages/SubscriptionPage.tsx | 7 +- 14 files changed, 815 insertions(+), 4 deletions(-) create mode 100644 backend/src/features/subscriptions/api/donations.controller.ts create mode 100644 backend/src/features/subscriptions/api/donations.routes.ts create mode 100644 backend/src/features/subscriptions/domain/donations.service.ts create mode 100644 frontend/src/features/subscription/components/DonationSection.tsx create mode 100644 frontend/src/features/subscription/components/DonationSectionMobile.tsx diff --git a/backend/src/app.ts b/backend/src/app.ts index cbe60d0..19a88b9 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -33,6 +33,7 @@ import { userPreferencesRoutes } from './features/user-preferences'; import { userExportRoutes } from './features/user-export'; import { userImportRoutes } from './features/user-import'; import { ownershipCostsRoutes } from './features/ownership-costs'; +import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions'; import { pool } from './core/config/database'; import { configRoutes } from './core/config/config.routes'; @@ -94,7 +95,7 @@ async function buildApp(): Promise { status: 'healthy', timestamp: new Date().toISOString(), environment: process.env['NODE_ENV'], - features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations'] }); }); @@ -104,7 +105,7 @@ async function buildApp(): Promise { status: 'healthy', scope: 'api', timestamp: new Date().toISOString(), - features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations'] }); }); @@ -147,6 +148,9 @@ async function buildApp(): Promise { await app.register(userExportRoutes, { prefix: '/api' }); await app.register(userImportRoutes, { prefix: '/api' }); await app.register(ownershipCostsRoutes, { prefix: '/api' }); + await app.register(subscriptionsRoutes, { prefix: '/api' }); + await app.register(donationsRoutes, { prefix: '/api' }); + await app.register(webhooksRoutes, { prefix: '/api' }); await app.register(configRoutes, { prefix: '/api' }); // 404 handler diff --git a/backend/src/features/subscriptions/api/donations.controller.ts b/backend/src/features/subscriptions/api/donations.controller.ts new file mode 100644 index 0000000..72f5209 --- /dev/null +++ b/backend/src/features/subscriptions/api/donations.controller.ts @@ -0,0 +1,81 @@ +/** + * @ai-summary Donations HTTP controller + * @ai-context Handles donation creation and history endpoints + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { DonationsService } from '../domain/donations.service'; +import { SubscriptionsRepository } from '../data/subscriptions.repository'; +import { StripeClient } from '../external/stripe/stripe.client'; +import { pool } from '../../../core/config/database'; +import { logger } from '../../../core/logging/logger'; + +interface CreateDonationBody { + amount: number; +} + +export class DonationsController { + private service: DonationsService; + + constructor() { + const repository = new SubscriptionsRepository(pool); + const stripeClient = new StripeClient(); + this.service = new DonationsService(repository, stripeClient, pool); + } + + /** + * POST /api/donations - Create donation payment intent + */ + async createDonation(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = (request as any).user.sub; + const { amount } = request.body as CreateDonationBody; + + logger.info('Creating donation', { userId, amount }); + + // Validate amount + if (!amount || amount <= 0) { + return reply.code(400).send({ error: 'Invalid amount' }); + } + + // Convert dollars to cents + const amountCents = Math.round(amount * 100); + + // Create donation + const result = await this.service.createDonation(userId, amountCents); + + return reply.code(201).send(result); + } catch (error: any) { + logger.error('Failed to create donation', { + error: error.message, + }); + + if (error.message.includes('must be at least')) { + return reply.code(400).send({ error: error.message }); + } + + return reply.code(500).send({ error: 'Failed to create donation' }); + } + } + + /** + * GET /api/donations - Get user's donation history + */ + async getDonations(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = (request as any).user.sub; + + logger.info('Getting donations', { userId }); + + const donations = await this.service.getUserDonations(userId); + + return reply.code(200).send(donations); + } catch (error: any) { + logger.error('Failed to get donations', { + error: error.message, + }); + + return reply.code(500).send({ error: 'Failed to get donations' }); + } + } +} diff --git a/backend/src/features/subscriptions/api/donations.routes.ts b/backend/src/features/subscriptions/api/donations.routes.ts new file mode 100644 index 0000000..3d7f16a --- /dev/null +++ b/backend/src/features/subscriptions/api/donations.routes.ts @@ -0,0 +1,26 @@ +/** + * @ai-summary Donations HTTP routes + * @ai-context Defines donation endpoints with authentication + */ + +import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify'; +import { DonationsController } from './donations.controller'; + +export const donationsRoutes: FastifyPluginAsync = async ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions +) => { + const controller = new DonationsController(); + + // POST /api/donations - Create donation + fastify.post('/donations', { + preHandler: [fastify.authenticate], + handler: controller.createDonation.bind(controller), + }); + + // GET /api/donations - Get donation history + fastify.get('/donations', { + preHandler: [fastify.authenticate], + handler: controller.getDonations.bind(controller), + }); +}; diff --git a/backend/src/features/subscriptions/domain/donations.service.ts b/backend/src/features/subscriptions/domain/donations.service.ts new file mode 100644 index 0000000..28ac36e --- /dev/null +++ b/backend/src/features/subscriptions/domain/donations.service.ts @@ -0,0 +1,150 @@ +/** + * @ai-summary Donations business logic and payment processing + * @ai-context Manages one-time donations with Stripe PaymentIntent + */ + +import { Pool } from 'pg'; +import { logger } from '../../../core/logging/logger'; +import { SubscriptionsRepository } from '../data/subscriptions.repository'; +import { StripeClient } from '../external/stripe/stripe.client'; +import { Donation, DonationResponse } from './subscriptions.types'; + +export class DonationsService { + constructor( + private repository: SubscriptionsRepository, + private stripeClient: StripeClient, + _pool: Pool + ) {} + + /** + * Create a payment intent for donation + */ + async createDonation( + userId: string, + amountCents: number, + currency: string = 'usd' + ): Promise<{ clientSecret: string; donationId: string }> { + try { + logger.info('Creating donation', { userId, amountCents, currency }); + + // Validate amount (must be positive, Stripe has $0.50 minimum) + if (amountCents < 50) { + throw new Error('Donation amount must be at least $0.50'); + } + + if (amountCents <= 0) { + throw new Error('Donation amount must be positive'); + } + + // Create Stripe PaymentIntent + const paymentIntent = await this.stripeClient.createPaymentIntent( + amountCents, + currency + ); + + // Create donation record in database (status: pending) + const donation = await this.repository.createDonation({ + userId, + stripePaymentIntentId: paymentIntent.id, + amountCents, + currency, + }); + + logger.info('Donation created', { + donationId: donation.id, + paymentIntentId: paymentIntent.id, + userId, + amountCents, + }); + + // Return clientSecret for frontend to complete payment + if (!paymentIntent.client_secret) { + throw new Error('Payment intent did not return client_secret'); + } + + return { + clientSecret: paymentIntent.client_secret, + donationId: donation.id, + }; + } catch (error: any) { + logger.error('Failed to create donation', { + userId, + amountCents, + currency, + error: error.message, + }); + throw error; + } + } + + /** + * Complete donation after payment succeeds + */ + async completeDonation( + stripePaymentIntentId: string + ): Promise { + try { + logger.info('Completing donation', { stripePaymentIntentId }); + + // Find donation by payment intent ID + const donation = await this.repository.findDonationByPaymentIntentId( + stripePaymentIntentId + ); + + if (!donation) { + logger.warn('Donation not found for payment intent', { stripePaymentIntentId }); + return null; + } + + // Update donation status to 'succeeded' + const updatedDonation = await this.repository.updateDonation(donation.id, { + status: 'succeeded', + }); + + logger.info('Donation completed', { + donationId: donation.id, + stripePaymentIntentId, + }); + + return updatedDonation; + } catch (error: any) { + logger.error('Failed to complete donation', { + stripePaymentIntentId, + error: error.message, + }); + throw error; + } + } + + /** + * Get user's donation history + */ + async getUserDonations(userId: string): Promise { + try { + const donations = await this.repository.findDonationsByUserId(userId); + return donations.map(donation => this.mapToResponse(donation)); + } catch (error: any) { + logger.error('Failed to get user donations', { + userId, + error: error.message, + }); + throw error; + } + } + + /** + * Map donation entity to response DTO + */ + private mapToResponse(donation: Donation): DonationResponse { + return { + id: donation.id, + userId: donation.userId, + stripePaymentIntentId: donation.stripePaymentIntentId, + amountCents: donation.amountCents, + currency: donation.currency, + status: donation.status, + createdAt: donation.createdAt.toISOString(), + updatedAt: donation.updatedAt.toISOString(), + }; + } +} diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index f422dd4..4f4be22 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -400,6 +400,9 @@ export class SubscriptionsService { case 'invoice.payment_failed': await this.handlePaymentFailed(event); break; + case 'payment_intent.succeeded': + await this.handleDonationPaymentSucceeded(event); + break; default: logger.info('Unhandled webhook event type', { eventType: event.type }); } @@ -598,6 +601,43 @@ export class SubscriptionsService { }); } + /** + * Handle payment_intent.succeeded webhook for donations + */ + private async handleDonationPaymentSucceeded(event: StripeWebhookEvent): Promise { + const paymentIntent = event.data.object; + + // Check if this is a donation (based on metadata) + if (paymentIntent.metadata?.type !== 'donation') { + logger.info('PaymentIntent is not a donation, skipping', { + paymentIntentId: paymentIntent.id, + }); + return; + } + + // Find donation by payment intent ID + const donation = await this.repository.findDonationByPaymentIntentId( + paymentIntent.id + ); + + if (!donation) { + logger.warn('Donation not found for payment intent', { + paymentIntentId: paymentIntent.id, + }); + return; + } + + // Update donation status to succeeded + await this.repository.updateDonation(donation.id, { + status: 'succeeded', + }); + + logger.info('Donation marked as succeeded via webhook', { + donationId: donation.id, + paymentIntentId: paymentIntent.id, + }); + } + /** * Sync subscription tier to user_profiles table */ diff --git a/backend/src/features/subscriptions/external/stripe/stripe.client.ts b/backend/src/features/subscriptions/external/stripe/stripe.client.ts index 1547c4a..d59019c 100644 --- a/backend/src/features/subscriptions/external/stripe/stripe.client.ts +++ b/backend/src/features/subscriptions/external/stripe/stripe.client.ts @@ -217,6 +217,7 @@ export class StripeClient { status: paymentIntent.status, customer: paymentIntent.customer as string | undefined, payment_method: paymentIntent.payment_method as string | undefined, + client_secret: paymentIntent.client_secret, created: paymentIntent.created, metadata: paymentIntent.metadata, }; diff --git a/backend/src/features/subscriptions/external/stripe/stripe.types.ts b/backend/src/features/subscriptions/external/stripe/stripe.types.ts index d26bc1d..7b9e35a 100644 --- a/backend/src/features/subscriptions/external/stripe/stripe.types.ts +++ b/backend/src/features/subscriptions/external/stripe/stripe.types.ts @@ -46,6 +46,7 @@ export interface StripePaymentIntent { status: 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'processing' | 'requires_capture' | 'canceled' | 'succeeded'; customer?: string; payment_method?: string; + client_secret: string | null; created: number; metadata?: Record; } diff --git a/backend/src/features/subscriptions/index.ts b/backend/src/features/subscriptions/index.ts index 949f5a8..b26ce49 100644 --- a/backend/src/features/subscriptions/index.ts +++ b/backend/src/features/subscriptions/index.ts @@ -40,9 +40,11 @@ export { StripeClient } from './external/stripe/stripe.client'; // Repository export { SubscriptionsRepository } from './data/subscriptions.repository'; -// Service +// Services export { SubscriptionsService } from './domain/subscriptions.service'; +export { DonationsService } from './domain/donations.service'; // Routes export { webhooksRoutes } from './api/webhooks.routes'; export { subscriptionsRoutes } from './api/subscriptions.routes'; +export { donationsRoutes } from './api/donations.routes'; diff --git a/frontend/src/features/subscription/api/subscription.api.ts b/frontend/src/features/subscription/api/subscription.api.ts index 61df5ed..de97eac 100644 --- a/frontend/src/features/subscription/api/subscription.api.ts +++ b/frontend/src/features/subscription/api/subscription.api.ts @@ -9,4 +9,6 @@ export const subscriptionApi = { updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data), getInvoices: () => apiClient.get('/subscriptions/invoices'), downgrade: (data: DowngradeRequest) => apiClient.post('/subscriptions/downgrade', data), + createDonation: (amount: number) => apiClient.post('/donations', { amount }), + getDonations: () => apiClient.get('/donations'), }; diff --git a/frontend/src/features/subscription/components/DonationSection.tsx b/frontend/src/features/subscription/components/DonationSection.tsx new file mode 100644 index 0000000..2a5d016 --- /dev/null +++ b/frontend/src/features/subscription/components/DonationSection.tsx @@ -0,0 +1,246 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + TextField, + Button, + CircularProgress, + Alert, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, +} from '@mui/material'; +import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { format } from 'date-fns'; +import toast from 'react-hot-toast'; +import { Card } from '../../../shared-minimal/components/Card'; +import { useCreateDonation, useDonations } from '../hooks/useSubscription'; +import type { StripeCardElementChangeEvent } from '@stripe/stripe-js'; +import type { SubscriptionTier } from '../types/subscription.types'; + +interface DonationSectionProps { + currentTier?: SubscriptionTier; +} + +const CARD_ELEMENT_OPTIONS = { + style: { + base: { + fontSize: '16px', + color: '#424770', + '::placeholder': { + color: '#aab7c4', + }, + }, + invalid: { + color: '#9e2146', + }, + }, +}; + +export const DonationSection: React.FC = ({ currentTier }) => { + const stripe = useStripe(); + const elements = useElements(); + const [amount, setAmount] = useState(''); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(false); + const [cardComplete, setCardComplete] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + + const createDonationMutation = useCreateDonation(); + const { data: donationsData, isLoading: isLoadingDonations } = useDonations(); + + const donations = donationsData?.data || []; + + const handleCardChange = (event: StripeCardElementChangeEvent) => { + setError(event.error?.message || null); + setCardComplete(event.complete); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + return; + } + + const cardElement = elements.getElement(CardElement); + if (!cardElement) { + return; + } + + // Validate amount + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum < 0.5) { + setError('Minimum donation amount is $0.50'); + return; + } + + setProcessing(true); + setError(null); + + try { + // Create donation payment intent + const donationResponse = await createDonationMutation.mutateAsync(amountNum); + const { clientSecret } = donationResponse.data; + + // Confirm payment with Stripe + const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, { + payment_method: { + card: cardElement, + }, + }); + + if (confirmError) { + setError(confirmError.message || 'Payment failed'); + setProcessing(false); + return; + } + + // Success! + setShowSuccess(true); + setAmount(''); + cardElement.clear(); + toast.success('Thank you for your donation!'); + + // Hide success message after 5 seconds + setTimeout(() => { + setShowSuccess(false); + }, 5000); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + setError(error.response?.data?.error || 'An unexpected error occurred'); + } finally { + setProcessing(false); + } + }; + + const isFormValid = amount && parseFloat(amount) >= 0.5 && cardComplete && !processing; + + return ( + + + Support MotoVaultPro + + + {currentTier === 'free' && ( + + Love MotoVaultPro? Consider making a one-time donation to support development! + + )} + + + Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support! + + + {showSuccess && ( + + Thank you for your generous donation! Your support means the world to us. + + )} + +
+ + + Donation Amount + + setAmount(e.target.value)} + placeholder="Enter amount" + InputProps={{ + startAdornment: $, + }} + inputProps={{ + min: 0.5, + step: 0.01, + }} + disabled={processing} + /> + + + + + Card Details + + + + + + + {error && ( + + {error} + + )} + + +
+ + {donations.length > 0 && ( + + + Donation History + + + {isLoadingDonations ? ( + + + + ) : ( + + + + + Date + Amount + Status + + + + {donations.map((donation: any) => ( + + {format(new Date(donation.createdAt), 'MMM dd, yyyy')} + ${(donation.amountCents / 100).toFixed(2)} + + + + + ))} + +
+
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/features/subscription/components/DonationSectionMobile.tsx b/frontend/src/features/subscription/components/DonationSectionMobile.tsx new file mode 100644 index 0000000..6c46769 --- /dev/null +++ b/frontend/src/features/subscription/components/DonationSectionMobile.tsx @@ -0,0 +1,224 @@ +import React, { useState } from 'react'; +import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { format } from 'date-fns'; +import toast from 'react-hot-toast'; +import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; +import { useCreateDonation, useDonations } from '../hooks/useSubscription'; +import type { StripeCardElementChangeEvent } from '@stripe/stripe-js'; +import type { SubscriptionTier } from '../types/subscription.types'; + +interface DonationSectionMobileProps { + currentTier?: SubscriptionTier; +} + +const CARD_ELEMENT_OPTIONS = { + style: { + base: { + fontSize: '16px', + color: '#424770', + '::placeholder': { + color: '#aab7c4', + }, + }, + invalid: { + color: '#9e2146', + }, + }, +}; + +export const DonationSectionMobile: React.FC = ({ currentTier }) => { + const stripe = useStripe(); + const elements = useElements(); + const [amount, setAmount] = useState(''); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(false); + const [cardComplete, setCardComplete] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + + const createDonationMutation = useCreateDonation(); + const { data: donationsData, isLoading: isLoadingDonations } = useDonations(); + + const donations = donationsData?.data || []; + + const handleCardChange = (event: StripeCardElementChangeEvent) => { + setError(event.error?.message || null); + setCardComplete(event.complete); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + return; + } + + const cardElement = elements.getElement(CardElement); + if (!cardElement) { + return; + } + + // Validate amount + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum < 0.5) { + setError('Minimum donation amount is $0.50'); + return; + } + + setProcessing(true); + setError(null); + + try { + // Create donation payment intent + const donationResponse = await createDonationMutation.mutateAsync(amountNum); + const { clientSecret } = donationResponse.data; + + // Confirm payment with Stripe + const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, { + payment_method: { + card: cardElement, + }, + }); + + if (confirmError) { + setError(confirmError.message || 'Payment failed'); + setProcessing(false); + return; + } + + // Success! + setShowSuccess(true); + setAmount(''); + cardElement.clear(); + toast.success('Thank you for your donation!'); + + // Hide success message after 5 seconds + setTimeout(() => { + setShowSuccess(false); + }, 5000); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + setError(error.response?.data?.error || 'An unexpected error occurred'); + } finally { + setProcessing(false); + } + }; + + const isFormValid = amount && parseFloat(amount) >= 0.5 && cardComplete && !processing; + + return ( + +

+ Support MotoVaultPro +

+ + {currentTier === 'free' && ( +
+

+ Love MotoVaultPro? Consider making a one-time donation to support development! +

+
+ )} + +

+ Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support! +

+ + {showSuccess && ( +
+

+ Thank you for your generous donation! Your support means the world to us. +

+
+ )} + +
+
+ +
+ $ + setAmount(e.target.value)} + placeholder="Enter amount" + min="0.5" + step="0.01" + disabled={processing} + className="w-full pl-8 pr-4 py-3 bg-white dark:bg-nero border border-slate-200 dark:border-grigio rounded-xl text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-titanio focus:outline-none focus:ring-2 focus:ring-rose-500 min-h-[44px]" + /> +
+
+ +
+ +
+ +
+
+ + {error && ( +
+

{error}

+
+ )} + + +
+ + {donations.length > 0 && ( +
+

+ Donation History +

+ + {isLoadingDonations ? ( +
+
+
+ ) : ( +
+ {donations.map((donation: any) => ( +
+
+
+ {format(new Date(donation.createdAt), 'MMM dd, yyyy')} +
+
+ ${(donation.amountCents / 100).toFixed(2)} +
+
+ + {donation.status} + +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/features/subscription/hooks/useSubscription.ts b/frontend/src/features/subscription/hooks/useSubscription.ts index 225ed4a..f091be7 100644 --- a/frontend/src/features/subscription/hooks/useSubscription.ts +++ b/frontend/src/features/subscription/hooks/useSubscription.ts @@ -91,3 +91,27 @@ export const useDowngrade = () => { }, }); }; + +export const useCreateDonation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (amount: number) => subscriptionApi.createDonation(amount), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['donations'] }); + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || 'Donation failed'); + }, + }); +}; + +export const useDonations = () => { + const { isAuthenticated, isLoading } = useAuth0(); + return useQuery({ + queryKey: ['donations'], + queryFn: () => subscriptionApi.getDonations(), + enabled: isAuthenticated && !isLoading, + staleTime: 5 * 60 * 1000, + }); +}; diff --git a/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx b/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx index c622685..77bcd47 100644 --- a/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx +++ b/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx @@ -5,6 +5,7 @@ import { format } from 'date-fns'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer'; import { PaymentMethodForm } from '../components/PaymentMethodForm'; +import { DonationSectionMobile } from '../components/DonationSectionMobile'; import { useSubscription, useCheckout, @@ -346,6 +347,10 @@ export const SubscriptionMobileScreen: React.FC = () => { )} + + + + { - + Billing History @@ -264,6 +265,10 @@ export const SubscriptionPage: React.FC = () => { )} + + + + !checkoutMutation.isPending && setShowPaymentDialog(false)} -- 2.49.1 From 1cf4b78075dc0ad83f2b4a830b1bae01c5754476 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:52:50 -0600 Subject: [PATCH 07/17] docs: update subscription feature documentation - M7 (refs #55) --- backend/src/features/subscriptions/CLAUDE.md | 55 +++++ backend/src/features/subscriptions/README.md | 243 ++++++++++++------- frontend/src/features/subscription/README.md | 132 ++++++---- 3 files changed, 304 insertions(+), 126 deletions(-) create mode 100644 backend/src/features/subscriptions/CLAUDE.md diff --git a/backend/src/features/subscriptions/CLAUDE.md b/backend/src/features/subscriptions/CLAUDE.md new file mode 100644 index 0000000..6a77922 --- /dev/null +++ b/backend/src/features/subscriptions/CLAUDE.md @@ -0,0 +1,55 @@ +# backend/src/features/subscriptions/ + +Stripe payment integration for subscription tiers and donations. + +## Files + +| File | What | When to read | +| ---- | ---- | ------------ | +| `README.md` | Feature overview with architecture diagram | Understanding subscription flow | + +## Subdirectories + +| Directory | What | When to read | +| --------- | ---- | ------------ | +| `api/` | HTTP controllers and routes | API endpoint changes | +| `domain/` | Services and type definitions | Business logic changes | +| `data/` | Repository for database operations | Database queries | +| `external/stripe/` | Stripe API client wrapper | Stripe integration | +| `migrations/` | Database schema | Schema changes | +| `jobs/` | Scheduled background jobs | Grace period processing | + +## Key Patterns + +- Repository mapRow() converts snake_case to camelCase +- Webhook idempotency via stripe_event_id unique constraint +- Tier sync to user_profiles.subscription_tier on changes +- Grace period: 30 days after payment failure +- Vehicle selections for tier downgrades (not deleted, just gated) + +## API Endpoints + +### Subscriptions (Authenticated) +- GET /api/subscriptions - Current subscription +- POST /api/subscriptions/checkout - Create subscription +- POST /api/subscriptions/cancel - Schedule cancellation +- POST /api/subscriptions/reactivate - Cancel pending cancellation +- POST /api/subscriptions/downgrade - Downgrade with vehicle selection +- PUT /api/subscriptions/payment-method - Update payment +- GET /api/subscriptions/invoices - Billing history + +### Donations (Authenticated) +- POST /api/donations - Create payment intent +- GET /api/donations - Donation history + +### Webhooks (Public) +- POST /api/webhooks/stripe - Stripe webhook (signature verified) + +## Environment Variables + +- STRIPE_SECRET_KEY +- STRIPE_WEBHOOK_SECRET +- STRIPE_PRO_MONTHLY_PRICE_ID +- STRIPE_PRO_YEARLY_PRICE_ID +- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID +- STRIPE_ENTERPRISE_YEARLY_PRICE_ID diff --git a/backend/src/features/subscriptions/README.md b/backend/src/features/subscriptions/README.md index 2a09f61..2e4e000 100644 --- a/backend/src/features/subscriptions/README.md +++ b/backend/src/features/subscriptions/README.md @@ -2,104 +2,181 @@ Stripe integration for subscription management, donations, and tier-based vehicle limits. -## Milestone 1: Core Infrastructure (COMPLETE) +## Architecture -### 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 +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend (React) │ +│ SubscriptionPage / SubscriptionMobileScreen │ +│ - TierCard, PaymentMethodForm, BillingHistory │ +│ - VehicleSelectionDialog, DowngradeFlow, DonationSection │ +└──────────────────────────┬──────────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────────┐ +│ Backend API │ +│ /api/subscriptions/* - Subscription management │ +│ /api/donations/* - One-time donations │ +│ /api/webhooks/stripe - Stripe webhook (public) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌────────────┐ ┌─────────────────┐ +│ Subscriptions │ │ Stripe │ │ User Profile │ +│ Service │ │ Client │ │ Repository │ +│ │ │ │ │ (tier sync) │ +└────────┬────────┘ └─────┬──────┘ └─────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌────────────┐ +│ Subscriptions │ │ Stripe │ +│ Repository │ │ API │ +└────────┬────────┘ └────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ PostgreSQL │ +│ subscriptions, subscription_events, donations, │ +│ tier_vehicle_selections │ +└─────────────────────────────────────────────────────────────────┘ +``` -### 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 +## Database Schema -### 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 +### subscriptions +Main subscription data linked to user profile. +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| user_id | VARCHAR(255) | FK to user_profiles.auth0_sub | +| stripe_customer_id | VARCHAR(255) | Stripe customer ID | +| stripe_subscription_id | VARCHAR(255) | Stripe subscription ID (nullable for free) | +| tier | subscription_tier | free, pro, enterprise | +| billing_cycle | billing_cycle | monthly, yearly | +| status | subscription_status | active, past_due, canceled, unpaid | +| current_period_start | TIMESTAMP | Billing period start | +| current_period_end | TIMESTAMP | Billing period end | +| grace_period_end | TIMESTAMP | Grace period expiry (30 days after payment failure) | +| cancel_at_period_end | BOOLEAN | Pending cancellation flag | -### 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 +### subscription_events +Webhook event log for idempotency. +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| subscription_id | UUID | FK to subscriptions | +| stripe_event_id | VARCHAR(255) | UNIQUE, prevents duplicate processing | +| event_type | VARCHAR(100) | Stripe event type | +| payload | JSONB | Full event payload | -## Environment Variables +### donations +One-time payment records. +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| user_id | VARCHAR(255) | FK to user_profiles.auth0_sub | +| stripe_payment_intent_id | VARCHAR(255) | UNIQUE | +| amount_cents | INTEGER | Amount in cents | +| currency | VARCHAR(3) | Currency code (default: usd) | +| status | donation_status | pending, succeeded, failed, canceled | -Required for Stripe integration: -- `STRIPE_SECRET_KEY` - Stripe API secret key -- `STRIPE_WEBHOOK_SECRET` - Stripe webhook signing secret +### tier_vehicle_selections +Tracks which vehicles user selected to keep during tier downgrade. +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| user_id | VARCHAR(255) | FK to user_profiles.auth0_sub | +| vehicle_id | UUID | FK to vehicles | +| selected_at | TIMESTAMP | Selection timestamp | + +## API Endpoints + +### Subscription Management (Authenticated) +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/subscriptions | Get current subscription | +| POST | /api/subscriptions/checkout | Create Stripe subscription | +| POST | /api/subscriptions/cancel | Schedule cancellation at period end | +| POST | /api/subscriptions/reactivate | Cancel pending cancellation | +| POST | /api/subscriptions/downgrade | Downgrade with vehicle selection | +| PUT | /api/subscriptions/payment-method | Update payment method | +| GET | /api/subscriptions/invoices | Get billing history | + +### Donations (Authenticated) +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | /api/donations | Create donation payment intent | +| GET | /api/donations | Get donation history | + +### Webhooks (Public) +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | /api/webhooks/stripe | Stripe webhook (signature verified) | + +## Webhook Events Handled + +| Event | Action | +|-------|--------| +| customer.subscription.created | Update subscription with Stripe subscription ID | +| customer.subscription.updated | Sync status, tier, period dates | +| customer.subscription.deleted | Mark canceled, downgrade to free tier | +| invoice.payment_succeeded | Clear grace period, mark active | +| invoice.payment_failed | Set 30-day grace period | +| payment_intent.succeeded | Mark donation as succeeded | ## Subscription Tiers -Defined in `user_profiles.subscription_tier` enum: -- `free` - Limited features (default) -- `pro` - Enhanced features -- `enterprise` - Full features +| Tier | Price | Vehicle Limit | Features | +|------|-------|---------------|----------| +| Free | $0 | 2 | Basic tracking, standard reports | +| Pro | $1.99/mo or $19.99/yr | 5 | VIN decoding, OCR, API access | +| Enterprise | $4.99/mo or $49.99/yr | Unlimited | All features, priority support | -## Billing Cycles +## Grace Period -- `monthly` - Monthly billing -- `yearly` - Annual billing +When payment fails: +1. Subscription status set to `past_due` +2. Grace period set to 30 days from failure +3. Email notifications sent: immediate, day 23, day 29 +4. Daily job at 2:30 AM checks expired grace periods +5. Expired subscriptions downgraded to free tier -## Subscription Status +## Downgrade Flow -- `active` - Subscription is active -- `past_due` - Payment failed, in grace period -- `canceled` - Subscription canceled -- `unpaid` - Payment failed, grace period expired +When user downgrades to a tier with fewer vehicle allowance: +1. Check if current vehicle count > target tier limit +2. If yes, show VehicleSelectionDialog +3. User selects which vehicles to keep +4. Unselected vehicles become tier-gated (hidden, not deleted) +5. Selections saved to tier_vehicle_selections table +6. On upgrade, all vehicles become accessible again -## Milestone 2: Service Layer + Webhook Endpoint (COMPLETE) +## Environment Variables -### Service Layer -- **SubscriptionsService** (`domain/subscriptions.service.ts`): Business logic for subscription management - - Get current subscription for user - - Create new subscription (Stripe customer + free tier record) - - Upgrade subscription (create Stripe subscription with payment method) - - Cancel subscription (schedule for end of period) - - Reactivate subscription (remove pending cancellation) - - Handle Stripe webhook events with idempotency - -### Webhook Processing -- **Webhook Events Handled**: - - `customer.subscription.created` - Update subscription record with Stripe subscription ID - - `customer.subscription.updated` - Update status, tier, period dates - - `customer.subscription.deleted` - Mark as canceled, downgrade to free tier - - `invoice.payment_succeeded` - Clear grace period, mark active - - `invoice.payment_failed` - Set 30-day grace period - -### API Endpoints -- **WebhooksController** (`api/webhooks.controller.ts`): Webhook event handler -- **Routes** (`api/webhooks.routes.ts`): PUBLIC endpoint with rawBody support - - POST /api/webhooks/stripe - Stripe webhook receiver (no JWT auth, signature verified) - -### Integration -- Syncs subscription tier changes to `user_profiles.subscription_tier` via UserProfileRepository -- Uses environment variables for Stripe price IDs (PRO/ENTERPRISE, MONTHLY/YEARLY) - -## Next Steps (Future Milestones) - -- M3: API endpoints for subscription management (user-facing CRUD) -- M4: Frontend integration and subscription UI -- M5: Testing and documentation - -## Database Migration - -Run migrations: -```bash -npm run migrate:feature subscriptions +``` +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRO_MONTHLY_PRICE_ID=price_... +STRIPE_PRO_YEARLY_PRICE_ID=price_... +STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_... +STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_... ``` -## Architecture Notes +## Files -- 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 +| File | Purpose | +|------|---------| +| migrations/001_subscriptions_tables.sql | Database schema | +| domain/subscriptions.types.ts | TypeScript interfaces | +| domain/subscriptions.service.ts | Business logic | +| domain/donations.service.ts | Donation logic | +| data/subscriptions.repository.ts | Database operations | +| external/stripe/stripe.client.ts | Stripe API wrapper | +| external/stripe/stripe.types.ts | Stripe type definitions | +| api/subscriptions.controller.ts | HTTP handlers | +| api/subscriptions.routes.ts | Authenticated routes | +| api/donations.controller.ts | Donation handlers | +| api/donations.routes.ts | Donation routes | +| api/webhooks.controller.ts | Webhook handler | +| api/webhooks.routes.ts | Public webhook endpoint | +| jobs/grace-period.job.ts | Daily grace period expiration job | diff --git a/frontend/src/features/subscription/README.md b/frontend/src/features/subscription/README.md index 473ef5e..74f8188 100644 --- a/frontend/src/features/subscription/README.md +++ b/frontend/src/features/subscription/README.md @@ -4,20 +4,21 @@ Frontend UI for subscription management with Stripe integration. ## Overview -Provides subscription tier management, payment method updates, and billing history viewing. +Provides subscription tier management, payment method updates, billing history, donations, and vehicle selection during downgrade flow. ## Components ### TierCard -Displays subscription plan with: -- Plan name and pricing (monthly/yearly) +Subscription plan display with: +- Plan name and pricing (monthly/yearly toggle) - Feature list - Current plan indicator - Upgrade/downgrade button ### PaymentMethodForm Stripe Elements integration for: -- Credit card input with validation +- Credit card input with CardElement +- Real-time validation - Payment method creation - Error handling @@ -27,85 +28,130 @@ Invoice list with: - PDF download links - MUI Table component +### VehicleSelectionDialog +Vehicle selection during downgrade: +- Checkbox list for each vehicle +- Counter showing selected vs allowed +- Warning about tier-gated vehicles +- Validation preventing over-selection + +### DowngradeFlow +Orchestrates downgrade process: +- Checks vehicle count vs target tier limit +- Shows VehicleSelectionDialog if needed +- Submits vehicle selections to backend + +### DonationSection / DonationSectionMobile +One-time donation form: +- Free-form amount input (no presets) +- $0.50 minimum (Stripe limit) +- Stripe CardElement for payment +- Donation history table +- Success feedback + ## Pages ### SubscriptionPage (Desktop) -- Current plan card with status -- Three-column tier cards layout -- Payment method section +MUI-based layout: +- Current plan card with status badges +- Three-column tier comparison +- Monthly/yearly toggle +- Payment method modal - Billing history table -- Material-UI components +- Donation section ### SubscriptionMobileScreen (Mobile) +Tailwind-based layout: +- GlassCard styling - Stacked card layout - Touch-friendly buttons (44px min) -- Tailwind styling -- GlassCard components +- Modal payment forms ## API Integration -All endpoints are in `/subscriptions`: -- GET `/subscriptions` - Current subscription -- POST `/subscriptions/checkout` - Upgrade subscription -- POST `/subscriptions/cancel` - Cancel subscription -- POST `/subscriptions/reactivate` - Reactivate subscription -- PUT `/subscriptions/payment-method` - Update payment method -- GET `/subscriptions/invoices` - Invoice history +### Subscriptions +| Endpoint | Method | Hook | +|----------|--------|------| +| /api/subscriptions | GET | useSubscription() | +| /api/subscriptions/checkout | POST | useCheckout() | +| /api/subscriptions/cancel | POST | useCancelSubscription() | +| /api/subscriptions/reactivate | POST | useReactivateSubscription() | +| /api/subscriptions/downgrade | POST | useDowngrade() | +| /api/subscriptions/payment-method | PUT | useUpdatePaymentMethod() | +| /api/subscriptions/invoices | GET | useInvoices() | + +### Donations +| Endpoint | Method | Hook | +|----------|--------|------| +| /api/donations | POST | useCreateDonation() | +| /api/donations | GET | useDonations() | ## Hooks -- `useSubscription()` - Fetch current subscription -- `useCheckout()` - Upgrade subscription -- `useCancelSubscription()` - Cancel subscription -- `useReactivateSubscription()` - Reactivate subscription -- `useInvoices()` - Fetch invoice history +| Hook | Purpose | +|------|---------| +| useSubscription() | Fetch current subscription | +| useCheckout() | Upgrade subscription | +| useCancelSubscription() | Cancel subscription | +| useReactivateSubscription() | Reactivate subscription | +| useDowngrade() | Downgrade with vehicle selection | +| useInvoices() | Fetch billing history | +| useCreateDonation() | Create donation payment | +| useDonations() | Fetch donation history | -## Environment Setup +## Environment Variables -Required environment variable: ```bash VITE_STRIPE_PUBLISHABLE_KEY=pk_test_... ``` ## Subscription Tiers -### Free +### Free ($0) - 2 vehicles - Basic tracking - Standard reports -- Price: $0 -### Pro +### Pro ($1.99/month or $19.99/year) - Up to 5 vehicles - VIN decoding - OCR functionality - API access -- Price: $1.99/month or $19.99/year -### Enterprise +### Enterprise ($4.99/month or $49.99/year) - Unlimited vehicles - All Pro features - Priority support -- Price: $4.99/month or $49.99/year -## Mobile Navigation +## Routing -Add subscription screen to settings navigation: -```typescript -navigateToScreen('Subscription') -``` +- Desktop: `/garage/settings/subscription` +- Mobile: `navigateToScreen('Subscription')` -## Desktop Routing +## Files -Route: `/garage/settings/subscription` +| File | Purpose | +|------|---------| +| types/subscription.types.ts | TypeScript interfaces | +| api/subscription.api.ts | API client calls | +| hooks/useSubscription.ts | React Query hooks | +| constants/plans.ts | Tier configuration | +| components/TierCard.tsx | Plan display card | +| components/PaymentMethodForm.tsx | Stripe Elements form | +| components/BillingHistory.tsx | Invoice table | +| components/VehicleSelectionDialog.tsx | Vehicle selection modal | +| components/DowngradeFlow.tsx | Downgrade orchestrator | +| components/DonationSection.tsx | Desktop donation UI | +| components/DonationSectionMobile.tsx | Mobile donation UI | +| pages/SubscriptionPage.tsx | Desktop page | +| mobile/SubscriptionMobileScreen.tsx | Mobile screen | ## Testing -Test subscription flow: 1. View current plan 2. Toggle monthly/yearly billing -3. Select upgrade tier -4. Enter payment method -5. Complete checkout -6. Verify subscription update -7. View billing history +3. Upgrade: Select tier, enter payment, complete checkout +4. Downgrade: Select vehicles to keep if over limit +5. Cancel/reactivate subscription +6. Make a donation +7. View billing and donation history -- 2.49.1 From 1718e8d41bcb6991965a9e7e8ccb81cb81fdf7f2 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:02:10 -0600 Subject: [PATCH 08/17] fix: use file-based secrets for Stripe API keys (refs #55) --- backend/src/core/config/config-loader.ts | 16 ++++++++++++++++ backend/src/features/subscriptions/CLAUDE.md | 9 ++++++--- backend/src/features/subscriptions/README.md | 13 ++++++++++--- .../external/stripe/stripe.client.ts | 18 +++++++----------- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/backend/src/core/config/config-loader.ts b/backend/src/core/config/config-loader.ts index 6035e52..f9aa904 100644 --- a/backend/src/core/config/config-loader.ts +++ b/backend/src/core/config/config-loader.ts @@ -126,6 +126,9 @@ const secretsSchema = z.object({ auth0_management_client_secret: z.string(), google_maps_api_key: z.string(), resend_api_key: z.string(), + // Stripe secrets (API keys only - price IDs are config, not secrets) + stripe_secret_key: z.string(), + stripe_webhook_secret: z.string(), }); type Config = z.infer; @@ -140,6 +143,10 @@ export interface AppConfiguration { getRedisUrl(): string; getAuth0Config(): { domain: string; audience: string; clientSecret: string }; getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string }; + getStripeConfig(): { + secretKey: string; + webhookSecret: string; + }; } class ConfigurationLoader { @@ -178,6 +185,8 @@ class ConfigurationLoader { 'auth0-management-client-secret', 'google-maps-api-key', 'resend-api-key', + 'stripe-secret-key', + 'stripe-webhook-secret', ]; for (const secretFile of secretFiles) { @@ -240,6 +249,13 @@ class ConfigurationLoader { clientSecret: secrets.auth0_management_client_secret, }; }, + + getStripeConfig() { + return { + secretKey: secrets.stripe_secret_key, + webhookSecret: secrets.stripe_webhook_secret, + }; + }, }; // Set RESEND_API_KEY in environment for EmailService diff --git a/backend/src/features/subscriptions/CLAUDE.md b/backend/src/features/subscriptions/CLAUDE.md index 6a77922..b413fbd 100644 --- a/backend/src/features/subscriptions/CLAUDE.md +++ b/backend/src/features/subscriptions/CLAUDE.md @@ -45,10 +45,13 @@ Stripe payment integration for subscription tiers and donations. ### Webhooks (Public) - POST /api/webhooks/stripe - Stripe webhook (signature verified) -## Environment Variables +## Configuration -- STRIPE_SECRET_KEY -- STRIPE_WEBHOOK_SECRET +### Secrets (files via config-loader) +- `/run/secrets/stripe-secret-key` - Stripe API secret key +- `/run/secrets/stripe-webhook-secret` - Stripe webhook signing secret + +### Environment Variables (docker-compose) - STRIPE_PRO_MONTHLY_PRICE_ID - STRIPE_PRO_YEARLY_PRICE_ID - STRIPE_ENTERPRISE_MONTHLY_PRICE_ID diff --git a/backend/src/features/subscriptions/README.md b/backend/src/features/subscriptions/README.md index 2e4e000..932de61 100644 --- a/backend/src/features/subscriptions/README.md +++ b/backend/src/features/subscriptions/README.md @@ -151,11 +151,18 @@ When user downgrades to a tier with fewer vehicle allowance: 5. Selections saved to tier_vehicle_selections table 6. On upgrade, all vehicles become accessible again -## Environment Variables +## Configuration +### Secrets (loaded from files via config-loader) +Secrets are loaded from `/run/secrets/` (or `SECRETS_DIR` env var): +``` +/run/secrets/stripe-secret-key # Stripe API secret key +/run/secrets/stripe-webhook-secret # Stripe webhook signing secret +``` + +### Environment Variables (docker-compose) +Price IDs are not secrets and are configured via environment variables: ``` -STRIPE_SECRET_KEY=sk_live_... -STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PRO_MONTHLY_PRICE_ID=price_... STRIPE_PRO_YEARLY_PRICE_ID=price_... STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_... diff --git a/backend/src/features/subscriptions/external/stripe/stripe.client.ts b/backend/src/features/subscriptions/external/stripe/stripe.client.ts index d59019c..597b8bb 100644 --- a/backend/src/features/subscriptions/external/stripe/stripe.client.ts +++ b/backend/src/features/subscriptions/external/stripe/stripe.client.ts @@ -5,6 +5,7 @@ import Stripe from 'stripe'; import { logger } from '../../../../core/logging/logger'; +import { appConfig } from '../../../../core/config/config-loader'; import { StripeCustomer, StripeSubscription, @@ -14,18 +15,18 @@ import { export class StripeClient { private stripe: Stripe; + private webhookSecret: string; constructor() { - const apiKey = process.env.STRIPE_SECRET_KEY; - if (!apiKey) { - throw new Error('STRIPE_SECRET_KEY environment variable is required'); - } + const stripeConfig = appConfig.getStripeConfig(); - this.stripe = new Stripe(apiKey, { + this.stripe = new Stripe(stripeConfig.secretKey, { apiVersion: '2025-12-15.clover', typescript: true, }); + this.webhookSecret = stripeConfig.webhookSecret; + logger.info('Stripe client initialized'); } @@ -237,15 +238,10 @@ export class StripeClient { */ 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 + this.webhookSecret ); logger.info('Stripe webhook event verified', { eventId: event.id, type: event.type }); -- 2.49.1 From 03fa9c3103c77d0b26d88c82e8314dc50c3488b0 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:50:00 -0600 Subject: [PATCH 09/17] feat: Stripe secret updates --- .claude/skills/planner/POST | 0 .claude/skills/planner/StripeClient.createSubscription | 0 .claude/skills/planner/SubscriptionPage | 0 .claude/skills/planner/sync | 0 docker-compose.yml | 5 +++++ secrets/app/stripe-secret-key.txt.example | 1 + secrets/app/stripe-webhook-secret.txt.example | 1 + 7 files changed, 7 insertions(+) create mode 100644 .claude/skills/planner/POST create mode 100644 .claude/skills/planner/StripeClient.createSubscription create mode 100644 .claude/skills/planner/SubscriptionPage create mode 100644 .claude/skills/planner/sync create mode 100644 secrets/app/stripe-secret-key.txt.example create mode 100644 secrets/app/stripe-webhook-secret.txt.example diff --git a/.claude/skills/planner/POST b/.claude/skills/planner/POST new file mode 100644 index 0000000..e69de29 diff --git a/.claude/skills/planner/StripeClient.createSubscription b/.claude/skills/planner/StripeClient.createSubscription new file mode 100644 index 0000000..e69de29 diff --git a/.claude/skills/planner/SubscriptionPage b/.claude/skills/planner/SubscriptionPage new file mode 100644 index 0000000..e69de29 diff --git a/.claude/skills/planner/sync b/.claude/skills/planner/sync new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index 518747c..d956c4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -104,6 +104,11 @@ services: # Service references DATABASE_HOST: mvp-postgres REDIS_HOST: mvp-redis + #Stripe Variables + STRIPE_PRO_MONTHLY_PRICE_ID: prod_Toj6BG9Z9JwREl + STRIPE_PRO_YEARLY_PRICE_ID: prod_Toj8oo0RpVBQmB + STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: prod_Toj8xGEui9jl6j + STRIPE_ENTERPRISE_YEARLY_PRICE_ID: prod_Toj9A7A773xrdn volumes: # Configuration files (K8s ConfigMap equivalent) - ./config/app/production.yml:/app/config/production.yml:ro diff --git a/secrets/app/stripe-secret-key.txt.example b/secrets/app/stripe-secret-key.txt.example new file mode 100644 index 0000000..f54eea8 --- /dev/null +++ b/secrets/app/stripe-secret-key.txt.example @@ -0,0 +1 @@ +stripe-secret-key \ No newline at end of file diff --git a/secrets/app/stripe-webhook-secret.txt.example b/secrets/app/stripe-webhook-secret.txt.example new file mode 100644 index 0000000..4fff8e1 --- /dev/null +++ b/secrets/app/stripe-webhook-secret.txt.example @@ -0,0 +1 @@ +stripe-webhook-secret \ No newline at end of file -- 2.49.1 From 52c0b59a86f146956e889d1eec3f2eccc8cb56cb Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:08:58 -0600 Subject: [PATCH 10/17] feat: Stripe secrets fixes --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index d956c4c..e16608c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,6 +121,8 @@ services: - ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro - ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro - ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro + - ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key + - ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret # Filesystem storage for documents - ./data/documents:/app/data/documents # Filesystem storage for backups -- 2.49.1 From 254bed18d020bada77f865116d3d7aacdae08b9b Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:20:29 -0600 Subject: [PATCH 11/17] fix: add Stripe secrets to CI/CD and build configuration (refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add VITE_STRIPE_PUBLISHABLE_KEY to frontend Dockerfile build args - Add VITE_STRIPE_PUBLISHABLE_KEY to docker-compose.yml build args - Add :ro flag to backend Stripe secret volume mounts for consistency - Update inject-secrets.sh with STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET - Add Stripe secrets to staging.yaml workflow (build arg + inject step) - Add Stripe secrets to production.yaml workflow (inject step) Requires STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET secrets and VITE_STRIPE_PUBLISHABLE_KEY variable to be configured in Gitea. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitea/workflows/production.yaml | 2 ++ .gitea/workflows/staging.yaml | 3 +++ docker-compose.yml | 5 +++-- frontend/Dockerfile | 4 +++- scripts/inject-secrets.sh | 6 ++++++ 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/production.yaml b/.gitea/workflows/production.yaml index 56f2334..76dc966 100644 --- a/.gitea/workflows/production.yaml +++ b/.gitea/workflows/production.yaml @@ -119,6 +119,8 @@ jobs: GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }} CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} - name: Initialize data directories run: | diff --git a/.gitea/workflows/staging.yaml b/.gitea/workflows/staging.yaml index fec55b3..c66c04f 100644 --- a/.gitea/workflows/staging.yaml +++ b/.gitea/workflows/staging.yaml @@ -67,6 +67,7 @@ jobs: --build-arg VITE_AUTH0_CLIENT_ID=${{ vars.VITE_AUTH0_CLIENT_ID }} \ --build-arg VITE_AUTH0_AUDIENCE=${{ vars.VITE_AUTH0_AUDIENCE }} \ --build-arg VITE_API_BASE_URL=/api \ + --build-arg VITE_STRIPE_PUBLISHABLE_KEY=${{ vars.VITE_STRIPE_PUBLISHABLE_KEY }} \ --cache-from $REGISTRY/egullickson/frontend:latest \ -t ${{ steps.tags.outputs.frontend_image }} \ -t $REGISTRY/egullickson/frontend:latest \ @@ -112,6 +113,8 @@ jobs: GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }} CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} - name: Initialize data directories run: | diff --git a/docker-compose.yml b/docker-compose.yml index e16608c..7c021ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,7 @@ services: VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com} VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} + VITE_STRIPE_PUBLISHABLE_KEY: ${VITE_STRIPE_PUBLISHABLE_KEY:-} container_name: mvp-frontend restart: unless-stopped environment: @@ -121,8 +122,8 @@ services: - ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro - ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro - ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro - - ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key - - ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret + - ./secrets/app/stripe-secret-key.txt:/run/secrets/stripe-secret-key:ro + - ./secrets/app/stripe-webhook-secret.txt:/run/secrets/stripe-webhook-secret:ro # Filesystem storage for documents - ./data/documents:/app/data/documents # Filesystem storage for backups diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 1a401cc..622d081 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -19,15 +19,17 @@ FROM deps AS build # Accept build arguments for environment variables ARG VITE_AUTH0_DOMAIN -ARG VITE_AUTH0_CLIENT_ID +ARG VITE_AUTH0_CLIENT_ID ARG VITE_AUTH0_AUDIENCE ARG VITE_API_BASE_URL +ARG VITE_STRIPE_PUBLISHABLE_KEY # Set environment variables from build args ENV VITE_AUTH0_DOMAIN=$VITE_AUTH0_DOMAIN ENV VITE_AUTH0_CLIENT_ID=$VITE_AUTH0_CLIENT_ID ENV VITE_AUTH0_AUDIENCE=$VITE_AUTH0_AUDIENCE ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ENV VITE_STRIPE_PUBLISHABLE_KEY=$VITE_STRIPE_PUBLISHABLE_KEY COPY . . RUN npm run build diff --git a/scripts/inject-secrets.sh b/scripts/inject-secrets.sh index 410fdf1..9081b4b 100755 --- a/scripts/inject-secrets.sh +++ b/scripts/inject-secrets.sh @@ -15,6 +15,8 @@ # - GOOGLE_MAPS_MAP_ID # - CF_DNS_API_TOKEN # - RESEND_API_KEY +# - STRIPE_SECRET_KEY +# - STRIPE_WEBHOOK_SECRET set -euo pipefail @@ -32,6 +34,8 @@ SECRET_FILES=( "google-maps-map-id.txt" "cloudflare-dns-token.txt" "resend-api-key.txt" + "stripe-secret-key.txt" + "stripe-webhook-secret.txt" ) echo "Injecting secrets..." @@ -99,6 +103,8 @@ inject_secret "GOOGLE_MAPS_API_KEY" "google-maps-api-key.txt" || FAILED=1 inject_secret "GOOGLE_MAPS_MAP_ID" "google-maps-map-id.txt" || FAILED=1 inject_secret "CF_DNS_API_TOKEN" "cloudflare-dns-token.txt" || FAILED=1 inject_secret "RESEND_API_KEY" "resend-api-key.txt" || FAILED=1 +inject_secret "STRIPE_SECRET_KEY" "stripe-secret-key.txt" || FAILED=1 +inject_secret "STRIPE_WEBHOOK_SECRET" "stripe-webhook-secret.txt" || FAILED=1 if [ $FAILED -eq 1 ]; then echo "" -- 2.49.1 From 29948134eb79482ede72960a23a9b16e3327b07e Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:25:56 -0600 Subject: [PATCH 12/17] feat: Stripe secrets, more work. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7c021ee..c2cca9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,7 @@ services: VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com} VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} - VITE_STRIPE_PUBLISHABLE_KEY: ${VITE_STRIPE_PUBLISHABLE_KEY:-} + VITE_STRIPE_PUBLISHABLE_KEY: ${VITE_STRIPE_PUBLISHABLE_KEY:-pk_live_51Sr2yQJk87CpWj04YNBIaUWUtnJjeVTgk5NqHdpjqxgsbjy3dMKkIsqhjcpSkCzp3KvLi23BGgxhwV021EnEW3H400HhPYVyfN} container_name: mvp-frontend restart: unless-stopped environment: -- 2.49.1 From 864a6b1e86ac24b623c05d8a477b7a0377c163f9 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:35:18 -0600 Subject: [PATCH 13/17] fix: sync docker-compose files to staging server during deploy (refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging workflow was not copying docker-compose.yml to the server, causing configuration changes (like Stripe secrets) to not take effect. Added rsync step to sync config, scripts, and compose files before deployment, matching the production workflow behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitea/workflows/staging.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/staging.yaml b/.gitea/workflows/staging.yaml index c66c04f..0605af5 100644 --- a/.gitea/workflows/staging.yaml +++ b/.gitea/workflows/staging.yaml @@ -95,6 +95,13 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Sync config, scripts, and compose files to deploy path + run: | + rsync -av --delete "$GITHUB_WORKSPACE/config/" "$DEPLOY_PATH/config/" + rsync -av --delete "$GITHUB_WORKSPACE/scripts/" "$DEPLOY_PATH/scripts/" + cp "$GITHUB_WORKSPACE/docker-compose.yml" "$DEPLOY_PATH/" + cp "$GITHUB_WORKSPACE/docker-compose.staging.yml" "$DEPLOY_PATH/" + - name: Login to registry run: | echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin "$REGISTRY" -- 2.49.1 From 26f9306d6ba7fb60957036e295470d5b46b35123 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:45:22 -0600 Subject: [PATCH 14/17] feat: add Subscription section to Settings page (refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a Subscription section to the desktop Settings page that displays: - Current subscription tier (Free/Pro/Enterprise) - Status indicator for non-active subscriptions - Manage button linking to the subscription management page - Descriptive text based on current tier 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/pages/SettingsPage.tsx | 75 +++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c724e20..8359d3b 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -11,6 +11,7 @@ import { useAdminAccess } from '../core/auth/useAdminAccess'; import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile'; import { useExportUserData } from '../features/settings/hooks/useExportUserData'; import { useVehicles } from '../features/vehicles/hooks/useVehicles'; +import { useSubscription } from '../features/subscription/hooks/useSubscription'; import { useTheme } from '../shared-minimal/theme/ThemeContext'; import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog'; import { PendingDeletionBanner } from '../features/settings/components/PendingDeletionBanner'; @@ -32,7 +33,8 @@ import { MenuItem, FormControl, TextField, - CircularProgress + CircularProgress, + Chip } from '@mui/material'; import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import NotificationsIcon from '@mui/icons-material/Notifications'; @@ -41,6 +43,7 @@ import SecurityIcon from '@mui/icons-material/Security'; import StorageIcon from '@mui/icons-material/Storage'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import DirectionsCarIcon from '@mui/icons-material/DirectionsCar'; +import CreditCardIcon from '@mui/icons-material/CreditCard'; import EditIcon from '@mui/icons-material/Edit'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -62,6 +65,11 @@ export const SettingsPage: React.FC = () => { // Vehicles state (for My Vehicles section) const { data: vehicles, isLoading: vehiclesLoading } = useVehicles(); + + // Subscription state + const { data: subscriptionData, isLoading: subscriptionLoading } = useSubscription(); + const subscription = subscriptionData?.data; + const [isEditingProfile, setIsEditingProfile] = useState(false); const [editedDisplayName, setEditedDisplayName] = useState(''); const [editedNotificationEmail, setEditedNotificationEmail] = useState(''); @@ -378,19 +386,78 @@ export const SettingsPage: React.FC = () => { )} + {/* Subscription Section */} + + + + + + Subscription + + + navigate('/garage/settings/subscription')} + sx={{ + backgroundColor: 'primary.main', + color: 'primary.contrastText', + '&:hover': { + backgroundColor: 'primary.dark' + } + }} + > + Manage + + + + {subscriptionLoading ? ( + + + + ) : ( + + + + Current Plan: + + + {subscription?.status && subscription.status !== 'active' && ( + + )} + + + {subscription?.tier === 'free' + ? 'Upgrade to Pro or Enterprise for more features and vehicle slots.' + : subscription?.tier === 'pro' + ? 'Pro plan with up to 5 vehicles and full features.' + : 'Enterprise plan with unlimited vehicles and all features.'} + + + )} + + {/* Notifications Section */} Notifications - + - -- 2.49.1 From c407396b85bea4904aab60c7bdb39e511f917159 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:50:38 -0600 Subject: [PATCH 15/17] fix: correct subscription description when data unavailable (refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed conditional logic for subscription description text to properly handle the case when subscription data is not loaded or unavailable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/pages/SettingsPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 8359d3b..5c22daf 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -435,9 +435,9 @@ export const SettingsPage: React.FC = () => { )} - {subscription?.tier === 'free' + {!subscription || subscription.tier === 'free' ? 'Upgrade to Pro or Enterprise for more features and vehicle slots.' - : subscription?.tier === 'pro' + : subscription.tier === 'pro' ? 'Pro plan with up to 5 vehicles and full features.' : 'Enterprise plan with unlimited vehicles and all features.'} -- 2.49.1 From d646b5db80b4d1d1e876b1336692468dafd077aa Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:53:12 -0600 Subject: [PATCH 16/17] feat: add Subscription section to mobile Settings (refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a Subscription section to the mobile Settings screen that displays: - Current subscription tier (Free/Pro/Enterprise) - Status indicator for non-active subscriptions - Manage button linking to the subscription screen - Descriptive text based on current tier This completes the subscription section on both desktop and mobile. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../settings/mobile/MobileSettingsScreen.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx index 8fe6ed8..b2c1ae9 100644 --- a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx +++ b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx @@ -7,6 +7,7 @@ import { useSettings } from '../hooks/useSettings'; import { useProfile, useUpdateProfile } from '../hooks/useProfile'; import { useExportUserData } from '../hooks/useExportUserData'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import { useSubscription } from '../../subscription/hooks/useSubscription'; import { useAdminAccess } from '../../../core/auth/useAdminAccess'; import { useNavigationStore } from '../../../core/store'; import { DeleteAccountModal } from './DeleteAccountModal'; @@ -86,6 +87,8 @@ export const MobileSettingsScreen: React.FC = () => { const updateProfileMutation = useUpdateProfile(); const exportMutation = useExportUserData(); const { data: vehicles, isLoading: vehiclesLoading } = useVehicles(); + const { data: subscriptionData, isLoading: subscriptionLoading } = useSubscription(); + const subscription = subscriptionData?.data; const { isAdmin, loading: adminLoading } = useAdminAccess(); const [showDataExport, setShowDataExport] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -382,6 +385,60 @@ export const MobileSettingsScreen: React.FC = () => { + {/* Subscription Section */} + +
+
+
+ + + +

+ Subscription +

+
+ +
+ + {subscriptionLoading ? ( +
+
+
+ ) : ( +
+
+ Current Plan: + + {(subscription?.tier || 'free').toUpperCase()} + + {subscription?.status && subscription.status !== 'active' && ( + + {subscription.status.replace('_', ' ')} + + )} +
+

+ {!subscription || subscription.tier === 'free' + ? 'Upgrade to Pro or Enterprise for more features and vehicle slots.' + : subscription.tier === 'pro' + ? 'Pro plan with up to 5 vehicles and full features.' + : 'Enterprise plan with unlimited vehicles and all features.'} +

+
+ )} +
+
+ {/* Notifications Section */}
-- 2.49.1 From 0674056e7e9dbb03d3c3e2cc7b98b804c38afda0 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:22:42 -0600 Subject: [PATCH 17/17] fix: add subscriptions to migration order (refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscriptions feature migration was not being run because it was missing from the MIGRATION_ORDER array. Added it after ownership-costs since it depends on user-profile (for subscription_tier enum) and vehicles (for FK relationships). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/_system/migrations/run-all.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/_system/migrations/run-all.ts b/backend/src/_system/migrations/run-all.ts index 2d0fd9e..136a8f3 100644 --- a/backend/src/_system/migrations/run-all.ts +++ b/backend/src/_system/migrations/run-all.ts @@ -29,6 +29,7 @@ const MIGRATION_ORDER = [ 'features/terms-agreement', // Terms & Conditions acceptance audit trail 'features/audit-log', // Centralized audit logging; independent 'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs + 'features/subscriptions', // Stripe subscriptions; depends on user-profile, vehicles ]; // Base directory where migrations are copied inside the image (set by Dockerfile) -- 2.49.1