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:
@@ -1,7 +1 @@
|
|||||||
/**
|
|
||||||
* Payments Module Entry Point
|
|
||||||
* Re-exports all payment utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './stripe.js';
|
export * from './stripe.js';
|
||||||
export { default as stripe } from './stripe.js';
|
|
||||||
|
|||||||
+44
-198
@@ -1,72 +1,33 @@
|
|||||||
/**
|
import { createHash } from 'crypto';
|
||||||
* Stripe Payment Utilities
|
import Stripe from 'stripe';
|
||||||
* Generic Stripe integration for payment processing
|
|
||||||
*
|
|
||||||
* Usage in modules:
|
|
||||||
* import { createCheckoutSession, isEnabled } from '@zen/core/stripe';
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
let _stripe = null;
|
||||||
* Get Stripe instance
|
|
||||||
* @returns {Promise<Object>} Stripe instance
|
export function getStripe() {
|
||||||
*/
|
|
||||||
export async function getStripe() {
|
|
||||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||||
|
|
||||||
if (!secretKey) {
|
if (!secretKey) {
|
||||||
throw new Error('Stripe is not configured. Please set STRIPE_SECRET_KEY environment variable.');
|
throw new Error('Stripe is not configured. Please set STRIPE_SECRET_KEY environment variable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const Stripe = (await import('stripe')).default;
|
if (!_stripe) {
|
||||||
return new Stripe(secretKey, {
|
_stripe = new Stripe(secretKey, { apiVersion: '2023-10-16' });
|
||||||
apiVersion: '2023-10-16',
|
}
|
||||||
});
|
|
||||||
|
return _stripe;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Stripe is enabled
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function isEnabled() {
|
export function isEnabled() {
|
||||||
return !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PUBLISHABLE_KEY);
|
return !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PUBLISHABLE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Stripe publishable key (for client-side)
|
|
||||||
* @returns {string|null}
|
|
||||||
*/
|
|
||||||
export function getPublishableKey() {
|
export function getPublishableKey() {
|
||||||
return process.env.STRIPE_PUBLISHABLE_KEY || null;
|
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) {
|
export async function createCheckoutSession(options) {
|
||||||
const stripe = await getStripe();
|
const stripe = getStripe();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
lineItems,
|
lineItems,
|
||||||
successUrl,
|
successUrl,
|
||||||
@@ -74,49 +35,34 @@ export async function createCheckoutSession(options) {
|
|||||||
customerEmail,
|
customerEmail,
|
||||||
metadata = {},
|
metadata = {},
|
||||||
mode = 'payment',
|
mode = 'payment',
|
||||||
paymentMethodTypes = ['card'],
|
|
||||||
clientReferenceId,
|
clientReferenceId,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const sessionConfig = {
|
const sessionConfig = {
|
||||||
payment_method_types: paymentMethodTypes,
|
|
||||||
line_items: lineItems,
|
line_items: lineItems,
|
||||||
mode,
|
mode,
|
||||||
success_url: successUrl,
|
success_url: successUrl,
|
||||||
cancel_url: cancelUrl,
|
cancel_url: cancelUrl,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (customerEmail) {
|
if (customerEmail) sessionConfig.customer_email = customerEmail;
|
||||||
sessionConfig.customer_email = customerEmail;
|
if (clientReferenceId) sessionConfig.client_reference_id = clientReferenceId;
|
||||||
}
|
|
||||||
|
return stripe.checkout.sessions.create(sessionConfig);
|
||||||
if (clientReferenceId) {
|
|
||||||
sessionConfig.client_reference_id = clientReferenceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await 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) {
|
export async function createPaymentIntent(options) {
|
||||||
const stripe = await getStripe();
|
const stripe = getStripe();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
amount,
|
amount,
|
||||||
currency = process.env.ZEN_CURRENCY?.toLowerCase() || 'cad',
|
currency = process.env.ZEN_CURRENCY?.toLowerCase() || 'cad',
|
||||||
metadata = {},
|
metadata = {},
|
||||||
automaticPaymentMethods = { enabled: true },
|
automaticPaymentMethods = { enabled: true },
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
return await stripe.paymentIntents.create({
|
return stripe.paymentIntents.create({
|
||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
metadata,
|
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) {
|
export async function getCheckoutSession(sessionId) {
|
||||||
const stripe = await getStripe();
|
return getStripe().checkout.sessions.retrieve(sessionId);
|
||||||
return await stripe.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) {
|
export async function getPaymentIntent(paymentIntentId) {
|
||||||
const stripe = await getStripe();
|
return getStripe().paymentIntents.retrieve(paymentIntentId);
|
||||||
return await stripe.paymentIntents.retrieve(paymentIntentId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function verifyWebhookSignature(payload, signature) {
|
||||||
* Verify webhook signature
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
* @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;
|
|
||||||
|
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
throw new Error('Stripe webhook secret is not configured');
|
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) {
|
export async function createCustomer(options) {
|
||||||
const stripe = await getStripe();
|
|
||||||
|
|
||||||
const { email, name, metadata = {} } = options;
|
const { email, name, metadata = {} } = options;
|
||||||
|
return getStripe().customers.create({ email, name, metadata });
|
||||||
return await stripe.customers.create({
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
metadata,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a customer by email.
|
* 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
|
* 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.
|
* 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
|
* If a duplicate is created despite this guard (extremely unlikely), operators
|
||||||
* should merge duplicates via the Stripe dashboard or a reconciliation job.
|
* 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 = {}) {
|
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({
|
if (existing.data.length > 0) return existing.data[0];
|
||||||
email,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
const idempotencyKey = 'get-or-create-customer-' + createHash('sha256').update(email).digest('hex');
|
||||||
|
return stripe.customers.create({ email, ...defaultData }, { idempotencyKey });
|
||||||
return await 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') {
|
export async function listPaymentMethods(customerId, type = 'card') {
|
||||||
const stripe = await getStripe();
|
const methods = await getStripe().paymentMethods.list({ customer: customerId, type });
|
||||||
|
|
||||||
const methods = await stripe.paymentMethods.list({
|
|
||||||
customer: customerId,
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
|
|
||||||
return methods.data;
|
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) {
|
export async function createRefund(options) {
|
||||||
const stripe = await getStripe();
|
|
||||||
|
|
||||||
const { paymentIntentId, amount, reason } = options;
|
const { paymentIntentId, amount, reason } = options;
|
||||||
|
|
||||||
const refundConfig = {
|
|
||||||
payment_intent: paymentIntentId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (amount) {
|
|
||||||
refundConfig.amount = amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reason) {
|
|
||||||
refundConfig.reason = reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await stripe.refunds.create(refundConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default export for convenience
|
const refundConfig = { payment_intent: paymentIntentId };
|
||||||
export default {
|
if (amount) refundConfig.amount = amount;
|
||||||
getStripe,
|
if (reason) refundConfig.reason = reason;
|
||||||
isEnabled,
|
|
||||||
getPublishableKey,
|
return getStripe().refunds.create(refundConfig);
|
||||||
createCheckoutSession,
|
}
|
||||||
createPaymentIntent,
|
|
||||||
getCheckoutSession,
|
|
||||||
getPaymentIntent,
|
|
||||||
verifyWebhookSignature,
|
|
||||||
createCustomer,
|
|
||||||
getOrCreateCustomer,
|
|
||||||
listPaymentMethods,
|
|
||||||
createRefund,
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user