From 0c71c6f330e0089f09fff3b31995b3f418d16938 Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sat, 18 Jul 2026 02:10:46 +0100 Subject: [PATCH] feat(payments): pay crypto from custodial balance, deposit only the shortfall Skip the deposit step when the buyer's own Circle wallet already holds enough USDC. At crypto checkout, read the wallet's USDC balance: - balance >= required -> settle immediately (mark the transaction paid and enqueue the mints, which spend straight from balance via approve + mintTicket). Response returns `paidFromBalance: true` and no deposit block. - balance < required -> return a deposit instruction for the SHORTFALL only; the buyer tops up the missing amount and the inbound webhook settles once it lands. - crypto-checkout: `prepareCrypto` replaces `createDepositIntent`, reads the balance via Circle `getWalletTokenBalance`, and returns a discriminated settlement plan (balance | deposit). - PaymentsModule imports CircleModule; TicketsService injects MintQueueService to enqueue mints on the pay-from-balance path. Refs #96 --- src/payments/crypto-checkout.service.spec.ts | 159 ++++++++++++------- src/payments/crypto-checkout.service.ts | 113 +++++++++---- src/payments/payments.module.ts | 2 + src/tickets/tickets.service.spec.ts | 2 + src/tickets/tickets.service.ts | 35 +++- 5 files changed, 225 insertions(+), 86 deletions(-) diff --git a/src/payments/crypto-checkout.service.spec.ts b/src/payments/crypto-checkout.service.spec.ts index 9e37da3..2c971c7 100644 --- a/src/payments/crypto-checkout.service.spec.ts +++ b/src/payments/crypto-checkout.service.spec.ts @@ -1,15 +1,15 @@ import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Prisma, WalletCreationStatus } from '@prisma/client'; +import { CircleService } from '../circle/circle.service'; import { CryptoCheckoutService } from './crypto-checkout.service'; +const USDC_ADDRESS = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; + // getChain reads from chains.config (env-driven). Stub it so the test // doesn't depend on ACTIVE_CHAINS / RPC env being set. jest.mock('../blockchain/chains.config', () => ({ - getChain: (id: string) => ({ - id, - usdcAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', - }), + getChain: (id: string) => ({ id, usdcAddress: USDC_ADDRESS }), })); function makeConfig(rate = 1600, expiry = 30): ConfigService { @@ -18,16 +18,36 @@ function makeConfig(rate = 1600, expiry = 30): ConfigService { } as unknown as ConfigService; } +// `null` → no USDC balance; a string → that USDC balance on the wallet. +function makeCircle(usdcAmount: string | null): CircleService { + return { + client: { + getWalletTokenBalance: jest.fn().mockResolvedValue({ + data: { + tokenBalances: + usdcAmount === null + ? [] + : [{ token: { tokenAddress: USDC_ADDRESS }, amount: usdcAmount }], + }, + }), + }, + } as unknown as CircleService; +} + describe('CryptoCheckoutService', () => { describe('ngnToUsdc', () => { it('converts NGN to 6-dp USDC at the configured rate', () => { - const svc = new CryptoCheckoutService({} as never, makeConfig(1600)); + const svc = new CryptoCheckoutService( + {} as never, + makeConfig(1600), + makeCircle('0'), + ); expect(svc.ngnToUsdc(new Prisma.Decimal(5000)).toString()).toBe('3.125'); expect(svc.ngnToUsdc(new Prisma.Decimal(1600)).toString()).toBe('1'); }); }); - describe('createDepositIntent', () => { + describe('prepareCrypto', () => { const wallet = { circleWalletId: 'cw-1', address: '0xWALLET', @@ -41,38 +61,71 @@ describe('CryptoCheckoutService', () => { }; } - it('creates a CryptoDeposit and returns the deposit instruction', async () => { + const input = { + transactionId: 'txn-1', + buyerId: 'buyer-1', + chain: 'BASE-SEPOLIA', + priceNgn: new Prisma.Decimal(5000), + quantity: 1, + }; + + it('pays from balance (no deposit) when the wallet already holds enough', async () => { const prisma = makePrisma(wallet); - const svc = new CryptoCheckoutService(prisma as never, makeConfig(1600)); - - const intent = await svc.createDepositIntent({ - transactionId: 'txn-1', - buyerId: 'buyer-1', - chain: 'BASE-SEPOLIA', - priceNgn: new Prisma.Decimal(5000), - quantity: 1, - }); + const svc = new CryptoCheckoutService( + prisma as never, + makeConfig(1600), + makeCircle('5'), + ); + + const plan = await svc.prepareCrypto(input); - // Organizer-bears: face price is 3.125 USDC; ticketFee is backed out - // of it and HostIT's 3% added back, landing at totalFee 3.124999 (a - // 1-base-unit rounding delta from face) — what the buyer must send. - expect(intent).toMatchObject({ - chain: 'BASE-SEPOLIA', - address: '0xWALLET', - amountUsdc: '3.124999', - usdcAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', - decimals: 6, + expect(plan).toMatchObject({ + mode: 'balance', + requiredUsdc: '3.124999', // organizer-bears total for a 5000 NGN ticket + walletBalanceUsdc: '5.000000', }); - expect(intent.expiresAt).toBeInstanceOf(Date); + expect(prisma.cryptoDeposit.create).not.toHaveBeenCalled(); + }); + it('returns a shortfall deposit when the balance is insufficient', async () => { + const prisma = makePrisma(wallet); + const svc = new CryptoCheckoutService( + prisma as never, + makeConfig(1600), + makeCircle('1'), + ); + + const plan = await svc.prepareCrypto(input); + + expect(plan.mode).toBe('deposit'); + if (plan.mode === 'deposit') { + // 3.124999 required - 1 held = 2.124999 to top up. + expect(plan.deposit).toMatchObject({ + address: '0xWALLET', + amountUsdc: '2.124999', + usdcAddress: USDC_ADDRESS, + decimals: 6, + }); + expect(plan.walletBalanceUsdc).toBe('1.000000'); + } const createArg = prisma.cryptoDeposit.create.mock.calls[0][0]; - expect(createArg.data).toMatchObject({ - transactionId: 'txn-1', - walletId: 'cw-1', - address: '0xWALLET', - chain: 'BASE-SEPOLIA', - }); - expect(createArg.data.amountUsdc.toString()).toBe('3.124999'); + expect(createArg.data.amountUsdc.toString()).toBe('2.124999'); + }); + + it('deposits the full amount when the wallet holds no USDC', async () => { + const prisma = makePrisma(wallet); + const svc = new CryptoCheckoutService( + prisma as never, + makeConfig(1600), + makeCircle(null), + ); + + const plan = await svc.prepareCrypto(input); + + expect(plan.mode).toBe('deposit'); + if (plan.mode === 'deposit') { + expect(plan.deposit.amountUsdc).toBe('3.124999'); + } }); it('rejects when the buyer wallet is not yet provisioned', async () => { @@ -80,33 +133,29 @@ describe('CryptoCheckoutService', () => { ...wallet, creationStatus: WalletCreationStatus.PENDING, }); - const svc = new CryptoCheckoutService(prisma as never, makeConfig()); - - await expect( - svc.createDepositIntent({ - transactionId: 'txn-1', - buyerId: 'buyer-1', - chain: 'BASE-SEPOLIA', - priceNgn: new Prisma.Decimal(5000), - quantity: 1, - }), - ).rejects.toBeInstanceOf(BadRequestException); + const svc = new CryptoCheckoutService( + prisma as never, + makeConfig(), + makeCircle('0'), + ); + + await expect(svc.prepareCrypto(input)).rejects.toBeInstanceOf( + BadRequestException, + ); expect(prisma.cryptoDeposit.create).not.toHaveBeenCalled(); }); it('rejects when the buyer has no wallet on the chain', async () => { const prisma = makePrisma(null); - const svc = new CryptoCheckoutService(prisma as never, makeConfig()); - - await expect( - svc.createDepositIntent({ - transactionId: 'txn-1', - buyerId: 'buyer-1', - chain: 'BASE-SEPOLIA', - priceNgn: new Prisma.Decimal(5000), - quantity: 1, - }), - ).rejects.toBeInstanceOf(BadRequestException); + const svc = new CryptoCheckoutService( + prisma as never, + makeConfig(), + makeCircle('0'), + ); + + await expect(svc.prepareCrypto(input)).rejects.toBeInstanceOf( + BadRequestException, + ); }); }); }); diff --git a/src/payments/crypto-checkout.service.ts b/src/payments/crypto-checkout.service.ts index 1b5a565..9ce29d3 100644 --- a/src/payments/crypto-checkout.service.ts +++ b/src/payments/crypto-checkout.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Prisma, WalletCreationStatus } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { CircleService } from '../circle/circle.service'; import { getChain } from '../blockchain/chains.config'; import { computeUsdcFees } from '../blockchain/onchain-fees'; @@ -21,17 +22,26 @@ export interface DepositIntent { } /** - * Direct USDC deposit checkout (MVP of #69). - * - * Builds a deposit instruction for a pending transaction: the buyer - * sends `amountUsdc` USDC to their own per-chain Circle wallet, and the - * `transactions.inbound` webhook settles the purchase (see the inbound - * handler in CircleWebhookProcessor). A `CryptoDeposit` row links the - * receiving wallet to the transaction so the webhook can match it. + * How a crypto purchase will settle: + * - `balance`: the buyer's custodial wallet already holds enough USDC; + * settle straight from balance (no deposit step). + * - `deposit`: top-up needed — `deposit.amountUsdc` is the SHORTFALL the + * buyer must send; the mint pulls the full total from the combined + * balance afterwards. + */ +export type CryptoSettlementPlan = + | { mode: 'balance'; requiredUsdc: string; walletBalanceUsdc: string } + | { mode: 'deposit'; deposit: DepositIntent; walletBalanceUsdc: string }; + +/** + * Crypto (USDC) checkout. Funds are always spent from the buyer's own + * HostIT-custodied Circle wallet — the settlement worker signs + * `approve` + `mintTicket` from it. This service decides whether that + * wallet can already cover the purchase (pay from balance) or needs a + * top-up deposit first. * - * MVP scope: funds land in the buyer's (HostIT-custodied) wallet; - * sweeping to treasury / organizer settlement is a follow-up tied to - * payouts (#68). Pricing uses a flat NGN→USDC rate, not a live oracle. + * Pricing uses a flat NGN→USDC rate (not a live oracle); the on-chain + * split + settlement is handled by MintTicketProcessor. */ @Injectable() export class CryptoCheckoutService { @@ -40,6 +50,7 @@ export class CryptoCheckoutService { constructor( private readonly prisma: PrismaService, private readonly config: ConfigService, + private readonly circle: CircleService, ) {} /** Convert an NGN amount to a 6-dp USDC Decimal using the flat rate. */ @@ -49,18 +60,22 @@ export class CryptoCheckoutService { } /** - * Create the deposit instruction + CryptoDeposit row for a pending - * crypto transaction. Throws if the buyer has no ready wallet on the - * event's chain (the webhook needs a known receiving wallet to match). + * Decide how a pending crypto transaction settles. Reads the buyer's + * custodial USDC balance: if it covers the required total, returns a + * `balance` plan (caller settles + enqueues mints immediately); else + * creates a `CryptoDeposit` for the shortfall and returns a `deposit` + * plan (the inbound webhook settles once the top-up lands). + * + * Throws if the buyer has no ready wallet on the event's chain. */ - async createDepositIntent(input: { + async prepareCrypto(input: { transactionId: string; buyerId: string; chain: string; /** Unit ticket price in the event currency (NGN). */ priceNgn: Prisma.Decimal | string; quantity: number; - }): Promise { + }): Promise { const chainCfg = getChain(input.chain); const wallet = await this.prisma.userWallet.findFirst({ @@ -77,19 +92,40 @@ export class CryptoCheckoutService { ); } - // The deposit must cover the on-chain `totalFee` (ticketFee + HostIT's - // cut) for every ticket, since `mintTicket` pulls totalFee per mint from - // this wallet. Estimated off-chain via the shared fee helper (same math - // that set the on-chain ticketFee at publish); the settlement worker - // approves the authoritative on-chain totalFee at mint time. + // Required = on-chain totalFee (ticketFee + HostIT's cut) per ticket, + // times quantity — exactly what `mintTicket` pulls across the N mints. + // Estimated off-chain via the shared fee helper (same math that set the + // on-chain ticketFee at publish); the worker approves the authoritative + // on-chain totalFee at mint time. const usdcNgnRate = this.config.getOrThrow('crypto.usdcNgnRate'); const totalFeeBaseUnits = computeUsdcFees( input.priceNgn, usdcNgnRate, ).totalFee; - const amountUsdc = new Prisma.Decimal(totalFeeBaseUnits) + const requiredUsdc = new Prisma.Decimal(totalFeeBaseUnits) .mul(input.quantity) .div(10 ** USDC_DECIMALS); + + const walletBalanceUsdc = await this.getUsdcBalance( + wallet.circleWalletId, + chainCfg.usdcAddress, + ); + + // Enough already in the custodial wallet — pay from balance, no deposit. + if (walletBalanceUsdc.gte(requiredUsdc)) { + this.logger.log( + `Crypto pay-from-balance (txn=${input.transactionId}, required=${requiredUsdc.toFixed(USDC_DECIMALS)}, balance=${walletBalanceUsdc.toFixed(USDC_DECIMALS)})`, + ); + return { + mode: 'balance', + requiredUsdc: requiredUsdc.toFixed(USDC_DECIMALS), + walletBalanceUsdc: walletBalanceUsdc.toFixed(USDC_DECIMALS), + }; + } + + // Short — deposit only the missing amount. The mint pulls the full + // total from the combined (existing + topped-up) balance. + const shortfall = requiredUsdc.sub(walletBalanceUsdc); const expiryMinutes = this.config.getOrThrow( 'crypto.depositExpiryMinutes', ); @@ -101,23 +137,42 @@ export class CryptoCheckoutService { chain: input.chain, walletId: wallet.circleWalletId, address: wallet.address, - amountUsdc, + amountUsdc: shortfall, usdcAddress: chainCfg.usdcAddress, expiresAt, }, }); this.logger.log( - `Crypto deposit intent created (txn=${input.transactionId}, chain=${input.chain}, amountUsdc=${amountUsdc.toString()})`, + `Crypto deposit (shortfall) created (txn=${input.transactionId}, shortfall=${shortfall.toFixed(USDC_DECIMALS)}, balance=${walletBalanceUsdc.toFixed(USDC_DECIMALS)})`, ); return { - chain: input.chain, - address: wallet.address, - amountUsdc: amountUsdc.toString(), - usdcAddress: chainCfg.usdcAddress, - decimals: USDC_DECIMALS, - expiresAt, + mode: 'deposit', + walletBalanceUsdc: walletBalanceUsdc.toFixed(USDC_DECIMALS), + deposit: { + chain: input.chain, + address: wallet.address, + amountUsdc: shortfall.toFixed(USDC_DECIMALS), + usdcAddress: chainCfg.usdcAddress, + decimals: USDC_DECIMALS, + expiresAt, + }, }; } + + /** Current USDC balance of a Circle wallet, as a Decimal (0 if none). */ + private async getUsdcBalance( + circleWalletId: string, + usdcAddress: string, + ): Promise { + const response = await this.circle.client.getWalletTokenBalance({ + id: circleWalletId, + }); + const balances = response.data?.tokenBalances ?? []; + const usdc = balances.find( + (b) => b.token?.tokenAddress?.toLowerCase() === usdcAddress.toLowerCase(), + ); + return new Prisma.Decimal(usdc?.amount ?? 0); + } } diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index d3bab6f..b47eeec 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { CircleModule } from '../circle/circle.module'; import { PaymentsController } from './payments.controller'; import { PaymentsService } from './payments.service'; import { CryptoCheckoutService } from './crypto-checkout.service'; @@ -10,6 +11,7 @@ import { PaystackProvider } from './providers/paystack.provider'; * follow-up PRs and registered with `PaymentsService` here. */ @Module({ + imports: [CircleModule], controllers: [PaymentsController], providers: [ PaymentsService, diff --git a/src/tickets/tickets.service.spec.ts b/src/tickets/tickets.service.spec.ts index 138c147..0b46548 100644 --- a/src/tickets/tickets.service.spec.ts +++ b/src/tickets/tickets.service.spec.ts @@ -6,6 +6,7 @@ import { PaymentsService } from '../payments/payments.service'; import { CryptoCheckoutService } from '../payments/crypto-checkout.service'; import { WalletsService } from '../wallets/wallets.service'; import { CheckinQueueService } from '../blockchain/checkin-queue.service'; +import { MintQueueService } from '../blockchain/mint-queue.service'; import { TicketsService } from './tickets.service'; import { QueryMyTicketsDto } from './dto/query-my-tickets.dto'; import { VerifyTicketDto } from './dto/verify-ticket.dto'; @@ -58,6 +59,7 @@ function makeService( {} as unknown as WalletsService, config, checkinQueue as unknown as CheckinQueueService, + { enqueueMint: jest.fn() } as unknown as MintQueueService, ); } diff --git a/src/tickets/tickets.service.ts b/src/tickets/tickets.service.ts index ea7db53..f347ae1 100644 --- a/src/tickets/tickets.service.ts +++ b/src/tickets/tickets.service.ts @@ -24,6 +24,7 @@ import { CryptoCheckoutService } from '../payments/crypto-checkout.service'; import { WalletsService } from '../wallets/wallets.service'; import { ConfigService } from '@nestjs/config'; import { CheckinQueueService } from '../blockchain/checkin-queue.service'; +import { MintQueueService } from '../blockchain/mint-queue.service'; import { PurchaseTicketDto } from './dto/purchase-ticket.dto'; import { QueryMyTicketsDto } from './dto/query-my-tickets.dto'; import { VerifyTicketDto } from './dto/verify-ticket.dto'; @@ -97,6 +98,8 @@ export class TicketsService { // so the back-reference for the check-in queue is circular. @Inject(forwardRef(() => CheckinQueueService)) private readonly checkinQueue: CheckinQueueService, + @Inject(forwardRef(() => MintQueueService)) + private readonly mintQueue: MintQueueService, ) { // Where the gateway redirects after checkout. For local dev this // is fine as a relative-ish URL; staging/prod override via env. @@ -583,13 +586,40 @@ export class TicketsService { // instruction; the `transactions.inbound` webhook settles the // transaction and triggers minting (see CircleWebhookProcessor). if (dto.paymentProvider === PaymentProvider.CRYPTO) { - const deposit = await this.cryptoCheckout.createDepositIntent({ + const plan = await this.cryptoCheckout.prepareCrypto({ transactionId: transaction.id, buyerId, chain: event.chain, priceNgn: ticketType.price, quantity: dto.quantity, }); + + // Enough USDC already in the buyer's custodial wallet — settle now: + // mark the transaction paid and enqueue the mints, which spend + // straight from balance (approve + mintTicket). No deposit step. + if (plan.mode === 'balance') { + await this.prisma.transaction.update({ + where: { id: transaction.id }, + data: { status: TransactionStatus.SUCCESS }, + }); + for (const t of tickets) { + await this.mintQueue.enqueueMint(t.id, event.id); + } + return { + reference: transaction.reference, + checkoutUrl: null, + amount: Number(totalAmount), + currency: 'NGN', + provider: dto.paymentProvider, + tickets, + free: false, + paidFromBalance: true, + crypto: null, + }; + } + + // Short — the buyer tops up the shortfall; the inbound webhook + // settles once it lands. return { reference: transaction.reference, checkoutUrl: null, @@ -598,7 +628,8 @@ export class TicketsService { provider: dto.paymentProvider, tickets, free: false, - crypto: deposit, + paidFromBalance: false, + crypto: plan.deposit, }; }