feat: Accept Payments - Stripe Integration with User Tiers (#55) #56

Merged
egullickson merged 17 commits from issue-55-stripe-integration into main 2026-01-19 02:52:25 +00:00
60 changed files with 5879 additions and 58 deletions

View File

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof configSchema>;
@@ -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

View File

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

View File

@@ -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"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Failed</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #d32f2f; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Payment Failed</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">We were unable to process your payment for your <strong>{{tier}}</strong> subscription.</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your subscription will remain active for <strong>30 days</strong> while we attempt to collect payment. After 30 days, your subscription will be downgraded to the free tier.</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Please update your payment method to avoid interruption of service.</p>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #d32f2f;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Amount Due:</strong> ${{amount}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Next Retry:</strong> {{retryDate}}</p>
</td>
</tr>
</table>
<div style="text-align: center; margin: 30px 0;">
<a href="https://app.motovaultpro.com/settings/billing" style="display: inline-block; padding: 14px 28px; background-color: #1976d2; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold;">Update Payment Method</a>
</div>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
),
(
'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"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Reminder - 7 Days Left</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #f57c00; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Urgent: 7 Days Until Downgrade</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">This is an urgent reminder that your <strong>{{tier}}</strong> subscription payment is still outstanding.</p>
<div style="background-color: #fff3e0; border-left: 4px solid #f57c00; padding: 20px; margin: 20px 0;">
<p style="color: #e65100; font-size: 18px; font-weight: bold; margin: 0 0 10px 0;">Your subscription will be downgraded in 7 days</p>
<p style="color: #333333; font-size: 14px; margin: 0;">If payment is not received by {{gracePeriodEnd}}, you will lose access to premium features.</p>
</div>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #f57c00;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Amount Due:</strong> ${{amount}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Grace Period Ends:</strong> {{gracePeriodEnd}}</p>
</td>
</tr>
</table>
<div style="text-align: center; margin: 30px 0;">
<a href="https://app.motovaultpro.com/settings/billing" style="display: inline-block; padding: 14px 28px; background-color: #f57c00; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold;">Update Payment Now</a>
</div>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
),
(
'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"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Final Notice - Downgrade Tomorrow</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #c62828; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">FINAL NOTICE</h1>
<p style="color: #ffffff; margin: 10px 0 0 0; font-size: 16px;">Downgrade Tomorrow</p>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<div style="background-color: #ffebee; border: 2px solid #c62828; border-radius: 4px; padding: 20px; margin: 20px 0;">
<p style="color: #b71c1c; font-size: 18px; font-weight: bold; margin: 0 0 10px 0;">FINAL NOTICE</p>
<p style="color: #333333; font-size: 16px; margin: 0;">Your <strong>{{tier}}</strong> subscription will be downgraded to the free tier <strong>tomorrow</strong> if payment is not received.</p>
</div>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #c62828;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Amount Due:</strong> ${{amount}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Grace Period Ends:</strong> {{gracePeriodEnd}}</p>
</td>
</tr>
</table>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">This is your last chance to update your payment method and keep your premium features.</p>
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 4px; margin: 20px 0;">
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">After downgrade:</p>
<ul style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0; padding-left: 20px;">
<li>Access to premium features will be lost</li>
<li>Data remains safe but with reduced vehicle limits</li>
<li>You can resubscribe at any time</li>
</ul>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="https://app.motovaultpro.com/settings/billing" style="display: inline-block; padding: 14px 28px; background-color: #c62828; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold; font-size: 16px;">Update Payment Now</a>
</div>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
);

View File

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

View File

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

View File

@@ -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' });
}
}
}

View File

@@ -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),
});
};

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
});
}
}
}

View File

@@ -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)
});
};

View File

@@ -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<void> {
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' });
}
}
}

View File

@@ -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)
);
};

View File

@@ -0,0 +1,581 @@
/**
* @ai-summary Data access layer for subscriptions
* @ai-context All database operations for subscriptions, events, donations, vehicle selections
*/
import { Pool } from 'pg';
import { logger } from '../../../core/logging/logger';
import {
Subscription,
SubscriptionEvent,
Donation,
TierVehicleSelection,
CreateSubscriptionRequest,
UpdateSubscriptionData,
CreateSubscriptionEventRequest,
CreateDonationRequest,
UpdateDonationData,
CreateTierVehicleSelectionRequest,
} from '../domain/subscriptions.types';
export class SubscriptionsRepository {
constructor(private pool: Pool) {}
// ========== Subscriptions ==========
/**
* Create a new subscription
*/
async create(data: CreateSubscriptionRequest & { stripeCustomerId: string }): Promise<Subscription> {
const query = `
INSERT INTO subscriptions (
user_id, stripe_customer_id, tier, billing_cycle
)
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const values = [
data.userId,
data.stripeCustomerId,
data.tier,
data.billingCycle,
];
try {
const result = await this.pool.query(query, values);
logger.info('Subscription created', { subscriptionId: result.rows[0].id, userId: data.userId });
return this.mapSubscriptionRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to create subscription', {
userId: data.userId,
error: error.message,
});
throw error;
}
}
/**
* Find subscription by user ID
*/
async findByUserId(userId: string): Promise<Subscription | null> {
const query = `
SELECT * FROM subscriptions
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 1
`;
try {
const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) {
return null;
}
return this.mapSubscriptionRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to find subscription by user ID', {
userId,
error: error.message,
});
throw error;
}
}
/**
* Find subscription by Stripe customer ID
*/
async findByStripeCustomerId(stripeCustomerId: string): Promise<Subscription | null> {
const query = `
SELECT * FROM subscriptions
WHERE stripe_customer_id = $1
ORDER BY created_at DESC
LIMIT 1
`;
try {
const result = await this.pool.query(query, [stripeCustomerId]);
if (result.rows.length === 0) {
return null;
}
return this.mapSubscriptionRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to find subscription by Stripe customer ID', {
stripeCustomerId,
error: error.message,
});
throw error;
}
}
/**
* Find subscription by Stripe subscription ID
*/
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<Subscription | null> {
const query = `
SELECT * FROM subscriptions
WHERE stripe_subscription_id = $1
`;
try {
const result = await this.pool.query(query, [stripeSubscriptionId]);
if (result.rows.length === 0) {
return null;
}
return this.mapSubscriptionRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to find subscription by Stripe subscription ID', {
stripeSubscriptionId,
error: error.message,
});
throw error;
}
}
/**
* Update a subscription
*/
async update(id: string, data: UpdateSubscriptionData): Promise<Subscription | null> {
const fields = [];
const values = [];
let paramCount = 1;
if (data.stripeSubscriptionId !== undefined) {
fields.push(`stripe_subscription_id = $${paramCount++}`);
values.push(data.stripeSubscriptionId);
}
if (data.tier !== undefined) {
fields.push(`tier = $${paramCount++}`);
values.push(data.tier);
}
if (data.billingCycle !== undefined) {
fields.push(`billing_cycle = $${paramCount++}`);
values.push(data.billingCycle);
}
if (data.status !== undefined) {
fields.push(`status = $${paramCount++}`);
values.push(data.status);
}
if (data.currentPeriodStart !== undefined) {
fields.push(`current_period_start = $${paramCount++}`);
values.push(data.currentPeriodStart);
}
if (data.currentPeriodEnd !== undefined) {
fields.push(`current_period_end = $${paramCount++}`);
values.push(data.currentPeriodEnd);
}
if (data.gracePeriodEnd !== undefined) {
fields.push(`grace_period_end = $${paramCount++}`);
values.push(data.gracePeriodEnd);
}
if (data.cancelAtPeriodEnd !== undefined) {
fields.push(`cancel_at_period_end = $${paramCount++}`);
values.push(data.cancelAtPeriodEnd);
}
if (fields.length === 0) {
logger.warn('No fields to update for subscription', { id });
return this.findById(id);
}
values.push(id);
const query = `
UPDATE subscriptions
SET ${fields.join(', ')}
WHERE id = $${paramCount}
RETURNING *
`;
try {
const result = await this.pool.query(query, values);
if (result.rows.length === 0) {
return null;
}
logger.info('Subscription updated', { subscriptionId: id });
return this.mapSubscriptionRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to update subscription', {
subscriptionId: id,
error: error.message,
});
throw error;
}
}
/**
* Find subscription by ID
*/
async findById(id: string): Promise<Subscription | null> {
const query = 'SELECT * FROM subscriptions WHERE id = $1';
try {
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return this.mapSubscriptionRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to find subscription by ID', {
subscriptionId: id,
error: error.message,
});
throw error;
}
}
// ========== Subscription Events ==========
/**
* Create a subscription event
*/
async createEvent(data: CreateSubscriptionEventRequest): Promise<SubscriptionEvent> {
const query = `
INSERT INTO subscription_events (
subscription_id, stripe_event_id, event_type, payload
)
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const values = [
data.subscriptionId,
data.stripeEventId,
data.eventType,
JSON.stringify(data.payload),
];
try {
const result = await this.pool.query(query, values);
logger.info('Subscription event created', {
eventId: result.rows[0].id,
stripeEventId: data.stripeEventId,
eventType: data.eventType,
});
return this.mapEventRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to create subscription event', {
stripeEventId: data.stripeEventId,
error: error.message,
});
throw error;
}
}
/**
* Find event by Stripe event ID (for idempotency)
*/
async findEventByStripeId(stripeEventId: string): Promise<SubscriptionEvent | null> {
const query = `
SELECT * FROM subscription_events
WHERE stripe_event_id = $1
`;
try {
const result = await this.pool.query(query, [stripeEventId]);
if (result.rows.length === 0) {
return null;
}
return this.mapEventRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to find subscription event by Stripe ID', {
stripeEventId,
error: error.message,
});
throw error;
}
}
// ========== Donations ==========
/**
* Create a donation
*/
async createDonation(data: CreateDonationRequest & { stripePaymentIntentId: string }): Promise<Donation> {
const query = `
INSERT INTO donations (
user_id, stripe_payment_intent_id, amount_cents, currency
)
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const values = [
data.userId,
data.stripePaymentIntentId,
data.amountCents,
data.currency || 'usd',
];
try {
const result = await this.pool.query(query, values);
logger.info('Donation created', {
donationId: result.rows[0].id,
userId: data.userId,
amountCents: data.amountCents,
});
return this.mapDonationRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to create donation', {
userId: data.userId,
error: error.message,
});
throw error;
}
}
/**
* Find donations by user ID
*/
async findDonationsByUserId(userId: string): Promise<Donation[]> {
const query = `
SELECT * FROM donations
WHERE user_id = $1
ORDER BY created_at DESC
`;
try {
const result = await this.pool.query(query, [userId]);
return result.rows.map(row => this.mapDonationRow(row));
} catch (error: any) {
logger.error('Failed to find donations by user ID', {
userId,
error: error.message,
});
throw error;
}
}
/**
* Find donation by Stripe payment intent ID
*/
async findDonationByPaymentIntentId(stripePaymentIntentId: string): Promise<Donation | null> {
const query = `
SELECT * FROM donations
WHERE stripe_payment_intent_id = $1
`;
try {
const result = await this.pool.query(query, [stripePaymentIntentId]);
if (result.rows.length === 0) {
return null;
}
return this.mapDonationRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to find donation by payment intent ID', {
stripePaymentIntentId,
error: error.message,
});
throw error;
}
}
/**
* Update a donation
*/
async updateDonation(id: string, data: UpdateDonationData): Promise<Donation | null> {
const fields = [];
const values = [];
let paramCount = 1;
if (data.status !== undefined) {
fields.push(`status = $${paramCount++}`);
values.push(data.status);
}
if (fields.length === 0) {
logger.warn('No fields to update for donation', { id });
return this.findDonationById(id);
}
values.push(id);
const query = `
UPDATE donations
SET ${fields.join(', ')}
WHERE id = $${paramCount}
RETURNING *
`;
try {
const result = await this.pool.query(query, values);
if (result.rows.length === 0) {
return null;
}
logger.info('Donation updated', { donationId: id });
return this.mapDonationRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to update donation', {
donationId: id,
error: error.message,
});
throw error;
}
}
/**
* Find donation by ID
*/
async findDonationById(id: string): Promise<Donation | null> {
const query = 'SELECT * FROM donations WHERE id = $1';
try {
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return this.mapDonationRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to find donation by ID', {
donationId: id,
error: error.message,
});
throw error;
}
}
// ========== Tier Vehicle Selections ==========
/**
* Create a tier vehicle selection
*/
async createVehicleSelection(data: CreateTierVehicleSelectionRequest): Promise<TierVehicleSelection> {
const query = `
INSERT INTO tier_vehicle_selections (
user_id, vehicle_id
)
VALUES ($1, $2)
RETURNING *
`;
const values = [data.userId, data.vehicleId];
try {
const result = await this.pool.query(query, values);
logger.info('Tier vehicle selection created', {
selectionId: result.rows[0].id,
userId: data.userId,
vehicleId: data.vehicleId,
});
return this.mapVehicleSelectionRow(result.rows[0]);
} catch (error: any) {
logger.error('Failed to create tier vehicle selection', {
userId: data.userId,
vehicleId: data.vehicleId,
error: error.message,
});
throw error;
}
}
/**
* Find vehicle selections by user ID
*/
async findVehicleSelectionsByUserId(userId: string): Promise<TierVehicleSelection[]> {
const query = `
SELECT * FROM tier_vehicle_selections
WHERE user_id = $1
ORDER BY selected_at DESC
`;
try {
const result = await this.pool.query(query, [userId]);
return result.rows.map(row => this.mapVehicleSelectionRow(row));
} catch (error: any) {
logger.error('Failed to find vehicle selections by user ID', {
userId,
error: error.message,
});
throw error;
}
}
/**
* Delete all vehicle selections for a user
*/
async deleteVehicleSelectionsByUserId(userId: string): Promise<void> {
const query = `
DELETE FROM tier_vehicle_selections
WHERE user_id = $1
`;
try {
await this.pool.query(query, [userId]);
logger.info('Vehicle selections deleted', { userId });
} catch (error: any) {
logger.error('Failed to delete vehicle selections', {
userId,
error: error.message,
});
throw error;
}
}
// ========== Private Mapping Methods ==========
private mapSubscriptionRow(row: any): Subscription {
return {
id: row.id,
userId: row.user_id,
stripeCustomerId: row.stripe_customer_id,
stripeSubscriptionId: row.stripe_subscription_id || undefined,
tier: row.tier,
billingCycle: row.billing_cycle || undefined,
status: row.status,
currentPeriodStart: row.current_period_start || undefined,
currentPeriodEnd: row.current_period_end || undefined,
gracePeriodEnd: row.grace_period_end || undefined,
cancelAtPeriodEnd: row.cancel_at_period_end,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
private mapEventRow(row: any): SubscriptionEvent {
return {
id: row.id,
subscriptionId: row.subscription_id,
stripeEventId: row.stripe_event_id,
eventType: row.event_type,
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
createdAt: row.created_at,
};
}
private mapDonationRow(row: any): Donation {
return {
id: row.id,
userId: row.user_id,
stripePaymentIntentId: row.stripe_payment_intent_id,
amountCents: row.amount_cents,
currency: row.currency,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
private mapVehicleSelectionRow(row: any): TierVehicleSelection {
return {
id: row.id,
userId: row.user_id,
vehicleId: row.vehicle_id,
selectedAt: row.selected_at,
};
}
}

View File

@@ -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<Donation | null> {
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<DonationResponse[]> {
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(),
};
}
}

View File

@@ -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<SubscriptionResponse | null> {
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<Subscription> {
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<Subscription> {
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<Subscription> {
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<Subscription> {
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<Subscription> {
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<SubscriptionTier, number | null> = {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, string> = {
'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<any[]> {
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(),
};
}
}

View File

@@ -0,0 +1,133 @@
/**
* @ai-summary Type definitions for subscriptions feature
* @ai-context Core business types for Stripe subscription management
*/
// Subscription tier types (matches DB enum)
export type SubscriptionTier = 'free' | 'pro' | 'enterprise';
// Subscription status types (matches DB enum)
export type SubscriptionStatus = 'active' | 'past_due' | 'canceled' | 'unpaid';
// Billing cycle types (matches DB enum)
export type BillingCycle = 'monthly' | 'yearly';
// Donation status types (matches DB enum)
export type DonationStatus = 'pending' | 'succeeded' | 'failed' | 'canceled';
// Main subscription entity
export interface Subscription {
id: string;
userId: string;
stripeCustomerId: string;
stripeSubscriptionId?: string;
tier: SubscriptionTier;
billingCycle?: BillingCycle;
status: SubscriptionStatus;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
gracePeriodEnd?: Date;
cancelAtPeriodEnd: boolean;
createdAt: Date;
updatedAt: Date;
}
// Subscription event entity (webhook event logging)
export interface SubscriptionEvent {
id: string;
subscriptionId: string;
stripeEventId: string;
eventType: string;
payload: Record<string, any>;
createdAt: Date;
}
// Donation entity (one-time payments)
export interface Donation {
id: string;
userId: string;
stripePaymentIntentId: string;
amountCents: number;
currency: string;
status: DonationStatus;
createdAt: Date;
updatedAt: Date;
}
// Tier vehicle selection entity (tracks which vehicles user selected to keep during downgrade)
export interface TierVehicleSelection {
id: string;
userId: string;
vehicleId: string;
selectedAt: Date;
}
// Request/Response types
export interface CreateSubscriptionRequest {
userId: string;
tier: SubscriptionTier;
billingCycle: BillingCycle;
paymentMethodId?: string;
}
export interface SubscriptionResponse {
id: string;
userId: string;
stripeCustomerId: string;
stripeSubscriptionId?: string;
tier: SubscriptionTier;
billingCycle?: BillingCycle;
status: SubscriptionStatus;
currentPeriodStart?: string;
currentPeriodEnd?: string;
gracePeriodEnd?: string;
cancelAtPeriodEnd: boolean;
createdAt: string;
updatedAt: string;
}
export interface DonationResponse {
id: string;
userId: string;
stripePaymentIntentId: string;
amountCents: number;
currency: string;
status: DonationStatus;
createdAt: string;
updatedAt: string;
}
export interface CreateDonationRequest {
userId: string;
amountCents: number;
currency?: string;
}
export interface CreateSubscriptionEventRequest {
subscriptionId: string;
stripeEventId: string;
eventType: string;
payload: Record<string, any>;
}
export interface CreateTierVehicleSelectionRequest {
userId: string;
vehicleId: string;
}
// Service layer types
export interface UpdateSubscriptionData {
stripeSubscriptionId?: string;
tier?: SubscriptionTier;
billingCycle?: BillingCycle;
status?: SubscriptionStatus;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
gracePeriodEnd?: Date;
cancelAtPeriodEnd?: boolean;
}
export interface UpdateDonationData {
status?: DonationStatus;
}

View File

@@ -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<StripeCustomer> {
try {
logger.info('Creating Stripe customer', { email, name });
const customer = await this.stripe.customers.create({
email,
name,
metadata: {
source: 'motovaultpro',
},
});
logger.info('Stripe customer created', { customerId: customer.id });
return {
id: customer.id,
email: customer.email || email,
name: customer.name || undefined,
created: customer.created,
metadata: customer.metadata,
};
} catch (error: any) {
logger.error('Failed to create Stripe customer', {
email,
error: error.message,
code: error.code,
});
throw error;
}
}
/**
* Create a new subscription for a customer
*/
async createSubscription(
customerId: string,
priceId: string,
paymentMethodId?: string
): Promise<StripeSubscription> {
try {
logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId });
const subscriptionParams: Stripe.SubscriptionCreateParams = {
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
};
if (paymentMethodId) {
subscriptionParams.default_payment_method = paymentMethodId;
}
const subscription = await this.stripe.subscriptions.create(subscriptionParams);
logger.info('Stripe subscription created', { subscriptionId: subscription.id });
return {
id: subscription.id,
customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'],
items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0,
currentPeriodEnd: (subscription as any).current_period_end || 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined,
created: subscription.created,
metadata: subscription.metadata,
};
} catch (error: any) {
logger.error('Failed to create Stripe subscription', {
customerId,
priceId,
error: error.message,
code: error.code,
});
throw error;
}
}
/**
* Cancel a subscription
*/
async cancelSubscription(
subscriptionId: string,
cancelAtPeriodEnd: boolean = false
): Promise<StripeSubscription> {
try {
logger.info('Canceling Stripe subscription', { subscriptionId, cancelAtPeriodEnd });
let subscription: Stripe.Subscription;
if (cancelAtPeriodEnd) {
// Cancel at period end (schedule cancellation)
subscription = await this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
logger.info('Stripe subscription scheduled for cancellation', { subscriptionId });
} else {
// Cancel immediately
subscription = await this.stripe.subscriptions.cancel(subscriptionId);
logger.info('Stripe subscription canceled immediately', { subscriptionId });
}
return {
id: subscription.id,
customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'],
items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0,
currentPeriodEnd: (subscription as any).current_period_end || 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined,
created: subscription.created,
metadata: subscription.metadata,
};
} catch (error: any) {
logger.error('Failed to cancel Stripe subscription', {
subscriptionId,
error: error.message,
code: error.code,
});
throw error;
}
}
/**
* Update the payment method for a customer
*/
async updatePaymentMethod(customerId: string, paymentMethodId: string): Promise<void> {
try {
logger.info('Updating Stripe payment method', { customerId, paymentMethodId });
// Attach payment method to customer
await this.stripe.paymentMethods.attach(paymentMethodId, {
customer: customerId,
});
// Set as default payment method
await this.stripe.customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
logger.info('Stripe payment method updated', { customerId, paymentMethodId });
} catch (error: any) {
logger.error('Failed to update Stripe payment method', {
customerId,
paymentMethodId,
error: error.message,
code: error.code,
});
throw error;
}
}
/**
* Create a payment intent for one-time donations
*/
async createPaymentIntent(amount: number, currency: string = 'usd'): Promise<StripePaymentIntent> {
try {
logger.info('Creating Stripe payment intent', { amount, currency });
const paymentIntent = await this.stripe.paymentIntents.create({
amount,
currency,
metadata: {
source: 'motovaultpro',
type: 'donation',
},
});
logger.info('Stripe payment intent created', { paymentIntentId: paymentIntent.id });
return {
id: paymentIntent.id,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
status: paymentIntent.status,
customer: paymentIntent.customer as string | undefined,
payment_method: paymentIntent.payment_method as string | undefined,
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<StripeSubscription> {
try {
logger.info('Retrieving Stripe subscription', { subscriptionId });
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
return {
id: subscription.id,
customer: subscription.customer as string,
status: subscription.status as StripeSubscription['status'],
items: subscription.items,
currentPeriodStart: (subscription as any).current_period_start || 0,
currentPeriodEnd: (subscription as any).current_period_end || 0,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at || undefined,
created: subscription.created,
metadata: subscription.metadata,
};
} catch (error: any) {
logger.error('Failed to retrieve Stripe subscription', {
subscriptionId,
error: error.message,
code: error.code,
});
throw error;
}
}
/**
* Retrieve a customer by ID
*/
async getCustomer(customerId: string): Promise<StripeCustomer> {
try {
logger.info('Retrieving Stripe customer', { customerId });
const customer = await this.stripe.customers.retrieve(customerId);
if (customer.deleted) {
throw new Error('Customer has been deleted');
}
return {
id: customer.id,
email: customer.email || '',
name: customer.name || undefined,
created: customer.created,
metadata: customer.metadata,
};
} catch (error: any) {
logger.error('Failed to retrieve Stripe customer', {
customerId,
error: error.message,
code: error.code,
});
throw error;
}
}
/**
* List invoices for a customer
*/
async listInvoices(customerId: string): Promise<any[]> {
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;
}
}
}

View File

@@ -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<string, string>;
}
// Stripe Subscription
export interface StripeSubscription {
id: string;
customer: string;
status: 'incomplete' | 'incomplete_expired' | 'trialing' | 'active' | 'past_due' | 'canceled' | 'unpaid' | 'paused';
items: unknown;
currentPeriodStart: number;
currentPeriodEnd: number;
cancelAtPeriodEnd: boolean;
canceledAt?: number;
created: number;
metadata?: Record<string, string>;
}
// Stripe Payment Method
export interface StripePaymentMethod {
id: string;
type: string;
card?: {
brand: string;
last4: string;
exp_month: number;
exp_year: number;
};
}
// Stripe Payment Intent (for donations)
export interface StripePaymentIntent {
id: string;
amount: number;
currency: string;
status: 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'processing' | 'requires_capture' | 'canceled' | 'succeeded';
customer?: string;
payment_method?: string;
client_secret: string | null;
created: number;
metadata?: Record<string, string>;
}
// Stripe Webhook Event
export interface StripeWebhookEvent {
id: string;
type: string;
data: {
object: any;
};
created: number;
}
// Stripe Price (for subscription plans)
export interface StripePrice {
id: string;
product: string;
unit_amount: number;
currency: string;
recurring?: {
interval: 'day' | 'week' | 'month' | 'year';
interval_count: number;
};
metadata?: Record<string, string>;
}
// Stripe Error
export interface StripeError {
type: string;
code?: string;
message: string;
param?: string;
}

View File

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

View File

@@ -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<GracePeriodResult> {
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;
}

View File

@@ -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();

View File

@@ -203,23 +203,85 @@ export class VehiclesService {
async getUserVehicles(userId: string): Promise<VehicleResponse[]> {
const cacheKey = `${this.cachePrefix}:user:${userId}`;
// Check cache
const cached = await cacheService.get<VehicleResponse[]>(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<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> {
// 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<SubscriptionTier, number | null> = {
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<VehicleResponse> {
const vehicle = await this.repository.findById(id);

View File

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

View File

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

View File

@@ -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
VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key
# Stripe Configuration
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here

View File

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

View File

@@ -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"
},

View File

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

View File

@@ -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() {
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "Subscription" && (
<motion.div
key="subscription"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="Subscription">
<React.Suspense fallback={
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<div className="text-slate-500 py-6 text-center">
Loading subscription...
</div>
</div>
</GlassCard>
</div>
}>
<SubscriptionMobileScreen />
</React.Suspense>
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "Documents" && (
<motion.div
key="documents"
@@ -1012,6 +1041,7 @@ function App() {
<Route path="/garage/stations" element={<StationsPage />} />
<Route path="/garage/settings" element={<SettingsPage />} />
<Route path="/garage/settings/security" element={<SecuritySettingsPage />} />
<Route path="/garage/settings/subscription" element={<SubscriptionPage />} />
<Route path="/garage/settings/admin/users" element={<AdminUsersPage />} />
<Route path="/garage/settings/admin/catalog" element={<AdminCatalogPage />} />
<Route path="/garage/settings/admin/community-stations" element={<AdminCommunityStationsPage />} />

View File

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

View File

@@ -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 = () => {
</div>
</GlassCard>
{/* Subscription Section */}
<GlassCard padding="md">
<div>
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus">
Subscription
</h2>
</div>
<button
onClick={() => navigateToScreen('Subscription')}
className="px-3 py-1.5 bg-primary-500 text-white rounded-lg text-sm font-medium hover:bg-primary-600 transition-colors dark:bg-primary-600 dark:hover:bg-primary-700"
style={{ minHeight: '44px', minWidth: '44px' }}
>
Manage
</button>
</div>
{subscriptionLoading ? (
<div className="flex justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-500"></div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-800 dark:text-avus">Current Plan:</span>
<span className="px-2 py-0.5 bg-primary-500 text-white text-xs font-semibold rounded-full">
{(subscription?.tier || 'free').toUpperCase()}
</span>
{subscription?.status && subscription.status !== 'active' && (
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${
subscription.status === 'past_due'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
}`}>
{subscription.status.replace('_', ' ')}
</span>
)}
</div>
<p className="text-sm text-slate-500 dark:text-titanio">
{!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.'}
</p>
</div>
)}
</div>
</GlassCard>
{/* Notifications Section */}
<GlassCard padding="md">
<div>

View File

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

View File

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

View File

@@ -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'),
};

View File

@@ -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<BillingHistoryProps> = ({ invoices }) => {
if (!invoices || invoices.length === 0) {
return (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No billing history available
</Typography>
</Box>
);
}
const getStatusColor = (status: Invoice['status']) => {
switch (status) {
case 'paid':
return 'success';
case 'pending':
return 'warning';
case 'failed':
return 'error';
default:
return 'default';
}
};
return (
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id} hover>
<TableCell>
{format(new Date(invoice.date), 'MMM dd, yyyy')}
</TableCell>
<TableCell>
${(invoice.amount / 100).toFixed(2)}
</TableCell>
<TableCell>
<Chip
label={invoice.status}
color={getStatusColor(invoice.status)}
size="small"
/>
</TableCell>
<TableCell align="right">
{invoice.pdfUrl && (
<IconButton
size="small"
href={invoice.pdfUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Download invoice PDF"
>
<DownloadIcon fontSize="small" />
</IconButton>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};

View File

@@ -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<DonationSectionProps> = ({ currentTier }) => {
const stripe = useStripe();
const elements = useElements();
const [amount, setAmount] = useState<string>('');
const [error, setError] = useState<string | null>(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 (
<Card padding="lg">
<Typography variant="h6" fontWeight="bold" gutterBottom>
Support MotoVaultPro
</Typography>
{currentTier === 'free' && (
<Alert severity="info" sx={{ mb: 3 }}>
Love MotoVaultPro? Consider making a one-time donation to support development!
</Alert>
)}
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mb: 3 }}>
Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support!
</Typography>
{showSuccess && (
<Alert severity="success" sx={{ mb: 3 }}>
Thank you for your generous donation! Your support means the world to us.
</Alert>
)}
<form onSubmit={handleSubmit}>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Donation Amount
</Typography>
<TextField
fullWidth
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Enter amount"
InputProps={{
startAdornment: <Typography sx={{ mr: 1 }}>$</Typography>,
}}
inputProps={{
min: 0.5,
step: 0.01,
}}
disabled={processing}
/>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Card Details
</Typography>
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 2,
'&:hover': {
borderColor: 'primary.main',
},
}}
>
<CardElement options={CARD_ELEMENT_OPTIONS} onChange={handleCardChange} />
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
disabled={!isFormValid}
startIcon={processing && <CircularProgress size={20} />}
>
{processing ? 'Processing...' : 'Donate'}
</Button>
</form>
{donations.length > 0 && (
<Box sx={{ mt: 4 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
Donation History
</Typography>
{isLoadingDonations ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
</Box>
) : (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{donations.map((donation: any) => (
<TableRow key={donation.id}>
<TableCell>{format(new Date(donation.createdAt), 'MMM dd, yyyy')}</TableCell>
<TableCell>${(donation.amountCents / 100).toFixed(2)}</TableCell>
<TableCell>
<Chip
label={donation.status}
color={donation.status === 'succeeded' ? 'success' : 'default'}
size="small"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
)}
</Card>
);
};

View File

@@ -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<DonationSectionMobileProps> = ({ currentTier }) => {
const stripe = useStripe();
const elements = useElements();
const [amount, setAmount] = useState<string>('');
const [error, setError] = useState<string | null>(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 (
<GlassCard>
<h2 className="text-xl font-bold text-slate-800 dark:text-avus mb-3">
Support MotoVaultPro
</h2>
{currentTier === 'free' && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-xl p-3 mb-4">
<p className="text-sm text-blue-800 dark:text-blue-300">
Love MotoVaultPro? Consider making a one-time donation to support development!
</p>
</div>
)}
<p className="text-sm text-slate-600 dark:text-titanio mb-4">
Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support!
</p>
{showSuccess && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-xl p-3 mb-4">
<p className="text-sm text-green-800 dark:text-green-300">
Thank you for your generous donation! Your support means the world to us.
</p>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-2">
Donation Amount
</label>
<div className="relative">
<span className="absolute left-3 top-3 text-slate-600 dark:text-titanio">$</span>
<input
type="number"
value={amount}
onChange={(e) => 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]"
/>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-2">
Card Details
</label>
<div className="border border-slate-200 dark:border-grigio rounded-xl p-3 bg-white dark:bg-nero">
<CardElement options={CARD_ELEMENT_OPTIONS} onChange={handleCardChange} />
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-xl p-3 mb-4">
<p className="text-sm text-red-800 dark:text-red-300">{error}</p>
</div>
)}
<button
type="submit"
disabled={!isFormValid}
className={`w-full py-3 px-4 rounded-xl font-semibold min-h-[44px] ${
isFormValid
? 'bg-rose-500 text-white hover:bg-rose-600'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{processing ? 'Processing...' : 'Donate'}
</button>
</form>
{donations.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-bold text-slate-800 dark:text-avus mb-3">
Donation History
</h3>
{isLoadingDonations ? (
<div className="flex justify-center p-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-rose-500"></div>
</div>
) : (
<div className="space-y-2">
{donations.map((donation: any) => (
<div
key={donation.id}
className="flex justify-between items-center p-3 bg-slate-50 dark:bg-scuro rounded-xl"
>
<div>
<div className="text-sm font-medium text-slate-800 dark:text-avus">
{format(new Date(donation.createdAt), 'MMM dd, yyyy')}
</div>
<div className="text-xs text-slate-600 dark:text-titanio">
${(donation.amountCents / 100).toFixed(2)}
</div>
</div>
<span
className={`text-xs px-2 py-1 rounded-full ${
donation.status === 'succeeded'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
}`}
>
{donation.status}
</span>
</div>
))}
</div>
)}
</div>
)}
</GlassCard>
);
};

View File

@@ -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<SubscriptionTier, number | null> = {
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 (
<VehicleSelectionDialog
open={showVehicleSelection}
onClose={handleVehicleSelectionCancel}
onConfirm={handleVehicleSelectionConfirm}
vehicles={vehicles || []}
maxSelections={targetLimit || 0}
targetTier={targetTier}
/>
);
};

View File

@@ -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<PaymentMethodFormProps> = ({
onSubmit,
isLoading = false,
}) => {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(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 (
<form onSubmit={handleSubmit}>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Card Details
</Typography>
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 2,
'&:hover': {
borderColor: 'primary.main',
},
}}
>
<CardElement options={CARD_ELEMENT_OPTIONS} onChange={handleCardChange} />
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
disabled={!stripe || processing || isLoading || !cardComplete}
>
{processing || isLoading ? (
<CircularProgress size={24} />
) : (
'Update Payment Method'
)}
</Button>
</form>
);
};

View File

@@ -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<TierCardProps> = ({
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 (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
border: isCurrent ? 2 : 1,
borderColor: isCurrent ? 'primary.main' : 'divider',
}}
>
{isCurrent && (
<Chip
label="Current Plan"
color="primary"
size="small"
sx={{
position: 'absolute',
top: 16,
right: 16,
}}
/>
)}
<CardContent sx={{ flexGrow: 1, pt: isCurrent ? 6 : 3 }}>
<Typography variant="h5" component="h3" gutterBottom fontWeight="bold">
{plan.name}
</Typography>
<Box sx={{ my: 3 }}>
<Typography variant="h3" component="div" fontWeight="bold">
${price.toFixed(2)}
</Typography>
<Typography variant="body2" color="text.secondary">
{priceLabel}
</Typography>
</Box>
<List dense>
{plan.features.map((feature, index) => (
<ListItem key={index} disableGutters>
<ListItemIcon sx={{ minWidth: 36 }}>
<CheckCircleIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={feature}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItem>
))}
</List>
</CardContent>
<CardContent sx={{ pt: 0 }}>
{isCurrent ? (
<Button
variant="outlined"
fullWidth
disabled
>
Current Plan
</Button>
) : (
<Button
variant={plan.tier === 'enterprise' ? 'contained' : 'outlined'}
color="primary"
fullWidth
disabled={isLoading}
onClick={onUpgrade}
>
{plan.tier === 'free' ? 'Downgrade' : 'Upgrade'}
</Button>
)}
</CardContent>
</Card>
);
};

View File

@@ -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<string[]>([]);
// 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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Select Vehicles to Keep</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
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.
</Alert>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="text.secondary">
Selected {selectedVehicleIds.length} of {maxSelections} allowed
</Typography>
</Box>
<FormGroup>
{vehicles.map((vehicle) => (
<FormControlLabel
key={vehicle.id}
control={
<Checkbox
checked={selectedVehicleIds.includes(vehicle.id)}
onChange={() => handleToggle(vehicle.id)}
disabled={
!selectedVehicleIds.includes(vehicle.id) &&
selectedVehicleIds.length >= maxSelections
}
/>
}
label={getVehicleLabel(vehicle)}
/>
))}
</FormGroup>
{selectedVehicleIds.length === 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
You must select at least one vehicle.
</Alert>
)}
{selectedVehicleIds.length > maxSelections && (
<Alert severity="error" sx={{ mt: 2 }}>
You can only select up to {maxSelections} vehicle{maxSelections > 1 ? 's' : ''}.
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={handleConfirm} variant="contained" disabled={!canConfirm}>
Confirm Downgrade
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -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'],
},
];

View File

@@ -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,
});
};

View File

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

View File

@@ -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<MobileTierCardProps> = ({
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 (
<GlassCard className={`${isCurrent ? 'border-2 border-rose-500' : ''}`}>
{isCurrent && (
<div className="mb-3">
<span className="inline-block bg-rose-500 text-white text-xs font-semibold px-3 py-1 rounded-full">
Current Plan
</span>
</div>
)}
<h3 className="text-xl font-bold text-slate-800 dark:text-avus mb-2">
{plan.name}
</h3>
<div className="my-4">
<div className="text-3xl font-bold text-slate-900 dark:text-white">
${price.toFixed(2)}
</div>
<div className="text-sm text-slate-600 dark:text-titanio">
{priceLabel}
</div>
</div>
<ul className="space-y-2 mb-4">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<span className="text-rose-500 mt-0.5"></span>
<span className="text-slate-700 dark:text-avus">{feature}</span>
</li>
))}
</ul>
{isCurrent ? (
<button
disabled
className="w-full py-3 px-4 bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-xl font-semibold"
>
Current Plan
</button>
) : (
<button
onClick={onUpgrade}
disabled={isLoading}
className={`w-full py-3 px-4 rounded-xl font-semibold min-h-[44px] ${
plan.tier === 'enterprise'
? 'bg-rose-500 text-white hover:bg-rose-600 disabled:bg-gray-300'
: 'border-2 border-rose-500 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-900/20 disabled:border-gray-300 disabled:text-gray-400'
}`}
>
{plan.tier === 'free' ? 'Downgrade' : 'Upgrade'}
</button>
)}
</GlassCard>
);
};
interface MobileModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const MobileModal: React.FC<MobileModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-scuro rounded-3xl p-6 max-w-md w-full">
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">{title}</h3>
{children}
<div className="flex justify-end mt-4">
<button
onClick={onClose}
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl font-medium min-h-[44px]"
>
Close
</button>
</div>
</div>
</div>
);
};
export const SubscriptionMobileScreen: React.FC = () => {
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(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 (
<MobileContainer>
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-rose-500"></div>
</div>
</MobileContainer>
);
}
return (
<MobileContainer>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-4">
Subscription
</h1>
{subscription && (
<GlassCard>
<div className="flex gap-2 mb-3">
<span className="inline-block bg-rose-500 text-white text-xs font-semibold px-3 py-1 rounded-full">
{subscription.tier.toUpperCase()}
</span>
<span className={`inline-block text-xs font-semibold px-3 py-1 rounded-full ${getStatusColor(subscription.status)}`}>
{subscription.status}
</span>
</div>
<h2 className="text-xl font-bold text-slate-800 dark:text-avus mb-2">
Current Plan: {PLANS.find((p) => p.tier === subscription.tier)?.name || subscription.tier}
</h2>
{subscription.currentPeriodEnd && (
<p className="text-sm text-slate-600 dark:text-titanio mb-3">
Next billing date: {format(new Date(subscription.currentPeriodEnd), 'MMM dd, yyyy')}
</p>
)}
{subscription.cancelAtPeriodEnd && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-xl p-3 mb-3">
<p className="text-sm text-yellow-800 dark:text-yellow-300">
Your subscription will be canceled at the end of the current billing period.
</p>
</div>
)}
<div className="mt-4">
{subscription.cancelAtPeriodEnd ? (
<button
onClick={handleReactivate}
disabled={reactivateMutation.isPending}
className="w-full py-3 px-4 border-2 border-rose-500 text-rose-500 rounded-xl font-semibold min-h-[44px] hover:bg-rose-50 dark:hover:bg-rose-900/20 disabled:opacity-50"
>
Reactivate
</button>
) : subscription.tier !== 'free' ? (
<button
onClick={handleCancel}
disabled={cancelMutation.isPending}
className="w-full py-3 px-4 border-2 border-red-500 text-red-500 rounded-xl font-semibold min-h-[44px] hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
>
Cancel Subscription
</button>
) : null}
</div>
</GlassCard>
)}
<GlassCard>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-slate-800 dark:text-avus">
Available Plans
</h2>
<div className="flex bg-slate-100 dark:bg-scuro rounded-lg p-1">
<button
onClick={() => setBillingCycle('monthly')}
className={`px-3 py-1 text-sm font-medium rounded min-h-[36px] ${
billingCycle === 'monthly'
? 'bg-white dark:bg-nero text-rose-500 shadow-sm'
: 'text-slate-600 dark:text-titanio'
}`}
>
Monthly
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={`px-3 py-1 text-sm font-medium rounded min-h-[36px] ${
billingCycle === 'yearly'
? 'bg-white dark:bg-nero text-rose-500 shadow-sm'
: 'text-slate-600 dark:text-titanio'
}`}
>
Yearly
</button>
</div>
</div>
<div className="space-y-4">
{PLANS.map((plan) => (
<MobileTierCard
key={plan.tier}
plan={plan}
billingCycle={billingCycle}
currentTier={subscription?.tier}
isLoading={checkoutMutation.isPending}
onUpgrade={() => handleUpgradeClick(plan.tier)}
/>
))}
</div>
</GlassCard>
<GlassCard>
<h2 className="text-xl font-bold text-slate-800 dark:text-avus mb-4">
Billing History
</h2>
{isLoadingInvoices ? (
<div className="flex justify-center p-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-rose-500"></div>
</div>
) : invoices.length === 0 ? (
<p className="text-center text-sm text-slate-600 dark:text-titanio p-6">
No billing history available
</p>
) : (
<div className="space-y-2">
{invoices.map((invoice: { id: string; date: string; amount: number; status: string; pdfUrl?: string }) => (
<div
key={invoice.id}
className="flex justify-between items-center p-3 bg-slate-50 dark:bg-scuro rounded-xl"
>
<div>
<div className="text-sm font-medium text-slate-800 dark:text-avus">
{format(new Date(invoice.date), 'MMM dd, yyyy')}
</div>
<div className="text-xs text-slate-600 dark:text-titanio">
${(invoice.amount / 100).toFixed(2)}
</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-1 rounded-full ${
invoice.status === 'paid'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: invoice.status === 'pending'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
}`}>
{invoice.status}
</span>
{invoice.pdfUrl && (
<a
href={invoice.pdfUrl}
target="_blank"
rel="noopener noreferrer"
className="text-rose-500 text-sm min-h-[44px] min-w-[44px] flex items-center justify-center"
>
</a>
)}
</div>
</div>
))}
</div>
)}
</GlassCard>
<Elements stripe={stripePromise}>
<DonationSectionMobile currentTier={subscription?.tier} />
</Elements>
</div>
<MobileModal
isOpen={showPaymentDialog}
onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)}
title={`Upgrade to ${selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}`}
>
<Elements stripe={stripePromise}>
<PaymentMethodForm
onSubmit={handlePaymentSubmit}
isLoading={checkoutMutation.isPending}
/>
</Elements>
</MobileModal>
</MobileContainer>
);
};

View File

@@ -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<BillingCycle>('monthly');
const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(null);
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
const [showDowngradeFlow, setShowDowngradeFlow] = useState(false);
const [downgradeTargetTier, setDowngradeTargetTier] = useState<SubscriptionTier | null>(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<HTMLElement>, 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 (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
<CircularProgress />
</Box>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'success';
case 'past_due':
return 'warning';
case 'canceled':
return 'error';
default:
return 'default';
}
};
return (
<Box sx={{ p: 3 }}>
<Typography variant="h4" gutterBottom fontWeight="bold">
Subscription
</Typography>
{subscription && (
<Card padding="lg" className="mb-6">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Chip
label={subscription.tier.toUpperCase()}
color="primary"
size="small"
/>
<Chip
label={subscription.status}
color={getStatusColor(subscription.status)}
size="small"
/>
</Box>
<Typography variant="h6" gutterBottom>
Current Plan: {PLANS.find((p) => p.tier === subscription.tier)?.name || subscription.tier}
</Typography>
{subscription.currentPeriodEnd && (
<Typography variant="body2" color="text.secondary" gutterBottom>
Next billing date: {format(new Date(subscription.currentPeriodEnd), 'MMM dd, yyyy')}
</Typography>
)}
{subscription.cancelAtPeriodEnd && (
<Alert severity="warning" sx={{ mt: 2 }}>
Your subscription will be canceled at the end of the current billing period.
</Alert>
)}
</Box>
<Box>
{subscription.cancelAtPeriodEnd ? (
<Button
variant="outlined"
color="primary"
onClick={handleReactivate}
disabled={reactivateMutation.isPending}
>
Reactivate
</Button>
) : subscription.tier !== 'free' ? (
<Button
variant="outlined"
color="error"
onClick={handleCancel}
disabled={cancelMutation.isPending}
>
Cancel Subscription
</Button>
) : null}
</Box>
</Box>
</Card>
)}
<Card padding="lg" className="mb-6">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6" fontWeight="bold">
Available Plans
</Typography>
<ToggleButtonGroup
value={billingCycle}
exclusive
onChange={handleBillingCycleChange}
size="small"
>
<ToggleButton value="monthly">Monthly</ToggleButton>
<ToggleButton value="yearly">Yearly</ToggleButton>
</ToggleButtonGroup>
</Box>
<Grid container spacing={3}>
{PLANS.map((plan) => (
<Grid item xs={12} md={4} key={plan.tier}>
<TierCard
plan={plan}
billingCycle={billingCycle}
currentTier={subscription?.tier}
isLoading={checkoutMutation.isPending}
onUpgrade={() => handleUpgradeClick(plan.tier)}
/>
</Grid>
))}
</Grid>
</Card>
<Card padding="lg" className="mb-6">
<Typography variant="h6" fontWeight="bold" gutterBottom>
Billing History
</Typography>
{isLoadingInvoices ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
</Box>
) : (
<BillingHistory invoices={invoices} />
)}
</Card>
<Elements stripe={stripePromise}>
<DonationSection currentTier={subscription?.tier} />
</Elements>
<Dialog
open={showPaymentDialog}
onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>
Upgrade to {selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}
</DialogTitle>
<DialogContent>
<Elements stripe={stripePromise}>
<PaymentMethodForm
onSubmit={handlePaymentSubmit}
isLoading={checkoutMutation.isPending}
/>
</Elements>
</DialogContent>
<DialogActions>
<Button
onClick={() => setShowPaymentDialog(false)}
disabled={checkoutMutation.isPending}
>
Cancel
</Button>
</DialogActions>
</Dialog>
{showDowngradeFlow && downgradeTargetTier && (
<DowngradeFlow
targetTier={downgradeTargetTier}
onComplete={handleDowngradeComplete}
onCancel={handleDowngradeCancel}
/>
)}
</Box>
);
};

View File

@@ -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[];
}

View File

@@ -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 = () => {
)}
</Card>
{/* Subscription Section */}
<Card>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CreditCardIcon color="primary" />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Subscription
</Typography>
</Box>
<MuiButton
variant="contained"
size="small"
onClick={() => navigate('/garage/settings/subscription')}
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.dark'
}
}}
>
Manage
</MuiButton>
</Box>
{subscriptionLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size={24} />
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
Current Plan:
</Typography>
<Chip
label={(subscription?.tier || 'free').toUpperCase()}
color="primary"
size="small"
/>
{subscription?.status && subscription.status !== 'active' && (
<Chip
label={subscription.status.replace('_', ' ')}
color={subscription.status === 'past_due' ? 'warning' : 'error'}
size="small"
/>
)}
</Box>
<Typography variant="body2" color="text.secondary">
{!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.'}
</Typography>
</Box>
)}
</Card>
{/* Notifications Section */}
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Notifications
</Typography>
<List disablePadding>
<ListItem>
<ListItemIcon>
<NotificationsIcon />
</ListItemIcon>
<ListItemText
primary="Push Notifications"
<ListItemText
primary="Push Notifications"
secondary="Receive notifications about your vehicles"
/>
<ListItemSecondaryAction>

View File

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

View File

@@ -0,0 +1 @@
stripe-secret-key

View File

@@ -0,0 +1 @@
stripe-webhook-secret