Разработка системы выставления счетов в криптовалюте
Разница между «принять крипто-платёж» и «выставить счёт в криптовалюте» — принципиальная. Счёт — это юридический документ с зафиксированной суммой, сроком оплаты, идентификатором контрагента и возможностью сверки. Большинство готовых решений останавливаются на первом — дают адрес для оплаты. Полноценный биллинг требует учёта, напоминаний, частичных оплат, мультивалютности и интеграции с бухгалтерией.
Жизненный цикл счёта
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 // уникальный deposit address
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, never private 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
}
}
Один 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 }
}
После истечения 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 backend, PostgreSQL, Bull/BullMQ для фоновых задач (мониторинг платежей, напоминания), React frontend для клиентского и внутреннего интерфейса. Интеграции: Alchemy Notify для webhook на входящие транзакции, SendGrid/Postmark для email. Срок разработки базовой системы с мультивалютным биллингом и автоматическим matching — 2–3 недели.







