feat: Accept Payments - Stripe Integration with User Tiers (#55) #56
0
.claude/skills/planner/POST
Normal file
0
.claude/skills/planner/POST
Normal file
0
.claude/skills/planner/SubscriptionPage
Normal file
0
.claude/skills/planner/SubscriptionPage
Normal file
0
.claude/skills/planner/sync
Normal file
0
.claude/skills/planner/sync
Normal 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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
36
backend/package-lock.json
generated
36
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>'
|
||||
);
|
||||
58
backend/src/features/subscriptions/CLAUDE.md
Normal file
58
backend/src/features/subscriptions/CLAUDE.md
Normal 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
|
||||
189
backend/src/features/subscriptions/README.md
Normal file
189
backend/src/features/subscriptions/README.md
Normal 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 |
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
26
backend/src/features/subscriptions/api/donations.routes.ts
Normal file
26
backend/src/features/subscriptions/api/donations.routes.ts
Normal 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),
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
24
backend/src/features/subscriptions/api/webhooks.routes.ts
Normal file
24
backend/src/features/subscriptions/api/webhooks.routes.ts
Normal 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)
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
150
backend/src/features/subscriptions/domain/donations.service.ts
Normal file
150
backend/src/features/subscriptions/domain/donations.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
133
backend/src/features/subscriptions/domain/subscriptions.types.ts
Normal file
133
backend/src/features/subscriptions/domain/subscriptions.types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for subscriptions feature
|
||||
* @ai-context Core business types for Stripe subscription management
|
||||
*/
|
||||
|
||||
// Subscription tier types (matches DB enum)
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
// Subscription status types (matches DB enum)
|
||||
export type SubscriptionStatus = 'active' | 'past_due' | 'canceled' | 'unpaid';
|
||||
|
||||
// Billing cycle types (matches DB enum)
|
||||
export type BillingCycle = 'monthly' | 'yearly';
|
||||
|
||||
// Donation status types (matches DB enum)
|
||||
export type DonationStatus = 'pending' | 'succeeded' | 'failed' | 'canceled';
|
||||
|
||||
// Main subscription entity
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId?: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
status: SubscriptionStatus;
|
||||
currentPeriodStart?: Date;
|
||||
currentPeriodEnd?: Date;
|
||||
gracePeriodEnd?: Date;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Subscription event entity (webhook event logging)
|
||||
export interface SubscriptionEvent {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
stripeEventId: string;
|
||||
eventType: string;
|
||||
payload: Record<string, any>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Donation entity (one-time payments)
|
||||
export interface Donation {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripePaymentIntentId: string;
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
status: DonationStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Tier vehicle selection entity (tracks which vehicles user selected to keep during downgrade)
|
||||
export interface TierVehicleSelection {
|
||||
id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
selectedAt: Date;
|
||||
}
|
||||
|
||||
// Request/Response types
|
||||
|
||||
export interface CreateSubscriptionRequest {
|
||||
userId: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle: BillingCycle;
|
||||
paymentMethodId?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId?: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
status: SubscriptionStatus;
|
||||
currentPeriodStart?: string;
|
||||
currentPeriodEnd?: string;
|
||||
gracePeriodEnd?: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DonationResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripePaymentIntentId: string;
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
status: DonationStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateDonationRequest {
|
||||
userId: string;
|
||||
amountCents: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionEventRequest {
|
||||
subscriptionId: string;
|
||||
stripeEventId: string;
|
||||
eventType: string;
|
||||
payload: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CreateTierVehicleSelectionRequest {
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
// Service layer types
|
||||
export interface UpdateSubscriptionData {
|
||||
stripeSubscriptionId?: string;
|
||||
tier?: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
status?: SubscriptionStatus;
|
||||
currentPeriodStart?: Date;
|
||||
currentPeriodEnd?: Date;
|
||||
gracePeriodEnd?: Date;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDonationData {
|
||||
status?: DonationStatus;
|
||||
}
|
||||
351
backend/src/features/subscriptions/external/stripe/stripe.client.ts
vendored
Normal file
351
backend/src/features/subscriptions/external/stripe/stripe.client.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
83
backend/src/features/subscriptions/external/stripe/stripe.types.ts
vendored
Normal file
83
backend/src/features/subscriptions/external/stripe/stripe.types.ts
vendored
Normal 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;
|
||||
}
|
||||
50
backend/src/features/subscriptions/index.ts
Normal file
50
backend/src/features/subscriptions/index.ts
Normal 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';
|
||||
132
backend/src/features/subscriptions/jobs/grace-period.job.ts
Normal file
132
backend/src/features/subscriptions/jobs/grace-period.job.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
41
frontend/package-lock.json
generated
41
frontend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
33
frontend/src/features/subscription/CLAUDE.md
Normal file
33
frontend/src/features/subscription/CLAUDE.md
Normal 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
|
||||
157
frontend/src/features/subscription/README.md
Normal file
157
frontend/src/features/subscription/README.md
Normal 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
|
||||
14
frontend/src/features/subscription/api/subscription.api.ts
Normal file
14
frontend/src/features/subscription/api/subscription.api.ts
Normal 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'),
|
||||
};
|
||||
100
frontend/src/features/subscription/components/BillingHistory.tsx
Normal file
100
frontend/src/features/subscription/components/BillingHistory.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
101
frontend/src/features/subscription/components/TierCard.tsx
Normal file
101
frontend/src/features/subscription/components/TierCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
28
frontend/src/features/subscription/constants/plans.ts
Normal file
28
frontend/src/features/subscription/constants/plans.ts
Normal 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'],
|
||||
},
|
||||
];
|
||||
117
frontend/src/features/subscription/hooks/useSubscription.ts
Normal file
117
frontend/src/features/subscription/hooks/useSubscription.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
5
frontend/src/features/subscription/index.ts
Normal file
5
frontend/src/features/subscription/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
308
frontend/src/features/subscription/pages/SubscriptionPage.tsx
Normal file
308
frontend/src/features/subscription/pages/SubscriptionPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
1
secrets/app/stripe-secret-key.txt.example
Normal file
1
secrets/app/stripe-secret-key.txt.example
Normal file
@@ -0,0 +1 @@
|
||||
stripe-secret-key
|
||||
1
secrets/app/stripe-webhook-secret.txt.example
Normal file
1
secrets/app/stripe-webhook-secret.txt.example
Normal file
@@ -0,0 +1 @@
|
||||
stripe-webhook-secret
|
||||
Reference in New Issue
Block a user