Add PayloadCMS type definitions, Prettier config, and PNPM lockfile

This commit is contained in:
2025-09-13 17:17:50 +02:00
parent bb86a68fa2
commit b995bdb505
50 changed files with 16356 additions and 0 deletions

View File

@@ -0,0 +1,283 @@
import type { TestProviderConfig} from '../types';
import { TestPaymentProvider } from '../providers/test/provider'
import { PaymentStatus } from '../types'
describe('TestPaymentProvider', () => {
let provider: TestPaymentProvider
let config: TestProviderConfig
beforeEach(() => {
config = {
autoComplete: true,
defaultDelay: 0,
enabled: true,
}
provider = new TestPaymentProvider(config)
})
afterEach(() => {
provider.clearStoredData()
})
describe('createPayment', () => {
it('should create a payment with succeeded status when autoComplete is true', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
description: 'Test payment',
})
expect(payment).toMatchObject({
amount: 2000,
currency: 'USD',
description: 'Test payment',
provider: 'test',
status: 'succeeded',
})
expect(payment.id).toBeDefined()
expect(payment.createdAt).toBeDefined()
expect(payment.updatedAt).toBeDefined()
expect(payment.providerData?.testMode).toBe(true)
})
it('should create a payment with pending status when autoComplete is false', async () => {
config.autoComplete = false
provider = new TestPaymentProvider(config)
const payment = await provider.createPayment({
amount: 1500,
currency: 'EUR',
})
expect(payment).toMatchObject({
amount: 1500,
currency: 'EUR',
status: 'pending',
})
})
it('should create a failed payment when simulateFailure is true', async () => {
const payment = await provider.createPayment({
amount: 1000,
currency: 'USD',
metadata: {
test: { simulateFailure: true },
},
})
expect(payment.status).toBe('failed')
expect(payment.providerData?.simulatedFailure).toBe(true)
})
it('should apply delay when specified', async () => {
const startTime = Date.now()
await provider.createPayment({
amount: 1000,
currency: 'USD',
metadata: {
test: { delayMs: 100 },
},
})
const endTime = Date.now()
expect(endTime - startTime).toBeGreaterThanOrEqual(100)
})
it('should store payment data', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const stored = provider.getStoredPayment(payment.id)
expect(stored).toEqual(payment)
})
})
describe('retrievePayment', () => {
it('should retrieve an existing payment', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const retrieved = await provider.retrievePayment(payment.id)
expect(retrieved).toEqual(payment)
})
it('should throw error for non-existent payment', async () => {
await expect(provider.retrievePayment('non-existent')).rejects.toThrow(
'Payment non-existent not found'
)
})
})
describe('cancelPayment', () => {
it('should cancel a pending payment', async () => {
config.autoComplete = false
provider = new TestPaymentProvider(config)
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const canceled = await provider.cancelPayment(payment.id)
expect(canceled.status).toBe('canceled')
expect(canceled.updatedAt).not.toBe(payment.updatedAt)
})
it('should not cancel a succeeded payment', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
await expect(provider.cancelPayment(payment.id)).rejects.toThrow(
'Cannot cancel a succeeded payment'
)
})
it('should throw error for non-existent payment', async () => {
await expect(provider.cancelPayment('non-existent')).rejects.toThrow(
'Payment non-existent not found'
)
})
})
describe('refundPayment', () => {
it('should create a full refund for succeeded payment', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const refund = await provider.refundPayment(payment.id)
expect(refund).toMatchObject({
amount: 2000,
currency: 'USD',
paymentId: payment.id,
status: 'succeeded',
})
expect(refund.id).toBeDefined()
expect(refund.createdAt).toBeDefined()
// Check payment status is updated
const updatedPayment = await provider.retrievePayment(payment.id)
expect(updatedPayment.status).toBe('refunded')
})
it('should create a partial refund', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
const refund = await provider.refundPayment(payment.id, 1000)
expect(refund.amount).toBe(1000)
// Check payment status is updated to partially_refunded
const updatedPayment = await provider.retrievePayment(payment.id)
expect(updatedPayment.status).toBe('partially_refunded')
})
it('should not refund a non-succeeded payment', async () => {
config.autoComplete = false
provider = new TestPaymentProvider(config)
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
await expect(provider.refundPayment(payment.id)).rejects.toThrow(
'Can only refund succeeded payments'
)
})
it('should not refund more than payment amount', async () => {
const payment = await provider.createPayment({
amount: 2000,
currency: 'USD',
})
await expect(provider.refundPayment(payment.id, 3000)).rejects.toThrow(
'Refund amount cannot exceed payment amount'
)
})
})
describe('handleWebhook', () => {
it('should handle webhook event', async () => {
const mockRequest = {
text: () => Promise.resolve(JSON.stringify({
type: 'payment.succeeded',
data: { paymentId: 'test_pay_123' }
}))
} as Request
const event = await provider.handleWebhook(mockRequest)
expect(event).toMatchObject({
type: 'payment.succeeded',
data: { paymentId: 'test_pay_123' },
provider: 'test',
verified: true,
})
expect(event.id).toBeDefined()
})
it('should throw error for invalid JSON', async () => {
const mockRequest = {
text: () => Promise.resolve('invalid json')
} as Request
await expect(provider.handleWebhook(mockRequest)).rejects.toThrow(
'Invalid JSON in webhook body'
)
})
it('should throw error when provider is disabled', async () => {
config.enabled = false
provider = new TestPaymentProvider(config)
const mockRequest = {
text: () => Promise.resolve('{}')
} as Request
await expect(provider.handleWebhook(mockRequest)).rejects.toThrow(
'Test provider is not enabled'
)
})
})
describe('data management', () => {
it('should clear all stored data', async () => {
await provider.createPayment({ amount: 1000, currency: 'USD' })
expect(provider.getAllPayments()).toHaveLength(1)
provider.clearStoredData()
expect(provider.getAllPayments()).toHaveLength(0)
expect(provider.getAllRefunds()).toHaveLength(0)
})
it('should return all payments and refunds', async () => {
const payment1 = await provider.createPayment({ amount: 1000, currency: 'USD' })
const payment2 = await provider.createPayment({ amount: 2000, currency: 'EUR' })
const refund = await provider.refundPayment(payment1.id)
const payments = provider.getAllPayments()
const refunds = provider.getAllRefunds()
expect(payments).toHaveLength(2)
expect(refunds).toHaveLength(1)
expect(refunds[0]).toEqual(refund)
})
})
})

View File

@@ -0,0 +1,149 @@
import type { CollectionConfig } from 'payload'
import type {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CustomerData,
CustomerDocument
} from '../types/payload'
export function createCustomersCollection(slug: string = 'customers'): CollectionConfig {
return {
slug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user,
update: ({ req: { user } }: AccessArgs) => !!user,
},
admin: {
defaultColumns: ['email', 'name', 'createdAt'],
group: 'Billing',
useAsTitle: 'email',
},
fields: [
{
name: 'email',
type: 'email',
admin: {
description: 'Customer email address',
},
index: true,
unique: true,
},
{
name: 'name',
type: 'text',
admin: {
description: 'Customer full name',
},
},
{
name: 'phone',
type: 'text',
admin: {
description: 'Customer phone number',
},
},
{
name: 'address',
type: 'group',
fields: [
{
name: 'line1',
type: 'text',
label: 'Address Line 1',
},
{
name: 'line2',
type: 'text',
label: 'Address Line 2',
},
{
name: 'city',
type: 'text',
label: 'City',
},
{
name: 'state',
type: 'text',
label: 'State/Province',
},
{
name: 'postal_code',
type: 'text',
label: 'Postal Code',
},
{
name: 'country',
type: 'text',
admin: {
description: 'ISO 3166-1 alpha-2 country code',
},
label: 'Country',
maxLength: 2,
},
],
},
{
name: 'providerIds',
type: 'json',
admin: {
description: 'Customer IDs from payment providers',
readOnly: true,
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional customer metadata',
},
},
{
name: 'payments',
type: 'relationship',
admin: {
description: 'Customer payments',
readOnly: true,
},
hasMany: true,
relationTo: 'payments',
},
{
name: 'invoices',
type: 'relationship',
admin: {
description: 'Customer invoices',
readOnly: true,
},
hasMany: true,
relationTo: 'invoices',
},
],
hooks: {
afterChange: [
({ doc, operation, req }: CollectionAfterChangeHook<CustomerDocument>) => {
if (operation === 'create') {
req.payload.logger.info(`Customer created: ${doc.id} (${doc.email})`)
}
},
],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<CustomerData>) => {
if (operation === 'create' || operation === 'update') {
// Normalize country code
if (data.address?.country) {
data.address.country = data.address.country.toUpperCase()
if (!/^[A-Z]{2}$/.test(data.address.country)) {
throw new Error('Country must be a 2-letter ISO code')
}
}
}
},
],
},
timestamps: true,
}
}

4
src/collections/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { createCustomersCollection } from './customers'
export { createInvoicesCollection } from './invoices'
export { createPaymentsCollection } from './payments'
export { createRefundsCollection } from './refunds'

248
src/collections/invoices.ts Normal file
View File

@@ -0,0 +1,248 @@
import type { CollectionConfig } from 'payload'
import type {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
InvoiceData,
InvoiceDocument,
InvoiceItemData
} from '../types/payload'
export function createInvoicesCollection(slug: string = 'invoices'): CollectionConfig {
return {
slug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user,
update: ({ req: { user } }: AccessArgs) => !!user,
},
admin: {
defaultColumns: ['number', 'customer', 'status', 'amount', 'currency', 'dueDate'],
group: 'Billing',
useAsTitle: 'number',
},
fields: [
{
name: 'number',
type: 'text',
admin: {
description: 'Invoice number (e.g., INV-001)',
},
index: true,
required: true,
unique: true,
},
{
name: 'customer',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: 'customers',
required: true,
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Open', value: 'open' },
{ label: 'Paid', value: 'paid' },
{ label: 'Void', value: 'void' },
{ label: 'Uncollectible', value: 'uncollectible' },
],
required: true,
},
{
name: 'currency',
type: 'text',
admin: {
description: 'ISO 4217 currency code (e.g., USD, EUR)',
},
defaultValue: 'USD',
maxLength: 3,
required: true,
},
{
name: 'items',
type: 'array',
admin: {
// Custom row labeling can be added here when needed
},
fields: [
{
name: 'description',
type: 'text',
admin: {
width: '40%',
},
required: true,
},
{
name: 'quantity',
type: 'number',
admin: {
width: '15%',
},
defaultValue: 1,
min: 1,
required: true,
},
{
name: 'unitAmount',
type: 'number',
admin: {
description: 'Amount in cents',
width: '20%',
},
min: 0,
required: true,
},
{
name: 'totalAmount',
type: 'number',
admin: {
description: 'Calculated: quantity × unitAmount',
readOnly: true,
width: '20%',
},
},
],
minRows: 1,
required: true,
},
{
name: 'subtotal',
type: 'number',
admin: {
description: 'Sum of all line items',
readOnly: true,
},
},
{
name: 'taxAmount',
type: 'number',
admin: {
description: 'Tax amount in cents',
},
defaultValue: 0,
},
{
name: 'amount',
type: 'number',
admin: {
description: 'Total amount (subtotal + tax)',
readOnly: true,
},
},
{
name: 'dueDate',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayOnly',
},
},
},
{
name: 'paidAt',
type: 'date',
admin: {
condition: (data: InvoiceData) => data.status === 'paid',
readOnly: true,
},
},
{
name: 'payment',
type: 'relationship',
admin: {
condition: (data: InvoiceData) => data.status === 'paid',
position: 'sidebar',
},
relationTo: 'payments',
},
{
name: 'notes',
type: 'textarea',
admin: {
description: 'Internal notes',
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional invoice metadata',
},
},
],
hooks: {
afterChange: [
({ doc, operation, req }: CollectionAfterChangeHook<InvoiceDocument>) => {
if (operation === 'create') {
req.payload.logger.info(`Invoice created: ${doc.number}`)
}
},
],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<InvoiceData>) => {
if (operation === 'create') {
// Generate invoice number if not provided
if (!data.number) {
const timestamp = Date.now()
data.number = `INV-${timestamp}`
}
// Validate currency format
if (data.currency) {
data.currency = data.currency.toUpperCase()
if (!/^[A-Z]{3}$/.test(data.currency)) {
throw new Error('Currency must be a 3-letter ISO code')
}
}
// Set due date if not provided (30 days from now)
if (!data.dueDate) {
const dueDate = new Date()
dueDate.setDate(dueDate.getDate() + 30)
data.dueDate = dueDate.toISOString()
}
}
// Set paid date when status changes to paid
if (data.status === 'paid' && !data.paidAt) {
data.paidAt = new Date().toISOString()
}
},
],
beforeValidate: [
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
if (data && data.items && Array.isArray(data.items)) {
// Calculate totals for each line item
data.items = data.items.map((item: InvoiceItemData) => ({
...item,
totalAmount: (item.quantity || 0) * (item.unitAmount || 0),
}))
// Calculate subtotal
data.subtotal = data.items.reduce(
(sum: number, item: InvoiceItemData) => sum + (item.totalAmount || 0),
0
)
// Calculate total amount
data.amount = (data.subtotal || 0) + (data.taxAmount || 0)
}
},
],
},
timestamps: true,
}
}

162
src/collections/payments.ts Normal file
View File

@@ -0,0 +1,162 @@
import type { CollectionConfig } from 'payload'
import type {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
PaymentData,
PaymentDocument
} from '../types/payload'
export function createPaymentsCollection(slug: string = 'payments'): CollectionConfig {
return {
slug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user,
update: ({ req: { user } }: AccessArgs) => !!user,
},
admin: {
defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
group: 'Billing',
useAsTitle: 'id',
},
fields: [
{
name: 'provider',
type: 'select',
admin: {
position: 'sidebar',
},
options: [
{ label: 'Stripe', value: 'stripe' },
{ label: 'Mollie', value: 'mollie' },
{ label: 'Test', value: 'test' },
],
required: true,
},
{
name: 'providerId',
type: 'text',
admin: {
description: 'The payment ID from the payment provider',
},
label: 'Provider Payment ID',
required: true,
unique: true,
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
options: [
{ label: 'Pending', value: 'pending' },
{ label: 'Processing', value: 'processing' },
{ label: 'Succeeded', value: 'succeeded' },
{ label: 'Failed', value: 'failed' },
{ label: 'Canceled', value: 'canceled' },
{ label: 'Refunded', value: 'refunded' },
{ label: 'Partially Refunded', value: 'partially_refunded' },
],
required: true,
},
{
name: 'amount',
type: 'number',
admin: {
description: 'Amount in cents (e.g., 2000 = $20.00)',
},
min: 1,
required: true,
},
{
name: 'currency',
type: 'text',
admin: {
description: 'ISO 4217 currency code (e.g., USD, EUR)',
},
maxLength: 3,
required: true,
},
{
name: 'description',
type: 'text',
admin: {
description: 'Payment description',
},
},
{
name: 'customer',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: 'customers',
},
{
name: 'invoice',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: 'invoices',
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional metadata for the payment',
},
},
{
name: 'providerData',
type: 'json',
admin: {
description: 'Raw data from the payment provider',
readOnly: true,
},
},
{
name: 'refunds',
type: 'relationship',
admin: {
position: 'sidebar',
readOnly: true,
},
hasMany: true,
relationTo: 'refunds',
},
],
hooks: {
afterChange: [
({ doc, operation, req }: CollectionAfterChangeHook<PaymentDocument>) => {
if (operation === 'create') {
req.payload.logger.info(`Payment created: ${doc.id} (${doc.provider})`)
}
},
],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<PaymentData>) => {
if (operation === 'create') {
// Validate amount format
if (data.amount && !Number.isInteger(data.amount)) {
throw new Error('Amount must be an integer (in cents)')
}
// Validate currency format
if (data.currency) {
data.currency = data.currency.toUpperCase()
if (!/^[A-Z]{3}$/.test(data.currency)) {
throw new Error('Currency must be a 3-letter ISO code')
}
}
}
},
],
},
timestamps: true,
}
}

163
src/collections/refunds.ts Normal file
View File

@@ -0,0 +1,163 @@
import type { CollectionConfig } from 'payload'
import type {
AccessArgs,
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
RefundData,
RefundDocument
} from '../types/payload'
export function createRefundsCollection(slug: string = 'refunds'): CollectionConfig {
return {
slug,
access: {
create: ({ req: { user } }: AccessArgs) => !!user,
delete: ({ req: { user } }: AccessArgs) => !!user,
read: ({ req: { user } }: AccessArgs) => !!user,
update: ({ req: { user } }: AccessArgs) => !!user,
},
admin: {
defaultColumns: ['id', 'payment', 'amount', 'currency', 'status', 'createdAt'],
group: 'Billing',
useAsTitle: 'id',
},
fields: [
{
name: 'providerId',
type: 'text',
admin: {
description: 'The refund ID from the payment provider',
},
label: 'Provider Refund ID',
required: true,
unique: true,
},
{
name: 'payment',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: 'payments',
required: true,
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
options: [
{ label: 'Pending', value: 'pending' },
{ label: 'Processing', value: 'processing' },
{ label: 'Succeeded', value: 'succeeded' },
{ label: 'Failed', value: 'failed' },
{ label: 'Canceled', value: 'canceled' },
],
required: true,
},
{
name: 'amount',
type: 'number',
admin: {
description: 'Refund amount in cents',
},
min: 1,
required: true,
},
{
name: 'currency',
type: 'text',
admin: {
description: 'ISO 4217 currency code (e.g., USD, EUR)',
},
maxLength: 3,
required: true,
},
{
name: 'reason',
type: 'select',
admin: {
description: 'Reason for the refund',
},
options: [
{ label: 'Duplicate', value: 'duplicate' },
{ label: 'Fraudulent', value: 'fraudulent' },
{ label: 'Requested by Customer', value: 'requested_by_customer' },
{ label: 'Other', value: 'other' },
],
},
{
name: 'description',
type: 'textarea',
admin: {
description: 'Additional details about the refund',
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional refund metadata',
},
},
{
name: 'providerData',
type: 'json',
admin: {
description: 'Raw data from the payment provider',
readOnly: true,
},
},
],
hooks: {
afterChange: [
async ({ doc, operation, req }: CollectionAfterChangeHook<RefundDocument>) => {
if (operation === 'create') {
req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
// Update the related payment's refund relationship
try {
const payment = await req.payload.findByID({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: 'payments',
})
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
await req.payload.update({
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
collection: 'payments',
data: {
refunds: [...refundIds, doc.id],
},
})
} catch (error) {
req.payload.logger.error(`Failed to update payment refunds: ${error}`)
}
}
},
],
beforeChange: [
({ data, operation }: CollectionBeforeChangeHook<RefundData>) => {
if (operation === 'create') {
// Validate amount format
if (data.amount && !Number.isInteger(data.amount)) {
throw new Error('Amount must be an integer (in cents)')
}
// Validate currency format
if (data.currency) {
data.currency = data.currency.toUpperCase()
if (!/^[A-Z]{3}$/.test(data.currency)) {
throw new Error('Currency must be a 3-letter ISO code')
}
}
}
},
],
},
timestamps: true,
}
}

68
src/exports/client.tsx Normal file
View File

@@ -0,0 +1,68 @@
/**
* Client-side components and utilities for the billing plugin
* These components run in the browser and have access to React hooks and client-side APIs
*/
'use client'
import React from 'react'
// Example client component that could be used in the admin dashboard
export const BillingDashboardWidget: React.FC = () => {
return (
<div className="billing-dashboard-widget">
<h3>Billing Overview</h3>
<p>Payment statistics and recent transactions will be displayed here.</p>
</div>
)
}
// Client-side utilities
export const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
currency: currency.toUpperCase(),
style: 'currency',
}).format(amount / 100)
}
export const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'canceled':
return 'gray'
case 'failed':
return 'red'
case 'pending':
return 'yellow'
case 'succeeded':
return 'green'
default:
return 'blue'
}
}
// Example of a client component for payment status display
export const PaymentStatusBadge: React.FC<{ status: string }> = ({ status }) => {
const color = getPaymentStatusColor(status)
return (
<span
className={`payment-status-badge payment-status-${status}`}
style={{
backgroundColor: color,
borderRadius: '4px',
color: 'white',
fontSize: '12px',
padding: '2px 8px'
}}
>
{status.toUpperCase()}
</span>
)
}
export default {
BillingDashboardWidget,
formatCurrency,
getPaymentStatusColor,
PaymentStatusBadge,
}

146
src/exports/rsc.tsx Normal file
View File

@@ -0,0 +1,146 @@
/**
* React Server Components (RSC) and server-side utilities for the billing plugin
* These components run on the server and can access server-side APIs and databases
*/
import React from 'react'
// Server component that can fetch data during server-side rendering
interface BillingServerStatsProps {
payloadInstance?: unknown
}
export const BillingServerStats: React.FC<BillingServerStatsProps> = async ({
payloadInstance
}) => {
// In a real implementation, this would fetch data from the database
// const stats = await payloadInstance?.find({
// collection: 'payments',
// limit: 0,
// depth: 0
// })
return (
<div className="billing-server-stats">
<h3>Payment Statistics</h3>
<div className="stats-grid">
<div className="stat-item">
<span className="stat-label">Total Payments</span>
<span className="stat-value">-</span>
</div>
<div className="stat-item">
<span className="stat-label">Successful</span>
<span className="stat-value">-</span>
</div>
<div className="stat-item">
<span className="stat-label">Pending</span>
<span className="stat-value">-</span>
</div>
<div className="stat-item">
<span className="stat-label">Failed</span>
<span className="stat-value">-</span>
</div>
</div>
</div>
)
}
// Server-side utility functions
export const generateInvoiceNumber = () => {
const timestamp = Date.now()
const random = Math.random().toString(36).substring(2, 8).toUpperCase()
return `INV-${timestamp}-${random}`
}
export const calculateInvoiceTotal = (items: Array<{
quantity: number
unitAmount: number
}>, taxRate: number = 0) => {
const subtotal = items.reduce((sum, item) => sum + (item.quantity * item.unitAmount), 0)
const taxAmount = Math.round(subtotal * taxRate)
return {
subtotal,
taxAmount,
total: subtotal + taxAmount
}
}
// Server component for displaying invoice details
interface InvoiceDetailsProps {
invoice?: {
amount: number
currency: string
customer?: {
email?: string
name?: string
}
dueDate?: string
items?: Array<{
description: string
quantity: number
totalAmount: number
unitAmount: number
}>
number: string
status: string
}
readonly?: boolean
}
export const InvoiceDetails: React.FC<InvoiceDetailsProps> = ({ invoice, readonly = false }) => {
if (!invoice) {
return <div>No invoice data available</div>
}
return (
<div className="invoice-details">
<div className="invoice-header">
<h3>Invoice {invoice.number}</h3>
<span className={`status-badge status-${invoice.status}`}>
{invoice.status}
</span>
</div>
<div className="invoice-content">
<div className="invoice-meta">
<p><strong>Customer:</strong> {invoice.customer?.name || invoice.customer?.email}</p>
<p><strong>Due Date:</strong> {invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : 'N/A'}</p>
<p><strong>Amount:</strong> {invoice.currency} {(invoice.amount / 100).toFixed(2)}</p>
</div>
{invoice.items && invoice.items.length > 0 && (
<div className="invoice-items">
<h4>Items</h4>
<table className="items-table">
<thead>
<tr>
<th>Description</th>
<th>Quantity</th>
<th>Unit Amount</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{invoice.items.map((item, index) => (
<tr key={index}>
<td>{item.description}</td>
<td>{item.quantity}</td>
<td>{invoice.currency} {(item.unitAmount / 100).toFixed(2)}</td>
<td>{invoice.currency} {(item.totalAmount / 100).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
export default {
BillingServerStats,
calculateInvoiceTotal,
generateInvoiceNumber,
InvoiceDetails,
}

132
src/index.ts Normal file
View File

@@ -0,0 +1,132 @@
import type { Config } from 'payload'
import type { BillingPluginConfig } from './types'
import { createCustomersCollection } from './collections/customers'
import { createInvoicesCollection } from './collections/invoices'
import { createPaymentsCollection } from './collections/payments'
import { createRefundsCollection } from './collections/refunds'
import { providerRegistry } from './providers/base/provider'
import { TestPaymentProvider } from './providers/test/provider'
export * from './providers/base/provider'
export * from './providers/test/provider'
export * from './types'
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
if (pluginConfig.disabled) {
return config
}
// Initialize collections
if (!config.collections) {
config.collections = []
}
config.collections.push(
createPaymentsCollection(pluginConfig.collections?.payments || 'payments'),
createCustomersCollection(pluginConfig.collections?.customers || 'customers'),
createInvoicesCollection(pluginConfig.collections?.invoices || 'invoices'),
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
)
// Initialize endpoints
if (!config.endpoints) {
config.endpoints = []
}
config.endpoints?.push(
// Webhook endpoints
{
handler: async (req) => {
try {
const provider = providerRegistry.get(req.routeParams?.provider as string)
if (!provider) {
return Response.json({ error: 'Provider not found' }, { status: 404 })
}
const signature = req.headers.get('stripe-signature') ||
req.headers.get('x-mollie-signature')
const event = await provider.handleWebhook(req as unknown as Request, signature || '')
// TODO: Process webhook event and update database
return Response.json({ eventId: event.id, received: true })
} catch (error) {
console.error('[BILLING] Webhook error:', error)
return Response.json({ error: 'Webhook processing failed' }, { status: 400 })
}
},
method: 'post',
path: '/billing/webhooks/:provider'
},
// Health check endpoint
{
handler: async () => {
const providers = providerRegistry.getAll().map(p => ({
name: p.name,
status: 'active'
}))
return Response.json({
providers,
status: 'ok',
version: '0.1.0'
})
},
method: 'get',
path: '/billing/health'
}
)
// Initialize providers and onInit hook
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
// Execute any existing onInit functions first
if (incomingOnInit) {
await incomingOnInit(payload)
}
// Initialize payment providers
initializeProviders(pluginConfig)
// Log initialization
console.log('[BILLING] Plugin initialized with providers:',
providerRegistry.getAll().map(p => p.name).join(', ')
)
}
return config
}
function initializeProviders(config: BillingPluginConfig) {
// Initialize test provider if enabled
if (config.providers?.test?.enabled) {
const testProvider = new TestPaymentProvider(config.providers.test)
providerRegistry.register(testProvider)
}
// TODO: Initialize Stripe provider
// TODO: Initialize Mollie provider
}
// Utility function to get payment provider
export function getPaymentProvider(name: string) {
const provider = providerRegistry.get(name)
if (!provider) {
throw new Error(`Payment provider '${name}' not found`)
}
return provider
}
// Utility function to list available providers
export function getAvailableProviders() {
return providerRegistry.getAll().map(p => ({
name: p.name,
// Add provider-specific info here
}))
}
export default billingPlugin

View File

@@ -0,0 +1,63 @@
import type { CreatePaymentOptions, Payment, PaymentProvider, Refund, WebhookEvent } from '../../types'
export abstract class BasePaymentProvider implements PaymentProvider {
abstract name: string
protected formatAmount(amount: number, currency: string): number {
this.validateAmount(amount)
this.validateCurrency(currency)
return amount
}
protected log(level: 'error' | 'info' | 'warn', message: string, data?: Record<string, unknown>): void {
const logData = {
message,
provider: this.name,
...data,
}
console[level](`[${this.name.toUpperCase()}]`, logData)
}
protected validateAmount(amount: number): void {
if (amount <= 0 || !Number.isInteger(amount)) {
throw new Error('Amount must be a positive integer in cents')
}
}
protected validateCurrency(currency: string): void {
if (!currency || currency.length !== 3) {
throw new Error('Currency must be a valid 3-letter ISO currency code')
}
}
abstract cancelPayment(id: string): Promise<Payment>
abstract createPayment(options: CreatePaymentOptions): Promise<Payment>
abstract handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
abstract refundPayment(id: string, amount?: number): Promise<Refund>
abstract retrievePayment(id: string): Promise<Payment>
}
export function createProviderRegistry() {
const providers = new Map<string, PaymentProvider>()
return {
register(provider: PaymentProvider): void {
providers.set(provider.name, provider)
},
get(name: string): PaymentProvider | undefined {
return providers.get(name)
},
getAll(): PaymentProvider[] {
return Array.from(providers.values())
},
has(name: string): boolean {
return providers.has(name)
}
}
}
export const providerRegistry = createProviderRegistry()

View File

@@ -0,0 +1,225 @@
import type {
CreatePaymentOptions,
Payment,
PaymentStatus,
Refund,
TestProviderConfig,
WebhookEvent
} from '../../types';
import {
RefundStatus
} from '../../types'
import { BasePaymentProvider } from '../base/provider'
interface TestPaymentData {
delayMs?: number
failAfterMs?: number
simulateFailure?: boolean
}
export class TestPaymentProvider extends BasePaymentProvider {
private config: TestProviderConfig
private payments = new Map<string, Payment>()
private refunds = new Map<string, Refund>()
name = 'test'
constructor(config: TestProviderConfig) {
super()
this.config = config
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async cancelPayment(id: string): Promise<Payment> {
const payment = this.payments.get(id)
if (!payment) {
throw new Error(`Payment ${id} not found`)
}
if (payment.status === 'succeeded') {
throw new Error('Cannot cancel a succeeded payment')
}
const canceledPayment = {
...payment,
status: 'canceled' as PaymentStatus,
updatedAt: new Date().toISOString()
}
this.payments.set(id, canceledPayment)
this.log('info', 'Payment canceled', { paymentId: id })
return canceledPayment
}
clearStoredData(): void {
this.payments.clear()
this.refunds.clear()
this.log('info', 'Test data cleared')
}
async createPayment(options: CreatePaymentOptions): Promise<Payment> {
const testData = options.metadata?.test as TestPaymentData || {}
const delay = testData.delayMs ?? this.config.defaultDelay ?? 0
if (delay > 0) {
await this.sleep(delay)
}
const shouldFail = testData.simulateFailure ??
(this.config.simulateFailures && Math.random() < (this.config.failureRate ?? 0.1))
const paymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substring(7)}`
const payment: Payment = {
id: paymentId,
amount: options.amount,
createdAt: new Date().toISOString(),
currency: options.currency,
customer: options.customer,
description: options.description,
metadata: options.metadata,
provider: this.name,
providerData: {
autoCompleted: this.config.autoComplete,
delayApplied: delay,
simulatedFailure: shouldFail,
testMode: true
},
status: shouldFail ? 'failed' : (this.config.autoComplete ? 'succeeded' : 'pending'),
updatedAt: new Date().toISOString()
}
this.payments.set(paymentId, payment)
this.log('info', 'Payment created', {
amount: options.amount,
currency: options.currency,
paymentId,
status: payment.status
})
// Simulate async status updates if configured
if (testData.failAfterMs && !shouldFail) {
setTimeout(() => {
const updatedPayment = { ...payment, status: 'failed' as PaymentStatus, updatedAt: new Date().toISOString() }
this.payments.set(paymentId, updatedPayment)
this.log('info', 'Payment failed after delay', { paymentId })
}, testData.failAfterMs)
}
return payment
}
getAllPayments(): Payment[] {
return Array.from(this.payments.values())
}
getAllRefunds(): Refund[] {
return Array.from(this.refunds.values())
}
// Test-specific methods
getStoredPayment(id: string): Payment | undefined {
return this.payments.get(id)
}
getStoredRefund(id: string): Refund | undefined {
return this.refunds.get(id)
}
async handleWebhook(request: Request, signature?: string): Promise<WebhookEvent> {
if (!this.config.enabled) {
throw new Error('Test provider is not enabled')
}
// For test provider, we'll simulate webhook events
const body = await request.text()
let eventData: Record<string, unknown>
try {
eventData = JSON.parse(body)
} catch (error) {
throw new Error('Invalid JSON in webhook body')
}
const event: WebhookEvent = {
id: `test_evt_${Date.now()}_${Math.random().toString(36).substring(7)}`,
type: (eventData.type as string) || 'payment.status_changed',
data: eventData,
provider: this.name,
verified: true // Test provider always considers webhooks verified
}
this.log('info', 'Webhook received', {
type: event.type,
dataKeys: Object.keys(event.data),
eventId: event.id
})
return event
}
async refundPayment(id: string, amount?: number): Promise<Refund> {
const payment = this.payments.get(id)
if (!payment) {
throw new Error(`Payment ${id} not found`)
}
if (payment.status !== 'succeeded') {
throw new Error('Can only refund succeeded payments')
}
const refundAmount = amount ?? payment.amount
if (refundAmount > payment.amount) {
throw new Error('Refund amount cannot exceed payment amount')
}
const refundId = `test_ref_${Date.now()}_${Math.random().toString(36).substring(7)}`
const refund: Refund = {
id: refundId,
amount: refundAmount,
createdAt: new Date().toISOString(),
currency: payment.currency,
paymentId: id,
providerData: {
autoCompleted: this.config.autoComplete,
testMode: true
},
status: this.config.autoComplete ? 'succeeded' : 'pending'
}
this.refunds.set(refundId, refund)
// Update payment status
const newPaymentStatus: PaymentStatus = refundAmount === payment.amount ? 'refunded' : 'partially_refunded'
const updatedPayment = {
...payment,
status: newPaymentStatus,
updatedAt: new Date().toISOString()
}
this.payments.set(id, updatedPayment)
this.log('info', 'Refund created', {
amount: refundAmount,
paymentId: id,
refundId,
status: refund.status
})
return refund
}
async retrievePayment(id: string): Promise<Payment> {
const payment = this.payments.get(id)
if (!payment) {
throw new Error(`Payment ${id} not found`)
}
return payment
}
}

224
src/types/index.ts Normal file
View File

@@ -0,0 +1,224 @@
import type { Config } from 'payload'
// Base payment provider interface
export interface PaymentProvider {
cancelPayment(id: string): Promise<Payment>
createPayment(options: CreatePaymentOptions): Promise<Payment>
handleWebhook(request: Request, signature?: string): Promise<WebhookEvent>
name: string
refundPayment(id: string, amount?: number): Promise<Refund>
retrievePayment(id: string): Promise<Payment>
}
// Payment types
export interface CreatePaymentOptions {
amount: number
cancelUrl?: string
currency: string
customer?: string
description?: string
metadata?: Record<string, unknown>
returnUrl?: string
}
export interface Payment {
amount: number
createdAt: string
currency: string
customer?: string
description?: string
id: string
metadata?: Record<string, unknown>
provider: string
providerData?: Record<string, unknown>
status: PaymentStatus
updatedAt: string
}
export interface Refund {
amount: number
createdAt: string
currency: string
id: string
paymentId: string
providerData?: Record<string, unknown>
reason?: string
status: RefundStatus
}
export interface WebhookEvent {
data: Record<string, unknown>
id: string
provider: string
type: string
verified: boolean
}
// Status enums
export type PaymentStatus =
| 'canceled'
| 'failed'
| 'partially_refunded'
| 'pending'
| 'processing'
| 'refunded'
| 'succeeded'
export type RefundStatus =
| 'canceled'
| 'failed'
| 'pending'
| 'processing'
| 'succeeded'
// Provider configurations
export interface StripeConfig {
apiVersion?: string
publishableKey: string
secretKey: string
webhookEndpointSecret: string
}
export interface MollieConfig {
apiKey: string
testMode?: boolean
webhookUrl: string
}
export interface TestProviderConfig {
autoComplete?: boolean
defaultDelay?: number
enabled: boolean
failureRate?: number
simulateFailures?: boolean
}
// Plugin configuration
export interface BillingPluginConfig {
admin?: {
customComponents?: boolean
dashboard?: boolean
}
collections?: {
customers?: string
invoices?: string
payments?: string
refunds?: string
}
disabled?: boolean
providers?: {
mollie?: MollieConfig
stripe?: StripeConfig
test?: TestProviderConfig
}
webhooks?: {
basePath?: string
cors?: boolean
}
}
// Collection types
export interface PaymentRecord {
amount: number
createdAt: string
currency: string
customer?: string
description?: string
id: string
metadata?: Record<string, unknown>
provider: string
providerData?: Record<string, unknown>
providerId: string
status: PaymentStatus
updatedAt: string
}
export interface CustomerRecord {
address?: {
city?: string
country?: string
line1?: string
line2?: string
postal_code?: string
state?: string
}
createdAt: string
email?: string
id: string
metadata?: Record<string, unknown>
name?: string
phone?: string
providerIds?: Record<string, string>
updatedAt: string
}
export interface InvoiceRecord {
amount: number
createdAt: string
currency: string
customer?: string
dueDate?: string
id: string
items: InvoiceItem[]
metadata?: Record<string, unknown>
number: string
paidAt?: string
status: InvoiceStatus
updatedAt: string
}
export interface InvoiceItem {
description: string
quantity: number
totalAmount: number
unitAmount: number
}
export type InvoiceStatus =
| 'draft'
| 'open'
| 'paid'
| 'uncollectible'
| 'void'
// Plugin type
export interface BillingPluginOptions extends BillingPluginConfig {
disabled?: boolean
}
// Error types
export class BillingError extends Error {
constructor(
message: string,
public code: string,
public provider?: string,
public details?: Record<string, unknown>
) {
super(message)
this.name = 'BillingError'
}
}
export class PaymentProviderError extends BillingError {
constructor(
message: string,
provider: string,
code?: string,
details?: Record<string, unknown>
) {
super(message, code || 'PROVIDER_ERROR', provider, details)
this.name = 'PaymentProviderError'
}
}
export class WebhookError extends BillingError {
constructor(
message: string,
provider: string,
code?: string,
details?: Record<string, unknown>
) {
super(message, code || 'WEBHOOK_ERROR', provider, details)
this.name = 'WebhookError'
}
}

148
src/types/payload.ts Normal file
View File

@@ -0,0 +1,148 @@
/**
* PayloadCMS type definitions for hooks and handlers
*/
import type { PayloadRequest, User } from 'payload'
// Collection hook types
export interface CollectionBeforeChangeHook<T = Record<string, unknown>> {
data: T
operation: 'create' | 'delete' | 'update'
originalDoc?: T
req: PayloadRequest
}
export interface CollectionAfterChangeHook<T = Record<string, unknown>> {
doc: T
operation: 'create' | 'delete' | 'update'
previousDoc?: T
req: PayloadRequest
}
export interface CollectionBeforeValidateHook<T = Record<string, unknown>> {
data?: T
operation: 'create' | 'update'
originalDoc?: T
req: PayloadRequest
}
// Access control types
export interface AccessArgs<T = unknown> {
data?: T
id?: number | string
req: {
payload: unknown
user: null | User
}
}
// Invoice item type for hooks
export interface InvoiceItemData {
description: string
quantity: number
totalAmount?: number
unitAmount: number
}
// Invoice data type for hooks
export interface InvoiceData {
amount?: number
currency?: string
customer?: string
dueDate?: string
items?: InvoiceItemData[]
metadata?: Record<string, unknown>
notes?: string
number?: string
paidAt?: string
payment?: string
status?: string
subtotal?: number
taxAmount?: number
}
// Payment data type for hooks
export interface PaymentData {
amount?: number
currency?: string
customer?: string
description?: string
invoice?: string
metadata?: Record<string, unknown>
provider?: string
providerData?: Record<string, unknown>
providerId?: string
status?: string
}
// Customer data type for hooks
export interface CustomerData {
address?: {
city?: string
country?: string
line1?: string
line2?: string
postal_code?: string
state?: string
}
email?: string
metadata?: Record<string, unknown>
name?: string
phone?: string
providerIds?: Record<string, string>
}
// Refund data type for hooks
export interface RefundData {
amount?: number
currency?: string
description?: string
metadata?: Record<string, unknown>
payment?: { id: string } | string
providerData?: Record<string, unknown>
providerId?: string
reason?: string
status?: string
}
// Document types with required fields after creation
export interface PaymentDocument extends PaymentData {
amount: number
createdAt: string
currency: string
id: string
provider: string
providerId: string
status: string
updatedAt: string
}
export interface CustomerDocument extends CustomerData {
createdAt: string
id: string
updatedAt: string
}
export interface InvoiceDocument extends InvoiceData {
amount: number
createdAt: string
currency: string
customer: string
id: string
items: InvoiceItemData[]
number: string
status: string
updatedAt: string
}
export interface RefundDocument extends RefundData {
amount: number
createdAt: string
currency: string
id: string
payment: { id: string } | string
providerId: string
refunds?: string[]
status: string
updatedAt: string
}

130
src/utils/currency.ts Normal file
View 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
View File

@@ -0,0 +1,3 @@
export * from './currency'
export * from './logger'
export * from './validation'

113
src/utils/logger.ts Normal file
View 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
View 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')
}
}