DartFlutter

Integrating Payment Gateways (Stripe, PayPal) in Flutter

Integrating Payment Gateways Stripe PayPal In Flutter

Introduction

Payments are a critical part of many mobile applications, from subscriptions to one-time purchases and marketplaces. When building Flutter apps, developers often integrate Stripe and PayPal because they are reliable, widely supported, and flexible across regions. However, payment integration requires careful architecture to ensure security, compliance, and a smooth user experience. In this comprehensive guide, you will learn how payment gateways work in Flutter, implement both Stripe and PayPal with production-ready code, handle subscriptions and webhooks, and follow security best practices that protect your users and your business.

Understanding Payment Architecture

Before writing code, you must understand how payment flows work in mobile applications. Flutter apps should never process or store raw card details directly. Instead, payments are handled through secure SDKs that tokenize sensitive data and backend services that create and verify transactions.

Payment Flow Diagram

┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Flutter   │     │   Backend   │     │   Payment   │     │    Bank     │
│     App     │     │   Server    │     │   Gateway   │     │   Network   │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │                   │
       │ 1. Request        │                   │                   │
       │    payment intent │                   │                   │
       │──────────────────>│                   │                   │
       │                   │                   │                   │
       │                   │ 2. Create payment │                   │
       │                   │    intent         │                   │
       │                   │──────────────────>│                   │
       │                   │                   │                   │
       │                   │ 3. Return client  │                   │
       │                   │    secret         │                   │
       │                   │<──────────────────│                   │
       │                   │                   │                   │
       │ 4. Client secret  │                   │                   │
       │<──────────────────│                   │                   │
       │                   │                   │                   │
       │ 5. Show payment   │                   │                   │
       │    sheet (SDK)    │                   │                   │
       │───────────────────────────────────────>│                   │
       │                   │                   │                   │
       │                   │                   │ 6. Process        │
       │                   │                   │    payment        │
       │                   │                   │──────────────────>│
       │                   │                   │                   │
       │                   │                   │ 7. Payment result │
       │                   │                   │<──────────────────│
       │                   │                   │                   │
       │ 8. Success/       │                   │                   │
       │    failure        │                   │                   │
       │<──────────────────────────────────────│                   │
       │                   │                   │                   │
       │                   │ 9. Webhook event  │                   │
       │                   │<──────────────────│                   │
       │                   │                   │                   │
       │                   │ 10. Update order  │                   │
       │                   │     status        │                   │

Integrating Stripe in Flutter

Stripe provides an official Flutter package that handles card input, validation, and PCI compliance automatically. The integration requires both client-side (Flutter) and server-side (backend) components.

Project Setup

# pubspec.yaml
dependencies:
  flutter_stripe: ^10.1.1
  dio: ^5.4.0  # For API calls
  
# iOS: Add to ios/Podfile
platform :ios, '13.0'

# Android: Update android/app/build.gradle
minSdkVersion 21

Initialize Stripe

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_stripe/flutter_stripe.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Load environment variables
  await dotenv.load(fileName: '.env');
  
  // Initialize Stripe with publishable key
  Stripe.publishableKey = dotenv.env['STRIPE_PUBLISHABLE_KEY']!;
  
  // Optional: Set merchant identifier for Apple Pay
  Stripe.merchantIdentifier = 'merchant.com.yourapp';
  
  // Optional: Enable URL scheme for 3D Secure
  await Stripe.instance.applySettings();
  
  runApp(const MyApp());
}

Payment Service Implementation

// lib/services/stripe_payment_service.dart
import 'package:dio/dio.dart';
import 'package:flutter_stripe/flutter_stripe.dart';

class StripePaymentService {
  final Dio _dio;
  final String _baseUrl;

  StripePaymentService({
    required String baseUrl,
    Dio? dio,
  })  : _baseUrl = baseUrl,
        _dio = dio ?? Dio();

  /// Create a payment intent on the server
  Future createPaymentIntent({
    required int amount, // Amount in cents
    required String currency,
    String? customerId,
    Map? metadata,
  }) async {
    try {
      final response = await _dio.post(
        '$_baseUrl/payments/create-intent',
        data: {
          'amount': amount,
          'currency': currency,
          'customer_id': customerId,
          'metadata': metadata,
        },
      );

      return PaymentIntentResult(
        clientSecret: response.data['client_secret'],
        paymentIntentId: response.data['payment_intent_id'],
        ephemeralKey: response.data['ephemeral_key'],
        customerId: response.data['customer_id'],
      );
    } on DioException catch (e) {
      throw PaymentException(
        message: e.response?.data['error'] ?? 'Failed to create payment intent',
        code: 'CREATE_INTENT_FAILED',
      );
    }
  }

  /// Initialize and present the payment sheet
  Future processPayment({
    required String clientSecret,
    required String merchantName,
    String? customerId,
    String? ephemeralKey,
    bool allowsDelayedPaymentMethods = false,
  }) async {
    try {
      // Initialize the payment sheet
      await Stripe.instance.initPaymentSheet(
        paymentSheetParameters: SetupPaymentSheetParameters(
          paymentIntentClientSecret: clientSecret,
          merchantDisplayName: merchantName,
          customerId: customerId,
          customerEphemeralKeySecret: ephemeralKey,
          allowsDelayedPaymentMethods: allowsDelayedPaymentMethods,
          style: ThemeMode.system,
          appearance: const PaymentSheetAppearance(
            colors: PaymentSheetAppearanceColors(
              primary: Color(0xFF6772E5),
            ),
            shapes: PaymentSheetShape(
              borderRadius: 12,
              shadow: PaymentSheetShadowParams(color: Colors.black12),
            ),
          ),
          billingDetails: const BillingDetails(
            address: Address(
              city: null,
              country: null,
              line1: null,
              line2: null,
              postalCode: null,
              state: null,
            ),
          ),
          billingDetailsCollectionConfiguration:
              const BillingDetailsCollectionConfiguration(
            name: CollectionMode.automatic,
            email: CollectionMode.automatic,
            address: AddressCollectionMode.automatic,
          ),
        ),
      );

      // Present the payment sheet
      await Stripe.instance.presentPaymentSheet();

      return PaymentResult(
        success: true,
        message: 'Payment completed successfully',
      );
    } on StripeException catch (e) {
      if (e.error.code == FailureCode.Canceled) {
        return PaymentResult(
          success: false,
          message: 'Payment cancelled',
          cancelled: true,
        );
      }
      throw PaymentException(
        message: e.error.localizedMessage ?? 'Payment failed',
        code: e.error.code.toString(),
      );
    }
  }

  /// Process a card payment directly (for custom UI)
  Future processCardPayment({
    required String clientSecret,
    required CardDetails cardDetails,
    BillingDetails? billingDetails,
  }) async {
    try {
      // Create card payment method
      await Stripe.instance.dangerouslyUpdateCardDetails(cardDetails);

      // Confirm the payment
      final paymentIntent = await Stripe.instance.confirmPayment(
        paymentIntentClientSecret: clientSecret,
        data: PaymentMethodParams.card(
          paymentMethodData: PaymentMethodData(
            billingDetails: billingDetails,
          ),
        ),
      );

      if (paymentIntent.status == PaymentIntentsStatus.Succeeded) {
        return PaymentResult(
          success: true,
          message: 'Payment successful',
          paymentIntentId: paymentIntent.id,
        );
      } else if (paymentIntent.status == PaymentIntentsStatus.RequiresAction) {
        // 3D Secure authentication required
        final result = await Stripe.instance.handleNextAction(clientSecret);
        return PaymentResult(
          success: result.status == PaymentIntentsStatus.Succeeded,
          message: result.status == PaymentIntentsStatus.Succeeded
              ? 'Payment successful'
              : 'Authentication required',
          requiresAction: result.status != PaymentIntentsStatus.Succeeded,
        );
      }

      return PaymentResult(
        success: false,
        message: 'Payment requires further action',
      );
    } on StripeException catch (e) {
      throw PaymentException(
        message: e.error.localizedMessage ?? 'Payment failed',
        code: e.error.code.toString(),
      );
    }
  }

  /// Setup for future payments (save card)
  Future setupFuturePayment({
    required String customerId,
  }) async {
    try {
      final response = await _dio.post(
        '$_baseUrl/payments/create-setup-intent',
        data: {'customer_id': customerId},
      );

      final clientSecret = response.data['client_secret'];

      await Stripe.instance.initPaymentSheet(
        paymentSheetParameters: SetupPaymentSheetParameters(
          setupIntentClientSecret: clientSecret,
          merchantDisplayName: 'Your App',
          customerId: customerId,
        ),
      );

      await Stripe.instance.presentPaymentSheet();

      return SetupIntentResult(
        success: true,
        setupIntentId: response.data['setup_intent_id'],
      );
    } on StripeException catch (e) {
      throw PaymentException(
        message: e.error.localizedMessage ?? 'Setup failed',
        code: e.error.code.toString(),
      );
    }
  }
}

// Data classes
class PaymentIntentResult {
  final String clientSecret;
  final String paymentIntentId;
  final String? ephemeralKey;
  final String? customerId;

  PaymentIntentResult({
    required this.clientSecret,
    required this.paymentIntentId,
    this.ephemeralKey,
    this.customerId,
  });
}

class PaymentResult {
  final bool success;
  final String message;
  final bool cancelled;
  final bool requiresAction;
  final String? paymentIntentId;

  PaymentResult({
    required this.success,
    required this.message,
    this.cancelled = false,
    this.requiresAction = false,
    this.paymentIntentId,
  });
}

class SetupIntentResult {
  final bool success;
  final String setupIntentId;

  SetupIntentResult({
    required this.success,
    required this.setupIntentId,
  });
}

class PaymentException implements Exception {
  final String message;
  final String code;

  PaymentException({required this.message, required this.code});

  @override
  String toString() => 'PaymentException: $message (code: $code)';
}

Backend Implementation (Node.js)

// server/routes/payments.js
const express = require('express');
const Stripe = require('stripe');

const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Create payment intent
router.post('/create-intent', async (req, res) => {
  const { amount, currency, customer_id, metadata } = req.body;

  try {
    // Validate amount
    if (!amount || amount < 50) {
      return res.status(400).json({ error: 'Invalid amount' });
    }

    // Create or retrieve customer
    let customer = customer_id;
    if (!customer) {
      const newCustomer = await stripe.customers.create({
        metadata: { user_id: req.user?.id },
      });
      customer = newCustomer.id;
    }

    // Create ephemeral key for mobile SDK
    const ephemeralKey = await stripe.ephemeralKeys.create(
      { customer },
      { apiVersion: '2023-10-16' }
    );

    // Create payment intent
    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency: currency || 'usd',
      customer,
      automatic_payment_methods: { enabled: true },
      metadata: {
        ...metadata,
        user_id: req.user?.id,
      },
    });

    res.json({
      client_secret: paymentIntent.client_secret,
      payment_intent_id: paymentIntent.id,
      ephemeral_key: ephemeralKey.secret,
      customer_id: customer,
    });
  } catch (error) {
    console.error('Create intent error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Create setup intent (save card for later)
router.post('/create-setup-intent', async (req, res) => {
  const { customer_id } = req.body;

  try {
    const setupIntent = await stripe.setupIntents.create({
      customer: customer_id,
      automatic_payment_methods: { enabled: true },
    });

    res.json({
      client_secret: setupIntent.client_secret,
      setup_intent_id: setupIntent.id,
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Webhook handler
router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      await handlePaymentSuccess(paymentIntent);
      break;

    case 'payment_intent.payment_failed':
      const failedIntent = event.data.object;
      await handlePaymentFailure(failedIntent);
      break;

    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      const subscription = event.data.object;
      await handleSubscriptionUpdate(subscription);
      break;

    case 'customer.subscription.deleted':
      const deletedSub = event.data.object;
      await handleSubscriptionCancelled(deletedSub);
      break;

    case 'invoice.payment_succeeded':
      const invoice = event.data.object;
      await handleInvoicePaid(invoice);
      break;

    case 'invoice.payment_failed':
      const failedInvoice = event.data.object;
      await handleInvoiceFailed(failedInvoice);
      break;

    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  res.json({ received: true });
});

async function handlePaymentSuccess(paymentIntent) {
  const { id, amount, currency, customer, metadata } = paymentIntent;
  
  // Update order status in database
  await Order.updateOne(
    { paymentIntentId: id },
    { 
      status: 'paid',
      paidAt: new Date(),
      amount: amount / 100,
      currency,
    }
  );

  // Send confirmation email, update inventory, etc.
  console.log(`Payment ${id} succeeded for customer ${customer}`);
}

module.exports = router;

Integrating PayPal in Flutter

PayPal integration typically uses a WebView-based checkout flow or the PayPal SDK. The WebView approach provides maximum compatibility across platforms.

PayPal Payment Service

// lib/services/paypal_payment_service.dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class PayPalPaymentService {
  final Dio _dio;
  final String _baseUrl;

  PayPalPaymentService({
    required String baseUrl,
    Dio? dio,
  })  : _baseUrl = baseUrl,
        _dio = dio ?? Dio();

  /// Create a PayPal order
  Future createOrder({
    required double amount,
    required String currency,
    required String description,
    Map? metadata,
  }) async {
    try {
      final response = await _dio.post(
        '$_baseUrl/paypal/create-order',
        data: {
          'amount': amount,
          'currency': currency,
          'description': description,
          'metadata': metadata,
        },
      );

      return PayPalOrderResult(
        orderId: response.data['order_id'],
        approvalUrl: response.data['approval_url'],
        captureUrl: response.data['capture_url'],
      );
    } on DioException catch (e) {
      throw PaymentException(
        message: e.response?.data['error'] ?? 'Failed to create PayPal order',
        code: 'PAYPAL_CREATE_FAILED',
      );
    }
  }

  /// Capture payment after user approval
  Future capturePayment(String orderId) async {
    try {
      final response = await _dio.post(
        '$_baseUrl/paypal/capture-order',
        data: {'order_id': orderId},
      );

      return PayPalCaptureResult(
        success: response.data['status'] == 'COMPLETED',
        transactionId: response.data['transaction_id'],
        status: response.data['status'],
      );
    } on DioException catch (e) {
      throw PaymentException(
        message: e.response?.data['error'] ?? 'Failed to capture payment',
        code: 'PAYPAL_CAPTURE_FAILED',
      );
    }
  }
}

class PayPalOrderResult {
  final String orderId;
  final String approvalUrl;
  final String captureUrl;

  PayPalOrderResult({
    required this.orderId,
    required this.approvalUrl,
    required this.captureUrl,
  });
}

class PayPalCaptureResult {
  final bool success;
  final String? transactionId;
  final String status;

  PayPalCaptureResult({
    required this.success,
    this.transactionId,
    required this.status,
  });
}

PayPal Checkout WebView

// lib/widgets/paypal_checkout_view.dart
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class PayPalCheckoutView extends StatefulWidget {
  final String approvalUrl;
  final String returnUrl;
  final String cancelUrl;
  final Function(String orderId) onSuccess;
  final VoidCallback onCancel;
  final Function(String error) onError;

  const PayPalCheckoutView({
    super.key,
    required this.approvalUrl,
    required this.returnUrl,
    required this.cancelUrl,
    required this.onSuccess,
    required this.onCancel,
    required this.onError,
  });

  @override
  State createState() => _PayPalCheckoutViewState();
}

class _PayPalCheckoutViewState extends State {
  late final WebViewController _controller;
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(
          onPageStarted: (_) => setState(() => _isLoading = true),
          onPageFinished: (_) => setState(() => _isLoading = false),
          onNavigationRequest: _handleNavigation,
          onWebResourceError: (error) {
            widget.onError(error.description);
          },
        ),
      )
      ..loadRequest(Uri.parse(widget.approvalUrl));
  }

  NavigationDecision _handleNavigation(NavigationRequest request) {
    final url = request.url;

    // Check for success return URL
    if (url.startsWith(widget.returnUrl)) {
      final uri = Uri.parse(url);
      final token = uri.queryParameters['token'];
      final payerId = uri.queryParameters['PayerID'];

      if (token != null) {
        widget.onSuccess(token);
      } else {
        widget.onError('Missing payment token');
      }
      return NavigationDecision.prevent;
    }

    // Check for cancel URL
    if (url.startsWith(widget.cancelUrl)) {
      widget.onCancel();
      return NavigationDecision.prevent;
    }

    return NavigationDecision.navigate;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('PayPal Checkout'),
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: widget.onCancel,
        ),
      ),
      body: Stack(
        children: [
          WebViewWidget(controller: _controller),
          if (_isLoading)
            const Center(child: CircularProgressIndicator()),
        ],
      ),
    );
  }
}

PayPal Backend (Node.js)

// server/routes/paypal.js
const express = require('express');
const axios = require('axios');

const router = express.Router();

const PAYPAL_API = process.env.PAYPAL_MODE === 'live'
  ? 'https://api-m.paypal.com'
  : 'https://api-m.sandbox.paypal.com';

async function getAccessToken() {
  const auth = Buffer.from(
    `${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`
  ).toString('base64');

  const response = await axios.post(
    `${PAYPAL_API}/v1/oauth2/token`,
    'grant_type=client_credentials',
    {
      headers: {
        Authorization: `Basic ${auth}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    }
  );

  return response.data.access_token;
}

router.post('/create-order', async (req, res) => {
  const { amount, currency, description, metadata } = req.body;

  try {
    const accessToken = await getAccessToken();

    const response = await axios.post(
      `${PAYPAL_API}/v2/checkout/orders`,
      {
        intent: 'CAPTURE',
        purchase_units: [
          {
            amount: {
              currency_code: currency || 'USD',
              value: amount.toFixed(2),
            },
            description,
            custom_id: metadata?.order_id,
          },
        ],
        application_context: {
          brand_name: 'Your App',
          landing_page: 'NO_PREFERENCE',
          user_action: 'PAY_NOW',
          return_url: `${process.env.APP_URL}/paypal/success`,
          cancel_url: `${process.env.APP_URL}/paypal/cancel`,
        },
      },
      {
        headers: {
          Authorization: `Bearer ${accessToken}`,
          'Content-Type': 'application/json',
        },
      }
    );

    const approvalLink = response.data.links.find(link => link.rel === 'approve');

    res.json({
      order_id: response.data.id,
      approval_url: approvalLink?.href,
      status: response.data.status,
    });
  } catch (error) {
    console.error('PayPal create order error:', error.response?.data || error);
    res.status(500).json({ error: 'Failed to create PayPal order' });
  }
});

router.post('/capture-order', async (req, res) => {
  const { order_id } = req.body;

  try {
    const accessToken = await getAccessToken();

    const response = await axios.post(
      `${PAYPAL_API}/v2/checkout/orders/${order_id}/capture`,
      {},
      {
        headers: {
          Authorization: `Bearer ${accessToken}`,
          'Content-Type': 'application/json',
        },
      }
    );

    const capture = response.data.purchase_units[0]?.payments?.captures?.[0];

    res.json({
      status: response.data.status,
      transaction_id: capture?.id,
      amount: capture?.amount,
    });
  } catch (error) {
    console.error('PayPal capture error:', error.response?.data || error);
    res.status(500).json({ error: 'Failed to capture payment' });
  }
});

// PayPal webhook handler
router.post('/webhook', async (req, res) => {
  const event = req.body;

  // Verify webhook signature (important for production)
  // See PayPal docs for verification implementation

  switch (event.event_type) {
    case 'CHECKOUT.ORDER.APPROVED':
      console.log('Order approved:', event.resource.id);
      break;

    case 'PAYMENT.CAPTURE.COMPLETED':
      const captureId = event.resource.id;
      const orderId = event.resource.supplementary_data?.related_ids?.order_id;
      await handlePayPalPaymentSuccess(orderId, captureId);
      break;

    case 'PAYMENT.CAPTURE.DENIED':
      await handlePayPalPaymentFailed(event.resource);
      break;
  }

  res.json({ received: true });
});

module.exports = router;

Unified Payment UI

// lib/screens/checkout_screen.dart
import 'package:flutter/material.dart';
import '../services/stripe_payment_service.dart';
import '../services/paypal_payment_service.dart';
import '../widgets/paypal_checkout_view.dart';

enum PaymentMethod { stripe, paypal }

class CheckoutScreen extends StatefulWidget {
  final double amount;
  final String currency;
  final String description;

  const CheckoutScreen({
    super.key,
    required this.amount,
    required this.currency,
    required this.description,
  });

  @override
  State createState() => _CheckoutScreenState();
}

class _CheckoutScreenState extends State {
  PaymentMethod _selectedMethod = PaymentMethod.stripe;
  bool _isProcessing = false;

  final _stripeService = StripePaymentService(baseUrl: 'https://api.yourapp.com');
  final _paypalService = PayPalPaymentService(baseUrl: 'https://api.yourapp.com');

  Future _processPayment() async {
    setState(() => _isProcessing = true);

    try {
      if (_selectedMethod == PaymentMethod.stripe) {
        await _processStripePayment();
      } else {
        await _processPayPalPayment();
      }
    } catch (e) {
      _showError(e.toString());
    } finally {
      setState(() => _isProcessing = false);
    }
  }

  Future _processStripePayment() async {
    // Create payment intent
    final intent = await _stripeService.createPaymentIntent(
      amount: (widget.amount * 100).toInt(), // Convert to cents
      currency: widget.currency.toLowerCase(),
    );

    // Process payment
    final result = await _stripeService.processPayment(
      clientSecret: intent.clientSecret,
      merchantName: 'Your App',
      customerId: intent.customerId,
      ephemeralKey: intent.ephemeralKey,
    );

    if (result.success) {
      _showSuccess('Payment successful!');
      Navigator.of(context).pop(true);
    } else if (result.cancelled) {
      // User cancelled, do nothing
    } else {
      _showError(result.message);
    }
  }

  Future _processPayPalPayment() async {
    // Create PayPal order
    final order = await _paypalService.createOrder(
      amount: widget.amount,
      currency: widget.currency,
      description: widget.description,
    );

    // Navigate to PayPal checkout
    if (!mounted) return;
    
    final success = await Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => PayPalCheckoutView(
          approvalUrl: order.approvalUrl,
          returnUrl: 'https://yourapp.com/paypal/success',
          cancelUrl: 'https://yourapp.com/paypal/cancel',
          onSuccess: (orderId) async {
            Navigator.pop(context);
            
            // Capture the payment
            final capture = await _paypalService.capturePayment(orderId);
            
            if (capture.success) {
              _showSuccess('Payment successful!');
              Navigator.of(context).pop(true);
            } else {
              _showError('Payment capture failed');
            }
          },
          onCancel: () => Navigator.pop(context),
          onError: (error) {
            Navigator.pop(context);
            _showError(error);
          },
        ),
      ),
    );
  }

  void _showSuccess(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Colors.green,
      ),
    );
  }

  void _showError(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Colors.red,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Checkout')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Order summary
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Order Summary',
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    const SizedBox(height: 8),
                    Text(widget.description),
                    const SizedBox(height: 16),
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        const Text('Total'),
                        Text(
                          '${widget.currency} ${widget.amount.toStringAsFixed(2)}',
                          style: Theme.of(context).textTheme.titleMedium,
                        ),
                      ],
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 24),

            // Payment method selection
            Text(
              'Payment Method',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),

            _PaymentMethodTile(
              icon: Icons.credit_card,
              title: 'Credit / Debit Card',
              subtitle: 'Powered by Stripe',
              selected: _selectedMethod == PaymentMethod.stripe,
              onTap: () => setState(() => _selectedMethod = PaymentMethod.stripe),
            ),
            const SizedBox(height: 8),
            _PaymentMethodTile(
              icon: Icons.account_balance_wallet,
              title: 'PayPal',
              subtitle: 'Pay with your PayPal account',
              selected: _selectedMethod == PaymentMethod.paypal,
              onTap: () => setState(() => _selectedMethod = PaymentMethod.paypal),
            ),

            const Spacer(),

            // Pay button
            ElevatedButton(
              onPressed: _isProcessing ? null : _processPayment,
              style: ElevatedButton.styleFrom(
                padding: const EdgeInsets.symmetric(vertical: 16),
              ),
              child: _isProcessing
                  ? const SizedBox(
                      height: 20,
                      width: 20,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : Text(
                      'Pay ${widget.currency} ${widget.amount.toStringAsFixed(2)}',
                    ),
            ),
          ],
        ),
      ),
    );
  }
}

class _PaymentMethodTile extends StatelessWidget {
  final IconData icon;
  final String title;
  final String subtitle;
  final bool selected;
  final VoidCallback onTap;

  const _PaymentMethodTile({
    required this.icon,
    required this.title,
    required this.subtitle,
    required this.selected,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: onTap,
      borderRadius: BorderRadius.circular(8),
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          border: Border.all(
            color: selected ? Theme.of(context).primaryColor : Colors.grey.shade300,
            width: selected ? 2 : 1,
          ),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Row(
          children: [
            Icon(icon, size: 32),
            const SizedBox(width: 16),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
                  Text(subtitle, style: TextStyle(color: Colors.grey.shade600)),
                ],
              ),
            ),
            if (selected)
              Icon(Icons.check_circle, color: Theme.of(context).primaryColor),
          ],
        ),
      ),
    );
  }
}

Handling Subscriptions

Subscriptions require server-side management with webhook handling for status updates:

// lib/services/subscription_service.dart
class SubscriptionService {
  final Dio _dio;
  final String _baseUrl;

  SubscriptionService({required String baseUrl, Dio? dio})
      : _baseUrl = baseUrl,
        _dio = dio ?? Dio();

  Future createSubscription({
    required String priceId,
    String? customerId,
  }) async {
    try {
      final response = await _dio.post(
        '$_baseUrl/subscriptions/create',
        data: {
          'price_id': priceId,
          'customer_id': customerId,
        },
      );

      return SubscriptionResult(
        subscriptionId: response.data['subscription_id'],
        clientSecret: response.data['client_secret'],
        status: response.data['status'],
      );
    } on DioException catch (e) {
      throw PaymentException(
        message: e.response?.data['error'] ?? 'Failed to create subscription',
        code: 'SUBSCRIPTION_FAILED',
      );
    }
  }

  Future cancelSubscription(String subscriptionId) async {
    await _dio.post(
      '$_baseUrl/subscriptions/cancel',
      data: {'subscription_id': subscriptionId},
    );
  }

  Future getSubscriptionStatus() async {
    final response = await _dio.get('$_baseUrl/subscriptions/status');
    return SubscriptionStatus.fromJson(response.data);
  }
}

Common Mistakes to Avoid

Mistake 1: Trusting Client-Side Payment Confirmation

// WRONG - Never trust client-side success
await Stripe.instance.presentPaymentSheet();
// DO NOT assume payment succeeded here!
await unlockPremiumFeatures(); // Dangerous!

// CORRECT - Verify on backend via webhook
// Client shows "Processing..." until backend confirms via webhook
await Stripe.instance.presentPaymentSheet();
await showProcessingDialog();
// Wait for push notification or poll backend for confirmation

Mistake 2: Exposing Secret Keys in Flutter

// WRONG - Secret key in Flutter code
Stripe.instance.secretKey = 'sk_live_xxx'; // NEVER DO THIS!

// CORRECT - Only use publishable key in Flutter
Stripe.publishableKey = 'pk_live_xxx'; // Safe
// Secret key stays on server only

Mistake 3: Not Implementing Webhooks

// WRONG - Only checking payment status after presentPaymentSheet
// This misses async events like 3D Secure failures, refunds, disputes

// CORRECT - Implement comprehensive webhook handling
switch (event.type) {
  case 'payment_intent.succeeded':
  case 'payment_intent.payment_failed':
  case 'charge.refunded':
  case 'charge.dispute.created':
    // Handle all payment lifecycle events
}

Mistake 4: Missing Idempotency Keys

// WRONG - Duplicate charges if user retries
await stripe.paymentIntents.create({ amount: 1000 });

// CORRECT - Use idempotency key
await stripe.paymentIntents.create(
  { amount: 1000 },
  { idempotencyKey: `order_${orderId}` }
);

Stripe vs PayPal Comparison

Feature Stripe PayPal
Setup Complexity Low (official SDK) Medium (WebView)
Card Payments Excellent Good
Wallet Support Apple Pay, Google Pay PayPal, Venmo
Subscriptions Native support Supported
International Good Excellent
Fees 2.9% + $0.30 2.9% + $0.30
Best For Developer experience Brand recognition

Conclusion

Integrating payment gateways in Flutter requires careful architecture, secure backend coordination, and reliable SDK usage. By combining Stripe's modern APIs with PayPal's global reach, you can offer flexible and trustworthy payment options to your users. Always process payments server-side, implement webhooks for reliable status updates, and never store sensitive card data in your Flutter app.

For building scalable Flutter backends, read Fullstack Flutter with Serverpod: Getting Started Guide. For secure authentication patterns, see OAuth2, JWT, and Session Tokens Explained. Reference the official Stripe Flutter documentation and PayPal developer documentation for the latest API updates.

1 Comment

Leave a Comment