mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 10:53:23 +00:00
Add PayloadCMS type definitions, Prettier config, and PNPM lockfile
This commit is contained in:
149
src/collections/customers.ts
Normal file
149
src/collections/customers.ts
Normal 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
4
src/collections/index.ts
Normal 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
248
src/collections/invoices.ts
Normal 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
162
src/collections/payments.ts
Normal 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
163
src/collections/refunds.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user