feat: add subscriptions feature capsule - M1 database schema and Stripe client (refs #55)
- Create 4 new tables: subscriptions, subscription_events, donations, tier_vehicle_selections - Add StripeClient wrapper with createCustomer, createSubscription, cancelSubscription, updatePaymentMethod, createPaymentIntent, constructWebhookEvent methods - Implement SubscriptionsRepository with full CRUD and mapRow case conversion - Add domain types for all subscription entities - Install stripe npm package v20.2.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
36
backend/package-lock.json
generated
36
backend/package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"opossum": "^8.0.0",
|
"opossum": "^8.0.0",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"resend": "^3.0.0",
|
"resend": "^3.0.0",
|
||||||
|
"stripe": "^20.2.0",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
@@ -1960,7 +1961,7 @@
|
|||||||
"version": "22.19.3",
|
"version": "22.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2899,7 +2900,6 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -6258,7 +6258,6 @@
|
|||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -6906,10 +6905,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -7319,7 +7317,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7339,7 +7336,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7356,7 +7352,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -7375,7 +7370,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -7635,6 +7629,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/strtok3": {
|
||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||||
|
|||||||
@@ -18,45 +18,46 @@
|
|||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg": "^8.13.1",
|
"@fastify/autoload": "^6.0.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/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/multipart": "^9.0.1",
|
||||||
"@fastify/type-provider-typebox": "^6.1.0",
|
"@fastify/type-provider-typebox": "^6.1.0",
|
||||||
"@sinclair/typebox": "^0.34.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",
|
"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": {
|
"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",
|
"@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"
|
"typescript-eslint": "^8.18.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
backend/src/features/subscriptions/README.md
Normal file
79
backend/src/features/subscriptions/README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Subscriptions Feature
|
||||||
|
|
||||||
|
Stripe integration for subscription management, donations, and tier-based vehicle limits.
|
||||||
|
|
||||||
|
## Milestone 1: Core Infrastructure (COMPLETE)
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
- `subscriptions` - User subscription records with Stripe integration
|
||||||
|
- `subscription_events` - Webhook event logging for idempotency
|
||||||
|
- `donations` - One-time payment tracking
|
||||||
|
- `tier_vehicle_selections` - User vehicle selections during tier downgrades
|
||||||
|
|
||||||
|
### Type Definitions
|
||||||
|
- **Domain Types** (`domain/subscriptions.types.ts`): Core business entities and request/response types
|
||||||
|
- **Stripe Types** (`external/stripe/stripe.types.ts`): Simplified Stripe API response types
|
||||||
|
|
||||||
|
### Data Access
|
||||||
|
- **Repository** (`data/subscriptions.repository.ts`): Database operations with proper snake_case to camelCase mapping
|
||||||
|
- Subscription CRUD operations
|
||||||
|
- Event logging with idempotency checks
|
||||||
|
- Donation tracking
|
||||||
|
- Vehicle selection management
|
||||||
|
|
||||||
|
### External Integration
|
||||||
|
- **Stripe Client** (`external/stripe/stripe.client.ts`): Stripe API wrapper with error handling
|
||||||
|
- Customer creation
|
||||||
|
- Subscription lifecycle management
|
||||||
|
- Payment intent creation for donations
|
||||||
|
- Webhook event verification
|
||||||
|
- Payment method updates
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required for Stripe integration:
|
||||||
|
- `STRIPE_SECRET_KEY` - Stripe API secret key
|
||||||
|
- `STRIPE_WEBHOOK_SECRET` - Stripe webhook signing secret
|
||||||
|
|
||||||
|
## Subscription Tiers
|
||||||
|
|
||||||
|
Defined in `user_profiles.subscription_tier` enum:
|
||||||
|
- `free` - Limited features (default)
|
||||||
|
- `pro` - Enhanced features
|
||||||
|
- `enterprise` - Full features
|
||||||
|
|
||||||
|
## Billing Cycles
|
||||||
|
|
||||||
|
- `monthly` - Monthly billing
|
||||||
|
- `yearly` - Annual billing
|
||||||
|
|
||||||
|
## Subscription Status
|
||||||
|
|
||||||
|
- `active` - Subscription is active
|
||||||
|
- `past_due` - Payment failed, in grace period
|
||||||
|
- `canceled` - Subscription canceled
|
||||||
|
- `unpaid` - Payment failed, grace period expired
|
||||||
|
|
||||||
|
## Next Steps (Future Milestones)
|
||||||
|
|
||||||
|
- M2: Subscription service layer with business logic
|
||||||
|
- M3: API endpoints for subscription management
|
||||||
|
- M4: Webhook handlers for Stripe events
|
||||||
|
- M5: Frontend integration and subscription UI
|
||||||
|
- M6: Testing and documentation
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
Run migrations:
|
||||||
|
```bash
|
||||||
|
npm run migrate:feature subscriptions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- All database columns use snake_case
|
||||||
|
- All TypeScript properties use camelCase
|
||||||
|
- Repository `mapRow()` methods handle case conversion
|
||||||
|
- Prepared statements used for all queries (no string concatenation)
|
||||||
|
- Comprehensive error logging with structured logger
|
||||||
|
- Webhook idempotency via `stripe_event_id` unique constraint
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
326
backend/src/features/subscriptions/external/stripe/stripe.client.ts
vendored
Normal file
326
backend/src/features/subscriptions/external/stripe/stripe.client.ts
vendored
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Stripe API client wrapper
|
||||||
|
* @ai-context Handles all Stripe API interactions with proper error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { logger } from '../../../../core/logging/logger';
|
||||||
|
import {
|
||||||
|
StripeCustomer,
|
||||||
|
StripeSubscription,
|
||||||
|
StripePaymentIntent,
|
||||||
|
StripeWebhookEvent,
|
||||||
|
} from './stripe.types';
|
||||||
|
|
||||||
|
export class StripeClient {
|
||||||
|
private stripe: Stripe;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const apiKey = process.env.STRIPE_SECRET_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('STRIPE_SECRET_KEY environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stripe = new Stripe(apiKey, {
|
||||||
|
apiVersion: '2025-12-15.clover',
|
||||||
|
typescript: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Stripe client initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Stripe customer
|
||||||
|
*/
|
||||||
|
async createCustomer(email: string, name?: string): Promise<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,
|
||||||
|
created: paymentIntent.created,
|
||||||
|
metadata: paymentIntent.metadata,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Failed to create Stripe payment intent', {
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct and verify a webhook event from Stripe
|
||||||
|
*/
|
||||||
|
constructWebhookEvent(payload: Buffer, signature: string): StripeWebhookEvent {
|
||||||
|
try {
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
if (!webhookSecret) {
|
||||||
|
throw new Error('STRIPE_WEBHOOK_SECRET environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = this.stripe.webhooks.constructEvent(
|
||||||
|
payload,
|
||||||
|
signature,
|
||||||
|
webhookSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Stripe webhook event verified', { eventId: event.id, type: event.type });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
type: event.type,
|
||||||
|
data: event.data,
|
||||||
|
created: event.created,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Failed to verify Stripe webhook event', {
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a subscription by ID
|
||||||
|
*/
|
||||||
|
async getSubscription(subscriptionId: string): Promise<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
backend/src/features/subscriptions/external/stripe/stripe.types.ts
vendored
Normal file
82
backend/src/features/subscriptions/external/stripe/stripe.types.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Type definitions for Stripe API responses
|
||||||
|
* @ai-context Simplified types for the Stripe API responses we care about
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Stripe Customer
|
||||||
|
export interface StripeCustomer {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
created: number;
|
||||||
|
metadata?: Record<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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
41
backend/src/features/subscriptions/index.ts
Normal file
41
backend/src/features/subscriptions/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Public API for subscriptions feature capsule
|
||||||
|
* @ai-context Export all public types, classes, and utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Domain types
|
||||||
|
export type {
|
||||||
|
Subscription,
|
||||||
|
SubscriptionEvent,
|
||||||
|
Donation,
|
||||||
|
TierVehicleSelection,
|
||||||
|
SubscriptionTier,
|
||||||
|
SubscriptionStatus,
|
||||||
|
BillingCycle,
|
||||||
|
DonationStatus,
|
||||||
|
CreateSubscriptionRequest,
|
||||||
|
SubscriptionResponse,
|
||||||
|
DonationResponse,
|
||||||
|
CreateDonationRequest,
|
||||||
|
CreateSubscriptionEventRequest,
|
||||||
|
CreateTierVehicleSelectionRequest,
|
||||||
|
UpdateSubscriptionData,
|
||||||
|
UpdateDonationData,
|
||||||
|
} from './domain/subscriptions.types';
|
||||||
|
|
||||||
|
// Stripe types
|
||||||
|
export type {
|
||||||
|
StripeCustomer,
|
||||||
|
StripeSubscription,
|
||||||
|
StripePaymentMethod,
|
||||||
|
StripePaymentIntent,
|
||||||
|
StripeWebhookEvent,
|
||||||
|
StripePrice,
|
||||||
|
StripeError,
|
||||||
|
} from './external/stripe/stripe.types';
|
||||||
|
|
||||||
|
// Stripe client
|
||||||
|
export { StripeClient } from './external/stripe/stripe.client';
|
||||||
|
|
||||||
|
// Repository
|
||||||
|
export { SubscriptionsRepository } from './data/subscriptions.repository';
|
||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user