Development of Crypto Billing Systems
The difference between "accept a crypto payment" and "issue a crypto invoice" is fundamental. An invoice is a legal document with fixed amount, due date, counterparty ID, and reconciliation capability. Most turnkey solutions stop at the first — they provide a payment address. Full billing requires accounting, reminders, partial payments, multi-currency, and accounting system integration.
Invoice lifecycle
DRAFT → SENT → PENDING_PAYMENT → PARTIALLY_PAID → PAID | OVERDUE | CANCELLED
Each transition is an event with timestamp and transaction data. For audit, statuses aren't overwritten but appended.
interface Invoice {
id: string // UUID
number: string // readable: INV-2024-0042
issuerId: string // organization/wallet
clientId: string
clientWallet?: string // if known
issuedAt: Date
dueDate: Date
lineItems: LineItem[]
baseCurrency: string // USD/EUR — invoice currency
subtotalFiat: Decimal
taxAmountFiat: Decimal
totalFiat: Decimal
acceptedTokens: AcceptedToken[] // payment options
paymentAddress: string // unique deposit address
status: InvoiceStatus
payments: InvoicePayment[] // received partial/full payments
}
interface AcceptedToken {
token: string // contract address
chain: string
amountEquiv: Decimal // amount in tokens at current rate
rateLockedAt?: Date // if rate is fixed
rateLockExpiry?: Date // until when fixed rate is valid
}
Generating unique payment addresses
Each invoice gets a unique address for receiving payments — key to automatic matching incoming transactions without manual memo/tag.
HD wallet derivation (BIP-32):
import { HDNodeWallet } from 'ethers'
class InvoiceAddressGenerator {
private xpub: string // master public key, never private key
generateAddress(invoiceIndex: number): string {
const node = HDNodeWallet.fromExtendedKey(this.xpub)
// Derivation path: m/0/{invoiceIndex}
return node.deriveChild(0).deriveChild(invoiceIndex).address
}
async createInvoiceAddress(invoiceId: string): Promise<string> {
// Atomically get next index
const index = await this.db.transaction(async (trx) => {
const result = await trx('address_counter')
.increment('counter', 1)
.returning('counter')
return result[0].counter
})
const address = this.generateAddress(index)
await this.db('invoice_addresses').insert({
invoice_id: invoiceId,
address,
derivation_index: index,
})
return address
}
}
One address per invoice enables automatic matching incoming transactions via address monitoring (Alchemy Notify, Moralis Streams, or custom event listener).
Monitoring incoming payments
class InvoicePaymentMonitor {
async handleIncomingTransaction(
toAddress: string,
token: string,
chain: string,
amount: bigint,
txHash: string,
blockNumber: number
): Promise<void> {
const invoiceAddress = await this.db('invoice_addresses')
.where({ address: toAddress.toLowerCase() })
.first()
if (!invoiceAddress) return // not our address
const invoice = await this.getInvoice(invoiceAddress.invoice_id)
if (!['sent', 'pending_payment', 'partially_paid'].includes(invoice.status)) {
// Invoice already paid or cancelled — alert for manual processing
await this.alertUnexpectedPayment(invoice, txHash, amount)
return
}
// Wait for confirmations before crediting
await this.pendingPayments.add({
invoiceId: invoice.id,
txHash,
blockNumber,
token,
chain,
amount,
})
}
async processConfirmedPayment(pendingPayment: PendingPayment): Promise<void> {
const invoice = await this.getInvoice(pendingPayment.invoiceId)
const tokenPrice = await this.priceService.getHistoricalPrice(
pendingPayment.token,
pendingPayment.chain,
pendingPayment.confirmedAt
)
const fiatEquivalent = new Decimal(pendingPayment.amount.toString())
.div(10 ** TOKEN_DECIMALS)
.mul(tokenPrice)
await this.db.transaction(async (trx) => {
await trx('invoice_payments').insert({
invoice_id: invoice.id,
tx_hash: pendingPayment.txHash,
token: pendingPayment.token,
chain: pendingPayment.chain,
crypto_amount: pendingPayment.amount.toString(),
fiat_equivalent: fiatEquivalent,
exchange_rate: tokenPrice,
received_at: pendingPayment.confirmedAt,
})
const totalPaid = await this.getTotalPaidFiat(invoice.id, trx)
const newStatus = totalPaid.gte(invoice.total_fiat)
? 'paid'
: 'partially_paid'
await trx('invoices')
.where({ id: invoice.id })
.update({ status: newStatus, updated_at: new Date() })
})
await this.notifyPaymentReceived(invoice, fiatEquivalent)
}
}
Rate locking and volatility protection
For B2B invoicing, clients may request fixed rate for 1-24 hours. Reduces uncertainty — client knows exactly how much USDC to send. For seller, it's risk if token drops (relevant for volatile tokens, not stablecoins).
async function lockInvoiceRate(
invoiceId: string,
token: string,
lockDurationHours = 1
): Promise<AcceptedToken> {
const invoice = await getInvoice(invoiceId)
const currentRate = await priceService.getRate('USD', token)
const tokenAmount = invoice.totalFiat.div(currentRate)
const expiry = new Date(Date.now() + lockDurationHours * 3600 * 1000)
await db('invoice_accepted_tokens')
.where({ invoice_id: invoiceId, token })
.update({
amount_equiv: tokenAmount,
rate_locked_at: new Date(),
rate_lock_expiry: expiry,
locked_rate: currentRate,
})
return { token, amountEquiv: tokenAmount, rateLockExpiry: expiry }
}
After rateLockExpiry expires, amount recalculates at current rate — client gets notification.
PDF generation and legal form
Invoices must look like invoices, not blockchain dumps. Generate PDF with QR code to payment address and amount:
import PDFDocument from 'pdfkit'
import QRCode from 'qrcode'
async function generateInvoicePdf(invoice: Invoice): Promise<Buffer> {
const doc = new PDFDocument({ margin: 50 })
const buffers: Buffer[] = []
doc.on('data', chunk => buffers.push(chunk))
// Header and details
doc.fontSize(20).text(`Invoice ${invoice.number}`, 50, 50)
doc.fontSize(10)
.text(`Issued: ${format(invoice.issuedAt, 'dd MMM yyyy')}`)
.text(`Due: ${format(invoice.dueDate, 'dd MMM yyyy')}`)
// Line items table, totals...
// QR code with EIP-681 URI for convenient payment
const paymentUri = `ethereum:${invoice.paymentAddress}?value=${invoice.totalFiat}`
const qrBuffer = await QRCode.toBuffer(paymentUri, { width: 150 })
doc.image(qrBuffer, 400, 650, { width: 100 })
doc.fontSize(8).text('Scan to pay', 410, 755)
doc.end()
return new Promise(resolve =>
doc.on('end', () => resolve(Buffer.concat(buffers)))
)
}
Reminders and automation
Background job for overdue invoices:
// Runs every hour
async function processOverdueInvoices(): Promise<void> {
const overdue = await db('invoices')
.where('status', 'in', ['sent', 'pending_payment', 'partially_paid'])
.where('due_date', '<', new Date())
for (const invoice of overdue) {
const daysPastDue = differenceInDays(new Date(), invoice.due_date)
if (daysPastDue === 1 || daysPastDue === 3 || daysPastDue === 7) {
await emailService.sendOverdueReminder(invoice, daysPastDue)
}
if (invoice.status !== 'overdue') {
await db('invoices').where({ id: invoice.id }).update({ status: 'overdue' })
}
}
}
Stack: Node.js/TypeScript backend, PostgreSQL, Bull/BullMQ for background tasks (payment monitoring, reminders), React frontend for client and internal interface. Integrations: Alchemy Notify for webhooks on incoming transactions, SendGrid/Postmark for email. Development time for basic system with multi-currency billing and automatic matching — 2–3 weeks.







