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, }; }