diff --git a/backend/.env.example b/backend/.env.example index fa6993af..40939186 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -157,6 +157,13 @@ STELLAR_NETWORK=testnet STELLAR_FEE=100 STELLAR_TIMEOUT=30000 STELLAR_MAX_RETRIES=3 +# Secret key of the account course payments are sent to (unified payments, Issue #391) +STELLAR_DISTRIBUTION_ACCOUNT= + +# Stripe Configuration (unified payments — fiat rail, Issue #391) +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= +STRIPE_WEBHOOK_SECRET= # Logging Configuration LOG_LEVEL=debug diff --git a/backend/package.json b/backend/package.json index c4e787b4..93aff3b9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,19 +31,19 @@ "test:smoke": "node scripts/smoke-test.mjs" }, "dependencies": { - "apollo-server-express": "^3.13.0", - "@stellar/stellar-sdk": "^14.5.0", "@socket.io/redis-adapter": "~8.3.0", + "@stellar/stellar-sdk": "^14.5.0", + "apollo-server-express": "^3.13.0", "aws-sdk": "^2.1668.0", "axios": "^1.5.0", "bcryptjs": "^2.4.3", "big-integer": "^1.6.52", "brain.js": "^2.0.0-beta.24", - "dataloader": "^2.2.2", - "compromise": "^14.10.0", "compression": "^1.7.4", + "compromise": "^14.10.0", "cors": "^2.8.5", "d3": "^7.8.5", + "dataloader": "^2.2.2", "dotenv": "^16.3.1", "ethers": "^6.9.0", "express": "^4.18.2", @@ -74,16 +74,17 @@ "nodemailer": "^8.0.4", "paillier-js": "^0.9.3", "pg": "^8.11.3", + "prom-client": "^15.1.3", "redis": "^4.6.8", "sentiment": "^5.0.2", "sharp": "^0.32.0", "socket.io": "^4.7.2", + "stripe": "^22.5.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "twilio": "^5.13.1", "uuid": "^9.0.1", "web-push": "^3.6.7", - "prom-client": "^15.1.3", "winston": "^3.10.0", "winston-daily-rotate-file": "^5.0.0" }, diff --git a/backend/src/__tests__/payments.test.ts b/backend/src/__tests__/payments.test.ts new file mode 100644 index 00000000..3d55edad --- /dev/null +++ b/backend/src/__tests__/payments.test.ts @@ -0,0 +1,461 @@ +/** + * Unified payments tests — Issue #391. + * + * Covers the payment state machine, unified checkout orchestration (Stripe + * fiat + Stellar crypto), Stripe webhook handling, on-chain reconciliation, + * and the purchase events emitted through the lifecycle. + */ + +import { PaymentMethod, PaymentStatus } from '../models/Enrollment'; +import { PaymentService } from '../services/PaymentService'; +import { CheckoutService } from '../services/payments/CheckoutService'; +import { PaymentReconciliationService, CryptoPaymentRecord } from '../services/payments/PaymentReconciliationService'; +import { StripePaymentService, StripeWebhookOutcome } from '../services/payments/StripePaymentService'; +import { assertValidPaymentTransition } from '../services/payments/paymentStateMachine'; +import { purchaseEventBus, PurchaseEvent } from '../events/purchaseEvents'; + +// The real StellarPaymentService talks to Horizon; replace it with a canned +// in-memory double so checkout flows can run end-to-end without a network. +jest.mock('../services/StellarPaymentService', () => { + class StellarPaymentService { + constructor(_settings: any) {} + async createPaymentTransaction( + _from: string, + _amount: string, + _assetCode: string, + _assetIssuer?: string, + _memo?: string, + ) { + return { transactionXDR: 'xdr_1', paymentId: 'pay_1' }; + } + async submitTransaction(_signedXDR: string) { + return { + from: 'GAAA', + to: 'GBBB', + amount: '49.99', + assetCode: 'XLM', + transactionHash: 'hash_stellar_1', + network: 'testnet', + }; + } + async verifyPayment() { + return { isValid: true, errors: [], warnings: [] }; + } + validatePaymentParameters() { + return { isValid: true, errors: [], warnings: [] }; + } + async getPaymentHistory() { + return { payments: [], cursor: undefined }; + } + getDistributionAddress() { + return 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA'; + } + async createRefundTransaction(_to: string, _amount: string, _assetCode: string) { + return { transactionXDR: 'xdr_refund_1', refundId: 'refund_1' }; + } + async getAccountBalance() { + return []; + } + async checkAccountExists() { + return true; + } + } + return { StellarPaymentService }; +}); + +/** In-memory Stripe double used by the checkout orchestration tests. */ +function createFakeStripe(overrides: Partial> = {}) { + return { + async createPaymentIntent(params: any) { + return { + paymentIntentId: 'pi_1', + clientSecret: 'cs_test_1', + status: 'requires_payment_method', + amount: params.amount, + currency: params.currency.toLowerCase(), + }; + }, + async retrievePaymentIntent() { + return { paymentIntentId: 'pi_1', clientSecret: null, status: 'succeeded', amount: 49.99, currency: 'usd' }; + }, + async confirmPaymentIntent() { + return { paymentIntentId: 'pi_1', clientSecret: null, status: 'succeeded', amount: 49.99, currency: 'usd' }; + }, + async refund() { + return { refundId: 're_1', status: 'succeeded', amount: 49.99, currency: 'usd' }; + }, + getPublishableKey() { + return 'pk_test_1'; + }, + ...overrides, + }; +} + +describe('payment state machine', () => { + it('allows the happy path pending → processing → completed', () => { + expect(() => assertValidPaymentTransition(PaymentStatus.PENDING, PaymentStatus.PROCESSING)).not.toThrow(); + expect(() => assertValidPaymentTransition(PaymentStatus.PROCESSING, PaymentStatus.COMPLETED)).not.toThrow(); + }); + + it('allows pending → failed and completed → refunded', () => { + expect(() => assertValidPaymentTransition(PaymentStatus.PENDING, PaymentStatus.FAILED)).not.toThrow(); + expect(() => assertValidPaymentTransition(PaymentStatus.COMPLETED, PaymentStatus.REFUNDED)).not.toThrow(); + }); + + it('rejects invalid transitions', () => { + expect(() => assertValidPaymentTransition(PaymentStatus.PENDING, PaymentStatus.COMPLETED)).toThrow(); + expect(() => assertValidPaymentTransition(PaymentStatus.COMPLETED, PaymentStatus.FAILED)).toThrow(); + expect(() => assertValidPaymentTransition(PaymentStatus.FAILED, PaymentStatus.COMPLETED)).toThrow(); + expect(() => assertValidPaymentTransition(PaymentStatus.REFUNDED, PaymentStatus.COMPLETED)).toThrow(); + }); + + it('treats same-status transitions as valid (idempotent updates)', () => { + expect(() => assertValidPaymentTransition(PaymentStatus.PENDING, PaymentStatus.PENDING)).not.toThrow(); + }); +}); + +describe('CheckoutService — Stellar (crypto) rail', () => { + const paymentService = new PaymentService(); + const checkoutService = new CheckoutService({ paymentService }); + let events: PurchaseEvent[] = []; + + beforeEach(() => { + events = []; + purchaseEventBus.onEvent('*', (e) => events.push(e)); + }); + + afterEach(() => { + purchaseEventBus.removeAllListeners(); + }); + + it('creates a pending checkout with XDR gateway data and stamps a memo', async () => { + const checkout = await checkoutService.createCheckout({ + enrollmentId: 'enr_1', + userId: 'u1', + courseId: 'course_1', + amount: 49.99, + currency: 'USD', + method: 'stellar', + stellar: { fromAddress: 'GAAA', assetCode: 'XLM' }, + }); + + expect(checkout.status).toBe('pending'); + expect(checkout.method).toBe('stellar'); + expect(checkout.gatewayData?.transactionXDR).toBe('xdr_1'); + expect(checkout.gatewayData?.memo).toBeTruthy(); + expect(checkout.gatewayData?.destination).toBeTruthy(); + expect(events.some((e) => e.type === 'PURCHASE_INITIATED')).toBe(true); + }); + + it('confirms a stellar checkout once the signed XDR is submitted', async () => { + const checkout = await checkoutService.createCheckout({ + enrollmentId: 'enr_2', + userId: 'u2', + courseId: 'course_2', + amount: 25, + currency: 'USD', + method: 'stellar', + stellar: { fromAddress: 'GAAA', assetCode: 'XLM' }, + }); + + const { checkout: confirmed, transaction } = await checkoutService.confirmCheckout(checkout.id, { + signedTransactionXDR: 'signed_xdr_1', + }); + + expect(confirmed.status).toBe('completed'); + expect(confirmed.transactionHash).toBe('hash_stellar_1'); + expect(transaction?.stellarTransactionHash).toBe('hash_stellar_1'); + expect(events.some((e) => e.type === 'PURCHASE_CONFIRMED' && e.checkoutId === checkout.id)).toBe(true); + }); + + it('refuses to confirm a stellar checkout without a signed transaction', async () => { + const checkout = await checkoutService.createCheckout({ + enrollmentId: 'enr_3', + userId: 'u3', + amount: 10, + currency: 'USD', + method: 'stellar', + stellar: { fromAddress: 'GAAA', assetCode: 'XLM' }, + }); + + await expect(checkoutService.confirmCheckout(checkout.id, {})).rejects.toThrow(/signedTransactionXDR/); + }); +}); + +describe('CheckoutService — Stripe (fiat) rail', () => { + let events: PurchaseEvent[] = []; + let paymentService: PaymentService; + let checkoutService: CheckoutService; + + beforeEach(() => { + events = []; + purchaseEventBus.onEvent('*', (e) => events.push(e)); + paymentService = new PaymentService(); + checkoutService = new CheckoutService({ + paymentService, + stripePaymentService: createFakeStripe() as any, + }); + }); + + afterEach(() => { + purchaseEventBus.removeAllListeners(); + }); + + it('creates a pending checkout with a Stripe client secret', async () => { + const checkout = await checkoutService.createCheckout({ + enrollmentId: 'enr_s1', + userId: 'u1', + courseId: 'course_1', + amount: 49.99, + currency: 'USD', + method: 'stripe', + }); + + expect(checkout.status).toBe('pending'); + expect(checkout.gatewayData?.paymentIntentId).toBe('pi_1'); + expect(checkout.gatewayData?.clientSecret).toBe('cs_test_1'); + expect(checkout.gatewayData?.publishableKey).toBe('pk_test_1'); + expect(events.some((e) => e.type === 'PURCHASE_INITIATED' && e.method === PaymentMethod.STRIPE)).toBe(true); + }); + + it('confirms a Stripe checkout with a payment method', async () => { + const checkout = await checkoutService.createCheckout({ + enrollmentId: 'enr_s2', + userId: 'u2', + amount: 49.99, + currency: 'USD', + method: 'stripe', + }); + + const { checkout: confirmed, transaction } = await checkoutService.confirmCheckout(checkout.id, { + paymentIntentId: 'pi_1', + paymentMethodId: 'pm_card_1', + }); + + expect(confirmed.status).toBe('completed'); + expect(transaction?.gatewayTransactionId).toBe('pi_1'); + expect(events.some((e) => e.type === 'PURCHASE_CONFIRMED' && e.checkoutId === checkout.id)).toBe(true); + }); + + it('marks the checkout failed when the Stripe intent fails', async () => { + checkoutService = new CheckoutService({ + paymentService, + stripePaymentService: createFakeStripe({ + confirmPaymentIntent: async () => ({ paymentIntentId: 'pi_1', clientSecret: null, status: 'requires_payment_method', amount: 49.99, currency: 'usd' }), + retrievePaymentIntent: async () => ({ paymentIntentId: 'pi_1', clientSecret: null, status: 'canceled', amount: 49.99, currency: 'usd' }), + }) as any, + }); + + const checkout = await checkoutService.createCheckout({ + enrollmentId: 'enr_s3', + userId: 'u3', + amount: 49.99, + currency: 'USD', + method: 'stripe', + }); + + const { checkout: confirmed } = await checkoutService.confirmCheckout(checkout.id, { paymentIntentId: 'pi_1' }); + expect(confirmed.status).toBe('failed'); + expect(events.some((e) => e.type === 'PURCHASE_FAILED' && e.checkoutId === checkout.id)).toBe(true); + }); +}); + +describe('Stripe webhook handling', () => { + let events: PurchaseEvent[] = []; + let paymentService: PaymentService; + let checkoutService: CheckoutService; + + beforeEach(() => { + events = []; + purchaseEventBus.onEvent('*', (e) => events.push(e)); + paymentService = new PaymentService(); + checkoutService = new CheckoutService({ + paymentService, + stripePaymentService: createFakeStripe() as any, + }); + }); + + afterEach(() => { + purchaseEventBus.removeAllListeners(); + }); + + it('finalizes a pending Stripe payment on payment_intent.succeeded', async () => { + const checkout = await checkoutService.createCheckout({ + enrollmentId: 'enr_w1', + userId: 'u1', + amount: 49.99, + currency: 'USD', + method: 'stripe', + }); + + await checkoutService.handleStripeWebhookOutcome({ + eventType: 'payment_intent.succeeded', + paymentIntentId: 'pi_1', + status: 'succeeded', + } as StripeWebhookOutcome); + + const payment = paymentService.findPaymentByGatewayTransactionId('pi_1'); + expect(payment?.status).toBe(PaymentStatus.COMPLETED); + expect(checkoutService.getCheckout(checkout.id)?.status).toBe('completed'); + expect(events.some((e) => e.type === 'PURCHASE_CONFIRMED')).toBe(true); + }); + + it('is idempotent when the same succeeded webhook arrives twice', async () => { + await checkoutService.createCheckout({ + enrollmentId: 'enr_w2', + userId: 'u2', + amount: 49.99, + currency: 'USD', + method: 'stripe', + }); + + await checkoutService.handleStripeWebhookOutcome({ eventType: 'payment_intent.succeeded', paymentIntentId: 'pi_1', status: 'succeeded' } as StripeWebhookOutcome); + await expect( + checkoutService.handleStripeWebhookOutcome({ eventType: 'payment_intent.succeeded', paymentIntentId: 'pi_1', status: 'succeeded' } as StripeWebhookOutcome), + ).resolves.not.toThrow(); + }); + + it('marks a completed payment refunded on charge.refunded', async () => { + const checkout = await checkoutService.createCheckout({ + enrollmentId: 'enr_w3', + userId: 'u3', + amount: 49.99, + currency: 'USD', + method: 'stripe', + }); + + await checkoutService.handleStripeWebhookOutcome({ eventType: 'payment_intent.succeeded', paymentIntentId: 'pi_1', status: 'succeeded' } as StripeWebhookOutcome); + await checkoutService.handleStripeWebhookOutcome({ eventType: 'charge.refunded', paymentIntentId: 'pi_1', status: 'refunded' } as StripeWebhookOutcome); + + const payment = paymentService.findPaymentByGatewayTransactionId('pi_1'); + expect(payment?.status).toBe(PaymentStatus.REFUNDED); + expect(checkoutService.getCheckout(checkout.id)?.status).toBe('refunded'); + expect(events.some((e) => e.type === 'PURCHASE_REFUNDED')).toBe(true); + }); +}); + +describe('PaymentReconciliationService', () => { + const baseRecord: CryptoPaymentRecord = { + paymentId: 'pay_local_1', + enrollmentId: 'enr_r1', + userId: 'u1', + courseId: 'course_1', + amount: 49.99, + currency: 'XLM', + method: PaymentMethod.STELLAR, + status: PaymentStatus.PENDING, + metadata: { paymentReference: 'ref_abc' }, + }; + + it('reconciles a pending payment when an on-chain memo matches', async () => { + const onReconciled = jest.fn(); + const stellar = { + getDistributionAddress: () => 'GBBB', + getPaymentHistory: jest.fn().mockResolvedValue({ + payments: [ + { memo: 'other', amount: '5', assetCode: 'XLM', transactionHash: 'h1' }, + { memo: 'ref_abc', amount: '49.99', assetCode: 'XLM', transactionHash: 'h2' }, + ], + cursor: undefined, + }), + verifyPayment: jest.fn().mockResolvedValue({ isValid: true, errors: [], warnings: [] }), + }; + + const service = new PaymentReconciliationService({ + stellar: stellar as any, + distributionAddress: 'GBBB', + fetchPending: () => [baseRecord], + onReconciled, + }); + + const summary = await service.reconcilePendingPayments(); + expect(summary.scanned).toBe(1); + expect(summary.reconciled).toBe(1); + expect(onReconciled).toHaveBeenCalledTimes(1); + expect(onReconciled).toHaveBeenCalledWith(baseRecord, expect.objectContaining({ transactionHash: 'h2' })); + }); + + it('leaves a payment pending when no on-chain payment matches', async () => { + const onReconciled = jest.fn(); + const stellar = { + getDistributionAddress: () => 'GBBB', + getPaymentHistory: jest.fn().mockResolvedValue({ + payments: [{ memo: 'unrelated', amount: '5', assetCode: 'XLM', transactionHash: 'h1' }], + cursor: undefined, + }), + verifyPayment: jest.fn().mockResolvedValue({ isValid: true, errors: [], warnings: [] }), + }; + + const service = new PaymentReconciliationService({ + stellar: stellar as any, + distributionAddress: 'GBBB', + fetchPending: () => [baseRecord], + onReconciled, + }); + + const summary = await service.reconcilePendingPayments(); + expect(summary.scanned).toBe(1); + expect(summary.reconciled).toBe(0); + expect(onReconciled).not.toHaveBeenCalled(); + }); + + it('rejects a memo match that fails on-chain verification', async () => { + const onReconciled = jest.fn(); + const stellar = { + getDistributionAddress: () => 'GBBB', + getPaymentHistory: jest.fn().mockResolvedValue({ + payments: [{ memo: 'ref_abc', amount: '49.99', assetCode: 'XLM', transactionHash: 'h2' }], + cursor: undefined, + }), + verifyPayment: jest.fn().mockResolvedValue({ isValid: false, errors: ['amount mismatch'], warnings: [] }), + }; + + const service = new PaymentReconciliationService({ + stellar: stellar as any, + distributionAddress: 'GBBB', + fetchPending: () => [baseRecord], + onReconciled, + }); + + const summary = await service.reconcilePendingPayments(); + expect(summary.reconciled).toBe(0); + expect(onReconciled).not.toHaveBeenCalled(); + expect(summary.outcomes[0].errors.join()).toContain('failed verification'); + }); +}); + +describe('StripePaymentService', () => { + const service = new StripePaymentService(); + + it('normalizes payment_intent.succeeded', () => { + const outcome = service.normalizeWebhookEvent({ + type: 'payment_intent.succeeded', + data: { object: { id: 'pi_9' } }, + } as any); + expect(outcome.status).toBe('succeeded'); + expect(outcome.paymentIntentId).toBe('pi_9'); + }); + + it('normalizes charge.refunded into a refund outcome', () => { + const outcome = service.normalizeWebhookEvent({ + type: 'charge.refunded', + data: { object: { id: 'ch_1', payment_intent: 'pi_9', amount_refunded: 4999, amount: 4999 } }, + } as any); + expect(outcome.status).toBe('refunded'); + expect(outcome.paymentIntentId).toBe('pi_9'); + }); + + it('parses a webhook payload without a secret in development', () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + const event = service.constructEvent( + Buffer.from(JSON.stringify({ type: 'payment_intent.succeeded', data: { object: { id: 'pi_1' } } })), + 'sig_whatever', + ); + expect(event.type).toBe('payment_intent.succeeded'); + } finally { + process.env.NODE_ENV = previous; + } + }); +}); diff --git a/backend/src/controllers/PaymentController.ts b/backend/src/controllers/PaymentController.ts index f400191f..35d123f8 100644 --- a/backend/src/controllers/PaymentController.ts +++ b/backend/src/controllers/PaymentController.ts @@ -6,6 +6,8 @@ import { Request, Response, NextFunction } from 'express'; import { PaymentService } from '../services/PaymentService'; import { StellarPaymentService } from '../services/StellarPaymentService'; +import { StripePaymentService, StripeWebhookOutcome } from '../services/payments/StripePaymentService'; +import { CheckoutService } from '../services/payments/CheckoutService'; import { NotificationService } from '../services/NotificationService'; import logger from '../utils/logger'; import { NotFoundError, ForbiddenError, ValidationError } from '../utils/errors'; @@ -22,18 +24,15 @@ import { UserRole } from '../models/User'; export class PaymentController { private paymentService: PaymentService; private stellarPaymentService: StellarPaymentService; + private stripePaymentService: StripePaymentService; + private checkoutService: CheckoutService; private notificationService: any; constructor() { this.paymentService = new PaymentService(); - this.stellarPaymentService = new StellarPaymentService({ - network: 'testnet', - horizonUrl: 'https://horizon-testnet.stellar.org', - distributionAccount: process.env.STELLAR_DISTRIBUTION_ACCOUNT || '', - acceptedAssets: [], - autoConfirmPayments: true, - confirmationThreshold: 1 - }); + this.stellarPaymentService = this.paymentService.getStellarPaymentService(); + this.stripePaymentService = new StripePaymentService(); + this.checkoutService = new CheckoutService({ paymentService: this.paymentService }); this.notificationService = new NotificationService(); } @@ -110,7 +109,11 @@ export class PaymentController { res.status(201).json({ success: true, - data: paymentIntent + data: { + ...paymentIntent, + // The frontend submits this id back to /stellar/submit. + paymentId: paymentIntent.id + } }); } catch (error) { logger.error('', error); @@ -154,11 +157,12 @@ export class PaymentController { */ async getPaymentById(req: Request, res: Response, next: NextFunction) { try { - const { id } = req.params; + const { paymentId, id } = req.params; + const paymentLookupId = paymentId || id; const userId = req.user!.id; const userRole = req.user!.role; - const payment = await this.paymentService.getPaymentById(id); + const payment = await this.paymentService.getPaymentById(paymentLookupId); if (!payment) { throw new NotFoundError('Payment not found'); } @@ -243,10 +247,13 @@ export class PaymentController { */ async processRefund(req: Request, res: Response, next: NextFunction) { try { - const { id } = req.params; + const { paymentId, id } = req.params; + const paymentLookupId = paymentId || id; const { amount, reason } = req.body; - const refundTransaction = await this.paymentService.processRefund(id, amount, reason); + const refundTransaction = await this.checkoutService.processRefund(paymentLookupId, amount, reason, { + actor: req.user!.id + }); // Send refund notification await this.notificationService.sendRefundNotification( @@ -486,6 +493,127 @@ export class PaymentController { } } + /** + * Create a unified checkout (Issue #391). Accepts either payment rail and + * returns gateway-specific data (Stripe client secret, or Stellar XDR). + */ + async createCheckout(req: Request, res: Response, next: NextFunction) { + try { + const { enrollmentId, method, amount, currency, courseId, stellar, metadata, receiptEmail } = req.body; + + const checkout = await this.checkoutService.createCheckout({ + enrollmentId, + method, + amount, + currency, + courseId: courseId || metadata?.courseId, + stellar, + metadata, + receiptEmail, + userId: req.user!.id + }); + + res.status(201).json({ + success: true, + data: checkout + }); + } catch (error) { + logger.error('', error); + next(error); + } + } + + /** + * Confirm a unified checkout: submit the signed Stellar XDR, or confirm the + * Stripe PaymentIntent with the client's payment method. + */ + async confirmCheckout(req: Request, res: Response, next: NextFunction) { + try { + const { checkoutId } = req.params; + const { paymentIntentId, paymentMethodId, signedTransactionXDR } = req.body; + + const result = await this.checkoutService.confirmCheckout( + checkoutId, + { paymentIntentId, paymentMethodId, signedTransactionXDR }, + { actor: req.user!.id } + ); + + res.json({ + success: true, + data: result + }); + } catch (error) { + logger.error('', error); + next(error); + } + } + + /** + * Get checkout details (ownership-checked). + */ + async getCheckout(req: Request, res: Response, next: NextFunction) { + try { + const { checkoutId } = req.params; + const checkout = this.checkoutService.getCheckout(checkoutId); + if (!checkout) { + throw new NotFoundError('Checkout not found'); + } + + if (checkout.userId !== req.user!.id && req.user!.role !== UserRole.ADMIN) { + throw new ForbiddenError('Access denied'); + } + + res.json({ + success: true, + data: checkout + }); + } catch (error) { + logger.error('', error); + next(error); + } + } + + /** + * Trigger a reconciliation sweep of pending crypto payments against the + * Stellar network (admin). + */ + async reconcilePayments(req: Request, res: Response, next: NextFunction) { + try { + const summary = await this.checkoutService.reconcilePendingPayments(); + + res.json({ + success: true, + data: summary + }); + } catch (error) { + logger.error('', error); + next(error); + } + } + + /** + * Handle Stripe webhook (raw body, signature verified). + */ + async handleStripeWebhook(req: Request, res: Response, next: NextFunction) { + try { + const signature = req.headers['stripe-signature'] as string; + if (!signature) { + throw new ValidationError('Missing stripe-signature header'); + } + + const rawBody = (req as any).rawBody ?? Buffer.from(JSON.stringify(req.body ?? {})); + const event = this.stripePaymentService.constructEvent(rawBody, signature); + const outcome = this.stripePaymentService.normalizeWebhookEvent(event); + + await this.checkoutService.handleStripeWebhookOutcome(outcome); + + res.json({ received: true }); + } catch (error) { + logger.error('', error); + next(error); + } + } + /** * Handle Stellar webhook */ @@ -496,8 +624,8 @@ export class PaymentController { // Process webhook based on type switch (type) { case 'payment': - // Handle payment confirmation - await this.processStellarWebhookPayment(transaction); + // Trigger on-chain reconciliation for pending crypto payments + await this.checkoutService.handleStellarWebhook(transaction, type); break; case 'refund': // Handle refund confirmation @@ -570,8 +698,23 @@ export class PaymentController { * Process Stripe webhook */ private async processStripeWebhook(event: string, data: any) { - // Handle Stripe webhook events - logger.info('Processing Stripe webhook', { event, data }); + let status: StripeWebhookOutcome['status'] = 'unknown'; + if (event === 'payment_intent.succeeded') { + status = 'succeeded'; + } else if (event === 'payment_intent.payment_failed' || event === 'payment_intent.canceled') { + status = 'failed'; + } else if (event === 'charge.refunded') { + status = 'refunded'; + } + + const outcome: StripeWebhookOutcome = { + eventType: event, + paymentIntentId: data?.payment_intent || data?.id, + status, + error: data?.last_payment_error?.message + }; + + await this.checkoutService.handleStripeWebhookOutcome(outcome); } /** diff --git a/backend/src/events/purchaseEvents.ts b/backend/src/events/purchaseEvents.ts new file mode 100644 index 00000000..2dc839c7 --- /dev/null +++ b/backend/src/events/purchaseEvents.ts @@ -0,0 +1,82 @@ +import { EventEmitter } from 'events'; +import { PaymentMethod, PaymentStatus } from '../models/Enrollment'; + +/** + * Unified payments — shared event contracts. + * + * The checkout orchestration (services/payments/CheckoutService.ts) emits + * these events whenever a course purchase moves through its lifecycle + * (initiated → pending → confirmed/failed, or refunded). Locally the events + * are dispatched through {@link purchaseEventBus} so in-process consumers + * (notifications, analytics, WebSocket fan-out) can react without waiting on + * a Redis round-trip. The same event is also published to the + * {@link PURCHASE_CHANNEL} Redis channel for cross-node / external consumers. + */ + +/** Redis channel used to fan purchase events out across nodes. */ +export const PURCHASE_CHANNEL = 'purchase:events'; + +/** + * Stable identifier for this process. Mirrors the presence system: when a + * node publishes an event it also receives its own echo on the subscriber + * connection, and the origin marker lets consumers skip that self-echo. + */ +export const PURCHASE_NODE_ID = `${process.pid}-${Math.random().toString(36).slice(2, 10)}`; + +/** Every event the unified payments system emits. */ +export type PurchaseEventType = + | 'PURCHASE_INITIATED' + | 'PURCHASE_PENDING' + | 'PURCHASE_CONFIRMED' + | 'PURCHASE_FAILED' + | 'PURCHASE_REFUNDED' + | 'PURCHASE_RECONCILED'; + +/** Normalized, serializable payload published over Redis and to the bus. */ +export interface PurchaseEvent { + type: PurchaseEventType; + /** Local payment record id (PaymentTransaction / Payment). */ + paymentId?: string; + /** Unified checkout id that owns this purchase. */ + checkoutId?: string; + userId: string; + enrollmentId: string; + courseId?: string; + amount: number; + currency: string; + method: PaymentMethod; + status: PaymentStatus; + /** On-chain transaction hash (Stellar) once confirmed. */ + transactionHash?: string; + /** Gateway reference (Stripe PaymentIntent id, etc.). */ + gatewayTransactionId?: string; + error?: string; + refundAmount?: number; + timestamp: number; + /** Process that published the event; used to ignore self-echoed events. */ + origin?: string; +} + +export type PurchaseListener = (event: PurchaseEvent) => void; + +/** + * In-process event bus for purchases. Consumers subscribe with + * {@link PurchaseEventBus.onEvent} and may pass `'*'` to receive every event. + */ +class PurchaseEventBus extends EventEmitter { + /** Dispatch an event to typed listeners and the wildcard channel. */ + dispatch(event: PurchaseEvent): void { + this.emit(event.type, event); + this.emit('*', event); + } + + onEvent(type: PurchaseEventType | '*', listener: PurchaseListener): this { + return this.on(type, listener); + } + + offEvent(type: PurchaseEventType | '*', listener: PurchaseListener): this { + return this.off(type, listener); + } +} + +export const purchaseEventBus = new PurchaseEventBus(); diff --git a/backend/src/index.ts b/backend/src/index.ts index 7dc47869..e1bbef76 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -150,9 +150,9 @@ const cspViolationRoutes = loadRoute('./routes/cspViolationRoutes'); // @ts-ignore const jobRoutes = loadRoute('./routes/jobRoutes'); -// DID registry routes — Issue #397 +// Unified payments routes — Issue #391 (Stripe fiat + Stellar crypto) // @ts-ignore -const didRoutes = loadRoute('./routes/did'); +const paymentsRoutes = loadRoute('./routes/payments'); // Initialize Express app const app: Application = express(); @@ -177,7 +177,14 @@ app.use(helmet()); app.use(cspMiddleware); app.use(securityHeadersMiddleware); app.use(cors()); -app.use(express.json({ limit: '10mb' })); +// Stash the raw request body so Stripe webhook signatures can be verified +// against the exact bytes Stripe signed (Issue #391). +app.use(express.json({ + limit: '10mb', + verify: (req: any, _res: any, buf: Buffer) => { + req.rawBody = buf; + }, +})); app.use(express.urlencoded({ extended: true })); app.use(requestId); app.use(requestLogger); @@ -275,10 +282,8 @@ app.use('/api/agi-tutor', agiTutorRoutes); app.use('/api/analytics', analyticsRoutes); app.use('/api/dashboard', dashboardRoutes); -// Progress tracking routes -// @ts-ignore -const progressRoutes = loadRoute('./routes/progress'); -app.use('/api/progress', progressRoutes); +// Unified payments — Issue #391 +app.use('/api/payments', paymentsRoutes); // Autonomous Agents routes // @ts-ignore @@ -401,7 +406,7 @@ app.use('/api/v1/secure-comm', secureCommRoutes); app.use('/api/v1/agi-tutor', agiTutorRoutes); app.use('/api/v1/analytics', analyticsRoutes); app.use('/api/v1/dashboard', dashboardRoutes); -app.use('/api/v1/progress', progressRoutes); +app.use('/api/v1/payments', paymentsRoutes); app.use('/api/v1/autonomous-agents', autonomousAgentsRoutes); app.use('/api/v1/gamification', gamificationRoutes); app.use('/api/v1/bridge', bridgeRoutes); diff --git a/backend/src/models/Enrollment.ts b/backend/src/models/Enrollment.ts index 90c1a305..ba74229a 100644 --- a/backend/src/models/Enrollment.ts +++ b/backend/src/models/Enrollment.ts @@ -27,6 +27,7 @@ export enum PaymentStatus { export enum PaymentMethod { STELLAR = 'stellar', + STRIPE = 'stripe', CREDIT_CARD = 'credit_card', BANK_TRANSFER = 'bank_transfer', CRYPTO = 'crypto', diff --git a/backend/src/models/Payment.ts b/backend/src/models/Payment.ts index 26bc65bf..1b55b0d8 100644 --- a/backend/src/models/Payment.ts +++ b/backend/src/models/Payment.ts @@ -12,6 +12,34 @@ export type Payment = PaymentTransaction; export type RefundRequest = { transactionId: string; reason: string; amount: number; }; export type RefundAnalytics = { totalRefunds: number; totalAmount: number; reasons: Record; }; +// Unified checkout (Issue #391) — one abstraction over both payment rails. +export type CheckoutMethod = 'stripe' | 'stellar'; +export type CheckoutStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'refunded' | 'expired'; + +export interface Checkout { + id: string; + enrollmentId: string; + userId: string; + courseId?: string; + amount: number; + currency: string; + method: CheckoutMethod; + status: CheckoutStatus; + /** PaymentService payment intent id backing this checkout. */ + paymentIntentId?: string; + /** Local payment record id (set at creation, confirmed once finalized). */ + paymentId?: string; + /** Gateway reference: Stripe PaymentIntent id or Stellar payment id. */ + gatewayPaymentIntentId?: string; + gatewayData?: Record; + transactionHash?: string; + createdAt: Date; + expiresAt?: Date; + confirmedAt?: Date; + failedAt?: Date; + failureReason?: string; +} + export interface PaymentGateway { id: string; name: string; diff --git a/backend/src/routes/paymentRoutes.ts b/backend/src/routes/paymentRoutes.ts deleted file mode 100644 index 91d247fd..00000000 --- a/backend/src/routes/paymentRoutes.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @openapi - * tags: - * - name: Payments - * description: Payment processing and transaction management - */ - -import express, { Router } from "express"; -// @ts-ignore - controller module not yet implemented -import { paymentController } from "../controllers/paymentController"; - -const router: Router = express.Router(); - -/** - * @openapi - * /api/payments/create-payment-intent: - * post: - * tags: [Payments] - * summary: Create payment intent - * responses: - * '200': - * description: Payment intent created - */ -router.post("/create-payment-intent", paymentController.createPaymentIntent); - -/** - * @openapi - * /api/payments/webhook: - * post: - * tags: [Payments] - * summary: Handle payment webhook - * responses: - * '200': - * description: Webhook processed - */ -router.post("/webhook", paymentController.handleWebhook); - -/** - * @openapi - * /api/payments/{paymentId}: - * get: - * tags: [Payments] - * summary: Get payment details - * parameters: - * - in: path - * name: paymentId - * required: true - * schema: - * type: string - * responses: - * '200': - * description: Payment details retrieved - */ -router.get("/:paymentId", paymentController.getPayment); - -/** - * @openapi - * /api/payments/{paymentId}/refund: - * post: - * tags: [Payments] - * summary: Refund payment - * parameters: - * - in: path - * name: paymentId - * required: true - * schema: - * type: string - * responses: - * '200': - * description: Payment refunded - */ -router.post("/:paymentId/refund", paymentController.refundPayment); - -/** - * @openapi - * /api/payments/history/{userId}: - * get: - * tags: [Payments] - * summary: Get payment history for user - * parameters: - * - in: path - * name: userId - * required: true - * schema: - * type: string - * responses: - * '200': - * description: Payment history retrieved - */ -router.get("/history/:userId", paymentController.getUserPaymentHistory); - -export default router; diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts new file mode 100644 index 00000000..4e6bd723 --- /dev/null +++ b/backend/src/routes/payments.ts @@ -0,0 +1,352 @@ +/** + * @openapi + * tags: + * - name: Payments + * description: Unified course purchase checkout (Stripe fiat & Stellar crypto), refunds and webhooks + */ + +import express, { Router, Request, Response, NextFunction } from 'express'; +import { PaymentController } from '../controllers/PaymentController'; +import { authenticate, requireAdmin } from '../middleware/auth'; + +const router: Router = express.Router(); +const paymentController = new PaymentController(); + +// Preserve `this` on controller methods when passed to Express. +const h = (fn: (req: Request, res: Response, next: NextFunction) => Promise) => + (req: Request, res: Response, next: NextFunction) => fn.call(paymentController, req, res, next); + +/** + * @openapi + * /api/payments/checkout: + * post: + * tags: [Payments] + * summary: Create a unified checkout for a course purchase (Stripe or Stellar) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [enrollmentId, method, amount, currency] + * properties: + * enrollmentId: + * type: string + * method: + * type: string + * enum: [stripe, stellar] + * amount: + * type: number + * currency: + * type: string + * stellar: + * type: object + * properties: + * fromAddress: + * type: string + * assetCode: + * type: string + * responses: + * '201': + * description: Checkout created with gateway data (Stripe client secret or Stellar XDR) + * '400': + * description: Invalid payment parameters + */ +router.post('/checkout', authenticate as any, h(paymentController.createCheckout)); + +/** + * @openapi + * /api/payments/checkout/{checkoutId}/confirm: + * post: + * tags: [Payments] + * summary: Confirm a checkout (submit signed Stellar XDR or confirm Stripe intent) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: checkoutId + * required: true + * schema: + * type: string + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * signedTransactionXDR: + * type: string + * paymentMethodId: + * type: string + * paymentIntentId: + * type: string + * responses: + * '200': + * description: Checkout confirmed + */ +router.post('/checkout/:checkoutId/confirm', authenticate as any, h(paymentController.confirmCheckout)); + +/** + * @openapi + * /api/payments/checkout/{checkoutId}: + * get: + * tags: [Payments] + * summary: Get checkout details + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: checkoutId + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Checkout details retrieved + */ +router.get('/checkout/:checkoutId', authenticate as any, h(paymentController.getCheckout)); + +/** + * @openapi + * /api/payments/intent: + * post: + * tags: [Payments] + * summary: Create payment intent + * security: + * - bearerAuth: [] + * responses: + * '201': + * description: Payment intent created + */ +router.post('/intent', authenticate as any, h(paymentController.createPaymentIntent)); + +/** + * @openapi + * /api/payments/stellar/create: + * post: + * tags: [Payments] + * summary: Create a Stellar payment intent + * security: + * - bearerAuth: [] + * responses: + * '201': + * description: Stellar payment intent created with XDR + */ +router.post('/stellar/create', authenticate as any, h(paymentController.createStellarPayment)); + +/** + * @openapi + * /api/payments/stellar/submit: + * post: + * tags: [Payments] + * summary: Submit a signed Stellar transaction + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Payment processed + */ +router.post('/stellar/submit', authenticate as any, h(paymentController.submitStellarPayment)); + +/** + * @openapi + * /api/payments/webhook/stripe: + * post: + * tags: [Payments] + * summary: Stripe webhook (signature verified, raw body) + * responses: + * '200': + * description: Webhook received + */ +router.post('/webhook/stripe', h(paymentController.handleStripeWebhook)); + +/** + * @openapi + * /api/payments/webhook/stellar: + * post: + * tags: [Payments] + * summary: Stellar webhook (triggers on-chain reconciliation) + * responses: + * '200': + * description: Webhook processed + */ +router.post('/webhook/stellar', h(paymentController.handleStellarWebhook)); + +/** + * @openapi + * /api/payments/reconcile: + * post: + * tags: [Payments] + * summary: Trigger reconciliation of pending crypto payments against the Stellar network + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Reconciliation summary + */ +router.post('/reconcile', authenticate as any, requireAdmin as any, h(paymentController.reconcilePayments)); + +/** + * @openapi + * /api/payments/methods: + * get: + * tags: [Payments] + * summary: Get supported payment methods + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Payment methods retrieved + */ +router.get('/methods', authenticate as any, h(paymentController.getSupportedPaymentMethods)); + +/** + * @openapi + * /api/payments/exchange-rates: + * get: + * tags: [Payments] + * summary: Get exchange rates + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Exchange rates retrieved + */ +router.get('/exchange-rates', authenticate as any, h(paymentController.getExchangeRates)); + +/** + * @openapi + * /api/payments/convert: + * post: + * tags: [Payments] + * summary: Convert currency amount + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Converted amount + */ +router.post('/convert', authenticate as any, h(paymentController.convertCurrency)); + +/** + * @openapi + * /api/payments/validate: + * post: + * tags: [Payments] + * summary: Validate payment parameters + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Validation result + */ +router.post('/validate', authenticate as any, h(paymentController.validatePaymentParameters)); + +/** + * @openapi + * /api/payments/analytics: + * get: + * tags: [Payments] + * summary: Get payment analytics + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Payment analytics retrieved + */ +router.get('/analytics', authenticate as any, requireAdmin as any, h(paymentController.getPaymentAnalytics)); + +/** + * @openapi + * /api/payments/history/{userId}: + * get: + * tags: [Payments] + * summary: Get payment history for a user + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Payment history retrieved + */ +router.get('/history/:userId', authenticate as any, h(paymentController.getUserPaymentHistory)); + +/** + * @openapi + * /api/payments/receipt/{paymentId}: + * get: + * tags: [Payments] + * summary: Generate payment receipt + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: paymentId + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Receipt generated + */ +router.get('/receipt/:paymentId', authenticate as any, h(paymentController.generateReceipt)); + +/** + * @openapi + * /api/payments/{paymentId}: + * get: + * tags: [Payments] + * summary: Get payment details + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: paymentId + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Payment details retrieved + */ +router.get('/:paymentId', authenticate as any, h(paymentController.getPaymentById)); + +/** + * @openapi + * /api/payments/{paymentId}/refund: + * post: + * tags: [Payments] + * summary: Refund a payment (Stripe or Stellar) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: paymentId + * required: true + * schema: + * type: string + * requestBody: + * content: + * application/json: + * schema: + * type: object + * required: [reason] + * properties: + * amount: + * type: number + * reason: + * type: string + * responses: + * '200': + * description: Payment refunded + */ +router.post('/:paymentId/refund', authenticate as any, h(paymentController.processRefund)); + +export default router; diff --git a/backend/src/services/PaymentService.ts b/backend/src/services/PaymentService.ts index d2005c2d..b2ff8b68 100644 --- a/backend/src/services/PaymentService.ts +++ b/backend/src/services/PaymentService.ts @@ -15,15 +15,19 @@ import { PaymentReceipt, RefundRequest, StellarPaymentSettings, - PaymentValidation + PaymentValidation, + type StellarPayment } from '../models/Payment'; import { StellarPaymentService } from './StellarPaymentService'; +import { StripePaymentService, StripeIntentResult } from './payments/StripePaymentService'; +import { assertValidPaymentTransition } from './payments/paymentStateMachine'; import { auditService } from './auditService'; import { AuditAction } from '../models/AuditLog'; import { v4 as uuidv4 } from 'uuid'; export class PaymentService { private stellarPaymentService: StellarPaymentService; + private stripePaymentService: StripePaymentService; private payments: Map = new Map(); private paymentIntents: Map = new Map(); private transactions: Map = new Map(); @@ -59,8 +63,9 @@ export class PaymentService { }; this.stellarPaymentService = new StellarPaymentService(stellarSettings); + this.stripePaymentService = new StripePaymentService(); this.paymentSettings = { - acceptedMethods: [PaymentMethod.STELLAR, PaymentMethod.CREDIT_CARD], + acceptedMethods: [PaymentMethod.STELLAR, PaymentMethod.STRIPE, PaymentMethod.CREDIT_CARD], defaultCurrency: 'USD', supportedCurrencies: ['USD', 'EUR', 'XLM'], autoRefundEnabled: true, @@ -95,8 +100,6 @@ export class PaymentService { expiresAt: new Date(Date.now() + 30 * 60 * 1000) // 30 minutes }; - this.paymentIntents.set(paymentIntent.id, paymentIntent); - // Create payment record const payment: Payment = { id: uuidv4(), @@ -113,6 +116,15 @@ export class PaymentService { this.payments.set(payment.id, payment); + // Link the payment intent to its payment record so webhooks and confirm + // flows can resolve one from the other without scanning by enrollment. + paymentIntent.metadata = { + ...(paymentIntent.metadata ?? {}), + enrollmentId, + paymentId: payment.id, + }; + this.paymentIntents.set(paymentIntent.id, paymentIntent); + if (auditContext) { await auditService.create( auditContext.actor, @@ -137,24 +149,35 @@ export class PaymentService { /** * Create Stellar payment intent + * + * A payment reference is generated up-front and stamped into the Stellar + * transaction memo so the reconciliation service can match the on-chain + * payment back to this record. */ async createStellarPaymentIntent( enrollmentId: string, details: any ): Promise { - const paymentIntent = await this.createPaymentIntent(enrollmentId, PaymentMethod.STELLAR, details); + const paymentReference = uuidv4(); + const paymentIntent = await this.createPaymentIntent(enrollmentId, PaymentMethod.STELLAR, { + ...details, + metadata: { ...(details.metadata || {}), paymentReference } + }); - // Create Stellar transaction + // Create Stellar transaction with the payment reference as the memo const { transactionXDR, paymentId } = await this.stellarPaymentService.createPaymentTransaction( details.fromAddress, details.amount.toString(), details.assetCode || 'XLM', - details.assetIssuer + details.assetIssuer, + paymentReference ); paymentIntent.gatewayData = { transactionXDR, paymentId, + memo: paymentReference, + destination: this.stellarPaymentService.getDistributionAddress(), horizonUrl: this.paymentSettings.stellarSettings.horizonUrl }; @@ -256,18 +279,142 @@ export class PaymentService { throw error; } + } /** + * Process a confirmed Stripe payment (fiat rail) + */ + async processStripePayment( + paymentIntentId: string, + stripeResult: StripeIntentResult, + auditContext?: { actor: string; ipAddress?: string } + ): Promise { + const paymentIntent = this.paymentIntents.get(paymentIntentId); + if (!paymentIntent) { + throw new Error('Payment intent not found'); + } + + const payment = this.resolvePaymentForIntent(paymentIntent); + if (!payment) { + throw new Error('Payment record not found for payment intent'); + } + + const transaction = this.buildStripeTransaction(payment, stripeResult); + this.transactions.set(transaction.id, transaction); + + payment.status = PaymentStatus.COMPLETED; + payment.transactionId = transaction.id; + payment.gateway = 'stripe'; + payment.gatewayTransactionId = stripeResult.paymentIntentId; + payment.completedAt = new Date(); + payment.updatedAt = new Date(); + this.payments.set(payment.id, payment); + + paymentIntent.status = 'succeeded'; + paymentIntent.confirmedAt = new Date(); + this.paymentIntents.set(paymentIntentId, paymentIntent); + + if (auditContext) { + await auditService.create( + auditContext.actor, + AuditAction.PAYMENT_PROCESS, + 'payment', + { + resourceId: transaction.id, + details: { + enrollmentId: transaction.enrollmentId, + courseId: transaction.courseId, + amount: transaction.amount, + currency: transaction.currency, + gatewayTransactionId: stripeResult.paymentIntentId + }, + ipAddress: auditContext.ipAddress, + } + ); + } + + return transaction; } -/** - * Process refund - */ + /** + * Finalize a Stripe payment directly from a payment record (webhook path + * when no checkout context exists). + */ + async completeStripePaymentByPaymentId( + paymentId: string, + stripeResult: StripeIntentResult, + auditContext?: { actor: string; ipAddress?: string } + ): Promise { + const payment = this.payments.get(paymentId); + if (!payment) { + throw new Error('Payment not found'); + } + assertValidPaymentTransition(payment.status, PaymentStatus.COMPLETED); + + const transaction = this.buildStripeTransaction(payment, stripeResult); + this.transactions.set(transaction.id, transaction); + + payment.status = PaymentStatus.COMPLETED; + payment.transactionId = transaction.id; + payment.gateway = 'stripe'; + payment.gatewayTransactionId = stripeResult.paymentIntentId; + payment.completedAt = new Date(); + payment.updatedAt = new Date(); + this.payments.set(paymentId, payment); + + if (auditContext) { + await auditService.create( + auditContext.actor, + AuditAction.PAYMENT_PROCESS, + 'payment', + { + resourceId: transaction.id, + details: { + enrollmentId: payment.enrollmentId, + courseId: payment.courseId, + amount: payment.amount, + currency: payment.currency, + gatewayTransactionId: stripeResult.paymentIntentId + }, + ipAddress: auditContext.ipAddress, + } + ); + } + + return transaction; + } + + /** + * Build the completed transaction record for a successful Stripe payment. + */ + private buildStripeTransaction(payment: Payment, stripeResult: StripeIntentResult): PaymentTransaction { + return { + id: uuidv4(), + enrollmentId: payment.enrollmentId, + userId: payment.userId, + courseId: payment.courseId, + amount: stripeResult.amount, + currency: stripeResult.currency.toUpperCase(), + method: PaymentMethod.STRIPE, + status: PaymentStatus.COMPLETED, + gateway: 'stripe', + gatewayTransactionId: stripeResult.paymentIntentId, + createdAt: new Date(), + updatedAt: new Date(), + completedAt: new Date() + }; + } + + /** + * Process refund + */ async processRefund( paymentId: string, amount: number, reason: string, auditContext?: { actor: string; ipAddress?: string } ): Promise { - const payment = this.payments.get(paymentId); + // Accept a payment id or, as a fallback for legacy callers, an enrollment + // id that maps to its completed payment. + const payment = this.payments.get(paymentId) || this.findCompletedPaymentByEnrollmentId(paymentId); if (!payment) { throw new Error('Payment not found'); } @@ -283,7 +430,32 @@ export class PaymentService { try { let refundTransaction: PaymentTransaction; - if (payment.method === PaymentMethod.STELLAR && payment.stellarTransactionHash) { + if (payment.method === PaymentMethod.STRIPE && payment.gatewayTransactionId) { + // Stripe refund (fiat rail) + const stripeRefund = await this.stripePaymentService.refund( + payment.gatewayTransactionId, + amount + ); + + refundTransaction = { + id: stripeRefund.refundId, + enrollmentId: payment.enrollmentId, + userId: payment.userId, + courseId: payment.courseId, + amount: -amount, // Negative amount for refund + currency: payment.currency, + method: payment.method, + status: PaymentStatus.COMPLETED, + gateway: 'stripe', + gatewayTransactionId: stripeRefund.refundId, + createdAt: new Date(), + updatedAt: new Date(), + completedAt: new Date(), + refundAmount: amount, + refundReason: reason, + refundedAt: new Date() + }; + } else if (payment.method === PaymentMethod.STELLAR && payment.stellarTransactionHash) { // Create Stellar refund transaction const { transactionXDR, refundId } = await this.stellarPaymentService.createRefundTransaction( payment.userId, // In production, this would be the actual user's Stellar address @@ -388,6 +560,135 @@ export class PaymentService { return this.payments.get(id) || null; } + /** + * Get the underlying Stellar payment service (used by reconciliation). + */ + getStellarPaymentService(): StellarPaymentService { + return this.stellarPaymentService; + } + + /** + * Distribution account address crypto payments are sent to. + */ + getStellarDistributionAddress(): string { + return this.stellarPaymentService.getDistributionAddress(); + } + + /** + * Pending crypto (Stellar) payments awaiting on-chain confirmation. + */ + getPendingCryptoPayments(): Payment[] { + return Array.from(this.payments.values()) + .filter(p => p.method === PaymentMethod.STELLAR && p.status === PaymentStatus.PENDING); + } + + /** + * Find a payment by its gateway reference: Stripe PaymentIntent id, + * Stellar transaction hash, or stored payment reference. + */ + findPaymentByGatewayTransactionId(gatewayTransactionId: string): Payment | null { + return Array.from(this.payments.values()).find(p => + p.gatewayTransactionId === gatewayTransactionId || + p.stellarTransactionHash === gatewayTransactionId || + p.metadata?.stripePaymentIntentId === gatewayTransactionId || + p.metadata?.paymentReference === gatewayTransactionId + ) || null; + } + + /** + * Attach a Stripe PaymentIntent reference to a locally created payment so + * webhooks and refunds can be matched back to it. + */ + attachStripeIntent(paymentIntentId: string, stripePaymentIntentId: string): void { + const paymentIntent = this.paymentIntents.get(paymentIntentId); + const payment = paymentIntent ? this.resolvePaymentForIntent(paymentIntent) : undefined; + if (payment) { + payment.gateway = 'stripe'; + payment.gatewayTransactionId = stripePaymentIntentId; + payment.metadata = { ...(payment.metadata ?? {}), stripePaymentIntentId }; + this.payments.set(payment.id, payment); + } + } + + /** + * Move a payment through the state machine. Invalid transitions (stale + * webhooks, duplicate confirms) throw instead of corrupting state. + */ + transitionPaymentStatus(paymentId: string, to: PaymentStatus, fields?: Partial): Payment { + const payment = this.payments.get(paymentId); + if (!payment) { + throw new Error('Payment not found'); + } + assertValidPaymentTransition(payment.status, to); + payment.status = to; + Object.assign(payment, fields ?? {}); + payment.updatedAt = new Date(); + this.payments.set(paymentId, payment); + return payment; + } + + /** + * Finalize a crypto payment once its on-chain transaction is verified. + * Called by the reconciliation service. + */ + async completeCryptoPayment(paymentId: string, stellarPayment: StellarPayment): Promise { + const payment = this.payments.get(paymentId); + if (!payment) { + throw new Error('Payment not found'); + } + assertValidPaymentTransition(payment.status, PaymentStatus.COMPLETED); + + const transaction: PaymentTransaction = { + id: uuidv4(), + enrollmentId: payment.enrollmentId, + userId: payment.userId, + courseId: payment.courseId, + amount: parseFloat(stellarPayment.amount) || payment.amount, + currency: stellarPayment.assetCode || payment.currency, + method: PaymentMethod.STELLAR, + status: PaymentStatus.COMPLETED, + gateway: 'stellar', + stellarTransaction: stellarPayment, + stellarTransactionHash: stellarPayment.transactionHash, + createdAt: new Date(), + updatedAt: new Date(), + completedAt: new Date() + }; + + this.transactions.set(transaction.id, transaction); + + payment.status = PaymentStatus.COMPLETED; + payment.transactionId = transaction.id; + payment.stellarTransactionHash = stellarPayment.transactionHash; + payment.completedAt = new Date(); + this.payments.set(paymentId, payment); + + return transaction; + } + + /** + * Resolve the payment record backing a payment intent. + */ + private resolvePaymentForIntent(paymentIntent: PaymentIntent): Payment | undefined { + const linkedId = paymentIntent.metadata?.paymentId; + if (linkedId && this.payments.has(linkedId)) { + return this.payments.get(linkedId); + } + return Array.from(this.payments.values()).find(p => + p.userId === paymentIntent.userId && + p.courseId === paymentIntent.courseId && + p.amount === paymentIntent.amount + ); + } + + /** + * Find a completed payment for an enrollment (legacy refund flow fallback). + */ + private findCompletedPaymentByEnrollmentId(enrollmentId: string): Payment | undefined { + return Array.from(this.payments.values()) + .find(p => p.enrollmentId === enrollmentId && p.status === PaymentStatus.COMPLETED); + } + /** * Get payments for enrollment */ diff --git a/backend/src/services/ReportingService.ts b/backend/src/services/ReportingService.ts index 061a9720..b408e387 100644 --- a/backend/src/services/ReportingService.ts +++ b/backend/src/services/ReportingService.ts @@ -302,7 +302,8 @@ export class ReportingService { averageRevenuePerEnrollment: 228, revenueByPaymentMethod: { [PaymentMethod.STELLAR]: 142500, - [PaymentMethod.CREDIT_CARD]: 114000, + [PaymentMethod.STRIPE]: 114000, + [PaymentMethod.CREDIT_CARD]: 0, [PaymentMethod.BANK_TRANSFER]: 28500, [PaymentMethod.CRYPTO]: 0, [PaymentMethod.INSTALLMENT]: 0 @@ -342,11 +343,16 @@ export class ReportingService { revenue: 142500, successRate: 96.5 }, - [PaymentMethod.CREDIT_CARD]: { + [PaymentMethod.STRIPE]: { count: 500, revenue: 114000, successRate: 94.8 }, + [PaymentMethod.CREDIT_CARD]: { + count: 0, + revenue: 0, + successRate: 0 + }, [PaymentMethod.BANK_TRANSFER]: { count: 100, revenue: 28500, @@ -415,7 +421,8 @@ export class ReportingService { averageRevenuePerEnrollment: 99.90, revenueByPaymentMethod: { [PaymentMethod.STELLAR]: 7492, - [PaymentMethod.CREDIT_CARD]: 5994, + [PaymentMethod.STRIPE]: 5994, + [PaymentMethod.CREDIT_CARD]: 0, [PaymentMethod.BANK_TRANSFER]: 1499, [PaymentMethod.CRYPTO]: 0, [PaymentMethod.INSTALLMENT]: 0 @@ -533,7 +540,8 @@ export class ReportingService { ], paymentMethods: { [PaymentMethod.STELLAR]: { count: 4, amount: 399.96 }, - [PaymentMethod.CREDIT_CARD]: { count: 3, amount: 299.96 }, + [PaymentMethod.STRIPE]: { count: 3, amount: 299.96 }, + [PaymentMethod.CREDIT_CARD]: { count: 0, amount: 0 }, [PaymentMethod.BANK_TRANSFER]: { count: 1, amount: 100.00 }, [PaymentMethod.CRYPTO]: { count: 0, amount: 0 }, [PaymentMethod.INSTALLMENT]: { count: 0, amount: 0 } diff --git a/backend/src/services/StellarPaymentService.ts b/backend/src/services/StellarPaymentService.ts index 4eb3b09c..70978d23 100644 --- a/backend/src/services/StellarPaymentService.ts +++ b/backend/src/services/StellarPaymentService.ts @@ -13,14 +13,44 @@ import logger from '../utils/logger'; export class StellarPaymentService { private server: any; private network: any; - private distributionKeypair: Keypair; + private distributionKeypair: Keypair | null = null; private settings: StellarPaymentSettings; constructor(settings: StellarPaymentSettings) { this.settings = settings; this.server = new Server(settings.horizonUrl); this.network = settings.network === 'mainnet' ? (Networks as any).PUBLIC : (Networks as any).TESTNET; - this.distributionKeypair = Keypair.fromSecret(settings.distributionAccount); + } + + /** + * Lazily derive the distribution keypair so the server can boot without + * STELLAR_DISTRIBUTION_ACCOUNT configured (development / CI). Operations + * that need the distribution account fail with a clear message instead of + * crashing at startup. + */ + private getDistributionKeypair(): Keypair { + if (!this.distributionKeypair) { + if (!this.settings.distributionAccount) { + throw new Error('Stellar distribution account is not configured (STELLAR_DISTRIBUTION_ACCOUNT)'); + } + this.distributionKeypair = Keypair.fromSecret(this.settings.distributionAccount); + } + return this.distributionKeypair; + } + + /** + * Public address of the distribution account payments are sent to. + * + * Returns '' when the account secret is not configured so the server can + * boot without secrets; operations that actually need the address (payment + * creation, refunds) surface the configuration error at call time. + */ + getDistributionAddress(): string { + try { + return this.getDistributionKeypair().publicKey(); + } catch { + return ''; + } } /** @@ -40,6 +70,7 @@ export class StellarPaymentService { // Load the source account const sourceAccount = await this.server.loadAccount(fromAddress); + const distributionAddress = this.getDistributionAddress(); // Create transaction const transaction = new TransactionBuilder(sourceAccount, { @@ -47,7 +78,7 @@ export class StellarPaymentService { networkPassphrase: this.network.passphrase }) .addOperation((Operation as any).payment({ - destination: this.distributionKeypair.publicKey(), + destination: distributionAddress, asset, amount })) @@ -120,7 +151,7 @@ export class StellarPaymentService { const op = (paymentOperation as any) as any; // Verify destination - if (op.destination !== this.distributionKeypair.publicKey()) { + if (op.destination !== this.getDistributionAddress()) { errors.push('Payment destination does not match distribution account'); } @@ -192,7 +223,7 @@ export class StellarPaymentService { return { from: paymentOp.body().paymentOp().sourceAccount().ed25519().toString(), - to: this.distributionKeypair.publicKey(), + to: this.getDistributionAddress(), amount: paymentOp.body().paymentOp().amount().toString(), assetCode, assetIssuer, @@ -330,7 +361,7 @@ export class StellarPaymentService { : new Asset(assetCode, assetIssuer!); // Load the source account (distribution account) - const sourceAccount = await this.server.loadAccount(this.distributionKeypair.publicKey()); + const sourceAccount = await this.server.loadAccount(this.getDistributionAddress()); // Create refund memo let memoText = originalTransactionHash diff --git a/backend/src/services/payments/CheckoutService.ts b/backend/src/services/payments/CheckoutService.ts new file mode 100644 index 00000000..fb6b62de --- /dev/null +++ b/backend/src/services/payments/CheckoutService.ts @@ -0,0 +1,566 @@ +/** + * Checkout Service — unified payments orchestration (Issue #391). + * + * A single checkout abstracts both rails: + * + * - `stripe` (fiat): creates a Stripe PaymentIntent server-side, confirms + * it with the client-provided payment method, and reconciles the result + * through the Stripe webhook. + * - `stellar` (crypto): builds an unsigned Stellar transaction the learner + * signs in their wallet; confirmation is detected by watching the chain + * (reconciliation) or by submitting the signed XDR directly. + * + * Every purchase emits typed purchase events (see events/purchaseEvents.ts) + * on the in-process bus and over Redis so downstream consumers (notifications, + * analytics, receipts) react to the lifecycle without polling. + */ + +import { v4 as uuidv4 } from 'uuid'; +import logger from '../../utils/logger'; +import { PaymentMethod, PaymentStatus } from '../../models/Enrollment'; +import type { StellarPayment } from '../../models/Enrollment'; +import { Checkout, CheckoutMethod, CheckoutStatus } from '../../models/Payment'; +import { PaymentService } from '../PaymentService'; +import { StripePaymentService, StripeIntentResult, StripeWebhookOutcome } from './StripePaymentService'; +import { PaymentReconciliationService, CryptoPaymentRecord, ReconciliationSummary } from './PaymentReconciliationService'; +import { + PURCHASE_CHANNEL, + PURCHASE_NODE_ID, + PurchaseEvent, + purchaseEventBus, +} from '../../events/purchaseEvents'; +import redisConfig from '../../config/redis'; + +export interface CreateCheckoutInput { + enrollmentId: string; + userId: string; + courseId?: string; + amount: number; + currency: string; + method: CheckoutMethod; + stellar?: { + fromAddress: string; + assetCode?: string; + assetIssuer?: string; + }; + metadata?: Record; + receiptEmail?: string; +} + +export interface CheckoutConfirmation { + checkout: Checkout; + transaction?: any; +} + +const CHECKOUT_STATUS_TRANSITIONS: Record = { + pending: ['processing', 'completed', 'failed', 'expired'], + processing: ['completed', 'failed'], + completed: ['refunded'], + failed: [], + refunded: [], + expired: [], +}; + +export interface CheckoutServiceOptions { + paymentService?: PaymentService; + stripePaymentService?: StripePaymentService; + reconciliation?: PaymentReconciliationService; +} + +export class CheckoutService { + private readonly paymentService: PaymentService; + private readonly stripePaymentService: StripePaymentService; + private readonly reconciliation: PaymentReconciliationService; + private readonly checkouts = new Map(); + + constructor(options: CheckoutServiceOptions = {}) { + this.paymentService = options.paymentService ?? new PaymentService(); + this.stripePaymentService = options.stripePaymentService ?? new StripePaymentService(); + + this.reconciliation = + options.reconciliation ?? + new PaymentReconciliationService({ + stellar: this.paymentService.getStellarPaymentService(), + distributionAddress: this.paymentService.getStellarDistributionAddress(), + fetchPending: () => this.toCryptoRecords(this.paymentService.getPendingCryptoPayments()), + onReconciled: (payment, onChain) => this.finalizeReconciledPayment(payment, onChain), + }); + } + + // ── Checkout lifecycle ───────────────────────────────────────────────────── + + /** + * Create a unified checkout for a course purchase. The payment method is + * chosen by the caller; the underlying rail is abstracted behind the + * checkout's `gatewayData` (Stripe client secret, or Stellar XDR + memo). + */ + async createCheckout(input: CreateCheckoutInput): Promise { + if (input.method !== 'stripe' && input.method !== 'stellar') { + throw new Error(`Unsupported checkout method: ${input.method}`); + } + + const paymentMethod = input.method === 'stripe' ? PaymentMethod.STRIPE : PaymentMethod.STELLAR; + const validation = this.paymentService.validatePaymentParameters( + input.amount, + input.currency, + paymentMethod, + input.stellar?.fromAddress, + ); + if (!validation.isValid) { + throw new Error(`Invalid payment parameters: ${validation.errors.join(', ')}`); + } + + let paymentIntentId: string; + let gatewayPaymentIntentId: string | undefined; + let paymentId: string | undefined; + let gatewayData: Record = {}; + + if (input.method === 'stripe') { + const paymentIntent = await this.paymentService.createPaymentIntent( + input.enrollmentId, + PaymentMethod.STRIPE, + { + userId: input.userId, + courseId: input.courseId, + amount: input.amount, + currency: input.currency, + metadata: input.metadata, + }, + ); + paymentIntentId = paymentIntent.id; + paymentId = paymentIntent.metadata?.paymentId as string | undefined; + + const stripeIntent = await this.stripePaymentService.createPaymentIntent({ + amount: input.amount, + currency: input.currency, + receiptEmail: input.receiptEmail, + metadata: { + checkout: '', + enrollmentId: input.enrollmentId, + courseId: input.courseId ?? '', + userId: input.userId, + paymentIntentId: paymentIntent.id, + }, + }); + gatewayPaymentIntentId = stripeIntent.paymentIntentId; + gatewayData = { + paymentIntentId: stripeIntent.paymentIntentId, + clientSecret: stripeIntent.clientSecret, + status: stripeIntent.status, + publishableKey: this.stripePaymentService.getPublishableKey(), + }; + + this.paymentService.attachStripeIntent(paymentIntent.id, stripeIntent.paymentIntentId); + } else { + const paymentIntent = await this.paymentService.createStellarPaymentIntent(input.enrollmentId, { + userId: input.userId, + courseId: input.courseId, + amount: input.amount, + currency: input.currency, + fromAddress: input.stellar?.fromAddress, + assetCode: input.stellar?.assetCode ?? 'XLM', + assetIssuer: input.stellar?.assetIssuer, + metadata: input.metadata, + }); + paymentIntentId = paymentIntent.id; + paymentId = paymentIntent.metadata?.paymentId as string | undefined; + gatewayPaymentIntentId = paymentIntent.gatewayData?.paymentId; + gatewayData = paymentIntent.gatewayData ?? {}; + } + + const checkout: Checkout = { + id: uuidv4(), + enrollmentId: input.enrollmentId, + userId: input.userId, + courseId: input.courseId, + amount: input.amount, + currency: input.currency, + method: input.method, + status: 'pending', + paymentIntentId, + paymentId, + gatewayPaymentIntentId, + gatewayData, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + }; + + this.checkouts.set(checkout.id, checkout); + + this.dispatch({ + type: 'PURCHASE_INITIATED', + checkoutId: checkout.id, + paymentId, + userId: checkout.userId, + enrollmentId: checkout.enrollmentId, + courseId: checkout.courseId, + amount: checkout.amount, + currency: checkout.currency, + method: paymentMethod, + status: PaymentStatus.PENDING, + gatewayTransactionId: gatewayPaymentIntentId, + }); + + return checkout; + } + + /** + * Confirm a checkout: + * - Stellar: submit the learner-signed transaction XDR. + * - Stripe: confirm the PaymentIntent with the client's payment method + * (or finalize a client-confirmed intent). + */ + async confirmCheckout( + checkoutId: string, + input: { paymentIntentId?: string; paymentMethodId?: string; signedTransactionXDR?: string }, + auditContext?: { actor: string; ipAddress?: string }, + ): Promise { + const checkout = this.checkouts.get(checkoutId); + if (!checkout) { + throw new Error('Checkout not found'); + } + + if (checkout.status === 'completed') { + return { checkout }; + } + if (checkout.status !== 'pending') { + throw new Error(`Checkout cannot be confirmed from status ${checkout.status}`); + } + + if (checkout.method === 'stellar') { + if (!input.signedTransactionXDR) { + throw new Error('signedTransactionXDR is required to confirm a Stellar checkout'); + } + const tx = await this.paymentService.processStellarPayment( + checkout.paymentIntentId!, + input.signedTransactionXDR, + auditContext, + ); + this.transitionCheckout(checkout, 'completed', { + confirmedAt: new Date(), + transactionHash: tx.stellarTransactionHash, + paymentId: tx.id, + }); + this.dispatch({ + type: 'PURCHASE_CONFIRMED', + checkoutId, + paymentId: tx.id, + userId: checkout.userId, + enrollmentId: checkout.enrollmentId, + courseId: checkout.courseId, + amount: checkout.amount, + currency: checkout.currency, + method: PaymentMethod.STELLAR, + status: PaymentStatus.COMPLETED, + transactionHash: tx.stellarTransactionHash, + gatewayTransactionId: tx.gatewayTransactionId, + }); + return { checkout, transaction: tx }; + } + + // Stripe rail + const stripeIntentId = input.paymentIntentId || checkout.gatewayPaymentIntentId; + if (!stripeIntentId) { + throw new Error('paymentIntentId is required to confirm a Stripe checkout'); + } + if (checkout.gatewayPaymentIntentId && input.paymentIntentId && input.paymentIntentId !== checkout.gatewayPaymentIntentId) { + throw new Error('paymentIntentId does not match the checkout'); + } + + const result: StripeIntentResult = input.paymentMethodId + ? await this.stripePaymentService.confirmPaymentIntent(stripeIntentId, input.paymentMethodId) + : await this.stripePaymentService.retrievePaymentIntent(stripeIntentId); + + if (result.status === 'succeeded') { + const tx = await this.paymentService.processStripePayment(checkout.paymentIntentId!, result, auditContext); + this.transitionCheckout(checkout, 'completed', { + confirmedAt: new Date(), + gatewayPaymentIntentId: result.paymentIntentId, + paymentId: tx.id, + }); + this.dispatch({ + type: 'PURCHASE_CONFIRMED', + checkoutId, + paymentId: tx.id, + userId: checkout.userId, + enrollmentId: checkout.enrollmentId, + courseId: checkout.courseId, + amount: checkout.amount, + currency: checkout.currency, + method: PaymentMethod.STRIPE, + status: PaymentStatus.COMPLETED, + gatewayTransactionId: result.paymentIntentId, + }); + return { checkout, transaction: tx }; + } + + if (result.status === 'requires_payment_method' || result.status === 'requires_action' || result.status === 'processing') { + // Still actionable by the client; the checkout stays pending. + return { checkout }; + } + + this.transitionCheckout(checkout, 'failed', { + failedAt: new Date(), + failureReason: `Stripe intent ${result.status}`, + }); + this.dispatch({ + type: 'PURCHASE_FAILED', + checkoutId, + paymentId: checkout.paymentId, + userId: checkout.userId, + enrollmentId: checkout.enrollmentId, + courseId: checkout.courseId, + amount: checkout.amount, + currency: checkout.currency, + method: PaymentMethod.STRIPE, + status: PaymentStatus.FAILED, + error: `Stripe intent ${result.status}`, + gatewayTransactionId: stripeIntentId, + }); + return { checkout }; + } + + /** Refund a completed payment on either rail and emit the purchase event. */ + async processRefund( + paymentId: string, + amount: number, + reason: string, + auditContext?: { actor: string; ipAddress?: string }, + ): Promise { + const refundTransaction = await this.paymentService.processRefund(paymentId, amount, reason, auditContext); + + const payment = await this.paymentService.getPaymentById(paymentId) + || this.paymentService.findPaymentByGatewayTransactionId(paymentId); + const checkout = payment ? this.findCheckoutByPaymentId(payment.id) : undefined; + if (checkout && checkout.status === 'completed') { + this.transitionCheckout(checkout, 'refunded', {}); + } + + this.dispatch({ + type: 'PURCHASE_REFUNDED', + checkoutId: checkout?.id, + paymentId: payment?.id ?? paymentId, + userId: payment?.userId ?? '', + enrollmentId: payment?.enrollmentId ?? '', + courseId: payment?.courseId, + amount: refundTransaction.amount < 0 ? -refundTransaction.amount : refundTransaction.amount, + currency: refundTransaction.currency, + method: payment?.method ?? PaymentMethod.STRIPE, + status: PaymentStatus.REFUNDED, + refundAmount: amount, + gatewayTransactionId: refundTransaction.gatewayTransactionId, + }); + + return refundTransaction; + } + + // ── Webhooks / reconciliation ────────────────────────────────────────────── + + /** + * Apply a normalized Stripe webhook outcome. Idempotent: state machine + * transitions reject stale or duplicate events. + */ + async handleStripeWebhookOutcome(outcome: StripeWebhookOutcome): Promise { + if (!outcome.paymentIntentId) { + logger.warn('Stripe webhook ignored: no payment intent id', { eventType: outcome.eventType }); + return; + } + + const checkout = this.getCheckoutByGatewayPaymentIntentId(outcome.paymentIntentId); + const payment = this.paymentService.findPaymentByGatewayTransactionId(outcome.paymentIntentId); + + switch (outcome.status) { + case 'succeeded': { + if (payment && payment.status === PaymentStatus.PENDING) { + const result = await this.stripePaymentService + .retrievePaymentIntent(outcome.paymentIntentId) + .catch(() => null); + if (result) { + const tx = checkout + ? await this.paymentService.processStripePayment(checkout.paymentIntentId!, result) + : await this.paymentService.completeStripePaymentByPaymentId(payment.id, result); + + if (checkout && checkout.status === 'pending') { + this.transitionCheckout(checkout, 'completed', { + confirmedAt: new Date(), + gatewayPaymentIntentId: result.paymentIntentId, + paymentId: tx.id, + }); + } + this.dispatch({ + type: 'PURCHASE_CONFIRMED', + checkoutId: checkout?.id, + paymentId: tx.id, + userId: payment.userId, + enrollmentId: payment.enrollmentId, + courseId: payment.courseId, + amount: tx.amount, + currency: tx.currency, + method: PaymentMethod.STRIPE, + status: PaymentStatus.COMPLETED, + gatewayTransactionId: outcome.paymentIntentId, + }); + } + } + break; + } + + case 'failed': { + if (payment && (payment.status === PaymentStatus.PENDING || payment.status === PaymentStatus.PROCESSING)) { + this.paymentService.transitionPaymentStatus(payment.id, PaymentStatus.FAILED, { + failedAt: new Date(), + failureReason: outcome.error, + }); + if (checkout && checkout.status === 'pending') { + this.transitionCheckout(checkout, 'failed', { failedAt: new Date(), failureReason: outcome.error }); + } + this.dispatch({ + type: 'PURCHASE_FAILED', + checkoutId: checkout?.id, + paymentId: payment.id, + userId: payment.userId, + enrollmentId: payment.enrollmentId, + courseId: payment.courseId, + amount: payment.amount, + currency: payment.currency, + method: PaymentMethod.STRIPE, + status: PaymentStatus.FAILED, + error: outcome.error, + gatewayTransactionId: outcome.paymentIntentId, + }); + } + break; + } + + case 'refunded': + case 'partially_refunded': { + if (payment && payment.status === PaymentStatus.COMPLETED) { + const fullyRefunded = outcome.status === 'refunded'; + this.paymentService.transitionPaymentStatus( + payment.id, + fullyRefunded ? PaymentStatus.REFUNDED : PaymentStatus.PARTIALLY_REFUNDED, + { refundedAt: new Date(), refundAmount: fullyRefunded ? payment.amount : undefined }, + ); + if (checkout && checkout.status === 'completed' && fullyRefunded) { + this.transitionCheckout(checkout, 'refunded', {}); + } + this.dispatch({ + type: 'PURCHASE_REFUNDED', + checkoutId: checkout?.id, + paymentId: payment.id, + userId: payment.userId, + enrollmentId: payment.enrollmentId, + courseId: payment.courseId, + amount: payment.amount, + currency: payment.currency, + method: PaymentMethod.STRIPE, + status: fullyRefunded ? PaymentStatus.REFUNDED : PaymentStatus.PARTIALLY_REFUNDED, + refundAmount: payment.amount, + gatewayTransactionId: outcome.paymentIntentId, + }); + } + break; + } + + default: + logger.debug('Stripe webhook outcome not handled', { eventType: outcome.eventType, status: outcome.status }); + break; + } + } + + /** + * Handle an incoming Stellar relay webhook by sweeping pending crypto + * payments against the chain. Idempotent and cheap when nothing matches. + */ + async handleStellarWebhook(_transaction: any, _type: string): Promise { + return this.reconcilePendingPayments(); + } + + /** Reconcile pending crypto payments against on-chain transactions. */ + async reconcilePendingPayments(): Promise { + return this.reconciliation.reconcilePendingPayments(); + } + + // ── Queries ──────────────────────────────────────────────────────────────── + + getCheckout(checkoutId: string): Checkout | null { + return this.checkouts.get(checkoutId) ?? null; + } + + getCheckoutsForUser(userId: string): Checkout[] { + return Array.from(this.checkouts.values()) + .filter((c) => c.userId === userId) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } + + // ── Internals ────────────────────────────────────────────────────────────── + + private toCryptoRecords(payments: any[]): CryptoPaymentRecord[] { + return payments.map((p) => ({ + paymentId: p.id, + enrollmentId: p.enrollmentId, + userId: p.userId, + courseId: p.courseId, + amount: p.amount, + currency: p.currency, + method: p.method as PaymentMethod, + status: p.status as PaymentStatus, + transactionHash: p.stellarTransactionHash, + metadata: p.metadata, + })); + } + + private async finalizeReconciledPayment(payment: CryptoPaymentRecord, onChain: StellarPayment): Promise { + const tx = await this.paymentService.completeCryptoPayment(payment.paymentId, onChain); + const checkout = this.findCheckoutByPaymentId(payment.paymentId); + if (checkout && checkout.status === 'pending') { + this.transitionCheckout(checkout, 'completed', { + confirmedAt: new Date(), + transactionHash: onChain.transactionHash, + paymentId: tx.id, + }); + } + this.dispatch({ + type: 'PURCHASE_CONFIRMED', + checkoutId: checkout?.id, + paymentId: payment.paymentId, + userId: payment.userId, + enrollmentId: payment.enrollmentId, + courseId: payment.courseId, + amount: payment.amount, + currency: payment.currency, + method: PaymentMethod.STELLAR, + status: PaymentStatus.COMPLETED, + transactionHash: onChain.transactionHash, + }); + } + + private getCheckoutByGatewayPaymentIntentId(gatewayPaymentIntentId: string): Checkout | undefined { + return Array.from(this.checkouts.values()).find( + (c) => c.gatewayPaymentIntentId === gatewayPaymentIntentId, + ); + } + + private findCheckoutByPaymentId(paymentId: string): Checkout | undefined { + return Array.from(this.checkouts.values()).find((c) => c.paymentId === paymentId); + } + + private transitionCheckout(checkout: Checkout, to: CheckoutStatus, fields: Partial): void { + const allowed = CHECKOUT_STATUS_TRANSITIONS[checkout.status] ?? []; + if (checkout.status !== to && !allowed.includes(to)) { + throw new Error(`Invalid checkout state transition: ${checkout.status} → ${to}`); + } + checkout.status = to; + Object.assign(checkout, fields); + this.checkouts.set(checkout.id, checkout); + } + + private dispatch(event: Omit): void { + const full: PurchaseEvent = { ...event, timestamp: Date.now(), origin: PURCHASE_NODE_ID }; + purchaseEventBus.dispatch(full); + // Fire-and-forget cross-node publish. Failures degrade to single-node + // behavior rather than breaking the caller. + void redisConfig.publish(PURCHASE_CHANNEL, full).catch(() => undefined); + } + +} diff --git a/backend/src/services/payments/PaymentReconciliationService.ts b/backend/src/services/payments/PaymentReconciliationService.ts new file mode 100644 index 00000000..cfa2fe73 --- /dev/null +++ b/backend/src/services/payments/PaymentReconciliationService.ts @@ -0,0 +1,183 @@ +/** + * Crypto payment reconciliation — Issue #391. + * + * Stellar payments are submitted by the learner's wallet, so the platform + * cannot mark a purchase confirmed at intent-creation time. Instead this + * service watches the distribution account on-chain and matches incoming + * payments back to local pending records using the memo stamped on every + * checkout (the payment reference). A match is only accepted once the + * on-chain transaction verifies (destination, amount, asset, success flag), + * after which the orchestration layer finalizes the purchase and emits the + * purchase events. + */ + +import logger from '../../utils/logger'; +import { PaymentMethod, PaymentStatus } from '../../models/Enrollment'; +import type { StellarPaymentService } from '../StellarPaymentService'; +import type { StellarPayment } from '../../models/Enrollment'; + +/** Minimal view of a local payment record needed for reconciliation. */ +export interface CryptoPaymentRecord { + paymentId: string; + enrollmentId: string; + userId: string; + courseId?: string; + amount: number; + currency: string; + method: PaymentMethod; + status: PaymentStatus; + transactionHash?: string; + metadata?: Record; +} + +export interface ReconciliationOutcome { + paymentId: string; + reconciled: boolean; + matched: boolean; + errors: string[]; +} + +export interface ReconciliationSummary { + scanned: number; + reconciled: number; + failed: number; + outcomes: ReconciliationOutcome[]; +} + +export interface ReconciliationDependencies { + /** On-chain lookups (StellarPaymentService). */ + stellar: Pick; + /** Distribution account whose incoming payments represent purchases. */ + distributionAddress: string; + /** Pending crypto payments to sweep. */ + fetchPending: () => CryptoPaymentRecord[]; + /** Called when an on-chain payment matches a local record. */ + onReconciled: (payment: CryptoPaymentRecord, onChain: StellarPayment) => Promise | void; +} + +export class PaymentReconciliationService { + private readonly deps: ReconciliationDependencies; + + constructor(deps: ReconciliationDependencies) { + this.deps = deps; + } + + /** + * Sweep all pending crypto payments and reconcile any that have settled + * on-chain. Returns a summary suitable for surfacing in an admin endpoint + * or scheduled job. + */ + async reconcilePendingPayments(): Promise { + // Without a distribution account there is nothing to watch on-chain; fail + // open so the server can boot without secrets configured (dev / CI). + if (!this.deps.distributionAddress) { + logger.warn('Crypto reconciliation skipped: distribution account not configured'); + return { scanned: 0, reconciled: 0, failed: 0, outcomes: [] }; + } + + const pending = this.deps + .fetchPending() + .filter((p) => p.method === PaymentMethod.STELLAR && p.status === PaymentStatus.PENDING); + + const outcomes: ReconciliationOutcome[] = []; + for (const payment of pending) { + outcomes.push(await this.reconcilePayment(payment)); + } + + return { + scanned: pending.length, + reconciled: outcomes.filter((o) => o.reconciled).length, + failed: outcomes.filter((o) => !o.reconciled && o.matched).length, + outcomes, + }; + } + + /** + * Reconcile a single pending crypto payment against the Stellar network. + * + * Matching strategy: + * 1. If the record already carries a transaction hash, verify it directly. + * 2. Otherwise page through payments to the distribution account and look + * for one whose memo equals the local payment reference. + */ + async reconcilePayment(payment: CryptoPaymentRecord): Promise { + const outcome: ReconciliationOutcome = { + paymentId: payment.paymentId, + reconciled: false, + matched: false, + errors: [], + }; + + try { + if (payment.transactionHash) { + const candidate: StellarPayment = { + from: '', + to: this.deps.distributionAddress, + amount: payment.amount.toString(), + assetCode: payment.currency, + transactionHash: payment.transactionHash, + network: 'testnet', + }; + const verification = await this.deps.stellar.verifyPayment(candidate); + if (verification.isValid) { + outcome.matched = true; + await this.deps.onReconciled(payment, candidate); + outcome.reconciled = true; + return outcome; + } + outcome.errors.push(...verification.errors); + return outcome; + } + + const reference = payment.metadata?.paymentReference as string | undefined; + if (!reference) { + outcome.errors.push('No payment reference or transaction hash available to reconcile'); + return outcome; + } + + let cursor: string | undefined; + // Bound the sweep to a few pages so a stuck record cannot scan forever. + for (let page = 0; page < 5; page += 1) { + const { payments, cursor: nextCursor } = await this.deps.stellar.getPaymentHistory( + this.deps.distributionAddress, + 50, + cursor, + ); + cursor = nextCursor; + + for (const onChain of payments) { + if ((onChain.memo ?? '').trim() !== reference) { + continue; + } + + const verification = await this.deps.stellar.verifyPayment(onChain); + if (!verification.isValid) { + outcome.errors.push(`On-chain match failed verification: ${verification.errors.join(', ')}`); + continue; + } + + outcome.matched = true; + await this.deps.onReconciled(payment, onChain); + outcome.reconciled = true; + return outcome; + } + + if (!cursor) { + break; + } + } + + if (!outcome.matched) { + outcome.errors.push('No on-chain payment matched the payment reference'); + } + return outcome; + } catch (error) { + logger.error('Error reconciling crypto payment', { + paymentId: payment.paymentId, + error: error instanceof Error ? error.message : String(error), + }); + outcome.errors.push(error instanceof Error ? error.message : 'Reconciliation failed'); + return outcome; + } + } +} diff --git a/backend/src/services/payments/StripePaymentService.ts b/backend/src/services/payments/StripePaymentService.ts new file mode 100644 index 00000000..2e0b35ce --- /dev/null +++ b/backend/src/services/payments/StripePaymentService.ts @@ -0,0 +1,222 @@ +/** + * Stripe Payment Service — fiat rail of the unified checkout (Issue #391). + * + * Wraps the official Stripe SDK behind a small, typed surface used by the + * checkout orchestration. The Stripe client is created lazily so the server + * can boot without STRIPE_SECRET_KEY configured (development / CI); any + * operation that actually needs the Stripe API fails with a clear + * configuration error instead of crashing startup. + */ + +import Stripe from 'stripe'; +import logger from '../../utils/logger'; + +export interface StripeIntentResult { + paymentIntentId: string; + clientSecret: string | null; + status: string; + amount: number; // major units (e.g. 49.99 USD) + currency: string; // lowercase ISO 4217 +} + +export interface StripeRefundResult { + refundId: string; + status: string; + amount: number; // major units + currency: string; +} + +/** Normalized webhook outcome consumed by the checkout orchestration. */ +export interface StripeWebhookOutcome { + eventType: string; + paymentIntentId?: string; + status: 'succeeded' | 'failed' | 'refunded' | 'partially_refunded' | 'unknown'; + error?: string; +} + +export class StripePaymentService { + private client: Stripe | null = null; + private readonly webhookSecret: string; + private readonly publishableKey: string | undefined; + + constructor() { + this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || ''; + this.publishableKey = process.env.STRIPE_PUBLISHABLE_KEY || undefined; + } + + /** True when a secret key is available for live Stripe API calls. */ + isConfigured(): boolean { + return Boolean(process.env.STRIPE_SECRET_KEY); + } + + getPublishableKey(): string | undefined { + return this.publishableKey; + } + + private getClient(): Stripe { + if (this.client) { + return this.client; + } + const secretKey = process.env.STRIPE_SECRET_KEY; + if (!secretKey) { + throw new Error('Stripe is not configured: STRIPE_SECRET_KEY is missing'); + } + this.client = new Stripe(secretKey); + return this.client; + } + + /** + * Create a PaymentIntent for a course purchase. `amount` is in major units + * (e.g. 49.99); Stripe works in minor units so it is converted internally. + */ + async createPaymentIntent(params: { + amount: number; + currency: string; + description?: string; + receiptEmail?: string; + metadata?: Record; + }): Promise { + const client = this.getClient(); + const intent = await client.paymentIntents.create({ + amount: this.toMinorUnits(params.amount), + currency: params.currency.toLowerCase(), + payment_method_types: ['card'], + description: params.description, + receipt_email: params.receiptEmail, + metadata: params.metadata, + }); + + return { + paymentIntentId: intent.id, + clientSecret: intent.client_secret, + status: intent.status, + amount: this.toMajorUnits(intent.amount, intent.currency), + currency: intent.currency, + }; + } + + /** Retrieve the current state of a PaymentIntent. */ + async retrievePaymentIntent(paymentIntentId: string): Promise { + const client = this.getClient(); + const intent = await client.paymentIntents.retrieve(paymentIntentId); + return { + paymentIntentId: intent.id, + clientSecret: intent.client_secret, + status: intent.status, + amount: this.toMajorUnits(intent.amount, intent.currency), + currency: intent.currency, + }; + } + + /** Confirm a PaymentIntent with a client-provided payment method. */ + async confirmPaymentIntent( + paymentIntentId: string, + paymentMethodId: string, + ): Promise { + const client = this.getClient(); + const intent = await client.paymentIntents.confirm(paymentIntentId, { + payment_method: paymentMethodId, + }); + return { + paymentIntentId: intent.id, + clientSecret: intent.client_secret, + status: intent.status, + amount: this.toMajorUnits(intent.amount, intent.currency), + currency: intent.currency, + }; + } + + /** + * Refund a completed PaymentIntent. When `amount` is omitted the full + * payment is refunded; otherwise a partial refund is issued. + */ + async refund( + paymentIntentId: string, + amount?: number, + ): Promise { + const client = this.getClient(); + const refund = await client.refunds.create({ + payment_intent: paymentIntentId, + ...(amount !== undefined ? { amount: this.toMinorUnits(amount) } : {}), + }); + + return { + refundId: refund.id, + status: refund.status ?? 'unknown', + amount: this.toMajorUnits(refund.amount, refund.currency), + currency: refund.currency ?? '', + }; + } + + /** + * Verify a Stripe webhook signature and return the typed event. + * + * When STRIPE_WEBHOOK_SECRET is not configured the signature cannot be + * verified. In non-production environments the payload is still parsed so + * local development can exercise the flow; in production an unverified + * webhook is rejected outright. + */ + constructEvent(rawBody: string | Buffer, signature: string): Stripe.Event { + if (!this.webhookSecret) { + const isProd = process.env.NODE_ENV === 'production'; + logger.warn('Stripe webhook received without STRIPE_WEBHOOK_SECRET configured'); + if (isProd) { + throw new Error('Stripe webhook secret is not configured'); + } + // Parse without signature verification (development only). + return JSON.parse(rawBody.toString()) as Stripe.Event; + } + + const client = this.getClient(); + return client.webhooks.constructEvent(rawBody, signature, this.webhookSecret); + } + + /** Normalize a Stripe event into the outcome shape the orchestration needs. */ + normalizeWebhookEvent(event: Stripe.Event): StripeWebhookOutcome { + const outcome: StripeWebhookOutcome = { + eventType: event.type, + status: 'unknown', + }; + + switch (event.type) { + case 'payment_intent.succeeded': { + const intent = event.data.object as Stripe.PaymentIntent; + outcome.paymentIntentId = intent.id; + outcome.status = 'succeeded'; + break; + } + case 'payment_intent.payment_failed': + case 'payment_intent.canceled': { + const intent = event.data.object as Stripe.PaymentIntent; + outcome.paymentIntentId = intent.id; + outcome.status = 'failed'; + const failureMessage = intent.last_payment_error?.message; + outcome.error = failureMessage || 'Payment intent failed'; + break; + } + case 'charge.refunded': { + const charge = event.data.object as Stripe.Charge; + outcome.paymentIntentId = charge.payment_intent as string; + outcome.status = charge.amount_refunded >= charge.amount ? 'refunded' : 'partially_refunded'; + break; + } + default: + break; + } + + return outcome; + } + + /** Stripe amounts are minor units (cents); the platform works in major units. */ + private toMinorUnits(amount: number): number { + return Math.round(amount * 100); + } + + private toMajorUnits(amount: number | null | undefined, currency: string): number { + // Zero-decimal currencies (e.g. JPY) are not supported by the checkout + // settings; every accepted currency uses 2 decimal places. Stripe types + // refund amounts as nullable, so treat an absent amount as zero. + void currency; + return (amount ?? 0) / 100; + } +} diff --git a/backend/src/services/payments/paymentStateMachine.ts b/backend/src/services/payments/paymentStateMachine.ts new file mode 100644 index 00000000..c363cfc7 --- /dev/null +++ b/backend/src/services/payments/paymentStateMachine.ts @@ -0,0 +1,57 @@ +/** + * Payment state machine — Issue #391. + * + * The unified checkout drives every payment through the same lifecycle: + * + * pending ──► processing ──► completed (confirmed on the payment rail) + * │ │ + * └──────┬──────┘ + * ▼ + * failed (terminal — a new checkout/payment must be created) + * + * completed ──► refunded + * completed ──► partially_refunded ──► refunded + * + * `completed` is the canonical "confirmed" state; the two names are used + * interchangeably across the codebase. + */ + +import { PaymentStatus } from '../../models/Enrollment'; + +/** Valid transitions keyed by the current status. */ +export const PAYMENT_STATUS_TRANSITIONS: Record = { + [PaymentStatus.PENDING]: [PaymentStatus.PROCESSING, PaymentStatus.FAILED], + [PaymentStatus.PROCESSING]: [PaymentStatus.COMPLETED, PaymentStatus.FAILED], + [PaymentStatus.COMPLETED]: [PaymentStatus.REFUNDED, PaymentStatus.PARTIALLY_REFUNDED], + [PaymentStatus.FAILED]: [], + [PaymentStatus.REFUNDED]: [], + [PaymentStatus.PARTIALLY_REFUNDED]: [PaymentStatus.REFUNDED], +}; + +/** + * Throws unless the `from → to` transition is permitted by the state machine. + * Used by the checkout orchestration before mutating any payment record so + * that a stale webhook or a duplicate confirm can never move a payment into + * an invalid state. + */ +export function assertValidPaymentTransition( + from: PaymentStatus, + to: PaymentStatus, +): void { + if (from === to) { + return; + } + + const allowed = PAYMENT_STATUS_TRANSITIONS[from] ?? []; + if (!allowed.includes(to)) { + throw new Error(`Invalid payment state transition: ${from} → ${to}`); + } +} + +/** True when the target status is reachable from the current one. */ +export function canTransition(from: PaymentStatus, to: PaymentStatus): boolean { + if (from === to) { + return true; + } + return (PAYMENT_STATUS_TRANSITIONS[from] ?? []).includes(to); +} diff --git a/frontend/src/lib/tour/TourContext.tsx b/frontend/src/lib/tour/TourContext.tsx index cc50d2ac..f3239635 100644 --- a/frontend/src/lib/tour/TourContext.tsx +++ b/frontend/src/lib/tour/TourContext.tsx @@ -27,7 +27,11 @@ type TourContextType = { const TourContext = createContext(undefined); export function TourProvider({ children }: { children: ReactNode }) { - const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true); // default true to prevent flash + // The provider is always mounted — even during SSR/static generation — so + // that components calling useTour() (TourGuide, OnboardingModal, profile + // pages) render safely. Onboarding state defaults to "completed" to avoid + // flashing the modal before the localStorage read resolves on the client. + const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true); const [dismissedTours, setDismissedTours] = useState([]); const [activeTour, setActiveTour] = useState(null); const [tourSteps, setTourSteps] = useState([]); @@ -92,8 +96,6 @@ export function TourProvider({ children }: { children: ReactNode }) { } }; - // Always render the provider so children that call useTour() during SSR - // (prerendering) receive a valid context instead of throwing. return ( =18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",