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/.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..0605af5 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 \ @@ -94,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" @@ -112,6 +120,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/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/_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) 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/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/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/CLAUDE.md b/backend/src/features/subscriptions/CLAUDE.md new file mode 100644 index 0000000..b413fbd --- /dev/null +++ b/backend/src/features/subscriptions/CLAUDE.md @@ -0,0 +1,58 @@ +# 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) + +## Configuration + +### 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 +- STRIPE_ENTERPRISE_YEARLY_PRICE_ID diff --git a/backend/src/features/subscriptions/README.md b/backend/src/features/subscriptions/README.md new file mode 100644 index 0000000..932de61 --- /dev/null +++ b/backend/src/features/subscriptions/README.md @@ -0,0 +1,189 @@ +# Subscriptions Feature + +Stripe integration for subscription management, donations, and tier-based vehicle limits. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Database Schema + +### 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 | + +### 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 | + +### 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 | + +### 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 + +| 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 | + +## Grace Period + +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 + +## Downgrade Flow + +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 + +## 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_PRO_MONTHLY_PRICE_ID=price_... +STRIPE_PRO_YEARLY_PRICE_ID=price_... +STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_... +STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_... +``` + +## Files + +| 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/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/api/subscriptions.controller.ts b/backend/src/features/subscriptions/api/subscriptions.controller.ts new file mode 100644 index 0000000..719eba3 --- /dev/null +++ b/backend/src/features/subscriptions/api/subscriptions.controller.ts @@ -0,0 +1,310 @@ +/** + * @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, + }); + } + } + + /** + * 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 new file mode 100644 index 0000000..cca8439 --- /dev/null +++ b/backend/src/features/subscriptions/api/subscriptions.routes.ts @@ -0,0 +1,57 @@ +/** + * @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) + }); + + // 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/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/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/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 new file mode 100644 index 0000000..4f4be22 --- /dev/null +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -0,0 +1,770 @@ +/** + * @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; + } + } + + /** + * 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 + */ + 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; + case 'payment_intent.succeeded': + await this.handleDonationPaymentSucceeded(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, + }); + } + + /** + * 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 + */ + 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'; + } + + /** + * 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 + */ + 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/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..597b8bb --- /dev/null +++ b/backend/src/features/subscriptions/external/stripe/stripe.client.ts @@ -0,0 +1,351 @@ +/** + * @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 { appConfig } from '../../../../core/config/config-loader'; +import { + StripeCustomer, + StripeSubscription, + StripePaymentIntent, + StripeWebhookEvent, +} from './stripe.types'; + +export class StripeClient { + private stripe: Stripe; + private webhookSecret: string; + + constructor() { + const stripeConfig = appConfig.getStripeConfig(); + + this.stripe = new Stripe(stripeConfig.secretKey, { + apiVersion: '2025-12-15.clover', + typescript: true, + }); + + this.webhookSecret = stripeConfig.webhookSecret; + + 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, + client_secret: paymentIntent.client_secret, + 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 event = this.stripe.webhooks.constructEvent( + payload, + signature, + this.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; + } + } + + /** + * 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/external/stripe/stripe.types.ts b/backend/src/features/subscriptions/external/stripe/stripe.types.ts new file mode 100644 index 0000000..7b9e35a --- /dev/null +++ b/backend/src/features/subscriptions/external/stripe/stripe.types.ts @@ -0,0 +1,83 @@ +/** + * @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; + client_secret: string | null; + 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..b26ce49 --- /dev/null +++ b/backend/src/features/subscriptions/index.ts @@ -0,0 +1,50 @@ +/** + * @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'; + +// 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/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; +} 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(); 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/docker-compose.yml b/docker-compose.yml index 518747c..c2cca9f 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:-pk_live_51Sr2yQJk87CpWj04YNBIaUWUtnJjeVTgk5NqHdpjqxgsbjy3dMKkIsqhjcpSkCzp3KvLi23BGgxhwV021EnEW3H400HhPYVyfN} container_name: mvp-frontend restart: unless-stopped environment: @@ -104,6 +105,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 @@ -116,6 +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: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/.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/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/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/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 */}
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..74f8188 --- /dev/null +++ b/frontend/src/features/subscription/README.md @@ -0,0 +1,157 @@ +# Subscription Feature + +Frontend UI for subscription management with Stripe integration. + +## Overview + +Provides subscription tier management, payment method updates, billing history, donations, and vehicle selection during downgrade flow. + +## Components + +### TierCard +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 CardElement +- Real-time validation +- Payment method creation +- Error handling + +### BillingHistory +Invoice list with: +- Date, amount, status +- 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) +MUI-based layout: +- Current plan card with status badges +- Three-column tier comparison +- Monthly/yearly toggle +- Payment method modal +- Billing history table +- Donation section + +### SubscriptionMobileScreen (Mobile) +Tailwind-based layout: +- GlassCard styling +- Stacked card layout +- Touch-friendly buttons (44px min) +- Modal payment forms + +## API Integration + +### 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 + +| 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 Variables + +```bash +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_... +``` + +## Subscription Tiers + +### Free ($0) +- 2 vehicles +- Basic tracking +- Standard reports + +### Pro ($1.99/month or $19.99/year) +- Up to 5 vehicles +- VIN decoding +- OCR functionality +- API access + +### Enterprise ($4.99/month or $49.99/year) +- Unlimited vehicles +- All Pro features +- Priority support + +## Routing + +- Desktop: `/garage/settings/subscription` +- Mobile: `navigateToScreen('Subscription')` + +## Files + +| 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 + +1. View current plan +2. Toggle monthly/yearly billing +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 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..de97eac --- /dev/null +++ b/frontend/src/features/subscription/api/subscription.api.ts @@ -0,0 +1,14 @@ +import { apiClient } from '../../../core/api/client'; +import type { CheckoutRequest, PaymentMethodUpdateRequest, DowngradeRequest } 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'), + 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/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/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/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/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/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/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..f091be7 --- /dev/null +++ b/frontend/src/features/subscription/hooks/useSubscription.ts @@ -0,0 +1,117 @@ +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, + }); +}; + +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'); + }, + }); +}; + +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/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..77bcd47 --- /dev/null +++ b/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx @@ -0,0 +1,370 @@ +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 { DonationSectionMobile } from '../components/DonationSectionMobile'; +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..c256cff --- /dev/null +++ b/frontend/src/features/subscription/pages/SubscriptionPage.tsx @@ -0,0 +1,308 @@ +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 { DowngradeFlow } from '../components/DowngradeFlow'; +import { DonationSection } from '../components/DonationSection'; +import { + useSubscription, + useCheckout, + useCancelSubscription, + useReactivateSubscription, + useInvoices, + useDowngrade, +} 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 [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 || []; + + const handleBillingCycleChange = (_: React.MouseEvent, newCycle: BillingCycle | null) => { + if (newCycle) { + setBillingCycle(newCycle); + } + }; + + const getTierRank = (tier: SubscriptionTier): number => { + const ranks = { free: 0, pro: 1, enterprise: 2 }; + return ranks[tier]; + }; + + const handleUpgradeClick = (tier: SubscriptionTier) => { + 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) => { + 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} + + + + + + + + + + + + {showDowngradeFlow && downgradeTargetTier && ( + + )} + + ); +}; 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..9f6398d --- /dev/null +++ b/frontend/src/features/subscription/types/subscription.types.ts @@ -0,0 +1,43 @@ +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; +} + +export interface DowngradeRequest { + targetTier: SubscriptionTier; + vehicleIdsToKeep: string[]; +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c724e20..5c22daf 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 || 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 - + - 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 "" 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