Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,9 @@ REFERRAL_RATE_LIMIT_MAX_ATTEMPTS=10
REFERRAL_ENABLE_BOT_DETECTION=true
# Enable VPN/Proxy detection (requires external service)
REFERRAL_ENABLE_VPN_DETECTION=false

# Stripe / Payments
# Secret key for the Stripe API (use a test-mode key, sk_test_..., in development)
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
# Signing secret used to verify webhook payloads (from the Stripe CLI or Dashboard webhook config)
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_signing_secret
4 changes: 4 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,7 @@ HSTS_MAX_AGE=31536000

# Session Configuration
SESSION_SECRET=REPLACE_WITH_STRONG_RANDOM_SECRET_MINIMUM_32_CHARACTERS

# Stripe / Payments - use live-mode keys from the Stripe Dashboard
STRIPE_SECRET_KEY=sk_live_YOUR_STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SIGNING_SECRET
108 changes: 21 additions & 87 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"speakeasy": "2.0.0",
"stripe": "^17.4.0",
"swagger-ui-express": "^5.0.1",
"toidentifier": "^1.0.1",
"typeorm": "^0.3.19",
Expand Down
13 changes: 13 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { AnalyticsModule } from "./analytics/analytics.module";
import { RateLimitModule } from "./quota/rate-limit.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { MessagingModule } from "./messaging/messaging.module";
import { PaymentsModule } from "./payments/payments.module";

// Auth entities
import { Conversation } from "./messaging/entities/conversation.entity";
Expand Down Expand Up @@ -73,6 +74,12 @@ import { Notification } from "./notifications/entities/notification.entity";
import { NotificationDeliveryLog } from "./notifications/entities/notification-delivery-log.entity";
import { NotificationPreference } from "./notifications/entities/notification-preference.entity";

// Payments entities
import { PaymentCustomer } from "./payments/entities/payment-customer.entity";
import { Subscription } from "./payments/entities/subscription.entity";
import { Transaction } from "./payments/entities/transaction.entity";
import { WebhookEvent } from "./payments/entities/webhook-event.entity";

// Guards
import { ThrottlerUserIpGuard } from "./common/guard/throttler.guard";
import { RolesGuard } from "./common/guard/roles.guard";
Expand Down Expand Up @@ -137,6 +144,11 @@ import { QuotaGuard } from "./common/guard/quota.guard";
Conversation,
Message,
UserPresence,
// Payments module entities
PaymentCustomer,
Subscription,
Transaction,
WebhookEvent,
],
synchronize: !isProduction,
logging: isProduction ? ["error"] : ["error", "warn", "schema"],
Expand Down Expand Up @@ -176,6 +188,7 @@ import { QuotaGuard } from "./common/guard/quota.guard";
RateLimitModule,
NotificationsModule,
MessagingModule,
PaymentsModule,
],

controllers: [AppController],
Expand Down
9 changes: 9 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ export class EnvironmentVariables {

@IsString()
EMAIL_FROM: string = '"StellAIverse" <noreply@stellaiverse.com>';

// Stripe / Payments
@IsOptional()
@IsString()
STRIPE_SECRET_KEY?: string;

@IsOptional()
@IsString()
STRIPE_WEBHOOK_SECRET?: string;
}

export function validateEnv(config: Record<string, unknown>): EnvironmentVariables {
Expand Down
4 changes: 3 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ async function bootstrap() {
}

// Create app with appropriate logging
const app = await NestFactory.create(AppModule);
// rawBody is required so the payments webhook controller can verify
// Stripe signatures against the exact bytes Stripe signed
const app = await NestFactory.create(AppModule, { rawBody: true });
const configService = app.get(ConfigService);
const nodeEnv = configService.get<string>("NODE_ENV");
const isProduction = nodeEnv === "production";
Expand Down
75 changes: 75 additions & 0 deletions src/payments/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Payments Module

Stripe-backed billing: customer management, subscriptions, and webhook-driven
transaction/invoice tracking.

## Architecture

```
src/payments/
├── entities/
│ ├── payment-customer.entity.ts # user <-> Stripe customer mapping
│ ├── subscription.entity.ts # persisted subscription state
│ ├── transaction.entity.ts # charges & invoices
│ └── webhook-event.entity.ts # processed webhook ids (idempotency)
├── dto/
├── events/
│ └── payment-events.ts # EventEmitter2 event names/payloads
├── stripe.service.ts # thin wrapper around the Stripe SDK
├── payments.service.ts # customer/subscription/billing logic
├── payments-webhook.service.ts # idempotent webhook event processing
├── payments.controller.ts # authenticated billing endpoints
├── payments-webhook.controller.ts # POST /payments/webhook
└── payments.module.ts
```

## API Endpoints

All endpoints below (except the webhook) require a valid JWT and act on the
authenticated user.

- `POST /payments/customer` - create/update the caller's Stripe customer
- `POST /payments/subscriptions` - start a subscription for a given price
- `GET /payments/subscriptions` - list the caller's subscriptions
- `PATCH /payments/subscriptions/:id` - change a subscription's plan
- `DELETE /payments/subscriptions/:id` - cancel a subscription
- `GET /payments/invoices` - list Stripe invoices for the caller
- `GET /payments/transactions` - billing history persisted from webhooks
- `POST /payments/webhook` - Stripe webhook receiver (signature-verified, no auth)

## Configuration

```env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
```

Both variables are read in `src/config/env.validation.ts`. `STRIPE_SECRET_KEY`
should be a **test-mode** key in development and a **live-mode** key in
production; `STRIPE_WEBHOOK_SECRET` is the signing secret for the webhook
endpoint you configure below.

## Testing with the Stripe CLI

1. Install the [Stripe CLI](https://stripe.com/docs/stripe-cli) and run
`stripe login`.
2. Forward events to your local server and copy the printed webhook signing
secret into `STRIPE_WEBHOOK_SECRET`:

```bash
stripe listen --forward-to localhost:3000/api/v1/payments/webhook
```

3. Trigger events to exercise the handlers in `payments-webhook.service.ts`:

```bash
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed
stripe trigger charge.succeeded
stripe trigger charge.failed
```

Each event is recorded in `payment_webhook_events` by its Stripe event id
before being acted on, so re-delivered events (Stripe retries on a non-2xx
response) are detected and skipped rather than double-processed.
7 changes: 7 additions & 0 deletions src/payments/dto/cancel-subscription.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from "class-validator";

export class CancelSubscriptionDto {
@IsOptional()
@IsBoolean()
atPeriodEnd?: boolean = true;
}
7 changes: 7 additions & 0 deletions src/payments/dto/create-customer.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsOptional, IsString } from "class-validator";

export class CreateCustomerDto {
@IsOptional()
@IsString()
paymentMethodId?: string;
}
11 changes: 11 additions & 0 deletions src/payments/dto/create-subscription.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsOptional, IsString } from "class-validator";

export class CreateSubscriptionDto {
@IsString()
@IsNotEmpty()
priceId: string;

@IsOptional()
@IsString()
paymentMethodId?: string;
}
7 changes: 7 additions & 0 deletions src/payments/dto/update-subscription.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from "class-validator";

export class UpdateSubscriptionDto {
@IsString()
@IsNotEmpty()
priceId: string;
}
31 changes: 31 additions & 0 deletions src/payments/entities/payment-customer.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from "typeorm";

@Entity("payment_customers")
export class PaymentCustomer {
@PrimaryGeneratedColumn("uuid")
id: string;

@Column({ unique: true })
@Index()
userId: string;

@Column({ unique: true })
@Index()
stripeCustomerId: string;

@Column({ nullable: true })
defaultPaymentMethodId?: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
58 changes: 58 additions & 0 deletions src/payments/entities/subscription.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from "typeorm";

export enum SubscriptionStatus {
INCOMPLETE = "incomplete",
TRIALING = "trialing",
ACTIVE = "active",
PAST_DUE = "past_due",
CANCELED = "canceled",
UNPAID = "unpaid",
}

@Entity("subscriptions")
export class Subscription {
@PrimaryGeneratedColumn("uuid")
id: string;

@Column()
@Index()
userId: string;

@Column({ unique: true })
@Index()
stripeSubscriptionId: string;

@Column()
stripeCustomerId: string;

@Column()
priceId: string;

@Column({ type: "enum", enum: SubscriptionStatus })
status: SubscriptionStatus;

@Column({ type: "timestamp" })
currentPeriodStart: Date;

@Column({ type: "timestamp" })
currentPeriodEnd: Date;

@Column({ default: false })
cancelAtPeriodEnd: boolean;

@Column({ type: "timestamp", nullable: true })
canceledAt?: Date;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
57 changes: 57 additions & 0 deletions src/payments/entities/transaction.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from "typeorm";

export enum TransactionType {
CHARGE = "charge",
INVOICE = "invoice",
}

export enum TransactionStatus {
SUCCEEDED = "succeeded",
FAILED = "failed",
PENDING = "pending",
REFUNDED = "refunded",
}

@Entity("payment_transactions")
export class Transaction {
@PrimaryGeneratedColumn("uuid")
id: string;

@Column()
@Index()
userId: string;

@Column({ nullable: true })
subscriptionId?: string;

@Column({ unique: true })
@Index()
stripeObjectId: string;

@Column({ type: "enum", enum: TransactionType })
type: TransactionType;

@Column({ type: "enum", enum: TransactionStatus })
status: TransactionStatus;

@Column({ type: "bigint" })
amount: number;

@Column()
currency: string;

@Column({ nullable: true })
invoiceUrl?: string;

@Column({ nullable: true })
failureReason?: string;

@CreateDateColumn()
createdAt: Date;
}
23 changes: 23 additions & 0 deletions src/payments/entities/webhook-event.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from "typeorm";

@Entity("payment_webhook_events")
export class WebhookEvent {
@PrimaryGeneratedColumn("uuid")
id: string;

@Column({ unique: true })
@Index()
stripeEventId: string;

@Column()
type: string;

@CreateDateColumn()
processedAt: Date;
}
16 changes: 16 additions & 0 deletions src/payments/events/payment-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const PAYMENT_SUBSCRIPTION_UPDATED = "payment.subscription.updated";
export const PAYMENT_CHARGE_SUCCEEDED = "payment.charge.succeeded";
export const PAYMENT_CHARGE_FAILED = "payment.charge.failed";

export interface PaymentSubscriptionEventPayload {
userId: string;
subscriptionId: string;
status: string;
}

export interface PaymentChargeEventPayload {
userId: string;
transactionId: string;
amount: number;
currency: string;
}
Loading
Loading