diff --git a/backend/src/index.ts b/backend/src/index.ts index 87e32d2c..c56f1f88 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -107,6 +107,7 @@ import { startScheduledRotation, stopScheduledRotation } from './config/credenti import { subscriptionsRouter } from './routes/subscriptions.js'; import { workspacesRouter } from './routes/workspaces.js'; import { teamsRouter } from './routes/teams.js'; +import { walletPaymentsRouter } from './routes/wallet-payments.js'; import { merchantAuditRouter } from './routes/merchant-audit.js'; import { installmentsRouter } from './routes/installments.js'; @@ -265,6 +266,7 @@ apiV1Router.use('/disputes', disputesRouter); apiV1Router.use('/subscriptions', subscriptionsRouter); apiV1Router.use('/workspaces', workspacesRouter); apiV1Router.use('/teams', teamsRouter); +apiV1Router.use('/wallet-payments', walletPaymentsRouter); apiV1Router.use('/merchant-audit', merchantAuditRouter); apiV1Router.use('/installments', installmentsRouter); apiV1Router.get('/compression/metrics', (_req, res) => { diff --git a/backend/src/routes/wallet-payments.ts b/backend/src/routes/wallet-payments.ts new file mode 100644 index 00000000..2c131727 --- /dev/null +++ b/backend/src/routes/wallet-payments.ts @@ -0,0 +1,165 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { walletPaymentService } from '../services/wallet-payments.js'; + +export const walletPaymentsRouter = Router(); + +const providerEnum = z.enum(['apple_pay', 'google_pay']); + +const registerMerchantSchema = z.object({ + merchantId: z.string().min(1), + provider: providerEnum, + merchantIdentifier: z.string().min(1).optional(), + displayName: z.string().min(1).max(120), + countryCode: z.string().length(2).optional(), +}); + +const registerDomainSchema = z.object({ + provider: providerEnum, + domain: z.string().min(1).max(253), +}); + +const verifyDomainSchema = z.object({ + provider: providerEnum, + domain: z.string().min(1).max(253), + token: z.string().min(1), +}); + +const createSessionSchema = z.object({ + merchantId: z.string().min(1), + provider: providerEnum, + amount: z.number().positive(), + currency: z.string().length(3).optional(), + label: z.string().max(120).optional(), +}); + +const paymentTokenSchema = z.object({ + network: z.string().min(1), + cryptogram: z.string().min(1), + transactionId: z.string().min(1).optional(), + expiresAt: z.string().datetime().optional(), + displayName: z.string().max(120).optional(), +}); + +const serviceHandler = + (handler: (req: Request, res: Response) => unknown | Promise) => + asyncHandler(async (req, res) => { + try { + await handler(req, res); + } catch (err) { + const serviceError = err as { statusCode?: number; code?: string; message?: string }; + if (typeof serviceError?.statusCode === 'number') { + throw new AppError(serviceError.statusCode, serviceError.message ?? 'Wallet payment error', serviceError.code); + } + throw err; + } + }); + +// --------------------------------------------------------------- merchants + +walletPaymentsRouter.post( + '/merchants', + validate(registerMerchantSchema), + serviceHandler((req: Request, res: Response) => { + res.status(201).json({ data: walletPaymentService.registerMerchant(req.body) }); + }), +); + +walletPaymentsRouter.get( + '/merchants/:merchantId', + serviceHandler((req: Request, res: Response) => { + const provider = String(req.query.provider) as 'apple_pay' | 'google_pay'; + const merchant = walletPaymentService.getMerchant(String(req.params.merchantId), provider); + if (!merchant) throw new AppError(404, 'Wallet merchant not found', 'NOT_FOUND'); + res.json({ data: merchant }); + }), +); + +walletPaymentsRouter.get( + '/networks/:provider', + serviceHandler((req: Request, res: Response) => { + res.json({ data: { provider: req.params.provider, networks: walletPaymentService.getSupportedNetworks(req.params.provider as never) } }); + }), +); + +walletPaymentsRouter.post( + '/merchants/:merchantId/domains', + validate(registerDomainSchema), + serviceHandler((req: Request, res: Response) => { + res.status(201).json({ + data: walletPaymentService.registerDomain(String(req.params.merchantId), req.body.provider, req.body.domain), + }); + }), +); + +walletPaymentsRouter.post( + '/merchants/:merchantId/domains/verify', + validate(verifyDomainSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ + data: walletPaymentService.verifyDomain( + String(req.params.merchantId), + req.body.provider, + req.body.domain, + req.body.token, + ), + }); + }), +); + +// ---------------------------------------------------------------- sessions + +walletPaymentsRouter.post( + '/sessions', + validate(createSessionSchema), + serviceHandler((req: Request, res: Response) => { + res.status(201).json({ data: walletPaymentService.createPaymentSession(req.body) }); + }), +); + +walletPaymentsRouter.get( + '/sessions', + serviceHandler((req: Request, res: Response) => { + const { merchantId, provider, status } = req.query; + res.json({ + data: walletPaymentService.listSessions({ + merchantId: merchantId as string | undefined, + provider: provider as never, + status: status as never, + }), + }); + }), +); + +walletPaymentsRouter.get( + '/sessions/:id', + serviceHandler((req: Request, res: Response) => { + const session = walletPaymentService.getSession(String(req.params.id)); + if (!session) throw new AppError(404, 'Wallet payment session not found', 'NOT_FOUND'); + res.json({ data: session }); + }), +); + +walletPaymentsRouter.post( + '/sessions/:id/validate', + serviceHandler((req: Request, res: Response) => { + res.json(walletPaymentService.validateMerchant(String(req.params.id))); + }), +); + +walletPaymentsRouter.post( + '/sessions/:id/process', + validate(paymentTokenSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: walletPaymentService.processPaymentToken(String(req.params.id), req.body) }); + }), +); + +walletPaymentsRouter.post( + '/sessions/:id/expire', + serviceHandler((req: Request, res: Response) => { + res.json({ data: walletPaymentService.expireSession(String(req.params.id)) }); + }), +); diff --git a/backend/src/services/__tests__/wallet-payments.test.ts b/backend/src/services/__tests__/wallet-payments.test.ts new file mode 100644 index 00000000..ceb60a92 --- /dev/null +++ b/backend/src/services/__tests__/wallet-payments.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { WalletPaymentService } from '../wallet-payments.js'; + +describe('WalletPaymentService — Apple Pay / Google Pay checkout (#916)', () => { + let service: WalletPaymentService; + let now: number; + let sessionId: string; + + beforeEach(() => { + now = new Date('2026-04-01T00:00:00.000Z').getTime(); + service = new WalletPaymentService(() => now); + }); + + const expectError = (fn: () => unknown, statusCode: number, message?: RegExp) => { + try { + fn(); + throw new Error('Expected function to throw'); + } catch (err) { + const error = err as Error & { statusCode?: number }; + expect(error.statusCode).toBe(statusCode); + if (message) expect(error.message).toMatch(message); + } + }; + + /** Register an Apple Pay merchant with one verified domain and open a session. */ + const readySession = (provider: 'apple_pay' | 'google_pay' = 'apple_pay') => { + service.registerMerchant({ + merchantId: 'm_1', + provider, + displayName: 'Acme Store', + merchantIdentifier: 'merchant.com.acme', + }); + const domain = service.registerDomain('m_1', provider, 'shop.acme.com'); + service.verifyDomain('m_1', provider, 'shop.acme.com', domain.verificationToken); + return service.createPaymentSession({ merchantId: 'm_1', provider, amount: 42.5, currency: 'usd' }); + }; + + describe('merchant registration', () => { + it('registers an Apple Pay merchant with provider networks', () => { + const merchant = service.registerMerchant({ + merchantId: 'm_1', + provider: 'apple_pay', + displayName: 'Acme', + merchantIdentifier: 'merchant.com.acme', + }); + + expect(merchant.merchantIdentifier).toBe('merchant.com.acme'); + expect(merchant.countryCode).toBe('US'); + expect(merchant.supportedNetworks).toContain('visa'); + }); + + it('requires a merchant identifier for Apple Pay', () => { + expectError( + () => service.registerMerchant({ merchantId: 'm_1', provider: 'apple_pay', displayName: 'Acme' }), + 400, + /merchantIdentifier is required/, + ); + }); + + it('defaults a Google Pay merchant identifier', () => { + const merchant = service.registerMerchant({ merchantId: 'g_1', provider: 'google_pay', displayName: 'Acme' }); + expect(merchant.merchantIdentifier).toBe('google.g_1'); + }); + + it('rejects an invalid country code', () => { + expectError( + () => + service.registerMerchant({ + merchantId: 'm_1', + provider: 'apple_pay', + displayName: 'Acme', + merchantIdentifier: 'merchant.com.acme', + countryCode: 'USA', + }), + 400, + /2-letter ISO code/, + ); + }); + + it('exposes supported networks per provider', () => { + expect(service.getSupportedNetworks('google_pay')).toContain('jcb'); + expect(service.getSupportedNetworks('apple_pay')).not.toContain('jcb'); + }); + }); + + describe('domain verification', () => { + it('registers an unverified domain and verifies it with the token', () => { + service.registerMerchant({ merchantId: 'm_1', provider: 'apple_pay', displayName: 'Acme', merchantIdentifier: 'm.acme' }); + + const registration = service.registerDomain('m_1', 'apple_pay', 'Shop.Acme.com'); + expect(registration.domain).toBe('shop.acme.com'); + expect(registration.verified).toBe(false); + + const verified = service.verifyDomain('m_1', 'apple_pay', 'shop.acme.com', registration.verificationToken); + expect(verified.verified).toBe(true); + expect(verified.verifiedAt).toBeTruthy(); + }); + + it('rejects an invalid hostname', () => { + service.registerMerchant({ merchantId: 'm_1', provider: 'apple_pay', displayName: 'Acme', merchantIdentifier: 'm.acme' }); + expectError(() => service.registerDomain('m_1', 'apple_pay', 'not a domain'), 400, /valid hostname/); + }); + + it('rejects duplicate domains', () => { + service.registerMerchant({ merchantId: 'm_1', provider: 'apple_pay', displayName: 'Acme', merchantIdentifier: 'm.acme' }); + service.registerDomain('m_1', 'apple_pay', 'shop.acme.com'); + expectError(() => service.registerDomain('m_1', 'apple_pay', 'shop.acme.com'), 409, /already registered/); + }); + + it('rejects a mismatched verification token', () => { + service.registerMerchant({ merchantId: 'm_1', provider: 'apple_pay', displayName: 'Acme', merchantIdentifier: 'm.acme' }); + service.registerDomain('m_1', 'apple_pay', 'shop.acme.com'); + expectError(() => service.verifyDomain('m_1', 'apple_pay', 'shop.acme.com', 'wrong'), 400, /does not match/); + }); + + it('reports missing merchants and domains', () => { + expectError(() => service.registerDomain('ghost', 'apple_pay', 'shop.acme.com'), 404, /merchant not found/i); + service.registerMerchant({ merchantId: 'm_1', provider: 'apple_pay', displayName: 'Acme', merchantIdentifier: 'm.acme' }); + expectError(() => service.verifyDomain('m_1', 'apple_pay', 'nope.com', 'token'), 404, /Domain registration not found/); + }); + }); + + describe('payment sessions', () => { + it('creates a session for a verified merchant', () => { + const session = readySession(); + expect(session.status).toBe('created'); + expect(session.amount).toBe(42.5); + expect(session.currency).toBe('USD'); + expect(session.supportedNetworks).toContain('visa'); + sessionId = session.id; + }); + + it('refuses sessions without a verified domain', () => { + service.registerMerchant({ merchantId: 'm_1', provider: 'apple_pay', displayName: 'Acme', merchantIdentifier: 'm.acme' }); + expectError( + () => service.createPaymentSession({ merchantId: 'm_1', provider: 'apple_pay', amount: 10 }), + 400, + /verified domain/, + ); + }); + + it('reports an unregistered merchant', () => { + expectError( + () => service.createPaymentSession({ merchantId: 'nope', provider: 'apple_pay', amount: 10 }), + 404, + /merchant not found/i, + ); + }); + + it('rejects a non-positive amount', () => { + expectError( + () => service.createPaymentSession({ merchantId: 'm_1', provider: 'apple_pay', amount: 0 }), + 400, + /greater than 0/, + ); + }); + + it('expires stale sessions on read', () => { + const session = readySession(); + now += 31 * 60 * 1000; + expect(service.getSession(session.id)?.status).toBe('expired'); + }); + + it('filters sessions', () => { + const session = readySession(); + expect(service.listSessions({ merchantId: 'm_1' })).toHaveLength(1); + expect(service.listSessions({ provider: 'apple_pay' })).toHaveLength(1); + expect(service.listSessions({ status: 'completed' })).toHaveLength(0); + + service.validateMerchant(session.id); + expect(service.listSessions({ status: 'validated' })).toHaveLength(1); + }); + }); + + describe('merchant validation and token processing', () => { + beforeEach(() => { + sessionId = readySession().id; + }); + + it('returns a merchant session during validation', () => { + const { session, merchantSession } = service.validateMerchant(sessionId); + expect(session.status).toBe('validated'); + expect(merchantSession.merchantSessionIdentifier).toMatch(/^msi_/); + expect(merchantSession.displayName).toBe('Acme Store'); + expect(merchantSession.domainName).toBe('shop.acme.com'); + }); + + it('cannot validate a session twice', () => { + service.validateMerchant(sessionId); + expectError(() => service.validateMerchant(sessionId), 409, /Cannot validate/); + }); + + it('settles a payment with a valid token (even before validation for Google Pay)', () => { + const result = service.processPaymentToken(sessionId, { + network: 'visa', + cryptogram: 'encrypted-payload', + displayName: 'Acme Card', + }); + + expect(result.status).toBe('completed'); + expect(result.transactionId).toMatch(/^wtx_/); + expect(result.network).toBe('visa'); + expect(service.getSession(sessionId)?.status).toBe('completed'); + }); + + it('processes a token after merchant validation', () => { + service.validateMerchant(sessionId); + const result = service.processPaymentToken(sessionId, { network: 'MASTERCARD', cryptogram: 'abc' }); + expect(result.network).toBe('mastercard'); + }); + + it('requires a cryptogram', () => { + expectError( + () => service.processPaymentToken(sessionId, { network: 'visa', cryptogram: '' }), + 400, + /cryptogram is required/, + ); + }); + + it('rejects an unsupported network and marks the session failed', () => { + expectError( + () => service.processPaymentToken(sessionId, { network: 'jcb', cryptogram: 'abc' }), + 400, + /not supported/, + ); + expect(service.getSession(sessionId)?.status).toBe('failed'); + }); + + it('rejects an expired payment token', () => { + expectError( + () => + service.processPaymentToken(sessionId, { + network: 'visa', + cryptogram: 'abc', + expiresAt: new Date(now - 1000).toISOString(), + }), + 400, + /token has expired/, + ); + }); + + it('cannot process an already completed session', () => { + service.processPaymentToken(sessionId, { network: 'visa', cryptogram: 'abc' }); + expectError( + () => service.processPaymentToken(sessionId, { network: 'visa', cryptogram: 'abc' }), + 409, + /Cannot process/, + ); + }); + + it('reports an unknown session', () => { + expectError(() => service.processPaymentToken('ghost', { network: 'visa', cryptogram: 'a' }), 404, /session not found/i); + }); + }); + + describe('Google Pay checkout', () => { + it('supports the full Google Pay flow', () => { + const session = readySession('google_pay'); + expect(session.provider).toBe('google_pay'); + + service.validateMerchant(session.id); + const result = service.processPaymentToken(session.id, { network: 'jcb', cryptogram: 'gp-token' }); + + expect(result.provider).toBe('google_pay'); + expect(result.status).toBe('completed'); + }); + }); + + it('clears state between tests', () => { + readySession(); + service.resetForTests(); + expect(service.listSessions()).toHaveLength(0); + }); +}); diff --git a/backend/src/services/wallet-payments.ts b/backend/src/services/wallet-payments.ts new file mode 100644 index 00000000..6d04edce --- /dev/null +++ b/backend/src/services/wallet-payments.ts @@ -0,0 +1,368 @@ +/** + * wallet-payments.ts — Issue #916 + * + * Apple Pay and Google Pay checkout support. + * + * Merchants register for a wallet provider and verify the domains they will + * accept payments from (Apple Pay requires domain verification). Checkout then + * follows the standard wallet flow: + * 1. create a payment session for the cart amount; + * 2. run merchant validation (Apple Pay's merchant session handshake); + * 3. submit the encrypted payment token for processing and settlement. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from './BaseService.js'; + +export type WalletProvider = 'apple_pay' | 'google_pay'; +export type WalletSessionStatus = 'created' | 'validated' | 'completed' | 'failed' | 'expired'; + +export interface DomainRegistration { + domain: string; + verificationToken: string; + verified: boolean; + registeredAt: string; + verifiedAt?: string; +} + +export interface WalletMerchant { + merchantId: string; + provider: WalletProvider; + merchantIdentifier: string; + displayName: string; + countryCode: string; + supportedNetworks: string[]; + domains: DomainRegistration[]; + createdAt: string; +} + +export interface WalletPaymentSession { + id: string; + merchantId: string; + provider: WalletProvider; + amount: number; + currency: string; + countryCode: string; + label: string; + supportedNetworks: string[]; + status: WalletSessionStatus; + expiresAt: string; + validatedAt?: string; + completedAt?: string; + transactionId?: string; + failureReason?: string; + createdAt: string; + updatedAt: string; +} + +export interface WalletPaymentToken { + /** Card network, e.g. "visa" or "mastercard". */ + network: string; + /** Encrypted payment data (cryptogram) produced by the device. */ + cryptogram: string; + /** Wallet-supplied transaction identifier. */ + transactionId?: string; + /** Token expiry (Apple Pay paymentData has no expiry; Google Pay tokens do). */ + expiresAt?: string; + displayName?: string; +} + +export interface WalletPaymentResult { + sessionId: string; + provider: WalletProvider; + status: WalletSessionStatus; + transactionId: string; + network: string; + amount: number; + currency: string; + processedAt: string; +} + +const PROVIDER_NETWORKS: Record = { + apple_pay: ['visa', 'mastercard', 'amex', 'discover'], + google_pay: ['visa', 'mastercard', 'amex', 'discover', 'jcb'], +}; + +const SESSION_TTL_MS = 30 * 60 * 1000; +const DOMAIN_PATTERN = /^(?!-)[a-z0-9-]+(\.[a-z0-9-]+)+$/; + +export class WalletPaymentService extends BaseService { + private merchants = new Map(); + private sessions = new Map(); + + constructor(private readonly now: () => number = Date.now) { + super(); + } + + // --------------------------------------------------------------- merchants + + registerMerchant(input: { + merchantId: string; + provider: WalletProvider; + merchantIdentifier?: string; + displayName: string; + countryCode?: string; + }): WalletMerchant { + this.validate(!!input.merchantId, 'merchantId is required'); + this.validate(!!input.displayName, 'displayName is required'); + this.validate( + input.provider === 'apple_pay' || input.provider === 'google_pay', + 'provider must be apple_pay or google_pay', + ); + + const countryCode = (input.countryCode ?? 'US').toUpperCase(); + this.validate(/^[A-Z]{2}$/.test(countryCode), 'countryCode must be a 2-letter ISO code'); + + const merchantIdentifier = input.merchantIdentifier ?? `${input.provider === 'apple_pay' ? 'merchant' : 'google'}.${input.merchantId}`; + if (input.provider === 'apple_pay') { + this.validate(!!input.merchantIdentifier, 'merchantIdentifier is required for Apple Pay'); + } + + const merchant: WalletMerchant = { + merchantId: input.merchantId, + provider: input.provider, + merchantIdentifier, + displayName: input.displayName, + countryCode, + supportedNetworks: [...PROVIDER_NETWORKS[input.provider]], + domains: [], + createdAt: new Date(this.now()).toISOString(), + }; + + this.merchants.set(this.merchantKey(input.merchantId, input.provider), merchant); + return merchant; + } + + getMerchant(merchantId: string, provider: WalletProvider): WalletMerchant | undefined { + return this.merchants.get(this.merchantKey(merchantId, provider)); + } + + getSupportedNetworks(provider: WalletProvider): string[] { + this.validate(!!PROVIDER_NETWORKS[provider], `Unsupported wallet provider: ${provider}`); + return [...PROVIDER_NETWORKS[provider]]; + } + + // ----------------------------------------------------------- domain checks + + registerDomain(merchantId: string, provider: WalletProvider, domain: string): DomainRegistration { + const merchant = this.getMerchant(merchantId, provider); + if (!merchant) this.notFound('Wallet merchant', merchantId); + + const normalized = domain.trim().toLowerCase(); + this.validate(DOMAIN_PATTERN.test(normalized), 'domain must be a valid hostname'); + if (merchant.domains.some((d) => d.domain === normalized)) { + this.conflict('Domain is already registered'); + } + + const registration: DomainRegistration = { + domain: normalized, + verificationToken: `ap_${randomUUID().replace(/-/g, '')}`, + verified: false, + registeredAt: new Date(this.now()).toISOString(), + }; + + merchant.domains.push(registration); + return registration; + } + + verifyDomain( + merchantId: string, + provider: WalletProvider, + domain: string, + token: string, + ): DomainRegistration { + const merchant = this.getMerchant(merchantId, provider); + if (!merchant) this.notFound('Wallet merchant', merchantId); + + const registration = merchant.domains.find((d) => d.domain === domain.trim().toLowerCase()); + if (!registration) this.notFound('Domain registration', domain); + this.validate( + registration.verificationToken === token, + 'Domain verification token does not match', + ); + + registration.verified = true; + registration.verifiedAt = new Date(this.now()).toISOString(); + return registration; + } + + // ------------------------------------------------------------------ sessions + + createPaymentSession(input: { + merchantId: string; + provider: WalletProvider; + amount: number; + currency?: string; + label?: string; + }): WalletPaymentSession { + this.validate(input.amount > 0, 'Amount must be greater than 0'); + const currency = (input.currency ?? 'USD').toUpperCase(); + this.validate(/^[A-Z]{3}$/.test(currency), 'currency must be a 3-letter ISO code'); + + const merchant = this.getMerchant(input.merchantId, input.provider); + if (!merchant) this.notFound('Wallet merchant', input.merchantId); + if (!merchant.domains.some((d) => d.verified)) { + this.validate(false, 'At least one verified domain is required to accept wallet payments'); + } + + const createdAt = this.now(); + const session: WalletPaymentSession = { + id: `wps_${randomUUID()}`, + merchantId: merchant.merchantId, + provider: merchant.provider, + amount: this.round(input.amount), + currency, + countryCode: merchant.countryCode, + label: input.label ?? merchant.displayName, + supportedNetworks: [...merchant.supportedNetworks], + status: 'created', + expiresAt: new Date(createdAt + SESSION_TTL_MS).toISOString(), + createdAt: new Date(createdAt).toISOString(), + updatedAt: new Date(createdAt).toISOString(), + }; + + this.sessions.set(session.id, session); + return session; + } + + getSession(id: string): WalletPaymentSession | undefined { + const session = this.sessions.get(id); + if (!session) return undefined; + + if ( + this.now() > new Date(session.expiresAt).getTime() && + (session.status === 'created' || session.status === 'validated') + ) { + session.status = 'expired'; + session.updatedAt = new Date(this.now()).toISOString(); + this.sessions.set(id, session); + } + + return session; + } + + /** + * Apple Pay merchant validation handshake. Returns the merchant session that + * the native payment sheet needs before it will authorise a payment. + */ + validateMerchant(sessionId: string): { session: WalletPaymentSession; merchantSession: Record } { + const session = this.getSession(sessionId); + if (!session) this.notFound('Wallet payment session', sessionId); + this.assertSessionStatus(session, ['created'], 'validate'); + + const now = this.now(); + session.status = 'validated'; + session.validatedAt = new Date(now).toISOString(); + session.updatedAt = session.validatedAt; + this.sessions.set(session.id, session); + + return { + session, + merchantSession: { + epochTimestamp: now, + expiresAt: now + SESSION_TTL_MS, + merchantSessionIdentifier: `msi_${randomUUID().replace(/-/g, '').slice(0, 24)}`, + merchantIdentifier: this.getMerchant(session.merchantId, session.provider)?.merchantIdentifier, + displayName: session.label, + domainName: this.getMerchant(session.merchantId, session.provider)?.domains.find((d) => d.verified)?.domain, + operationalAnalyticsIdentifier: undefined, + }, + }; + } + + /** Submit the encrypted wallet token and settle the payment. */ + processPaymentToken(sessionId: string, token: WalletPaymentToken): WalletPaymentResult { + const session = this.getSession(sessionId); + if (!session) this.notFound('Wallet payment session', sessionId); + this.assertSessionStatus(session, ['created', 'validated'], 'process'); + + this.validate(!!token && typeof token === 'object', 'payment token is required'); + this.validate(!!token?.network, 'payment token network is required'); + this.validate(!!token?.cryptogram, 'payment token cryptogram is required'); + + const network = token.network.toLowerCase(); + if (!session.supportedNetworks.includes(network)) { + session.status = 'failed'; + session.failureReason = `Network ${network} is not supported by ${session.provider}`; + session.updatedAt = new Date(this.now()).toISOString(); + this.sessions.set(session.id, session); + this.validate(false, session.failureReason); + } + + if (token.expiresAt) { + const expiry = new Date(token.expiresAt).getTime(); + this.validate(!Number.isNaN(expiry), 'token expiresAt must be a valid ISO date'); + if (this.now() > expiry) { + session.status = 'failed'; + session.failureReason = 'Payment token has expired'; + session.updatedAt = new Date(this.now()).toISOString(); + this.sessions.set(session.id, session); + this.validate(false, session.failureReason); + } + } + + const processedAt = this.now(); + session.status = 'completed'; + session.completedAt = new Date(processedAt).toISOString(); + session.transactionId = `wtx_${randomUUID().replace(/-/g, '').slice(0, 20)}`; + session.updatedAt = session.completedAt; + this.sessions.set(session.id, session); + + return { + sessionId: session.id, + provider: session.provider, + status: session.status, + transactionId: session.transactionId, + network, + amount: session.amount, + currency: session.currency, + processedAt: session.completedAt, + }; + } + + listSessions(filter: { merchantId?: string; provider?: WalletProvider; status?: WalletSessionStatus } = {}): WalletPaymentSession[] { + return Array.from(this.sessions.values()).filter( + (session) => + (!filter.merchantId || session.merchantId === filter.merchantId) && + (!filter.provider || session.provider === filter.provider) && + (!filter.status || session.status === filter.status), + ); + } + + expireSession(id: string): WalletPaymentSession { + const session = this.sessions.get(id); + if (!session) this.notFound('Wallet payment session', id); + + session.status = 'expired'; + session.updatedAt = new Date(this.now()).toISOString(); + this.sessions.set(id, session); + return session; + } + + resetForTests(): void { + this.merchants.clear(); + this.sessions.clear(); + } + + // --------------------------------------------------------------- internals + + private merchantKey(merchantId: string, provider: WalletProvider): string { + return `${provider}:${merchantId}`; + } + + private assertSessionStatus( + session: WalletPaymentSession, + allowed: WalletSessionStatus[], + action: string, + ): void { + if (!allowed.includes(session.status)) { + this.conflict(`Cannot ${action} a session in status ${session.status}`); + } + } + + private round(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; + } +} + +export const walletPaymentService = new WalletPaymentService(); diff --git a/docs/WALLET_PAYMENTS.md b/docs/WALLET_PAYMENTS.md new file mode 100644 index 00000000..7c1cffd6 --- /dev/null +++ b/docs/WALLET_PAYMENTS.md @@ -0,0 +1,53 @@ +# Wallet Payments (Apple Pay & Google Pay) + +Issue: [#916](https://github.com/Smartdevs17/agenticpay/issues/916) + +Accept Apple Pay and Google Pay at checkout. Merchants register per provider, +verify the domains they will take payments from, then run the standard wallet +flow against a payment session. + +## Backend + +Service: `backend/src/services/wallet-payments.ts` +Routes: `backend/src/routes/wallet-payments.ts` (mounted at `/api/v1/wallet-payments`) + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/merchants` | Register a merchant for `apple_pay` or `google_pay` | +| `GET` | `/merchants/:merchantId?provider=` | Fetch a merchant configuration | +| `GET` | `/networks/:provider` | Supported card networks for a provider | +| `POST` | `/merchants/:merchantId/domains` | Register a domain (returns a verification token) | +| `POST` | `/merchants/:merchantId/domains/verify` | Verify a domain with the token | +| `POST` | `/sessions` | Create a payment session for the cart amount | +| `GET` | `/sessions/:id` | Read a session (expires stale sessions on read) | +| `POST` | `/sessions/:id/validate` | Apple Pay merchant validation handshake | +| `POST` | `/sessions/:id/process` | Submit the encrypted wallet token and settle | +| `POST` | `/sessions/:id/expire` | Expire a session | + +Apple Pay requires a `merchantIdentifier`; Google Pay derives a default. A +session can only be created once the merchant has at least one verified domain. + +Wallet token processing validates that the token network is supported by the +provider and that the token has not expired. Failures (unsupported network, +expired token) mark the session `failed` with a `failureReason`. + +## Frontend + +`frontend/src/components/payments/WalletPayButtons.tsx` renders branded Apple +Pay / Google Pay buttons. Availability is feature-detected +(`ApplePaySession`, `PaymentRequest`) and can be overridden through props for +SSR and tests. + +```tsx + pay('apple_pay')} + onGooglePay={() => pay('google_pay')} +/> +``` + +## Tests + +- `backend/src/services/__tests__/wallet-payments.test.ts` +- `frontend/src/components/payments/__tests__/WalletPayButtons.test.tsx` diff --git a/frontend/src/components/payments/WalletPayButtons.tsx b/frontend/src/components/payments/WalletPayButtons.tsx new file mode 100644 index 00000000..da4492ef --- /dev/null +++ b/frontend/src/components/payments/WalletPayButtons.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { cn } from '@/lib/utils'; + +export type WalletProviderOption = 'apple_pay' | 'google_pay'; + +export interface WalletPayButtonsProps { + /** Amount to charge, in major currency units (e.g. 42.5). */ + amount: number; + /** ISO-4217 currency code. Defaults to USD. */ + currency?: string; + /** Force Apple Pay availability; when omitted the browser is feature-detected. */ + applePayAvailable?: boolean; + /** Force Google Pay availability; when omitted the browser is feature-detected. */ + googlePayAvailable?: boolean; + onApplePay?: () => void | Promise; + onGooglePay?: () => void | Promise; + disabled?: boolean; + className?: string; +} + +/** True when the current browser exposes the Apple Pay JS API. */ +export function detectApplePay(): boolean { + if (typeof window === 'undefined') return false; + return typeof (window as { ApplePaySession?: unknown }).ApplePaySession !== 'undefined'; +} + +/** True when the current browser exposes the Payment Request API used by Google Pay. */ +export function detectGooglePay(): boolean { + if (typeof window === 'undefined') return false; + return typeof (window as { PaymentRequest?: unknown }).PaymentRequest !== 'undefined'; +} + +export function formatWalletAmount(amount: number, currency = 'USD'): string { + try { + return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount); + } catch { + return `${amount} ${currency}`; + } +} + +/** + * Apple Pay and Google Pay checkout buttons. + * + * Availability is feature-detected in the browser but can be overridden with + * the `applePayAvailable` / `googlePayAvailable` props (handy for SSR and tests). + */ +export function WalletPayButtons({ + amount, + currency = 'USD', + applePayAvailable, + googlePayAvailable, + onApplePay, + onGooglePay, + disabled = false, + className, +}: WalletPayButtonsProps) { + const appleAvailable = applePayAvailable ?? detectApplePay(); + const googleAvailable = googlePayAvailable ?? detectGooglePay(); + const formattedAmount = formatWalletAmount(amount, currency); + + if (!appleAvailable && !googleAvailable) { + return ( +

+ Apple Pay and Google Pay are not available on this device. +

+ ); + } + + return ( +
+ {appleAvailable && ( + + )} + + {googleAvailable && ( + + )} +
+ ); +} + +function AppleGlyph() { + return ( + + ); +} + +function GoogleGlyph() { + return ( + + ); +} + +export default WalletPayButtons; diff --git a/frontend/src/components/payments/__tests__/WalletPayButtons.test.tsx b/frontend/src/components/payments/__tests__/WalletPayButtons.test.tsx new file mode 100644 index 00000000..010e8247 --- /dev/null +++ b/frontend/src/components/payments/__tests__/WalletPayButtons.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + WalletPayButtons, + detectApplePay, + detectGooglePay, + formatWalletAmount, +} from '../WalletPayButtons'; + +const render = (props: Parameters[0]) => + renderToStaticMarkup(); + +describe('WalletPayButtons (#916)', () => { + it('renders both wallet buttons when both providers are available', () => { + const html = render({ amount: 42.5, applePayAvailable: true, googlePayAvailable: true }); + + expect(html).toContain('data-testid="wallet-pay-buttons"'); + expect(html).toContain('data-testid="apple-pay-button"'); + expect(html).toContain('data-testid="google-pay-button"'); + expect(html).toContain('Pay with Apple Pay'); + expect(html).toContain('Pay with Google Pay'); + }); + + it('labels the buttons with the formatted amount and currency', () => { + const html = render({ + amount: 42.5, + currency: 'EUR', + applePayAvailable: true, + googlePayAvailable: false, + }); + + expect(html).toContain('€42.50'); + expect(html).not.toContain('data-testid="google-pay-button"'); + }); + + it('renders only Google Pay when Apple Pay is unavailable', () => { + const html = render({ amount: 10, applePayAvailable: false, googlePayAvailable: true }); + + expect(html).not.toContain('data-testid="apple-pay-button"'); + expect(html).toContain('data-testid="google-pay-button"'); + }); + + it('shows a fallback message when no wallet is available', () => { + const html = render({ amount: 10, applePayAvailable: false, googlePayAvailable: false }); + + expect(html).toContain('not available on this device'); + expect(html).not.toContain('data-testid="apple-pay-button"'); + }); + + it('disables the buttons when requested', () => { + const html = render({ + amount: 10, + applePayAvailable: true, + googlePayAvailable: true, + disabled: true, + }); + + expect((html.match(/disabled=""/g) ?? []).length).toBe(2); + }); + + it('formats amounts, falling back safely for unknown currencies', () => { + expect(formatWalletAmount(1234.5, 'USD')).toBe('$1,234.50'); + expect(formatWalletAmount(10, 'NOT_A_CURRENCY')).toBe('10 NOT_A_CURRENCY'); + }); + + it('detects unavailable wallet APIs in a non-browser environment', () => { + expect(detectApplePay()).toBe(false); + expect(detectGooglePay()).toBe(false); + }); +});