Skip to content
← All posts
building payments

One invoice, three ways to pay

How I turned invoices and payment links into one payment system for manual transfers, NOWPayments, and automated Binance verification—without breaking the checkout that already worked.


Royal Subz already had a checkout. A customer chose a product, followed the payment instructions, submitted a transaction ID, and waited for an admin to confirm it. It was manual, but it worked.

What it did not handle well was everything outside that clean storefront path.

Sometimes I needed to prepare a custom invoice for a specific customer. Sometimes I needed a simple payment link with a flat amount and a description. Sometimes the customer should choose how to pay; sometimes I needed to lock the link to one method. And if a payment method could verify itself, making the customer wait for manual approval no longer made sense.

It would have been easy to add separate flows until every case had its own page, table, and confirmation logic. That was exactly what I wanted to avoid.

The principle I wrote at the top of the plan was simple: the existing manual checkout is the fallback, and it must keep working. Everything new would be additive. The payments table would remain the money ledger.

That constraint turned a collection of payment features into one system.

The first decision was to model invoices and payment links together.

An invoice contains products, quantities, prices, discounts, and a due date. A payment link contains a flat amount and description. Operationally, both say the same thing: this customer can pay this amount through this URL.

So both live in invoices, separated by a kind column. A payment link is an invoice without line items. They use the same statuses, public pay page, gateway entry points, expiry rules, and settlement function.

The public token is a separate random 32-byte value, never derived from the human-readable invoice number. A number that appears in emails and reports should not also grant access to the payment page.

Invoice line items store snapshots of the product name, variant, period, quantity, unit price, and line total. If I change a catalog price next month, an invoice issued today must not rewrite its own history.

Expiry follows the same rule. An overdue invoice is marked expired; it is never deleted. A provider-side payment page can outlive our due date. If late money arrives, I still need the customer, invoice, amount, and event trail available to reconcile it.

Payment and fulfilment are different clocks

The existing subscription statuses treated pending as a broad waiting room. The automated paths made that meaning too vague.

Once a gateway confirms a payment, the payment is no longer pending. But many Royal Subz products still require credentials to be prepared and delivered manually. Calling the subscription active would promise access that does not exist yet. Leaving the payment pending would deny money we already received.

I added awaiting_setup: paid, but waiting for fulfilment.

Gateway confirmation creates subscriptions in that state and places them in a Pending Subscriptions queue. The admin sets credentials and per-product cost, then activates access. Costs roll up to the payment so existing profit reports keep working.

This separation also kept the customer story honest. “We are checking whether you paid” and “we have your payment and are preparing access” are two very different messages.

The important part happens in one transaction

Every payment method eventually needs to perform the same settlement work. I put that work in a SECURITY DEFINER database function named confirm_invoice_payment.

It locks the invoice row with SELECT ... FOR UPDATE, then performs the full transition:

confirm payment
  -> write the payment ledger row
  -> create subscriptions from invoice items
  -> record coupon usage
  -> mark the invoice paid
  -> link the invoice to the payment

If the invoice is already paid, the function returns the existing payment instead of repeating the work. That makes gateway retries harmless. If it is cancelled or expired, it refuses to resurrect it.

The coupon burns at payment, not invoice creation. An unpaid or abandoned link should not consume a customer’s discount. The payment row also preserves the existing comma-joined product reference format because reporting already depends on it. A new system does not earn points for making an old report quietly wrong.

I wrapped the old manual confirmation path in a sibling transaction too. Its behavior stayed the same—manual approval still activates access directly—but it stopped being three client-side calls that could leave half a payment behind.

The public page needed less access, not more RLS

Anyone with /pay/:token can pay. That does not mean anonymous visitors should be allowed to query invoices directly.

There is no public table policy. The page loads through get-public-invoice, a service-role edge function that converts a valid token into an explicit safe projection. It never returns the user ID, email, internal cost, admin comment, or the private half of the invoice number.

The page shows line items, discount, total, and due date. If the admin selected a payment method while creating the invoice, the method is locked. If not, the customer chooses from the enabled methods.

Manual methods reuse the existing instruction dialog and TXID submission. Automated providers use their own verification flow. Paid, expired, and cancelled tokens render terminal states instead of leaving an active button on a dead invoice.

The client dashboard lets customers see pending and paid invoices, resume payment, and expand line items. The admin builders reuse the existing product, variant, and pricing picker, reducing both new UI work and the chance of two selectors disagreeing.

I shipped the structure before the gateways

I split delivery so the system could become useful before it depended on an external provider.

Phase 0 contained the tables, atomic functions, admin invoice and payment-link builders, public pay page with manual methods, client invoice section, email delivery, expiry job, and fulfilment queue. It carried most of the structural risk—public tokens, row security, status transitions, and atomicity—without adding webhook uncertainty.

Only after that passed did I add NOWPayments.

The gateway method was seeded disabled. Enabling it was an operational step after the functions, API key, separate IPN secret, payout wallet, callback URL, sandbox checks, and one small real payment were all verified. A feature flag was already present in the payment-method table; there was no reason to expose an untested path just because the code had deployed.

NOWPayments introduced events, not a new settlement system

For NOWPayments, the customer chooses a USDT network—TRC20, BEP20, Polygon, or ERC20—and is redirected to the hosted invoice. The webhook does not implement fulfilment. It verifies the signature and calls the atomic function Phase 0 already established.

Each callback is stored in payment_events. Because NOWPayments retries and emits multiple transitions, the event identity combines payment ID and status. A recursively key-sorted payload is verified with HMAC-SHA512 before processing.

The status mapping is intentionally conservative:

  • finished settles the invoice.
  • partially_paid moves it to review and creates no access.
  • intermediate, failed, and provider-expired states are recorded without pretending our invoice has disappeared.

I later extended the pipeline to cart and Buy Now. The frontend creates an invoice behind the scenes, then uses the same network picker, redirect, webhook, and confirmation screen. The invoice system became the shared payment core.

Payment choices need configuration and honest limits

The first network picker hardcoded four chains. I moved that list into app_settings so an admin can enable, disable, rename, and reorder networks without a deployment. Both the UI and server validation read the same setting; hiding a network in the browser while the API still accepts it would not be configuration.

NOWPayments also rejects payments below a network-specific minimum. Some Royal Subz products cost less than certain networks allow, and discovering that only after redirect is a terrible checkout experience.

The final design fetches minimums on demand from the admin page and caches them with timestamps. Admin invoice creation shows a warning but remains possible. Customer network pickers disable only ineligible networks, and the whole instant-crypto method disappears when none can accept the total. The server enforces the same rule.

I briefly tried to display per-network fees, then removed it. NOWPayments exposes a minimum payable amount, not the customer’s incoming transaction fee. Labelling that number as a fee would make the interface look more informative by making it less truthful.

Currency display needed the same precision. Dashboard amounts can follow IP-based BDT preference, but checkout belongs to a real payment method: BDT appears only for a BDT method and a Bangladesh visitor; NOWPayments remains USD-authoritative.

Binance required verification, not a webhook

The next automated path could not use Binance Pay Merchant without a merchant application and KYB. I used the personal account’s read-only Pay history API instead.

Before building, I tested it against a real customer payment. The Order ID shown on the customer’s receipt maps to Binance’s orderId, not transactionId. Matching the wrong field would have produced a clean implementation that failed on every real payment.

The verifier checks that the transfer is incoming, positive, USDT, received by the configured Binance UID, and within the allowed amount tolerance. A customer submits the Order ID once; the browser polls five times over roughly fourteen seconds. The progress bar reaches 90% while checking and reaches 100% only after real confirmation. If a completed search finds no match, the invoice moves to review rather than inventing success.

The limitation is explicit: incoming payer information is masked, so the payment is bound to a single-use Order ID, receiver, currency, and amount—not a verified sender identity. A read-only key also cannot move money, which is the security boundary I cared about most for a personal account integration.

A reload should not mint another invoice

Client-side reuse stopped repeated clicks, but React state disappears on reload. Buy Now reconstructs its product from the URL, so reloading could create another invoice for the same order.

I moved deduplication to the server. checkout_session_id is a SHA-256 fingerprint of the server-resolved user, method, coupon, total, and sorted line items. It uses prices already revalidated against the database, never raw values supplied by the browser.

The same resolved order now returns the existing pending or review invoice. A changed quantity, coupon, method, or price creates a different fingerprint and a genuinely new quote. A partial unique index supplies the actual concurrency guarantee, and the loser of a simultaneous insert catches the unique violation and returns the winning invoice.

Idempotency was no longer a component-state convenience. It survived tabs, devices, and reloads.

The review passes found the dangerous bugs

Several defects were invisible on the happy path.

Manual approval could settle an invoice that a gateway had already paid, creating two payments and two sets of subscriptions. A cancelled invoice could be revived by a late callback. “Check Again” on a Binance review invoice returned early without checking Binance at all.

The worst class came from one library assumption: Supabase .rpc() returns { error } for a database failure; it does not throw automatically. Confirmation code fell through to “success” after a failed RPC. The webhook also treated an existing audit event as fully processed even when processing had failed, so every legitimate retry was discarded. I changed success to require rereading the invoice as paid, and idempotency now depends on processed_at, not mere event existence.

The same assumption made every database-backed rate limit fail open. I centralized the check and made RPC failure return 503. Invalid webhook bodies gained a size cap and a global audit-row limit so a signed endpoint could not become unauthenticated write amplification.

A separate security pass restricted CORS to known origins, used timing-safe secret comparisons, protected sensitive settings, and measured the real client IP header instead of trusting the caller-controlled left side of x-forwarded-for.

Then production exposed a problem no local test could: Binance rejected Supabase’s US execution region with 451. I added a region tester, pinned calls to an eligible region, classified geo-blocks as definitive instead of retryable, and sent affected invoices to review. Even that fix needed a CORS change because Supabase adds an x-region header that browsers preflight.

The system became comprehensive by staying separated

The final design does not force every method into identical mechanics.

Manual transfers create a pending payment for an admin. NOWPayments proves payment through a signed webhook. Binance verifies a customer-supplied receipt identifier against read-only transaction history. PipraPay remains a future provider rather than being rushed in before its VPS setup is ready.

What they share is the part that should be shared: invoice ownership, immutable totals, public-token access, review states, deduplication, the payment ledger, atomic settlement, and manual fulfilment after automated confirmation.

That is what made the invoice and payment-link system more comprehensive. It was not the number of logos in the method picker. It was giving each method an honest verification path while making every successful path produce the same reliable financial and customer outcome.