@ciphercross/nestjs-stripe
v1.2.0
Published
NestJS Stripe integration package with Checkout-first and Connect Express support.
Readme
@ciphercross/nestjs-stripe
NestJS Stripe integration package with Checkout-first and Connect Express support.
Installation
npm install @ciphercross/nestjs-stripe stripeEnvironment Variables
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_API_VERSION=2024-06-20
DEFAULT_CURRENCY=usdQuick Start
1. Import Core Module
import { StripeCoreModule } from '@ciphercross/nestjs-stripe';
@Module({
imports: [
StripeCoreModule.forRoot({
secretKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
apiVersion: process.env.STRIPE_API_VERSION || '2024-06-20',
defaultCurrency: process.env.DEFAULT_CURRENCY || 'usd',
productName: 'MyApp',
environment: process.env.NODE_ENV === 'production' ? 'prod' : 'dev',
webhookPath: '/stripe/webhook',
}),
],
})
export class AppModule {}2. Raw Body Setup (Critical for Webhooks)
Important: Do NOT use JSON parser on the webhook route, or configure raw body parser BEFORE JSON parser.
For Express:
import { json } from 'express';
app.use('/stripe/webhook', express.raw({ type: 'application/json' }));
app.use(json());For Fastify, use the provided middleware or configure accordingly.
3. Implement Storage Adapters
import { StripeCustomerStorage } from '@ciphercross/nestjs-stripe';
@Injectable()
export class PrismaStripeCustomerStorage implements StripeCustomerStorage {
constructor(private readonly prisma: PrismaService) {}
async getCustomerId(subjectId: string): Promise<string | null> {
const user = await this.prisma.user.findUnique({
where: { id: subjectId },
select: { stripeCustomerId: true },
});
return user?.stripeCustomerId ?? null;
}
async setCustomerId(subjectId: string, customerId: string): Promise<void> {
await this.prisma.user.update({
where: { id: subjectId },
data: { stripeCustomerId: customerId },
});
}
}4. Import Feature Modules
import {
StripeCheckoutModule,
StripeSubscriptionsModule,
StripeConnectModule,
} from '@ciphercross/nestjs-stripe';
@Module({
imports: [
StripeCheckoutModule.forRoot({
customerStorage: {
provide: 'STRIPE_CUSTOMER_STORAGE',
useClass: PrismaStripeCustomerStorage,
},
}),
StripeSubscriptionsModule.forRoot({
customerStorage: {
provide: 'STRIPE_CUSTOMER_STORAGE',
useClass: PrismaStripeCustomerStorage,
},
subscriptionSync: {
provide: 'STRIPE_SUBSCRIPTION_SYNC',
useClass: PrismaSubscriptionSync,
},
}),
StripeConnectModule.forRoot({
connectStorage: {
provide: 'STRIPE_CONNECT_STORAGE',
useClass: PrismaStripeConnectStorage,
},
}),
],
})
export class PaymentsModule {}Usage Examples
One-Time Checkout
import { StripeCheckoutService, CreateCheckoutOneTimeDto } from '@ciphercross/nestjs-stripe';
@Controller('payments')
export class PaymentsController {
constructor(private readonly stripeCheckout: StripeCheckoutService) {}
@Post('checkout')
async createCheckout(@Body() dto: CreateCheckoutOneTimeDto) {
const session = await this.stripeCheckout.createOneTimeSession(dto);
return { url: session.url, id: session.id };
}
}Example with application fee (for marketplace/connect scenarios):
@Post('checkout-with-fee')
async createCheckoutWithFee(@Body() dto: CreateCheckoutOneTimeDto) {
// For marketplace: charge customer $100, keep $5 as platform fee
const session = await this.stripeCheckout.createOneTimeSession({
...dto,
connectedAccountId: 'acct_xxx', // Connected account to receive funds
applicationFeeAmount: 500, // $5.00 in cents (5% commission)
});
return { url: session.url, id: session.id };
}Subscription Checkout
import { StripeSubscriptionsService, CreateCheckoutSubscriptionDto } from '@ciphercross/nestjs-stripe';
@Controller('subscriptions')
export class SubscriptionsController {
constructor(private readonly stripeSubscriptions: StripeSubscriptionsService) {}
@Post('checkout')
async createSubCheckout(@Body() dto: CreateCheckoutSubscriptionDto) {
const session = await this.stripeSubscriptions.createSubscriptionCheckoutSession(dto);
return { url: session.url, id: session.id };
}
}Billing Portal
@Post('portal')
async portal(@Body() dto: CreateBillingPortalDto) {
const session = await this.stripeSubscriptions.createBillingPortalSession(
dto.subjectId,
dto.returnUrl,
);
return { url: session.url };
}Connect Onboarding
import { StripeConnectService, CreateAccountLinkDto } from '@ciphercross/nestjs-stripe';
@Controller('connect')
export class ConnectController {
constructor(private readonly stripeConnect: StripeConnectService) {}
@Post('onboard')
async onboard(@Body() dto: CreateAccountLinkDto) {
const link = await this.stripeConnect.createOnboardingLink(
dto.subjectId,
dto.refreshUrl,
dto.returnUrl,
);
return { url: link.url };
}
}Webhook Events
The webhook endpoint is automatically registered at /stripe/webhook (or your configured path).
Supported Events
The package automatically handles the following webhook events:
checkout.session.completed- Fired when a checkout session is completedcustomer.subscription.created- Fired when a subscription is createdcustomer.subscription.updated- Fired when a subscription is updatedcustomer.subscription.deleted- Fired when a subscription is deletedinvoice.paid- Fired when an invoice payment succeedsinvoice.payment_failed- Fired when an invoice payment failsaccount.updated- Fired when a Connect account is updatedpayment_intent.succeeded- Fired when a payment intent succeedspayment_intent.payment_failed- Fired when a payment intent failspayout.paid- Fired when a payout is paidpayout.failed- Fired when a payout fails
Webhook Event Handling
Events are automatically dispatched to registered handlers. To handle events in your project:
- For one-time purchases: Implement your own logic in
CheckoutSessionCompletedHandleror extend it:
@Injectable()
export class CustomCheckoutHandler extends CheckoutSessionCompletedHandler {
constructor(
private readonly ordersService: OrdersService,
customerStorage: StripeCustomerStorage,
) {
super(customerStorage);
}
async handle(event: Stripe.Event): Promise<void> {
await super.handle(event);
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.cc_order_id;
if (orderId) {
await this.ordersService.markPaid(orderId, session.id);
}
}
}- For subscriptions: Implement
StripeSubscriptionSync:
@Injectable()
export class PrismaSubscriptionSync implements StripeSubscriptionSync {
constructor(private readonly prisma: PrismaService) {}
async onCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
// Handle subscription checkout completion
const subjectId = session.metadata?.cc_subject_id;
const plan = session.metadata?.cc_plan;
// Save initial subscription state
}
async onSubscriptionEvent(event: Stripe.Event): Promise<void> {
const subscription = event.data.object as Stripe.Subscription;
const subjectId = subscription.metadata?.cc_subject_id;
await this.prisma.user.update({
where: { id: subjectId },
data: {
subscriptionStatus: subscription.status,
subscriptionCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
subscriptionCancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}
async onInvoiceEvent(event: Stripe.Event): Promise<void> {
const invoice = event.data.object as Stripe.Invoice;
if (event.type === 'invoice.paid') {
// Grant access, activate subscription
} else if (event.type === 'invoice.payment_failed') {
// Revoke access, notify user
}
}
}Metadata Convention
All Stripe objects use standardized metadata keys with cc_ prefix:
cc_project- Project namecc_env- Environment (dev/prod)cc_subject_id- User or business IDcc_subject_type- 'user' | 'business'cc_order_id- Order ID for one-time purchasescc_purpose- 'purchase' | 'subscription' | 'connect'cc_plan- Plan identifier (e.g., 'PLUS_MONTHLY')cc_source- 'mobile' | 'web' | 'admin'
Use buildStripeMetadata() helper to ensure consistency.
Idempotency
The package automatically generates idempotency keys for all operations to prevent duplicate payments:
- One-time checkout:
cc:checkout:purchase:<orderId> - Subscription checkout:
cc:checkout:sub:<subjectId>:<priceId> - Connect account:
cc:connect:account:<subjectId>
All Stripe API calls use these idempotency keys to ensure operations are idempotent.
Advanced Usage
Using Metadata Helper
Always use buildStripeMetadata() to ensure consistent metadata:
import { buildStripeMetadata, StripeMetadataKeys } from '@ciphercross/nestjs-stripe';
const metadata = buildStripeMetadata({
project: 'MyApp',
env: 'prod',
subjectId: userId,
subjectType: 'user',
purpose: 'purchase',
orderId: order.id,
source: 'web',
});
// Access metadata keys
const orderId = session.metadata[StripeMetadataKeys.orderId];Money Utilities
Convert between major and minor units:
import { toMinorUnits, fromMinorUnits } from '@ciphercross/nestjs-stripe';
const cents = toMinorUnits(10.50, 'usd'); // 1050
const dollars = fromMinorUnits(1050, 'usd'); // 10.50Error Handling
import {
isStripeError,
getStripeErrorMessage,
isRateLimitError,
} from '@ciphercross/nestjs-stripe';
try {
await stripe.checkout.sessions.create(...);
} catch (error) {
if (isRateLimitError(error)) {
// Retry with exponential backoff
} else if (isStripeError(error)) {
console.error(getStripeErrorMessage(error));
}
}Complete Wallet Top-Up Example
import {
StripeCheckoutService,
CreateCheckoutOneTimeDto,
SubjectType,
Source,
toMinorUnits,
} from '@ciphercross/nestjs-stripe';
@Controller('wallet')
export class WalletController {
constructor(private readonly checkoutService: StripeCheckoutService) {}
@Post('top-up')
async topUpWallet(
@Body('userId') userId: string,
@Body('amount') amount: number, // e.g., 100 for $100
@Body('email') email?: string,
) {
const amountInCents = toMinorUnits(amount, 'usd');
const session = await this.checkoutService.createOneTimeSession({
subjectId: userId,
subjectType: SubjectType.USER,
orderId: `topup-${Date.now()}`,
successUrl: 'https://app.com/wallet/success',
cancelUrl: 'https://app.com/wallet/cancel',
customerEmail: email,
source: Source.WEB,
lineItems: [
{
name: 'Wallet Top-Up',
description: `Top-up wallet with $${amount}`,
unitAmount: amountInCents,
quantity: 1,
currency: 'usd',
},
],
});
return { url: session.url, sessionId: session.id };
}
}Connect Account Status
const account = await stripeConnect.getAccountStatus(businessId);
if (account?.charges_enabled && account?.payouts_enabled) {
// Account is fully onboarded
}Transfers to Connected Accounts
import { StripeTransfersService, CreateTransferDto } from '@ciphercross/nestjs-stripe';
@Controller('transfers')
export class TransfersController {
constructor(private readonly transfersService: StripeTransfersService) {}
@Post()
async createTransfer(@Body() dto: CreateTransferDto) {
const transfer = await this.transfersService.createTransfer({
destinationAccountId: dto.destinationAccountId,
amount: dto.amount, // in cents
currency: dto.currency || 'usd',
description: dto.description,
transferGroup: dto.transferGroup, // Optional: group related transfers
metadata: dto.metadata,
});
return { transferId: transfer.id, status: transfer.status };
}
@Get(':id')
async getTransfer(@Param('id') transferId: string) {
return await this.transfersService.getTransfer(transferId);
}
@Get()
async listTransfers(@Query('destination') destinationAccountId?: string) {
return await this.transfersService.listTransfers({
destination: destinationAccountId,
limit: 10,
});
}
}Payouts (Standard and Instant)
import { StripePayoutsService, CreatePayoutDto } from '@ciphercross/nestjs-stripe';
@Controller('payouts')
export class PayoutsController {
constructor(private readonly payoutsService: StripePayoutsService) {}
@Post()
async createPayout(@Body() dto: CreatePayoutDto) {
const payout = await this.payoutsService.createPayout({
accountId: dto.accountId, // Connected account ID
amount: dto.amount, // in cents
currency: dto.currency || 'usd',
description: dto.description,
statementDescriptor: dto.statementDescriptor,
instant: dto.instant || false, // true for instant payouts
});
return {
payoutId: payout.id,
status: payout.status,
arrivalDate: payout.arrival_date,
method: payout.method, // 'standard' or 'instant'
};
}
@Get(':id')
async getPayout(
@Param('id') payoutId: string,
@Query('accountId') accountId?: string,
) {
return await this.payoutsService.getPayout(payoutId, accountId);
}
@Get()
async listPayouts(
@Query('accountId') accountId?: string,
@Query('status') status?: string,
) {
return await this.payoutsService.listPayouts(accountId, {
status: status as any,
limit: 10,
});
}
@Post(':id/cancel')
async cancelPayout(
@Param('id') payoutId: string,
@Query('accountId') accountId?: string,
) {
return await this.payoutsService.cancelPayout(payoutId, accountId);
}
}Dashboard Login Links
import { StripeConnectService } from '@ciphercross/nestjs-stripe';
@Controller('connect')
export class ConnectController {
constructor(private readonly connectService: StripeConnectService) {}
@Post('dashboard-link')
async getDashboardLink(@Body('subjectId') subjectId: string) {
const { url } = await this.connectService.createDashboardLoginLink(subjectId);
return { url };
}
@Post('dashboard-link/:accountId')
async getDashboardLinkById(@Param('accountId') accountId: string) {
const { url } = await this.connectService.createDashboardLoginLinkById(accountId);
return { url };
}
}Payment Intent Webhooks
import {
PaymentIntentEventsHandler,
PaymentIntentEventHandler,
} from '@ciphercross/nestjs-stripe';
@Injectable()
export class CustomPaymentIntentHandler implements PaymentIntentEventHandler {
constructor(
private readonly paymentIntentHandler: PaymentIntentEventsHandler,
private readonly walletService: WalletService,
) {}
onModuleInit() {
// Register this handler
this.paymentIntentHandler.registerHandler(this);
}
async onPaymentIntentSucceeded(event: Stripe.Event): Promise<void> {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const subjectId = paymentIntent.metadata?.cc_subject_id;
if (subjectId) {
// Update wallet balance after successful payment
await this.walletService.addFunds(
subjectId,
paymentIntent.amount,
paymentIntent.currency,
);
}
}
async onPaymentIntentFailed(event: Stripe.Event): Promise<void> {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const subjectId = paymentIntent.metadata?.cc_subject_id;
if (subjectId) {
// Handle failed payment
await this.walletService.notifyPaymentFailed(subjectId, paymentIntent.id);
}
}
}Payout Webhooks
import {
PayoutEventsHandler,
PayoutEventHandler,
} from '@ciphercross/nestjs-stripe';
@Injectable()
export class CustomPayoutHandler implements PayoutEventHandler {
constructor(
private readonly payoutHandler: PayoutEventsHandler,
private readonly withdrawalService: WithdrawalService,
) {}
onModuleInit() {
// Register this handler
this.payoutHandler.registerHandler(this);
}
async onPayoutPaid(event: Stripe.Event): Promise<void> {
const payout = event.data.object as Stripe.Payout;
// Mark withdrawal as completed
await this.withdrawalService.markCompleted(
payout.id,
payout.amount,
payout.arrival_date,
);
}
async onPayoutFailed(event: Stripe.Event): Promise<void> {
const payout = event.data.object as Stripe.Payout;
// Handle failed payout - refund to business wallet
await this.withdrawalService.handleFailed(
payout.id,
payout.failure_code,
payout.failure_message,
);
}
}Module Configuration
Async Configuration
All modules support async configuration:
StripeCoreModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
secretKey: configService.get('STRIPE_SECRET_KEY'),
webhookSecret: configService.get('STRIPE_WEBHOOK_SECRET'),
apiVersion: configService.get('STRIPE_API_VERSION'),
defaultCurrency: configService.get('DEFAULT_CURRENCY'),
productName: configService.get('PRODUCT_NAME'),
environment: configService.get('NODE_ENV') === 'production' ? 'prod' : 'dev',
}),
inject: [ConfigService],
}),Best Practices
Always verify webhook signatures: The package does this automatically, but ensure raw body is configured correctly.
Use webhooks as source of truth: Don't rely on redirect URLs for payment confirmation. Always verify via webhooks.
Implement idempotency in your handlers: Even though Stripe provides idempotency, your handlers should also be idempotent.
Store event IDs: Consider implementing an event store to prevent processing duplicate webhook events.
Handle failures gracefully: Implement retry logic for failed webhook processing.
Use metadata consistently: Always use
buildStripeMetadata()to ensure consistent metadata across your application.
Troubleshooting
Webhook signature verification fails
- Ensure raw body middleware is configured BEFORE JSON parser
- Verify
STRIPE_WEBHOOK_SECRETmatches your Stripe dashboard webhook secret - Check that the request body is not being modified by other middleware
Customer not found errors
- Ensure
StripeCustomerStorageis properly implemented and registered - Verify that customer IDs are being stored after customer creation
Subscription sync not working
- Ensure
StripeSubscriptionSyncis properly implemented and registered - Check that webhook events are being received (check Stripe dashboard)
- Verify event handlers are registered in the dispatcher
License
MIT
