mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 10:53:23 +00:00
@@ -92,7 +92,7 @@ export interface Config {
|
|||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
};
|
};
|
||||||
db: {
|
db: {
|
||||||
defaultIDType: string;
|
defaultIDType: number;
|
||||||
};
|
};
|
||||||
globals: {};
|
globals: {};
|
||||||
globalsSelect: {};
|
globalsSelect: {};
|
||||||
@@ -128,7 +128,7 @@ export interface UserAuthOperations {
|
|||||||
* via the `definition` "posts".
|
* via the `definition` "posts".
|
||||||
*/
|
*/
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: string;
|
id: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ export interface Post {
|
|||||||
* via the `definition` "media".
|
* via the `definition` "media".
|
||||||
*/
|
*/
|
||||||
export interface Media {
|
export interface Media {
|
||||||
id: string;
|
id: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
@@ -155,7 +155,7 @@ export interface Media {
|
|||||||
* via the `definition` "payments".
|
* via the `definition` "payments".
|
||||||
*/
|
*/
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
id: string;
|
id: number;
|
||||||
provider: 'stripe' | 'mollie' | 'test';
|
provider: 'stripe' | 'mollie' | 'test';
|
||||||
/**
|
/**
|
||||||
* The payment ID from the payment provider
|
* The payment ID from the payment provider
|
||||||
@@ -174,8 +174,8 @@ export interface Payment {
|
|||||||
* Payment description
|
* Payment description
|
||||||
*/
|
*/
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
customer?: (string | null) | Customer;
|
customer?: (number | null) | Customer;
|
||||||
invoice?: (string | null) | Invoice;
|
invoice?: (number | null) | Invoice;
|
||||||
/**
|
/**
|
||||||
* Additional metadata for the payment
|
* Additional metadata for the payment
|
||||||
*/
|
*/
|
||||||
@@ -200,7 +200,7 @@ export interface Payment {
|
|||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
| null;
|
| null;
|
||||||
refunds?: (string | Refund)[] | null;
|
refunds?: (number | Refund)[] | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ export interface Payment {
|
|||||||
* via the `definition` "customers".
|
* via the `definition` "customers".
|
||||||
*/
|
*/
|
||||||
export interface Customer {
|
export interface Customer {
|
||||||
id: string;
|
id: number;
|
||||||
/**
|
/**
|
||||||
* Customer email address
|
* Customer email address
|
||||||
*/
|
*/
|
||||||
@@ -260,11 +260,11 @@ export interface Customer {
|
|||||||
/**
|
/**
|
||||||
* Customer payments
|
* Customer payments
|
||||||
*/
|
*/
|
||||||
payments?: (string | Payment)[] | null;
|
payments?: (number | Payment)[] | null;
|
||||||
/**
|
/**
|
||||||
* Customer invoices
|
* Customer invoices
|
||||||
*/
|
*/
|
||||||
invoices?: (string | Invoice)[] | null;
|
invoices?: (number | Invoice)[] | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -273,12 +273,12 @@ export interface Customer {
|
|||||||
* via the `definition` "invoices".
|
* via the `definition` "invoices".
|
||||||
*/
|
*/
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
id: string;
|
id: number;
|
||||||
/**
|
/**
|
||||||
* Invoice number (e.g., INV-001)
|
* Invoice number (e.g., INV-001)
|
||||||
*/
|
*/
|
||||||
number: string;
|
number: string;
|
||||||
customer: string | Customer;
|
customer: number | Customer;
|
||||||
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
|
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
|
||||||
/**
|
/**
|
||||||
* ISO 4217 currency code (e.g., USD, EUR)
|
* ISO 4217 currency code (e.g., USD, EUR)
|
||||||
@@ -311,7 +311,7 @@ export interface Invoice {
|
|||||||
amount?: number | null;
|
amount?: number | null;
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
paidAt?: string | null;
|
paidAt?: string | null;
|
||||||
payment?: (string | null) | Payment;
|
payment?: (number | null) | Payment;
|
||||||
/**
|
/**
|
||||||
* Internal notes
|
* Internal notes
|
||||||
*/
|
*/
|
||||||
@@ -336,12 +336,12 @@ export interface Invoice {
|
|||||||
* via the `definition` "refunds".
|
* via the `definition` "refunds".
|
||||||
*/
|
*/
|
||||||
export interface Refund {
|
export interface Refund {
|
||||||
id: string;
|
id: number;
|
||||||
/**
|
/**
|
||||||
* The refund ID from the payment provider
|
* The refund ID from the payment provider
|
||||||
*/
|
*/
|
||||||
providerId: string;
|
providerId: string;
|
||||||
payment: string | Payment;
|
payment: number | Payment;
|
||||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled';
|
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'canceled';
|
||||||
/**
|
/**
|
||||||
* Refund amount in cents
|
* Refund amount in cents
|
||||||
@@ -391,7 +391,7 @@ export interface Refund {
|
|||||||
* via the `definition` "users".
|
* via the `definition` "users".
|
||||||
*/
|
*/
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -408,40 +408,40 @@ export interface User {
|
|||||||
* via the `definition` "payload-locked-documents".
|
* via the `definition` "payload-locked-documents".
|
||||||
*/
|
*/
|
||||||
export interface PayloadLockedDocument {
|
export interface PayloadLockedDocument {
|
||||||
id: string;
|
id: number;
|
||||||
document?:
|
document?:
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'posts';
|
relationTo: 'posts';
|
||||||
value: string | Post;
|
value: number | Post;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'media';
|
relationTo: 'media';
|
||||||
value: string | Media;
|
value: number | Media;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'payments';
|
relationTo: 'payments';
|
||||||
value: string | Payment;
|
value: number | Payment;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'customers';
|
relationTo: 'customers';
|
||||||
value: string | Customer;
|
value: number | Customer;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'invoices';
|
relationTo: 'invoices';
|
||||||
value: string | Invoice;
|
value: number | Invoice;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'refunds';
|
relationTo: 'refunds';
|
||||||
value: string | Refund;
|
value: number | Refund;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: number | User;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user: {
|
user: {
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: number | User;
|
||||||
};
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -451,10 +451,10 @@ export interface PayloadLockedDocument {
|
|||||||
* via the `definition` "payload-preferences".
|
* via the `definition` "payload-preferences".
|
||||||
*/
|
*/
|
||||||
export interface PayloadPreference {
|
export interface PayloadPreference {
|
||||||
id: string;
|
id: number;
|
||||||
user: {
|
user: {
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: number | User;
|
||||||
};
|
};
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
value?:
|
value?:
|
||||||
@@ -474,7 +474,7 @@ export interface PayloadPreference {
|
|||||||
* via the `definition` "payload-migrations".
|
* via the `definition` "payload-migrations".
|
||||||
*/
|
*/
|
||||||
export interface PayloadMigration {
|
export interface PayloadMigration {
|
||||||
id: string;
|
id: number;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
batch?: number | null;
|
batch?: number | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
|||||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { buildConfig } from 'payload'
|
import { buildConfig } from 'payload'
|
||||||
import { billingPlugin } from '../dist/index.js'
|
import { billingPlugin, defaultCustomerInfoExtractor } from '../dist/index.js'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
|
import { testEmailAdapter } from './helpers/testEmailAdapter'
|
||||||
import { seed } from './seed.js'
|
import { seed } from './seed'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@@ -59,7 +59,25 @@ const buildConfigWithSQLite = () => {
|
|||||||
customers: 'customers',
|
customers: 'customers',
|
||||||
invoices: 'invoices',
|
invoices: 'invoices',
|
||||||
refunds: 'refunds',
|
refunds: 'refunds',
|
||||||
}
|
// customerRelation: false, // Set to false to disable customer relationship in invoices
|
||||||
|
// customerRelation: 'clients', // Or set to a custom collection slug
|
||||||
|
},
|
||||||
|
// Use the default extractor for the built-in customer collection
|
||||||
|
customerInfoExtractor: defaultCustomerInfoExtractor,
|
||||||
|
// Or provide a custom extractor for your own customer collection structure:
|
||||||
|
// customerInfoExtractor: (customer) => ({
|
||||||
|
// name: customer.fullName,
|
||||||
|
// email: customer.contactEmail,
|
||||||
|
// phone: customer.phoneNumber,
|
||||||
|
// company: customer.companyName,
|
||||||
|
// taxId: customer.vatNumber,
|
||||||
|
// billingAddress: {
|
||||||
|
// line1: customer.billing.street,
|
||||||
|
// city: customer.billing.city,
|
||||||
|
// postalCode: customer.billing.zip,
|
||||||
|
// country: customer.billing.countryCode,
|
||||||
|
// }
|
||||||
|
// })
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Payload } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
|
|
||||||
import { devUser } from './helpers/credentials.js'
|
import { devUser } from './helpers/credentials'
|
||||||
|
|
||||||
export const seed = async (payload: Payload) => {
|
export const seed = async (payload: Payload) => {
|
||||||
// Seed default user first
|
// Seed default user first
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-billing",
|
"name": "@xtr-dev/payload-billing",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -71,7 +71,7 @@ export function createCustomersCollection(slug: string = 'customers'): Collectio
|
|||||||
label: 'State/Province',
|
label: 'State/Province',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'postal_code',
|
name: 'postalCode',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
label: 'Postal Code',
|
label: 'Postal Code',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { createCustomersCollection } from './customers'
|
|
||||||
export { createInvoicesCollection } from './invoices'
|
export { createInvoicesCollection } from './invoices'
|
||||||
export { createPaymentsCollection } from './payments'
|
export { createPaymentsCollection } from './payments'
|
||||||
export { createRefundsCollection } from './refunds'
|
export { createRefundsCollection } from './refunds'
|
||||||
@@ -9,8 +9,13 @@ import type {
|
|||||||
InvoiceDocument,
|
InvoiceDocument,
|
||||||
InvoiceItemData
|
InvoiceItemData
|
||||||
} from '../types/payload'
|
} from '../types/payload'
|
||||||
|
import type { CustomerInfoExtractor } from '../types'
|
||||||
|
|
||||||
export function createInvoicesCollection(slug: string = 'invoices'): CollectionConfig {
|
export function createInvoicesCollection(
|
||||||
|
slug: string = 'invoices',
|
||||||
|
customerCollectionSlug?: string,
|
||||||
|
customerInfoExtractor?: CustomerInfoExtractor
|
||||||
|
): CollectionConfig {
|
||||||
return {
|
return {
|
||||||
slug,
|
slug,
|
||||||
access: {
|
access: {
|
||||||
@@ -20,7 +25,7 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
|
|||||||
update: ({ req: { user } }: AccessArgs) => !!user,
|
update: ({ req: { user } }: AccessArgs) => !!user,
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
defaultColumns: ['number', 'customer', 'status', 'amount', 'currency', 'dueDate'],
|
defaultColumns: ['number', 'customerInfo.name', 'status', 'amount', 'currency', 'dueDate'],
|
||||||
group: 'Billing',
|
group: 'Billing',
|
||||||
useAsTitle: 'number',
|
useAsTitle: 'number',
|
||||||
},
|
},
|
||||||
@@ -35,14 +40,135 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
|
|||||||
required: true,
|
required: true,
|
||||||
unique: true,
|
unique: true,
|
||||||
},
|
},
|
||||||
{
|
// Optional customer relationship
|
||||||
|
...(customerCollectionSlug ? [{
|
||||||
name: 'customer',
|
name: 'customer',
|
||||||
type: 'relationship',
|
type: 'relationship' as const,
|
||||||
admin: {
|
admin: {
|
||||||
position: 'sidebar',
|
position: 'sidebar' as const,
|
||||||
|
description: 'Link to customer record (optional)',
|
||||||
},
|
},
|
||||||
relationTo: 'customers',
|
relationTo: customerCollectionSlug as any,
|
||||||
required: true,
|
required: false,
|
||||||
|
}] : []),
|
||||||
|
// Basic customer info fields (embedded)
|
||||||
|
{
|
||||||
|
name: 'customerInfo',
|
||||||
|
type: 'group',
|
||||||
|
admin: {
|
||||||
|
description: customerCollectionSlug && customerInfoExtractor
|
||||||
|
? 'Customer billing information (auto-populated from customer relationship)'
|
||||||
|
: 'Customer billing information',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Customer name',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
required: !customerCollectionSlug || !customerInfoExtractor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
type: 'email',
|
||||||
|
admin: {
|
||||||
|
description: 'Customer email address',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
required: !customerCollectionSlug || !customerInfoExtractor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'phone',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Customer phone number',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'company',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Company name (optional)',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'taxId',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Tax ID or VAT number',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'billingAddress',
|
||||||
|
type: 'group',
|
||||||
|
admin: {
|
||||||
|
description: customerCollectionSlug && customerInfoExtractor
|
||||||
|
? 'Billing address (auto-populated from customer relationship)'
|
||||||
|
: 'Billing address',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'line1',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Address line 1',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
required: !customerCollectionSlug || !customerInfoExtractor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'line2',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Address line 2',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'city',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
required: !customerCollectionSlug || !customerInfoExtractor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'state',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'State or province',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'postalCode',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Postal or ZIP code',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
required: !customerCollectionSlug || !customerInfoExtractor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'country',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Country code (e.g., US, GB)',
|
||||||
|
readOnly: customerCollectionSlug && customerInfoExtractor ? true : false,
|
||||||
|
},
|
||||||
|
maxLength: 2,
|
||||||
|
required: !customerCollectionSlug || !customerInfoExtractor,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'status',
|
name: 'status',
|
||||||
@@ -192,7 +318,43 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
beforeChange: [
|
beforeChange: [
|
||||||
({ data, operation }: CollectionBeforeChangeHook<InvoiceData>) => {
|
async ({ data, operation, req, originalDoc }: CollectionBeforeChangeHook<InvoiceData>) => {
|
||||||
|
// Sync customer info from relationship if extractor is provided
|
||||||
|
if (customerCollectionSlug && customerInfoExtractor && data.customer) {
|
||||||
|
// Check if customer changed or this is a new invoice
|
||||||
|
const customerChanged = operation === 'create' ||
|
||||||
|
(originalDoc && originalDoc.customer !== data.customer)
|
||||||
|
|
||||||
|
if (customerChanged) {
|
||||||
|
try {
|
||||||
|
// Fetch the customer data
|
||||||
|
const customer = await req.payload.findByID({
|
||||||
|
collection: customerCollectionSlug,
|
||||||
|
id: data.customer,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extract customer info using the provided callback
|
||||||
|
const extractedInfo = customerInfoExtractor(customer)
|
||||||
|
|
||||||
|
// Update the invoice data with extracted info
|
||||||
|
data.customerInfo = {
|
||||||
|
name: extractedInfo.name,
|
||||||
|
email: extractedInfo.email,
|
||||||
|
phone: extractedInfo.phone,
|
||||||
|
company: extractedInfo.company,
|
||||||
|
taxId: extractedInfo.taxId,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedInfo.billingAddress) {
|
||||||
|
data.billingAddress = extractedInfo.billingAddress
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
req.payload.logger.error(`Failed to extract customer info: ${error}`)
|
||||||
|
throw new Error('Failed to extract customer information')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (operation === 'create') {
|
if (operation === 'create') {
|
||||||
// Generate invoice number if not provided
|
// Generate invoice number if not provided
|
||||||
if (!data.number) {
|
if (!data.number) {
|
||||||
@@ -224,6 +386,24 @@ export function createInvoicesCollection(slug: string = 'invoices'): CollectionC
|
|||||||
],
|
],
|
||||||
beforeValidate: [
|
beforeValidate: [
|
||||||
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
|
({ data }: CollectionBeforeValidateHook<InvoiceData>) => {
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
// If using extractor, customer relationship is required
|
||||||
|
if (customerCollectionSlug && customerInfoExtractor && !data.customer) {
|
||||||
|
throw new Error('Please select a customer')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not using extractor but have customer collection, either relationship or info is required
|
||||||
|
if (customerCollectionSlug && !customerInfoExtractor &&
|
||||||
|
!data.customer && (!data.customerInfo?.name || !data.customerInfo?.email)) {
|
||||||
|
throw new Error('Either select a customer or provide customer information')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no customer collection, ensure customer info is provided
|
||||||
|
if (!customerCollectionSlug && (!data.customerInfo?.name || !data.customerInfo?.email)) {
|
||||||
|
throw new Error('Customer name and email are required')
|
||||||
|
}
|
||||||
|
|
||||||
if (data && data.items && Array.isArray(data.items)) {
|
if (data && data.items && Array.isArray(data.items)) {
|
||||||
// Calculate totals for each line item
|
// Calculate totals for each line item
|
||||||
data.items = data.items.map((item: InvoiceItemData) => ({
|
data.items = data.items.map((item: InvoiceItemData) => ({
|
||||||
|
|||||||
@@ -125,12 +125,11 @@ export function createRefundsCollection(slug: string = 'refunds'): CollectionCon
|
|||||||
})
|
})
|
||||||
|
|
||||||
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
|
const refundIds = Array.isArray(payment.refunds) ? payment.refunds : []
|
||||||
|
|
||||||
await req.payload.update({
|
await req.payload.update({
|
||||||
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
id: typeof doc.payment === 'string' ? doc.payment : doc.payment.id,
|
||||||
collection: 'payments',
|
collection: 'payments',
|
||||||
data: {
|
data: {
|
||||||
refunds: [...refundIds, doc.id],
|
refunds: [...refundIds, doc.id as any],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
100
src/index.ts
100
src/index.ts
@@ -1,18 +1,33 @@
|
|||||||
import type { Config } from 'payload'
|
import type { Config } from 'payload'
|
||||||
|
|
||||||
import type { BillingPluginConfig } from './types'
|
import type { BillingPluginConfig, CustomerInfoExtractor } from './types'
|
||||||
|
|
||||||
import { createCustomersCollection } from './collections/customers'
|
import { createCustomersCollection } from './collections/customers'
|
||||||
import { createInvoicesCollection } from './collections/invoices'
|
import { createInvoicesCollection } from './collections/invoices'
|
||||||
import { createPaymentsCollection } from './collections/payments'
|
import { createPaymentsCollection } from './collections/payments'
|
||||||
import { createRefundsCollection } from './collections/refunds'
|
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 * from './types'
|
||||||
|
|
||||||
|
// Default customer info extractor for the built-in customer collection
|
||||||
|
export const defaultCustomerInfoExtractor: CustomerInfoExtractor = (customer) => {
|
||||||
|
return {
|
||||||
|
name: customer.name || '',
|
||||||
|
email: customer.email || '',
|
||||||
|
phone: customer.phone,
|
||||||
|
company: customer.company,
|
||||||
|
taxId: customer.taxId,
|
||||||
|
billingAddress: customer.address ? {
|
||||||
|
line1: customer.address.line1 || '',
|
||||||
|
line2: customer.address.line2,
|
||||||
|
city: customer.address.city || '',
|
||||||
|
state: customer.address.state,
|
||||||
|
postalCode: customer.address.postalCode || '',
|
||||||
|
country: customer.address.country || '',
|
||||||
|
} : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
|
export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config: Config): Config => {
|
||||||
if (pluginConfig.disabled) {
|
if (pluginConfig.disabled) {
|
||||||
return config
|
return config
|
||||||
@@ -23,10 +38,16 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
config.collections = []
|
config.collections = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customerSlug = pluginConfig.collections?.customers || 'customers'
|
||||||
|
|
||||||
config.collections.push(
|
config.collections.push(
|
||||||
createPaymentsCollection(pluginConfig.collections?.payments || 'payments'),
|
createPaymentsCollection(pluginConfig.collections?.payments || 'payments'),
|
||||||
createCustomersCollection(pluginConfig.collections?.customers || 'customers'),
|
createCustomersCollection(customerSlug),
|
||||||
createInvoicesCollection(pluginConfig.collections?.invoices || 'invoices'),
|
createInvoicesCollection(
|
||||||
|
pluginConfig.collections?.invoices || 'invoices',
|
||||||
|
pluginConfig.collections?.customerRelation !== false ? customerSlug : undefined,
|
||||||
|
pluginConfig.customerInfoExtractor
|
||||||
|
),
|
||||||
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
|
createRefundsCollection(pluginConfig.collections?.refunds || 'refunds'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,22 +59,19 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
config.endpoints?.push(
|
config.endpoints?.push(
|
||||||
// Webhook endpoints
|
// Webhook endpoints
|
||||||
{
|
{
|
||||||
handler: async (req) => {
|
handler: (_req) => {
|
||||||
try {
|
try {
|
||||||
const provider = providerRegistry.get(req.routeParams?.provider as string)
|
const provider = null
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return Response.json({ error: 'Provider not found' }, { status: 404 })
|
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
|
// TODO: Process webhook event and update database
|
||||||
|
|
||||||
return Response.json({ eventId: event.id, received: true })
|
return Response.json({ received: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// TODO: Use proper logger instead of console
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.error('[BILLING] Webhook error:', error)
|
console.error('[BILLING] Webhook error:', error)
|
||||||
return Response.json({ error: 'Webhook processing failed' }, { status: 400 })
|
return Response.json({ error: 'Webhook processing failed' }, { status: 400 })
|
||||||
}
|
}
|
||||||
@@ -61,23 +79,6 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
method: 'post',
|
method: 'post',
|
||||||
path: '/billing/webhooks/:provider'
|
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
|
// Initialize providers and onInit hook
|
||||||
@@ -89,44 +90,9 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
await incomingOnInit(payload)
|
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
|
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
|
export default billingPlugin
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,6 +93,25 @@ export interface TestProviderConfig {
|
|||||||
simulateFailures?: boolean
|
simulateFailures?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customer info extractor callback type
|
||||||
|
export interface CustomerInfoExtractor {
|
||||||
|
(customer: any): {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
phone?: string
|
||||||
|
company?: string
|
||||||
|
taxId?: string
|
||||||
|
billingAddress?: {
|
||||||
|
line1: string
|
||||||
|
line2?: string
|
||||||
|
city: string
|
||||||
|
state?: string
|
||||||
|
postalCode: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Plugin configuration
|
// Plugin configuration
|
||||||
export interface BillingPluginConfig {
|
export interface BillingPluginConfig {
|
||||||
admin?: {
|
admin?: {
|
||||||
@@ -100,11 +119,13 @@ export interface BillingPluginConfig {
|
|||||||
dashboard?: boolean
|
dashboard?: boolean
|
||||||
}
|
}
|
||||||
collections?: {
|
collections?: {
|
||||||
|
customerRelation?: boolean | string // false to disable, string for custom collection slug
|
||||||
customers?: string
|
customers?: string
|
||||||
invoices?: string
|
invoices?: string
|
||||||
payments?: string
|
payments?: string
|
||||||
refunds?: string
|
refunds?: string
|
||||||
}
|
}
|
||||||
|
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
providers?: {
|
providers?: {
|
||||||
mollie?: MollieConfig
|
mollie?: MollieConfig
|
||||||
@@ -139,7 +160,7 @@ export interface CustomerRecord {
|
|||||||
country?: string
|
country?: string
|
||||||
line1?: string
|
line1?: string
|
||||||
line2?: string
|
line2?: string
|
||||||
postal_code?: string
|
postalCode?: string
|
||||||
state?: string
|
state?: string
|
||||||
}
|
}
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -154,9 +175,24 @@ export interface CustomerRecord {
|
|||||||
|
|
||||||
export interface InvoiceRecord {
|
export interface InvoiceRecord {
|
||||||
amount: number
|
amount: number
|
||||||
|
billingAddress?: {
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
line1: string
|
||||||
|
line2?: string
|
||||||
|
postalCode: string
|
||||||
|
state?: string
|
||||||
|
}
|
||||||
createdAt: string
|
createdAt: string
|
||||||
currency: string
|
currency: string
|
||||||
customer?: string
|
customer?: string // Optional relationship to customer collection
|
||||||
|
customerInfo?: {
|
||||||
|
company?: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
phone?: string
|
||||||
|
taxId?: string
|
||||||
|
}
|
||||||
dueDate?: string
|
dueDate?: string
|
||||||
id: string
|
id: string
|
||||||
items: InvoiceItem[]
|
items: InvoiceItem[]
|
||||||
|
|||||||
@@ -47,8 +47,23 @@ export interface InvoiceItemData {
|
|||||||
// Invoice data type for hooks
|
// Invoice data type for hooks
|
||||||
export interface InvoiceData {
|
export interface InvoiceData {
|
||||||
amount?: number
|
amount?: number
|
||||||
|
billingAddress?: {
|
||||||
|
city?: string
|
||||||
|
country?: string
|
||||||
|
line1?: string
|
||||||
|
line2?: string
|
||||||
|
postalCode?: string
|
||||||
|
state?: string
|
||||||
|
}
|
||||||
currency?: string
|
currency?: string
|
||||||
customer?: string
|
customer?: string // Optional relationship
|
||||||
|
customerInfo?: {
|
||||||
|
company?: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
phone?: string
|
||||||
|
taxId?: string
|
||||||
|
}
|
||||||
dueDate?: string
|
dueDate?: string
|
||||||
items?: InvoiceItemData[]
|
items?: InvoiceItemData[]
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
@@ -71,7 +86,7 @@ export interface PaymentData {
|
|||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
provider?: string
|
provider?: string
|
||||||
providerData?: Record<string, unknown>
|
providerData?: Record<string, unknown>
|
||||||
providerId?: string
|
providerId?: string | number
|
||||||
status?: string
|
status?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,14 +97,14 @@ export interface CustomerData {
|
|||||||
country?: string
|
country?: string
|
||||||
line1?: string
|
line1?: string
|
||||||
line2?: string
|
line2?: string
|
||||||
postal_code?: string
|
postalCode?: string
|
||||||
state?: string
|
state?: string
|
||||||
}
|
}
|
||||||
email?: string
|
email?: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
name?: string
|
name?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
providerIds?: Record<string, string>
|
providerIds?: Record<string, string | number>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refund data type for hooks
|
// Refund data type for hooks
|
||||||
@@ -98,9 +113,9 @@ export interface RefundData {
|
|||||||
currency?: string
|
currency?: string
|
||||||
description?: string
|
description?: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
payment?: { id: string } | string
|
payment?: { id: string | number } | string
|
||||||
providerData?: Record<string, unknown>
|
providerData?: Record<string, unknown>
|
||||||
providerId?: string
|
providerId?: string | number
|
||||||
reason?: string
|
reason?: string
|
||||||
status?: string
|
status?: string
|
||||||
}
|
}
|
||||||
@@ -110,16 +125,16 @@ export interface PaymentDocument extends PaymentData {
|
|||||||
amount: number
|
amount: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
currency: string
|
currency: string
|
||||||
id: string
|
id: string | number
|
||||||
provider: string
|
provider: string
|
||||||
providerId: string
|
providerId: string | number
|
||||||
status: string
|
status: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerDocument extends CustomerData {
|
export interface CustomerDocument extends CustomerData {
|
||||||
createdAt: string
|
createdAt: string
|
||||||
id: string
|
id: string | number
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,8 +142,23 @@ export interface InvoiceDocument extends InvoiceData {
|
|||||||
amount: number
|
amount: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
currency: string
|
currency: string
|
||||||
customer: string
|
customer?: string // Optional relationship
|
||||||
id: string
|
customerInfo?: { // Optional when customer relationship exists
|
||||||
|
company?: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
phone?: string
|
||||||
|
taxId?: string
|
||||||
|
}
|
||||||
|
billingAddress?: { // Optional when customer relationship exists
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
line1: string
|
||||||
|
line2?: string
|
||||||
|
postalCode: string
|
||||||
|
state?: string
|
||||||
|
}
|
||||||
|
id: string | number
|
||||||
items: InvoiceItemData[]
|
items: InvoiceItemData[]
|
||||||
number: string
|
number: string
|
||||||
status: string
|
status: string
|
||||||
@@ -139,7 +169,7 @@ export interface RefundDocument extends RefundData {
|
|||||||
amount: number
|
amount: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
currency: string
|
currency: string
|
||||||
id: string
|
id: string | number
|
||||||
payment: { id: string } | string
|
payment: { id: string } | string
|
||||||
providerId: string
|
providerId: string
|
||||||
refunds?: string[]
|
refunds?: string[]
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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)')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './currency'
|
|
||||||
export * from './logger'
|
|
||||||
export * from './validation'
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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