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:
Eric Gullickson
2026-01-18 16:04:11 -06:00
parent 411a569788
commit 88b820b1c3
9 changed files with 1404 additions and 41 deletions

View File

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

View File

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

View 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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}
}

View 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;
}

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

View File

@@ -0,0 +1,106 @@
-- Migration: Subscriptions tables for Stripe integration
-- Creates: subscriptions, subscription_events, donations, tier_vehicle_selections
-- Enable uuid-ossp extension if not already enabled
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create subscription status enum
CREATE TYPE subscription_status AS ENUM ('active', 'past_due', 'canceled', 'unpaid');
-- Create billing cycle enum
CREATE TYPE billing_cycle AS ENUM ('monthly', 'yearly');
-- Create donation status enum
CREATE TYPE donation_status AS ENUM ('pending', 'succeeded', 'failed', 'canceled');
-- Create updated_at trigger function if not exists
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Main subscriptions table
CREATE TABLE IF NOT EXISTS subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
stripe_customer_id VARCHAR(255) UNIQUE NOT NULL,
stripe_subscription_id VARCHAR(255) UNIQUE,
tier subscription_tier NOT NULL DEFAULT 'free',
billing_cycle billing_cycle,
status subscription_status NOT NULL DEFAULT 'active',
current_period_start TIMESTAMP WITH TIME ZONE,
current_period_end TIMESTAMP WITH TIME ZONE,
grace_period_end TIMESTAMP WITH TIME ZONE,
cancel_at_period_end BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_subscriptions_user_id FOREIGN KEY (user_id) REFERENCES user_profiles(auth0_sub) ON DELETE CASCADE
);
-- Subscription events table (webhook event logging)
CREATE TABLE IF NOT EXISTS subscription_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
subscription_id UUID NOT NULL,
stripe_event_id VARCHAR(255) UNIQUE NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_subscription_events_subscription_id FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
);
-- Donations table (one-time payments)
CREATE TABLE IF NOT EXISTS donations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
stripe_payment_intent_id VARCHAR(255) UNIQUE NOT NULL,
amount_cents INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'usd',
status donation_status NOT NULL DEFAULT 'pending',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_donations_user_id FOREIGN KEY (user_id) REFERENCES user_profiles(auth0_sub) ON DELETE CASCADE
);
-- Tier vehicle selections table (tracks which vehicles user selected to keep during downgrade)
CREATE TABLE IF NOT EXISTS tier_vehicle_selections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL,
selected_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tier_vehicle_selections_user_id FOREIGN KEY (user_id) REFERENCES user_profiles(auth0_sub) ON DELETE CASCADE,
CONSTRAINT fk_tier_vehicle_selections_vehicle_id FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe_customer_id ON subscriptions(stripe_customer_id);
CREATE INDEX idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_subscriptions_tier ON subscriptions(tier);
CREATE INDEX idx_subscription_events_subscription_id ON subscription_events(subscription_id);
CREATE INDEX idx_subscription_events_stripe_event_id ON subscription_events(stripe_event_id);
CREATE INDEX idx_subscription_events_event_type ON subscription_events(event_type);
CREATE INDEX idx_subscription_events_created_at ON subscription_events(created_at);
CREATE INDEX idx_donations_user_id ON donations(user_id);
CREATE INDEX idx_donations_stripe_payment_intent_id ON donations(stripe_payment_intent_id);
CREATE INDEX idx_donations_status ON donations(status);
CREATE INDEX idx_donations_created_at ON donations(created_at);
CREATE INDEX idx_tier_vehicle_selections_user_id ON tier_vehicle_selections(user_id);
CREATE INDEX idx_tier_vehicle_selections_vehicle_id ON tier_vehicle_selections(vehicle_id);
-- Add updated_at triggers
CREATE TRIGGER update_subscriptions_updated_at
BEFORE UPDATE ON subscriptions
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_donations_updated_at
BEFORE UPDATE ON donations
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();