Розробка системи виставлення рахунків у крипто
Різниця між "прийняти крипто-платіж" та "виставити рахунок у крипто" — принципіальна. Рахунок — це юридичний документ з зафіксованою сумою, строком оплати, ідентифікатором контрагента та можливістю сверки. Більшість готових рішень зупиняються на першому — дають адресу для оплати. Повнофункціональний біллінг вимагає обліку, напоминань, часткових оплат, мультивалютності та інтеграції з бухгалтерією.
Життєвий цикл рахунку
DRAFT → SENT → PENDING_PAYMENT → PARTIALLY_PAID → PAID | OVERDUE | CANCELLED
Кожен перехід — подія з timestamp та даними транзакції. Для аудиту статусі не перезаписуються, а додаються нові записи.
interface Invoice {
id: string // UUID
number: string // читаємий: INV-2024-0042
issuerId: string // організація/гаманець
clientId: string
clientWallet?: string // якщо відомий
issuedAt: Date
dueDate: Date
lineItems: LineItem[]
baseCurrency: string // USD/EUR — у чім виставлен рахунок
subtotalFiat: Decimal
taxAmountFiat: Decimal
totalFiat: Decimal
acceptedTokens: AcceptedToken[] // у чім можна оплатити
paymentAddress: string // унікальна адреса депозиту
status: InvoiceStatus
payments: InvoicePayment[] // прийняті часткові/повні оплати
}
interface AcceptedToken {
token: string // contract address
chain: string
amountEquiv: Decimal // сума у токенах по поточному курсу
rateLockedAt?: Date // якщо курс зафіксований
rateLockExpiry?: Date // до коли діє зафіксований курс
}
Генерація унікальних payment addresses
Кожен рахунок отримує унікальну адресу для приймання платежів — це ключ до автоматичного matching входящих транзакцій зі рахунками без ручного мемо/тегу.
Деривація через HD wallet (BIP-32):
import { HDNodeWallet } from 'ethers'
class InvoiceAddressGenerator {
private xpub: string // master public key, ніколи приватний ключ
generateAddress(invoiceIndex: number): string {
const node = HDNodeWallet.fromExtendedKey(this.xpub)
// Шлях деривації: m/0/{invoiceIndex}
return node.deriveChild(0).deriveChild(invoiceIndex).address
}
async createInvoiceAddress(invoiceId: string): Promise<string> {
// Атомарно отримати наступний індекс
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
}
}
Одна адреса на один рахунок дозволяє автоматично сматчити входящі транзакції через мониторинг адрес (Alchemy Notify, Moralis Streams або власний event listener).
Мониторинг входящих платежів
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 // не наш адрес
const invoice = await this.getInvoice(invoiceAddress.invoice_id)
if (!['sent', 'pending_payment', 'partially_paid'].includes(invoice.status)) {
// Рахунок уже оплачений або скасований — алерт для ручної обробки
await this.alertUnexpectedPayment(invoice, txHash, amount)
return
}
// Чекаємо confirmations перед кредитуванням
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 та захист від волатильності
Для B2B-виставлення рахунків клієнт може попросити зафіксувати курс на 1-24 години. Це знижує невизначеність — клієнт знає точно скільки USDC потрібно перевести. Для продавця це ризик якщо токен упаде за час очікування (актуально для volatile токенів, не для стейблкоінів).
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 }
}
Після istechennya rateLockExpiry сума пересчитається по поточному курсу — клієнт отримає нотифікацію.
PDF-генерація та юридична форма
Рахунок повинен виглядати як рахунок, а не як виписка з блокчейну. Генерація PDF з QR-кодом на payment address та суму:
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))
// Заголовок та реквізити
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')}`)
// Таблиця позицій, итоги...
// QR-код з EIP-681 URI для удобної оплати
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)))
)
}
Напоминання та автоматизація
Фоновий джоб для overdue рахунків:
// Запускається кожну годину
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' })
}
}
}
Стек: Node.js/TypeScript бекенд, PostgreSQL, Bull/BullMQ для фонових задач (мониторинг платежів, напоминання), React фронтенд для клієнтського та внутрішнього інтерфейсу. Інтеграції: Alchemy Notify для webhook на входящі транзакції, SendGrid/Postmark для email. Термін розробки базової системи з мультивалютним біллінгом та автоматичним matching — 2–3 тижні.







