From 2374dbcec8ba860991288e5877f81af8836aedf3 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sat, 20 Sep 2025 21:16:55 +0200 Subject: [PATCH] feat: implement structured logging system throughout the codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add logger utility adapted from payload-mailing pattern - Use PAYLOAD_BILLING_LOG_LEVEL environment variable for configuration - Replace console.* calls with contextual loggers across providers - Update webhook utilities to support proper logging - Export logging utilities for external use - Maintain fallback console logging for compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/collections/invoices.ts | 7 ++++-- src/collections/refunds.ts | 7 ++++-- src/index.ts | 3 +++ src/providers/mollie.ts | 6 +++-- src/providers/stripe.ts | 19 ++++++++------- src/providers/test.ts | 47 ++++++++++++++++++++++++------------ src/providers/utils.ts | 44 +++++++++++++++++++++++++--------- src/utils/logger.ts | 48 +++++++++++++++++++++++++++++++++++++ 8 files changed, 141 insertions(+), 40 deletions(-) create mode 100644 src/utils/logger.ts diff --git a/src/collections/invoices.ts b/src/collections/invoices.ts index 6731797..ec79ffc 100644 --- a/src/collections/invoices.ts +++ b/src/collections/invoices.ts @@ -8,6 +8,7 @@ import { import type { BillingPluginConfig} from '../plugin/config'; import { defaults } from '../plugin/config' import { extractSlug } from '../plugin/utils' +import { createContextLogger } from '../utils/logger' import type { Invoice } from '../plugin/types/invoices' export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig { @@ -314,7 +315,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col afterChange: [ ({ doc, operation, req }) => { if (operation === 'create') { - req.payload.logger.info(`Invoice created: ${doc.number}`) + const logger = createContextLogger(req.payload, 'Invoices Collection') + logger.info(`Invoice created: ${doc.number}`) } }, ] satisfies CollectionAfterChangeHook[], @@ -350,7 +352,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col data.billingAddress = extractedInfo.billingAddress } } catch (error) { - req.payload.logger.error(`Failed to extract customer info: ${error}`) + const logger = createContextLogger(req.payload, 'Invoices Collection') + logger.error(`Failed to extract customer info: ${error}`) throw new Error('Failed to extract customer information') } } diff --git a/src/collections/refunds.ts b/src/collections/refunds.ts index 66fc6d8..059c39b 100644 --- a/src/collections/refunds.ts +++ b/src/collections/refunds.ts @@ -2,6 +2,7 @@ import type { AccessArgs, CollectionConfig } from 'payload' import { BillingPluginConfig, defaults } from '../plugin/config' import { extractSlug } from '../plugin/utils' import { Payment } from '../plugin/types/index' +import { createContextLogger } from '../utils/logger' export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig { // TODO: finish collection overrides @@ -111,7 +112,8 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll afterChange: [ async ({ doc, operation, req }) => { if (operation === 'create') { - req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`) + const logger = createContextLogger(req.payload, 'Refunds Collection') + logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`) // Update the related payment's refund relationship try { @@ -129,7 +131,8 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll }, }) } catch (error) { - req.payload.logger.error(`Failed to update payment refunds: ${error}`) + const logger = createContextLogger(req.payload, 'Refunds Collection') + logger.error(`Failed to update payment refunds: ${error}`) } } }, diff --git a/src/index.ts b/src/index.ts index ddbd14e..fe9282a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,9 @@ export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderCo export type { Invoice, Payment, Refund } from './plugin/types/index.js' export type { PaymentProvider, ProviderData } from './providers/types.js' +// Export logging utilities +export { getPluginLogger, createContextLogger } from './utils/logger.js' + // Export all providers export { testProvider } from './providers/test.js' export type { diff --git a/src/providers/mollie.ts b/src/providers/mollie.ts index 208a6f1..c2ab492 100644 --- a/src/providers/mollie.ts +++ b/src/providers/mollie.ts @@ -12,6 +12,7 @@ import { validateProductionUrl } from './utils' import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency' +import { createContextLogger } from '../utils/logger' const symbol = Symbol('mollie') export type MollieProviderConfig = Parameters[0] @@ -96,12 +97,13 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & { if (status === 'succeeded' && updateSuccess) { await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig) } else if (!updateSuccess) { - console.warn(`[Mollie Webhook] Failed to update payment ${payment.id}, skipping invoice update`) + const logger = createContextLogger(payload, 'Mollie Webhook') + logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`) } return webhookResponses.success() } catch (error) { - return handleWebhookError('Mollie', error) + return handleWebhookError('Mollie', error, undefined, req.payload) } } } diff --git a/src/providers/stripe.ts b/src/providers/stripe.ts index 28dca74..186bd9e 100644 --- a/src/providers/stripe.ts +++ b/src/providers/stripe.ts @@ -12,6 +12,7 @@ import { logWebhookEvent } from './utils' import { isValidAmount, isValidCurrencyCode } from './currency' +import { createContextLogger } from '../utils/logger' const symbol = Symbol('stripe') @@ -60,13 +61,13 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => { return webhookResponses.missingBody() } } catch (error) { - return handleWebhookError('Stripe', error, 'Failed to read request body') + return handleWebhookError('Stripe', error, 'Failed to read request body', req.payload) } const signature = req.headers.get('stripe-signature') if (!signature) { - return webhookResponses.error('Missing webhook signature', 400) + return webhookResponses.error('Missing webhook signature', 400, req.payload) } // webhookSecret is guaranteed to exist since we only register this endpoint when it's configured @@ -76,7 +77,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => { try { event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!) } catch (err) { - return handleWebhookError('Stripe', err, 'Signature verification failed') + return handleWebhookError('Stripe', err, 'Signature verification failed', req.payload) } // Handle different event types @@ -90,7 +91,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => { const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig) if (!payment) { - logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`) + logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`, undefined, req.payload) return webhookResponses.success() // Still return 200 to acknowledge receipt } @@ -129,7 +130,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => { if (status === 'succeeded' && updateSuccess) { await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig) } else if (!updateSuccess) { - console.warn(`[Stripe Webhook] Failed to update payment ${payment.id}, skipping invoice update`) + const logger = createContextLogger(payload, 'Stripe Webhook') + logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`) } break } @@ -172,7 +174,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => { ) if (!updateSuccess) { - console.warn(`[Stripe Webhook] Failed to update refund status for payment ${payment.id}`) + const logger = createContextLogger(payload, 'Stripe Webhook') + logger.warn(`Failed to update refund status for payment ${payment.id}`) } } break @@ -180,12 +183,12 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => { default: // Unhandled event type - logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`) + logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`, undefined, req.payload) } return webhookResponses.success() } catch (error) { - return handleWebhookError('Stripe', error) + return handleWebhookError('Stripe', error, undefined, req.payload) } } } diff --git a/src/providers/test.ts b/src/providers/test.ts index b4b653b..6533b73 100644 --- a/src/providers/test.ts +++ b/src/providers/test.ts @@ -4,6 +4,12 @@ import type { BillingPluginConfig } from '../plugin/config' import type { Payload } from 'payload' import { handleWebhookError, logWebhookEvent } from './utils' import { isValidAmount, isValidCurrencyCode } from './currency' +import { createContextLogger } from '../utils/logger' + +const TestModeWarningSymbol = Symbol('TestModeWarning') +const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis +const setTestModeWarning = () => ((globalThis)[TestModeWarningSymbol] = true) + // Request validation schemas interface ProcessPaymentRequest { @@ -97,7 +103,8 @@ async function updatePaymentInDatabase( return { success: true } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown database error' - console.error('[Test Provider] Database update failed:', errorMessage) + const logger = createContextLogger(payload, 'Test Provider') + logger.error('Database update failed:', errorMessage) return { success: false, error: errorMessage } } } @@ -224,10 +231,7 @@ export const testProvider = (testConfig: TestProviderConfig) => { const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000') const uiRoute = testConfig.customUiRoute || '/test-payment' - // Log test mode warnings if enabled - if (testConfig.testModeIndicators?.consoleWarnings !== false) { - console.warn('🧪 [TEST PROVIDER] Payment system is running in test mode') - } + // Test mode warnings will be logged in onInit when payload is available return { key: 'test', @@ -342,7 +346,8 @@ export const testProvider = (testConfig: TestProviderConfig) => { // Process payment after delay setTimeout(() => { processTestPayment(payload, session, pluginConfig).catch(async (error) => { - console.error('[Test Provider] Failed to process payment:', error) + const logger = createContextLogger(payload, 'Test Provider') + logger.error('Failed to process payment:', error) // Ensure session status is updated consistently session.status = 'failed' @@ -368,10 +373,11 @@ export const testProvider = (testConfig: TestProviderConfig) => { ) if (!dbResult.success) { - console.error('[Test Provider] Database error during failure handling:', dbResult.error) + const logger = createContextLogger(payload, 'Test Provider') + logger.error('Database error during failure handling:', dbResult.error) // Even if database update fails, we maintain session consistency } else { - logWebhookEvent('Test Provider', `Payment ${session.id} marked as failed after processing error`) + logWebhookEvent('Test Provider', `Payment ${session.id} marked as failed after processing error`, undefined, req.payload) } }) }, scenario.delay || testConfig.defaultDelay || 1000) @@ -385,7 +391,7 @@ export const testProvider = (testConfig: TestProviderConfig) => { headers: { 'Content-Type': 'application/json' } }) } catch (error) { - return handleWebhookError('Test Provider', error, 'Failed to process test payment') + return handleWebhookError('Test Provider', error, 'Failed to process test payment', req.payload) } } }, @@ -433,7 +439,14 @@ export const testProvider = (testConfig: TestProviderConfig) => { ] }, onInit: (payload: Payload) => { - logWebhookEvent('Test Provider', 'Test payment provider initialized') + logWebhookEvent('Test Provider', 'Test payment provider initialized', undefined, payload) + + // Log test mode warnings if enabled + if (testConfig.testModeIndicators?.consoleWarnings !== false && !hasGivenTestModeWarning()) { + setTestModeWarning() + const logger = createContextLogger(payload, 'Test Provider') + logger.warn('🧪 Payment system is running in test mode') + } // Clean up old sessions periodically (older than 1 hour) setInterval(() => { @@ -562,9 +575,10 @@ async function processTestPayment( ) if (dbResult.success) { - logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`) + logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`, undefined, payload) } else { - console.error('[Test Provider] Failed to update payment in database:', dbResult.error) + const logger = createContextLogger(payload, 'Test Provider') + logger.error('Failed to update payment in database:', dbResult.error) // Update session status to indicate database error, but don't throw // This allows the UI to still show the intended test result session.status = 'failed' @@ -572,7 +586,8 @@ async function processTestPayment( } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown processing error' - console.error('[Test Provider] Failed to process payment:', errorMessage) + const logger = createContextLogger(payload, 'Test Provider') + logger.error('Failed to process payment:', errorMessage) session.status = 'failed' throw error // Re-throw to be handled by the caller } @@ -913,12 +928,14 @@ function generateTestPaymentUI( setTimeout(() => pollStatus(), 2000); } } catch (error) { - console.error('Failed to poll status:', error); + const logger = createContextLogger(payload, 'Test Provider') + logger.error('Failed to poll status:', error); } } ${testModeIndicators.consoleWarnings !== false ? ` - console.warn('🧪 TEST MODE: This is a simulated payment interface for development purposes'); + const logger = createContextLogger(payload, 'Test Provider') + logger.warn('🧪 TEST MODE: This is a simulated payment interface for development purposes'); ` : ''} diff --git a/src/providers/utils.ts b/src/providers/utils.ts index 4241db5..42fc3c3 100644 --- a/src/providers/utils.ts +++ b/src/providers/utils.ts @@ -4,6 +4,7 @@ import type { BillingPluginConfig } from '../plugin/config' import type { ProviderData } from './types' import { defaults } from '../plugin/config' import { extractSlug, toPayloadId } from '../plugin/utils' +import { createContextLogger } from '../utils/logger' /** * Common webhook response utilities @@ -11,9 +12,14 @@ import { extractSlug, toPayloadId } from '../plugin/utils' */ export const webhookResponses = { success: () => Response.json({ received: true }, { status: 200 }), - error: (message: string, status = 400) => { + error: (message: string, status = 400, payload?: Payload) => { // Log error internally but don't expose details - console.error('[Webhook] Error:', message) + if (payload) { + const logger = createContextLogger(payload, 'Webhook') + logger.error('Error:', message) + } else { + console.error('[Webhook] Error:', message) + } return Response.json({ error: 'Invalid request' }, { status }) }, missingBody: () => Response.json({ received: true }, { status: 200 }), @@ -63,7 +69,8 @@ export async function updatePaymentStatus( }) as Payment if (!currentPayment) { - console.error(`[Payment Update] Payment ${paymentId} not found`) + const logger = createContextLogger(payload, 'Payment Update') + logger.error(`Payment ${paymentId} not found`) return false } @@ -74,7 +81,8 @@ export async function updatePaymentStatus( const transactionID = await payload.db.beginTransaction() if (!transactionID) { - console.error(`[Payment Update] Failed to begin transaction`) + const logger = createContextLogger(payload, 'Payment Update') + logger.error('Failed to begin transaction') return false } @@ -89,7 +97,8 @@ export async function updatePaymentStatus( // Check if version still matches if ((paymentInTransaction.version || 1) !== currentVersion) { // Version conflict detected - payment was modified by another process - console.warn(`[Payment Update] Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`) + const logger = createContextLogger(payload, 'Payment Update') + logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`) await payload.db.rollbackTransaction(transactionID) return false } @@ -116,7 +125,8 @@ export async function updatePaymentStatus( throw error } } catch (error) { - console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error) + const logger = createContextLogger(payload, 'Payment Update') + logger.error(`Failed to update payment ${paymentId}:`, error) return false } } @@ -152,13 +162,19 @@ export async function updateInvoiceOnPaymentSuccess( export function handleWebhookError( provider: string, error: unknown, - context?: string + context?: string, + payload?: Payload ): Response { const message = error instanceof Error ? error.message : 'Unknown error' - const fullContext = context ? `[${provider} Webhook - ${context}]` : `[${provider} Webhook]` + const fullContext = context ? `${provider} Webhook - ${context}` : `${provider} Webhook` // Log detailed error internally for debugging - console.error(`${fullContext} Error:`, error) + if (payload) { + const logger = createContextLogger(payload, fullContext) + logger.error('Error:', error) + } else { + console.error(`[${fullContext}] Error:`, error) + } // Return generic response to avoid information disclosure return Response.json({ @@ -173,9 +189,15 @@ export function handleWebhookError( export function logWebhookEvent( provider: string, event: string, - details?: any + details?: any, + payload?: Payload ): void { - console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '') + if (payload) { + const logger = createContextLogger(payload, `${provider} Webhook`) + logger.info(event, details ? JSON.stringify(details) : '') + } else { + console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '') + } } /** diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..c009032 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,48 @@ +import type { Payload } from 'payload' + +let pluginLogger: any = null + +/** + * Get or create the plugin logger instance + * Uses PAYLOAD_BILLING_LOG_LEVEL environment variable to configure log level + * Defaults to 'info' if not set + */ +export function getPluginLogger(payload: Payload) { + if (!pluginLogger && payload.logger) { + const logLevel = process.env.PAYLOAD_BILLING_LOG_LEVEL || 'info' + + pluginLogger = payload.logger.child({ + level: logLevel, + plugin: '@xtr-dev/payload-billing' + }) + + // Log the configured log level on first initialization + pluginLogger.info(`Logger initialized with level: ${logLevel}`) + } + + // Fallback to console if logger not available (shouldn't happen in normal operation) + if (!pluginLogger) { + return { + debug: (...args: any[]) => console.log('[BILLING DEBUG]', ...args), + info: (...args: any[]) => console.log('[BILLING INFO]', ...args), + warn: (...args: any[]) => console.warn('[BILLING WARN]', ...args), + error: (...args: any[]) => console.error('[BILLING ERROR]', ...args), + } + } + + return pluginLogger +} + +/** + * Create a context-specific logger for a particular operation + */ +export function createContextLogger(payload: Payload, context: string) { + const logger = getPluginLogger(payload) + + return { + debug: (message: string, ...args: any[]) => logger.debug(`[${context}] ${message}`, ...args), + info: (message: string, ...args: any[]) => logger.info(`[${context}] ${message}`, ...args), + warn: (message: string, ...args: any[]) => logger.warn(`[${context}] ${message}`, ...args), + error: (message: string, ...args: any[]) => logger.error(`[${context}] ${message}`, ...args), + } +} \ No newline at end of file