Розробка системи виставлення рахунків у криптовалюті

Проєктуємо та розробляємо блокчейн-рішення повного циклу: від архітектури смарт-контрактів до запуску DeFi-протоколів, NFT-маркетплейсів та криптобірж. Аудит безпеки, токеноміка, інтеграція з наявною інфраструктурою.
Показано 1 з 1Усі 1306 послуг
Розробка системи виставлення рахунків у криптовалюті
Середній
~1-2 тижні
Часті запитання

Напрямки блокчейн-розробки

Етапи блокчейн-розробки

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1309
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1222
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    922
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1151
  • image_logo-advance_0.webp
    Розробка логотипу компанії B2B Advance
    614
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    887

Розробка системи виставлення рахунків у крипто

Різниця між "прийняти крипто-платіж" та "виставити рахунок у крипто" — принципіальна. Рахунок — це юридичний документ з зафіксованою сумою, строком оплати, ідентифікатором контрагента та можливістю сверки. Більшість готових рішень зупиняються на першому — дають адресу для оплати. Повнофункціональний біллінг вимагає обліку, напоминань, часткових оплат, мультивалютності та інтеграції з бухгалтерією.

Життєвий цикл рахунку

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 тижні.