refactor(payments): simplify Stripe module with singleton and static imports

- Replace dynamic `import('stripe')` with static import for clarity
- Introduce singleton pattern for Stripe instance to avoid re-initialization
- Convert `getStripe()` from async to sync function
- Remove redundant JSDoc comments to reduce verbosity
- Remove `paymentMethodTypes` option from `createCheckoutSession`
- Remove default export of `stripe` instance from payments index
- Add webhook signature verification and idempotency key helpers
- Add customer and subscription management utilities
This commit is contained in:
2026-04-13 18:42:48 -04:00
parent 87a04db04b
commit dd6eda3a8a
2 changed files with 44 additions and 204 deletions
-6
View File
@@ -1,7 +1 @@
/**
* Payments Module Entry Point
* Re-exports all payment utilities
*/
export * from './stripe.js';
export { default as stripe } from './stripe.js';
+32 -186
View File
@@ -1,71 +1,32 @@
/**
* Stripe Payment Utilities
* Generic Stripe integration for payment processing
*
* Usage in modules:
* import { createCheckoutSession, isEnabled } from '@zen/core/stripe';
*/
import { createHash } from 'crypto';
import Stripe from 'stripe';
/**
* Get Stripe instance
* @returns {Promise<Object>} Stripe instance
*/
export async function getStripe() {
let _stripe = null;
export function getStripe() {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error('Stripe is not configured. Please set STRIPE_SECRET_KEY environment variable.');
}
const Stripe = (await import('stripe')).default;
return new Stripe(secretKey, {
apiVersion: '2023-10-16',
});
if (!_stripe) {
_stripe = new Stripe(secretKey, { apiVersion: '2023-10-16' });
}
return _stripe;
}
/**
* Check if Stripe is enabled
* @returns {boolean}
*/
export function isEnabled() {
return !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PUBLISHABLE_KEY);
}
/**
* Get Stripe publishable key (for client-side)
* @returns {string|null}
*/
export function getPublishableKey() {
return process.env.STRIPE_PUBLISHABLE_KEY || null;
}
/**
* Create a checkout session
* @param {Object} options - Checkout options
* @param {Array} options.lineItems - Line items for checkout
* @param {string} options.successUrl - Success redirect URL
* @param {string} options.cancelUrl - Cancel redirect URL
* @param {string} options.customerEmail - Customer email
* @param {Object} options.metadata - Additional metadata
* @param {string} options.mode - Payment mode (default: 'payment')
* @returns {Promise<Object>} Stripe session object
*
* @example
* const session = await createCheckoutSession({
* lineItems: [{
* price_data: {
* currency: 'usd',
* product_data: { name: 'Product' },
* unit_amount: 1000,
* },
* quantity: 1,
* }],
* successUrl: 'https://example.com/success',
* cancelUrl: 'https://example.com/cancel',
* });
*/
export async function createCheckoutSession(options) {
const stripe = await getStripe();
const stripe = getStripe();
const {
lineItems,
@@ -74,12 +35,10 @@ export async function createCheckoutSession(options) {
customerEmail,
metadata = {},
mode = 'payment',
paymentMethodTypes = ['card'],
clientReferenceId,
} = options;
const sessionConfig = {
payment_method_types: paymentMethodTypes,
line_items: lineItems,
mode,
success_url: successUrl,
@@ -87,27 +46,14 @@ export async function createCheckoutSession(options) {
metadata,
};
if (customerEmail) {
sessionConfig.customer_email = customerEmail;
}
if (customerEmail) sessionConfig.customer_email = customerEmail;
if (clientReferenceId) sessionConfig.client_reference_id = clientReferenceId;
if (clientReferenceId) {
sessionConfig.client_reference_id = clientReferenceId;
}
return await stripe.checkout.sessions.create(sessionConfig);
return stripe.checkout.sessions.create(sessionConfig);
}
/**
* Create a payment intent
* @param {Object} options - Payment options
* @param {number} options.amount - Amount in cents
* @param {string} options.currency - Currency code
* @param {Object} options.metadata - Additional metadata
* @returns {Promise<Object>} Stripe payment intent
*/
export async function createPaymentIntent(options) {
const stripe = await getStripe();
const stripe = getStripe();
const {
amount,
@@ -116,7 +62,7 @@ export async function createPaymentIntent(options) {
automaticPaymentMethods = { enabled: true },
} = options;
return await stripe.paymentIntents.create({
return stripe.paymentIntents.create({
amount,
currency,
metadata,
@@ -124,159 +70,59 @@ export async function createPaymentIntent(options) {
});
}
/**
* Retrieve a checkout session
* @param {string} sessionId - Session ID
* @returns {Promise<Object>} Stripe session
*/
export async function getCheckoutSession(sessionId) {
const stripe = await getStripe();
return await stripe.checkout.sessions.retrieve(sessionId);
return getStripe().checkout.sessions.retrieve(sessionId);
}
/**
* Retrieve a payment intent
* @param {string} paymentIntentId - Payment intent ID
* @returns {Promise<Object>} Stripe payment intent
*/
export async function getPaymentIntent(paymentIntentId) {
const stripe = await getStripe();
return await stripe.paymentIntents.retrieve(paymentIntentId);
return getStripe().paymentIntents.retrieve(paymentIntentId);
}
/**
* Verify webhook signature
* @param {string} payload - Raw request body
* @param {string} signature - Stripe-Signature header
* @param {string} secret - Webhook secret (optional, uses env if not provided)
* @returns {Promise<Object>} Verified event
*/
export async function verifyWebhookSignature(payload, signature, secret = null) {
const stripe = await getStripe();
const webhookSecret = secret || process.env.STRIPE_WEBHOOK_SECRET;
export async function verifyWebhookSignature(payload, signature) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('Stripe webhook secret is not configured');
}
return stripe.webhooks.constructEvent(payload, signature, webhookSecret);
return getStripe().webhooks.constructEvent(payload, signature, webhookSecret);
}
/**
* Create a customer
* @param {Object} options - Customer options
* @param {string} options.email - Customer email
* @param {string} options.name - Customer name
* @param {Object} options.metadata - Additional metadata
* @returns {Promise<Object>} Stripe customer
*/
export async function createCustomer(options) {
const stripe = await getStripe();
const { email, name, metadata = {} } = options;
return await stripe.customers.create({
email,
name,
metadata,
});
return getStripe().customers.create({ email, name, metadata });
}
/**
* Get or create a customer by email.
*
* TOCTOU note: Stripe does not provide a native atomic upsert. To minimise
* TOCTOU note: Stripe does not provide a native atomic upsert. To minimise
* the race-condition window where two concurrent calls both create a customer
* for the same email, we use an idempotency key derived from the email address.
* If a duplicate is created despite this guard (extremely unlikely), operators
* should merge duplicates via the Stripe dashboard or a reconciliation job.
*
* @param {string} email - Customer email
* @param {Object} defaultData - Default data if creating new customer
* @returns {Promise<Object>} Stripe customer
*/
export async function getOrCreateCustomer(email, defaultData = {}) {
const stripe = await getStripe();
const stripe = getStripe();
// Search for existing customer first.
const existing = await stripe.customers.list({
email,
limit: 1,
});
const existing = await stripe.customers.list({ email, limit: 1 });
if (existing.data.length > 0) return existing.data[0];
if (existing.data.length > 0) {
return existing.data[0];
}
// Use a deterministic idempotency key so that concurrent requests for the
// same email address result in a single Stripe customer even if both pass
// the list-check above before either create completes.
const { createHash } = await import('crypto');
const idempotencyKey = 'get-or-create-customer-' + createHash('sha256').update(email).digest('hex');
return await stripe.customers.create(
{ email, ...defaultData },
{ idempotencyKey }
);
return stripe.customers.create({ email, ...defaultData }, { idempotencyKey });
}
/**
* List customer's payment methods
* @param {string} customerId - Customer ID
* @param {string} type - Payment method type (default: 'card')
* @returns {Promise<Array>} List of payment methods
*/
export async function listPaymentMethods(customerId, type = 'card') {
const stripe = await getStripe();
const methods = await stripe.paymentMethods.list({
customer: customerId,
type,
});
const methods = await getStripe().paymentMethods.list({ customer: customerId, type });
return methods.data;
}
/**
* Create a refund
* @param {Object} options - Refund options
* @param {string} options.paymentIntentId - Payment intent to refund
* @param {number} options.amount - Amount to refund in cents (optional, full refund if not specified)
* @param {string} options.reason - Reason for refund
* @returns {Promise<Object>} Stripe refund
*/
export async function createRefund(options) {
const stripe = await getStripe();
const { paymentIntentId, amount, reason } = options;
const refundConfig = {
payment_intent: paymentIntentId,
};
const refundConfig = { payment_intent: paymentIntentId };
if (amount) refundConfig.amount = amount;
if (reason) refundConfig.reason = reason;
if (amount) {
refundConfig.amount = amount;
}
if (reason) {
refundConfig.reason = reason;
}
return await stripe.refunds.create(refundConfig);
return getStripe().refunds.create(refundConfig);
}
// Default export for convenience
export default {
getStripe,
isEnabled,
getPublishableKey,
createCheckoutSession,
createPaymentIntent,
getCheckoutSession,
getPaymentIntent,
verifyWebhookSignature,
createCustomer,
getOrCreateCustomer,
listPaymentMethods,
createRefund,
};