mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-11 11:23:23 +00:00
Add PayloadCMS type definitions, Prettier config, and PNPM lockfile
This commit is contained in:
130
src/utils/currency.ts
Normal file
130
src/utils/currency.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Currency utility functions for payment processing
|
||||
*/
|
||||
|
||||
// Common currency configurations
|
||||
export const CURRENCY_CONFIG = {
|
||||
AUD: { name: 'Australian Dollar', decimals: 2, symbol: 'A$' },
|
||||
CAD: { name: 'Canadian Dollar', decimals: 2, symbol: 'C$' },
|
||||
CHF: { name: 'Swiss Franc', decimals: 2, symbol: 'Fr' },
|
||||
DKK: { name: 'Danish Krone', decimals: 2, symbol: 'kr' },
|
||||
EUR: { name: 'Euro', decimals: 2, symbol: '€' },
|
||||
GBP: { name: 'British Pound', decimals: 2, symbol: '£' },
|
||||
JPY: { name: 'Japanese Yen', decimals: 0, symbol: '¥' },
|
||||
NOK: { name: 'Norwegian Krone', decimals: 2, symbol: 'kr' },
|
||||
SEK: { name: 'Swedish Krona', decimals: 2, symbol: 'kr' },
|
||||
USD: { name: 'US Dollar', decimals: 2, symbol: '$' },
|
||||
} as const
|
||||
|
||||
export type SupportedCurrency = keyof typeof CURRENCY_CONFIG
|
||||
|
||||
/**
|
||||
* Validates if a currency code is supported
|
||||
*/
|
||||
export function isSupportedCurrency(currency: string): currency is SupportedCurrency {
|
||||
return currency in CURRENCY_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates currency format (3-letter ISO code)
|
||||
*/
|
||||
export function isValidCurrencyCode(currency: string): boolean {
|
||||
return /^[A-Z]{3}$/.test(currency)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts amount from cents to major currency unit
|
||||
*/
|
||||
export function fromCents(amount: number, currency: string): number {
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
|
||||
if (!config) {
|
||||
// Default to 2 decimals for unknown currencies
|
||||
return amount / 100
|
||||
}
|
||||
|
||||
return config.decimals === 0 ? amount : amount / Math.pow(10, config.decimals)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts amount from major currency unit to cents
|
||||
*/
|
||||
export function toCents(amount: number, currency: string): number {
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
|
||||
if (!config) {
|
||||
// Default to 2 decimals for unknown currencies
|
||||
return Math.round(amount * 100)
|
||||
}
|
||||
|
||||
return config.decimals === 0
|
||||
? Math.round(amount)
|
||||
: Math.round(amount * Math.pow(10, config.decimals))
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats amount for display with currency symbol
|
||||
*/
|
||||
export function formatAmount(amount: number, currency: string, options?: {
|
||||
showCode?: boolean
|
||||
showSymbol?: boolean
|
||||
}): string {
|
||||
const { showCode = false, showSymbol = true } = options || {}
|
||||
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
const majorAmount = fromCents(amount, currency)
|
||||
const config = CURRENCY_CONFIG[currency as SupportedCurrency]
|
||||
|
||||
let formatted = majorAmount.toFixed(config?.decimals ?? 2)
|
||||
|
||||
if (showSymbol && config?.symbol) {
|
||||
formatted = `${config.symbol}${formatted}`
|
||||
}
|
||||
|
||||
if (showCode) {
|
||||
formatted += ` ${currency}`
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets currency information
|
||||
*/
|
||||
export function getCurrencyInfo(currency: string) {
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error(`Invalid currency code: ${currency}`)
|
||||
}
|
||||
|
||||
return CURRENCY_CONFIG[currency as SupportedCurrency] || {
|
||||
name: currency,
|
||||
decimals: 2,
|
||||
symbol: currency
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates amount is positive and properly formatted
|
||||
*/
|
||||
export function validateAmount(amount: number): void {
|
||||
if (!Number.isFinite(amount)) {
|
||||
throw new Error('Amount must be a finite number')
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
throw new Error('Amount must be positive')
|
||||
}
|
||||
|
||||
if (!Number.isInteger(amount)) {
|
||||
throw new Error('Amount must be an integer (in cents)')
|
||||
}
|
||||
}
|
||||
3
src/utils/index.ts
Normal file
3
src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './currency'
|
||||
export * from './logger'
|
||||
export * from './validation'
|
||||
113
src/utils/logger.ts
Normal file
113
src/utils/logger.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Structured logging utilities for the billing plugin
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'error' | 'info' | 'warn'
|
||||
|
||||
export interface LogContext {
|
||||
[key: string]: unknown
|
||||
amount?: number
|
||||
currency?: string
|
||||
customerId?: string
|
||||
invoiceId?: string
|
||||
paymentId?: string
|
||||
provider?: string
|
||||
refundId?: string
|
||||
webhookId?: string
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string, context?: LogContext): void
|
||||
error(message: string, context?: LogContext): void
|
||||
info(message: string, context?: LogContext): void
|
||||
warn(message: string, context?: LogContext): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a structured logger with consistent formatting
|
||||
*/
|
||||
export function createLogger(namespace: string = 'BILLING'): Logger {
|
||||
const log = (level: LogLevel, message: string, context: LogContext = {}) => {
|
||||
const timestamp = new Date().toISOString()
|
||||
const logData = {
|
||||
level: level.toUpperCase(),
|
||||
message,
|
||||
namespace,
|
||||
timestamp,
|
||||
...context,
|
||||
}
|
||||
|
||||
// Use console methods based on log level
|
||||
const consoleMethod = console[level] || console.log
|
||||
consoleMethod(`[${namespace}] ${message}`, logData)
|
||||
}
|
||||
|
||||
return {
|
||||
debug: (message: string, context?: LogContext) => log('debug', message, context),
|
||||
error: (message: string, context?: LogContext) => log('error', message, context),
|
||||
info: (message: string, context?: LogContext) => log('info', message, context),
|
||||
warn: (message: string, context?: LogContext) => log('warn', message, context),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default logger instance for the plugin
|
||||
*/
|
||||
export const logger = createLogger('BILLING')
|
||||
|
||||
/**
|
||||
* Creates a provider-specific logger
|
||||
*/
|
||||
export function createProviderLogger(providerName: string): Logger {
|
||||
return createLogger(`BILLING:${providerName.toUpperCase()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log payment operations with consistent structure
|
||||
*/
|
||||
export function logPaymentOperation(
|
||||
operation: string,
|
||||
paymentId: string,
|
||||
provider: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
logger.info(`Payment ${operation}`, {
|
||||
operation,
|
||||
paymentId,
|
||||
provider,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Log webhook events with consistent structure
|
||||
*/
|
||||
export function logWebhookEvent(
|
||||
provider: string,
|
||||
eventType: string,
|
||||
webhookId: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
logger.info(`Webhook received`, {
|
||||
eventType,
|
||||
provider,
|
||||
webhookId,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Log errors with consistent structure
|
||||
*/
|
||||
export function logError(
|
||||
error: Error,
|
||||
operation: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
logger.error(`Operation failed: ${operation}`, {
|
||||
error: error.message,
|
||||
operation,
|
||||
stack: error.stack,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
181
src/utils/validation.ts
Normal file
181
src/utils/validation.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Validation utilities for billing data
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { isValidCurrencyCode } from './currency'
|
||||
|
||||
/**
|
||||
* Zod schema for payment creation options
|
||||
*/
|
||||
export const createPaymentSchema = z.object({
|
||||
amount: z.number().int().positive('Amount must be positive').min(1, 'Amount must be at least 1 cent'),
|
||||
cancelUrl: z.string().url('Invalid cancel URL').optional(),
|
||||
currency: z.string().length(3, 'Currency must be 3 characters').regex(/^[A-Z]{3}$/, 'Currency must be uppercase'),
|
||||
customer: z.string().optional(),
|
||||
description: z.string().max(500, 'Description too long').optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
returnUrl: z.string().url('Invalid return URL').optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for customer data
|
||||
*/
|
||||
export const customerSchema = z.object({
|
||||
name: z.string().max(100, 'Name too long').optional(),
|
||||
address: z.object({
|
||||
city: z.string().max(50).optional(),
|
||||
country: z.string().length(2, 'Country must be 2 characters').regex(/^[A-Z]{2}$/, 'Country must be uppercase').optional(),
|
||||
line1: z.string().max(100).optional(),
|
||||
line2: z.string().max(100).optional(),
|
||||
postal_code: z.string().max(20).optional(),
|
||||
state: z.string().max(50).optional(),
|
||||
}).optional(),
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
phone: z.string().max(20, 'Phone number too long').optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for invoice items
|
||||
*/
|
||||
export const invoiceItemSchema = z.object({
|
||||
description: z.string().min(1, 'Description is required').max(200, 'Description too long'),
|
||||
quantity: z.number().int().positive('Quantity must be positive'),
|
||||
unitAmount: z.number().int().min(0, 'Unit amount must be non-negative'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for invoice creation
|
||||
*/
|
||||
export const invoiceSchema = z.object({
|
||||
currency: z.string().length(3).regex(/^[A-Z]{3}$/),
|
||||
customer: z.string().min(1, 'Customer is required'),
|
||||
dueDate: z.string().datetime().optional(),
|
||||
items: z.array(invoiceItemSchema).min(1, 'At least one item is required'),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
taxAmount: z.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validates payment creation data
|
||||
*/
|
||||
export function validateCreatePayment(data: unknown) {
|
||||
const result = createPaymentSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid payment data: ${result.error.issues.map(i => i.message).join(', ')}`)
|
||||
}
|
||||
|
||||
// Additional currency validation
|
||||
if (!isValidCurrencyCode(result.data.currency)) {
|
||||
throw new Error(`Unsupported currency: ${result.data.currency}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates customer data
|
||||
*/
|
||||
export function validateCustomer(data: unknown) {
|
||||
const result = customerSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid customer data: ${result.error.issues.map(i => i.message).join(', ')}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates invoice data
|
||||
*/
|
||||
export function validateInvoice(data: unknown) {
|
||||
const result = invoiceSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid invoice data: ${result.error.issues.map(i => i.message).join(', ')}`)
|
||||
}
|
||||
|
||||
// Additional currency validation
|
||||
if (!isValidCurrencyCode(result.data.currency)) {
|
||||
throw new Error(`Unsupported currency: ${result.data.currency}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates webhook signature format
|
||||
*/
|
||||
export function validateWebhookSignature(signature: string, provider: string): void {
|
||||
if (!signature) {
|
||||
throw new Error(`Missing webhook signature for ${provider}`)
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case 'mollie':
|
||||
if (signature.length < 32) {
|
||||
throw new Error('Invalid Mollie webhook signature length')
|
||||
}
|
||||
break
|
||||
case 'stripe':
|
||||
if (!signature.startsWith('t=')) {
|
||||
throw new Error('Invalid Stripe webhook signature format')
|
||||
}
|
||||
break
|
||||
case 'test':
|
||||
// Test provider accepts any signature
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${provider}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates payment provider name
|
||||
*/
|
||||
export function validateProviderName(provider: string): void {
|
||||
const validProviders = ['stripe', 'mollie', 'test']
|
||||
if (!validProviders.includes(provider)) {
|
||||
throw new Error(`Invalid provider: ${provider}. Must be one of: ${validProviders.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates payment amount and currency combination
|
||||
*/
|
||||
export function validateAmountAndCurrency(amount: number, currency: string): void {
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
throw new Error('Amount must be a positive integer')
|
||||
}
|
||||
|
||||
if (!isValidCurrencyCode(currency)) {
|
||||
throw new Error('Invalid currency code')
|
||||
}
|
||||
|
||||
// Validate minimum amounts for different currencies
|
||||
const minimums: Record<string, number> = {
|
||||
EUR: 50, // €0.50
|
||||
GBP: 30, // £0.30
|
||||
JPY: 50, // ¥50
|
||||
USD: 50, // $0.50
|
||||
}
|
||||
|
||||
const minimum = minimums[currency] || 50
|
||||
if (amount < minimum) {
|
||||
throw new Error(`Amount too small for ${currency}. Minimum: ${minimum} cents`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates refund amount against original payment
|
||||
*/
|
||||
export function validateRefundAmount(refundAmount: number, paymentAmount: number): void {
|
||||
if (!Number.isInteger(refundAmount) || refundAmount <= 0) {
|
||||
throw new Error('Refund amount must be a positive integer')
|
||||
}
|
||||
|
||||
if (refundAmount > paymentAmount) {
|
||||
throw new Error('Refund amount cannot exceed original payment amount')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user