← Back to Blog

Flutter Payment Localization: Stripe, PayPal, and In-App Purchase Translations

flutterpaymentsstripepaypallocalizationin-app-purchase

Flutter Payment Localization: Stripe, PayPal, and In-App Purchase Multilingual Support

Build payment flows that speak your users' language and currency. This guide covers localizing payment forms, receipts, error messages, and checkout experiences in Flutter.

Payment Localization Challenges

Payment apps require localization for:

  • Currency formatting - Symbols, decimal separators
  • Payment methods - Regional preferences
  • Error messages - Card declined, insufficient funds
  • Legal text - Terms, refund policies
  • Receipts - Tax labels, itemization

Localized Currency Display

Currency Formatter

class LocalizedCurrencyFormatter {
  final String locale;
  final String currencyCode;

  LocalizedCurrencyFormatter({
    required this.locale,
    required this.currencyCode,
  });

  String format(double amount) {
    final formatter = NumberFormat.currency(
      locale: locale,
      symbol: _getCurrencySymbol(),
      decimalDigits: _getDecimalDigits(),
    );
    return formatter.format(amount);
  }

  String formatCompact(double amount) {
    final formatter = NumberFormat.compactCurrency(
      locale: locale,
      symbol: _getCurrencySymbol(),
    );
    return formatter.format(amount);
  }

  String _getCurrencySymbol() {
    final symbols = {
      'USD': '\$',
      'EUR': '€',
      'GBP': '£',
      'JPY': '¥',
      'CNY': '¥',
      'KRW': '₩',
      'INR': '₹',
      'BRL': 'R\$',
      'RUB': '₽',
      'AED': 'د.إ',
      'SAR': '﷼',
    };
    return symbols[currencyCode] ?? currencyCode;
  }

  int _getDecimalDigits() {
    // Some currencies don't use decimal places
    final noDecimals = ['JPY', 'KRW', 'VND', 'IDR'];
    return noDecimals.contains(currencyCode) ? 0 : 2;
  }
}

// Usage
final formatter = LocalizedCurrencyFormatter(
  locale: 'de_DE',
  currencyCode: 'EUR',
);
print(formatter.format(1234.56)); // 1.234,56 €

Localized Checkout Flow

Payment Form with Full Localization

class LocalizedCheckoutScreen extends StatefulWidget {
  final Cart cart;
  final String userLocale;
  final String userCurrency;

  @override
  State<LocalizedCheckoutScreen> createState() => _LocalizedCheckoutScreenState();
}

class _LocalizedCheckoutScreenState extends State<LocalizedCheckoutScreen> {
  PaymentMethod? _selectedMethod;
  bool _isProcessing = false;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final currencyFormatter = LocalizedCurrencyFormatter(
      locale: widget.userLocale,
      currencyCode: widget.userCurrency,
    );

    return Scaffold(
      appBar: AppBar(title: Text(l10n.checkout)),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Order summary
            _buildOrderSummary(l10n, currencyFormatter),
            SizedBox(height: 24),

            // Payment methods
            _buildPaymentMethods(l10n),
            SizedBox(height: 24),

            // Payment form
            if (_selectedMethod != null)
              _buildPaymentForm(l10n),
            SizedBox(height: 24),

            // Terms
            _buildTerms(l10n),
            SizedBox(height: 24),

            // Pay button
            _buildPayButton(l10n, currencyFormatter),
          ],
        ),
      ),
    );
  }

  Widget _buildOrderSummary(
    AppLocalizations l10n,
    LocalizedCurrencyFormatter formatter,
  ) {
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.orderSummary,
              style: Theme.of(context).textTheme.titleLarge,
            ),
            SizedBox(height: 16),

            // Items
            ...widget.cart.items.map((item) => _buildItemRow(
              item.name,
              formatter.format(item.price * item.quantity),
              quantity: item.quantity,
            )),

            Divider(height: 24),

            // Subtotal
            _buildSummaryRow(
              l10n.subtotal,
              formatter.format(widget.cart.subtotal),
            ),

            // Tax
            _buildSummaryRow(
              l10n.tax(_getTaxLabel()),
              formatter.format(widget.cart.tax),
            ),

            // Shipping
            if (widget.cart.shipping > 0)
              _buildSummaryRow(
                l10n.shipping,
                formatter.format(widget.cart.shipping),
              )
            else
              _buildSummaryRow(
                l10n.shipping,
                l10n.free,
                valueColor: Colors.green,
              ),

            // Discount
            if (widget.cart.discount > 0)
              _buildSummaryRow(
                l10n.discount,
                '-${formatter.format(widget.cart.discount)}',
                valueColor: Colors.green,
              ),

            Divider(height: 24),

            // Total
            _buildSummaryRow(
              l10n.total,
              formatter.format(widget.cart.total),
              isBold: true,
              fontSize: 18,
            ),
          ],
        ),
      ),
    );
  }

  String _getTaxLabel() {
    // Different tax names by region
    final taxLabels = {
      'US': 'Sales Tax',
      'CA': 'GST/HST',
      'GB': 'VAT',
      'DE': 'MwSt.',
      'FR': 'TVA',
      'JP': '消費税',
      'AU': 'GST',
      'IN': 'GST',
    };
    final country = widget.userLocale.split('_').last;
    return taxLabels[country] ?? 'Tax';
  }

  Widget _buildPaymentMethods(AppLocalizations l10n) {
    final availableMethods = _getAvailableMethodsForRegion();

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.paymentMethod,
          style: Theme.of(context).textTheme.titleLarge,
        ),
        SizedBox(height: 12),
        ...availableMethods.map((method) => _buildMethodTile(method, l10n)),
      ],
    );
  }

  List<PaymentMethod> _getAvailableMethodsForRegion() {
    final country = widget.userLocale.split('_').last;

    // Different payment methods available by region
    final methods = <PaymentMethod>[
      PaymentMethod.card, // Universal
    ];

    // Add regional methods
    switch (country) {
      case 'US':
        methods.addAll([PaymentMethod.applePay, PaymentMethod.googlePay]);
        break;
      case 'DE':
      case 'NL':
      case 'AT':
        methods.addAll([PaymentMethod.sepa, PaymentMethod.klarna]);
        break;
      case 'GB':
        methods.addAll([PaymentMethod.applePay, PaymentMethod.googlePay]);
        break;
      case 'CN':
        methods.addAll([PaymentMethod.alipay, PaymentMethod.wechatPay]);
        break;
      case 'JP':
        methods.addAll([PaymentMethod.konbini, PaymentMethod.paypay]);
        break;
      case 'BR':
        methods.addAll([PaymentMethod.pix, PaymentMethod.boleto]);
        break;
      case 'IN':
        methods.addAll([PaymentMethod.upi, PaymentMethod.paytm]);
        break;
    }

    // PayPal available in most countries
    methods.add(PaymentMethod.paypal);

    return methods;
  }

  Widget _buildMethodTile(PaymentMethod method, AppLocalizations l10n) {
    final isSelected = _selectedMethod == method;

    return Card(
      color: isSelected ? Theme.of(context).primaryColor.withOpacity(0.1) : null,
      child: ListTile(
        leading: _getMethodIcon(method),
        title: Text(_getMethodName(method, l10n)),
        subtitle: Text(_getMethodDescription(method, l10n)),
        trailing: isSelected
            ? Icon(Icons.check_circle, color: Theme.of(context).primaryColor)
            : null,
        onTap: () => setState(() => _selectedMethod = method),
      ),
    );
  }

  String _getMethodName(PaymentMethod method, AppLocalizations l10n) {
    switch (method) {
      case PaymentMethod.card:
        return l10n.creditDebitCard;
      case PaymentMethod.paypal:
        return 'PayPal';
      case PaymentMethod.applePay:
        return 'Apple Pay';
      case PaymentMethod.googlePay:
        return 'Google Pay';
      case PaymentMethod.sepa:
        return l10n.bankTransferSepa;
      case PaymentMethod.klarna:
        return 'Klarna';
      case PaymentMethod.alipay:
        return l10n.alipay;
      case PaymentMethod.wechatPay:
        return l10n.wechatPay;
      case PaymentMethod.pix:
        return 'PIX';
      case PaymentMethod.upi:
        return 'UPI';
      default:
        return method.name;
    }
  }

  String _getMethodDescription(PaymentMethod method, AppLocalizations l10n) {
    switch (method) {
      case PaymentMethod.card:
        return l10n.cardDescription;
      case PaymentMethod.sepa:
        return l10n.sepaDescription;
      case PaymentMethod.klarna:
        return l10n.klarnaDescription;
      default:
        return '';
    }
  }
}

enum PaymentMethod {
  card,
  paypal,
  applePay,
  googlePay,
  sepa,
  klarna,
  alipay,
  wechatPay,
  konbini,
  paypay,
  pix,
  boleto,
  upi,
  paytm,
}

Localized Card Form

Credit Card Input with Localization

class LocalizedCardForm extends StatefulWidget {
  final Function(CardDetails) onCardSubmit;

  @override
  State<LocalizedCardForm> createState() => _LocalizedCardFormState();
}

class _LocalizedCardFormState extends State<LocalizedCardForm> {
  final _formKey = GlobalKey<FormState>();
  final _cardNumberController = TextEditingController();
  final _expiryController = TextEditingController();
  final _cvcController = TextEditingController();
  final _nameController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Form(
      key: _formKey,
      child: Column(
        children: [
          // Card number
          TextFormField(
            controller: _cardNumberController,
            decoration: InputDecoration(
              labelText: l10n.cardNumber,
              hintText: '1234 5678 9012 3456',
              prefixIcon: Icon(Icons.credit_card),
            ),
            keyboardType: TextInputType.number,
            inputFormatters: [
              FilteringTextInputFormatter.digitsOnly,
              CardNumberFormatter(),
            ],
            validator: (value) => _validateCardNumber(value, l10n),
          ),
          SizedBox(height: 16),

          // Expiry and CVC row
          Row(
            children: [
              Expanded(
                child: TextFormField(
                  controller: _expiryController,
                  decoration: InputDecoration(
                    labelText: l10n.expiryDate,
                    hintText: 'MM/YY',
                  ),
                  keyboardType: TextInputType.number,
                  inputFormatters: [
                    FilteringTextInputFormatter.digitsOnly,
                    ExpiryDateFormatter(),
                  ],
                  validator: (value) => _validateExpiry(value, l10n),
                ),
              ),
              SizedBox(width: 16),
              Expanded(
                child: TextFormField(
                  controller: _cvcController,
                  decoration: InputDecoration(
                    labelText: l10n.cvc,
                    hintText: '123',
                  ),
                  keyboardType: TextInputType.number,
                  obscureText: true,
                  maxLength: 4,
                  inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                  validator: (value) => _validateCVC(value, l10n),
                ),
              ),
            ],
          ),
          SizedBox(height: 16),

          // Cardholder name
          TextFormField(
            controller: _nameController,
            decoration: InputDecoration(
              labelText: l10n.cardholderName,
              hintText: l10n.nameOnCard,
            ),
            textCapitalization: TextCapitalization.words,
            validator: (value) => _validateName(value, l10n),
          ),
        ],
      ),
    );
  }

  String? _validateCardNumber(String? value, AppLocalizations l10n) {
    if (value == null || value.isEmpty) {
      return l10n.errorCardNumberRequired;
    }
    final digits = value.replaceAll(' ', '');
    if (digits.length < 13 || digits.length > 19) {
      return l10n.errorCardNumberInvalid;
    }
    if (!_luhnCheck(digits)) {
      return l10n.errorCardNumberInvalid;
    }
    return null;
  }

  String? _validateExpiry(String? value, AppLocalizations l10n) {
    if (value == null || value.isEmpty) {
      return l10n.errorExpiryRequired;
    }
    final parts = value.split('/');
    if (parts.length != 2) {
      return l10n.errorExpiryInvalid;
    }
    final month = int.tryParse(parts[0]);
    final year = int.tryParse('20${parts[1]}');
    if (month == null || year == null || month < 1 || month > 12) {
      return l10n.errorExpiryInvalid;
    }
    final expiry = DateTime(year, month + 1, 0);
    if (expiry.isBefore(DateTime.now())) {
      return l10n.errorCardExpired;
    }
    return null;
  }

  String? _validateCVC(String? value, AppLocalizations l10n) {
    if (value == null || value.isEmpty) {
      return l10n.errorCvcRequired;
    }
    if (value.length < 3) {
      return l10n.errorCvcInvalid;
    }
    return null;
  }

  bool _luhnCheck(String cardNumber) {
    int sum = 0;
    bool alternate = false;
    for (int i = cardNumber.length - 1; i >= 0; i--) {
      int digit = int.parse(cardNumber[i]);
      if (alternate) {
        digit *= 2;
        if (digit > 9) digit -= 9;
      }
      sum += digit;
      alternate = !alternate;
    }
    return sum % 10 == 0;
  }
}

Payment Error Handling

Localized Error Messages

class PaymentErrorHandler {
  static String getErrorMessage(
    String errorCode,
    AppLocalizations l10n,
  ) {
    switch (errorCode) {
      // Card errors
      case 'card_declined':
        return l10n.errorCardDeclined;
      case 'insufficient_funds':
        return l10n.errorInsufficientFunds;
      case 'expired_card':
        return l10n.errorExpiredCard;
      case 'incorrect_cvc':
        return l10n.errorIncorrectCvc;
      case 'incorrect_number':
        return l10n.errorIncorrectNumber;
      case 'processing_error':
        return l10n.errorProcessing;

      // 3D Secure
      case 'authentication_required':
        return l10n.errorAuthenticationRequired;
      case 'authentication_failed':
        return l10n.errorAuthenticationFailed;

      // Network
      case 'network_error':
        return l10n.errorNetwork;
      case 'timeout':
        return l10n.errorTimeout;

      // General
      case 'payment_failed':
        return l10n.errorPaymentFailed;
      case 'duplicate_transaction':
        return l10n.errorDuplicateTransaction;

      default:
        return l10n.errorGenericPayment;
    }
  }

  static String getDeclineReason(
    String declineCode,
    AppLocalizations l10n,
  ) {
    switch (declineCode) {
      case 'do_not_honor':
        return l10n.declineContactBank;
      case 'lost_card':
      case 'stolen_card':
        return l10n.declineContactBank;
      case 'pickup_card':
        return l10n.declineContactBank;
      case 'restricted_card':
        return l10n.declineRestrictedCard;
      case 'security_violation':
        return l10n.declineSecurityViolation;
      case 'withdrawal_count_limit_exceeded':
        return l10n.declineLimitExceeded;
      default:
        return l10n.declineGeneric;
    }
  }
}

Receipt Generation

Localized Receipt

class LocalizedReceipt extends StatelessWidget {
  final Order order;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context).toString();
    final formatter = LocalizedCurrencyFormatter(
      locale: locale,
      currencyCode: order.currency,
    );
    final dateFormatter = DateFormat.yMMMMd(locale);
    final timeFormatter = DateFormat.jm(locale);

    return SingleChildScrollView(
      padding: EdgeInsets.all(24),
      child: Column(
        children: [
          // Success icon
          Icon(Icons.check_circle, size: 64, color: Colors.green),
          SizedBox(height: 16),
          Text(
            l10n.paymentSuccessful,
            style: Theme.of(context).textTheme.headlineSmall,
          ),
          SizedBox(height: 8),
          Text(
            l10n.thankYouForPurchase,
            style: TextStyle(color: Colors.grey[600]),
          ),
          SizedBox(height: 32),

          // Receipt card
          Card(
            child: Padding(
              padding: EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // Header
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(
                        l10n.receipt,
                        style: Theme.of(context).textTheme.titleLarge,
                      ),
                      Text(
                        '#${order.id}',
                        style: TextStyle(color: Colors.grey[600]),
                      ),
                    ],
                  ),
                  Divider(height: 24),

                  // Date & Time
                  _buildRow(
                    l10n.date,
                    dateFormatter.format(order.createdAt),
                  ),
                  _buildRow(
                    l10n.time,
                    timeFormatter.format(order.createdAt),
                  ),
                  Divider(height: 24),

                  // Items
                  Text(l10n.items, style: TextStyle(fontWeight: FontWeight.bold)),
                  SizedBox(height: 8),
                  ...order.items.map((item) => _buildItemRow(
                    '${item.quantity}x ${item.name}',
                    formatter.format(item.total),
                  )),
                  Divider(height: 24),

                  // Totals
                  _buildRow(l10n.subtotal, formatter.format(order.subtotal)),
                  _buildRow(l10n.tax(''), formatter.format(order.tax)),
                  if (order.shipping > 0)
                    _buildRow(l10n.shipping, formatter.format(order.shipping)),
                  if (order.discount > 0)
                    _buildRow(
                      l10n.discount,
                      '-${formatter.format(order.discount)}',
                      valueColor: Colors.green,
                    ),
                  Divider(height: 24),
                  _buildRow(
                    l10n.total,
                    formatter.format(order.total),
                    isBold: true,
                  ),
                  Divider(height: 24),

                  // Payment method
                  _buildRow(
                    l10n.paymentMethod,
                    _formatPaymentMethod(order.paymentMethod, l10n),
                  ),
                  if (order.last4 != null)
                    _buildRow(
                      l10n.cardEnding,
                      '•••• ${order.last4}',
                    ),
                ],
              ),
            ),
          ),
          SizedBox(height: 24),

          // Actions
          Row(
            children: [
              Expanded(
                child: OutlinedButton.icon(
                  icon: Icon(Icons.download),
                  label: Text(l10n.downloadReceipt),
                  onPressed: () => _downloadReceipt(order),
                ),
              ),
              SizedBox(width: 12),
              Expanded(
                child: OutlinedButton.icon(
                  icon: Icon(Icons.email),
                  label: Text(l10n.emailReceipt),
                  onPressed: () => _emailReceipt(order),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

ARB File Structure

{
  "@@locale": "en",

  "checkout": "Checkout",
  "orderSummary": "Order Summary",
  "paymentMethod": "Payment Method",

  "subtotal": "Subtotal",
  "tax": "Tax {label}",
  "@tax": {
    "placeholders": {"label": {"type": "String"}}
  },
  "shipping": "Shipping",
  "discount": "Discount",
  "total": "Total",
  "free": "Free",

  "creditDebitCard": "Credit or Debit Card",
  "bankTransferSepa": "Bank Transfer (SEPA)",
  "alipay": "Alipay",
  "wechatPay": "WeChat Pay",

  "cardDescription": "Visa, Mastercard, American Express",
  "sepaDescription": "Direct bank transfer in EUR",
  "klarnaDescription": "Buy now, pay later",

  "cardNumber": "Card Number",
  "expiryDate": "Expiry Date",
  "cvc": "CVC",
  "cardholderName": "Cardholder Name",
  "nameOnCard": "Name as shown on card",

  "errorCardNumberRequired": "Please enter your card number",
  "errorCardNumberInvalid": "Please enter a valid card number",
  "errorExpiryRequired": "Please enter the expiry date",
  "errorExpiryInvalid": "Please enter a valid expiry date",
  "errorCardExpired": "This card has expired",
  "errorCvcRequired": "Please enter the CVC",
  "errorCvcInvalid": "Please enter a valid CVC",

  "errorCardDeclined": "Your card was declined. Please try another card.",
  "errorInsufficientFunds": "Insufficient funds. Please try another card.",
  "errorExpiredCard": "Your card has expired. Please use a different card.",
  "errorIncorrectCvc": "The security code is incorrect.",
  "errorIncorrectNumber": "The card number is incorrect.",
  "errorProcessing": "An error occurred while processing. Please try again.",
  "errorAuthenticationRequired": "Additional authentication required.",
  "errorAuthenticationFailed": "Authentication failed. Please try again.",
  "errorNetwork": "Network error. Please check your connection.",
  "errorTimeout": "Request timed out. Please try again.",
  "errorPaymentFailed": "Payment failed. Please try again.",
  "errorDuplicateTransaction": "This appears to be a duplicate transaction.",
  "errorGenericPayment": "Something went wrong. Please try again.",

  "declineContactBank": "Please contact your bank for more information.",
  "declineRestrictedCard": "This card cannot be used for this purchase.",
  "declineSecurityViolation": "Transaction declined for security reasons.",
  "declineLimitExceeded": "Transaction limit exceeded.",
  "declineGeneric": "Your card was declined. Please try another card.",

  "payButton": "Pay {amount}",
  "@payButton": {
    "placeholders": {"amount": {"type": "String"}}
  },
  "processing": "Processing...",

  "paymentSuccessful": "Payment Successful!",
  "thankYouForPurchase": "Thank you for your purchase",
  "receipt": "Receipt",
  "date": "Date",
  "time": "Time",
  "items": "Items",
  "cardEnding": "Card",
  "downloadReceipt": "Download",
  "emailReceipt": "Email"
}

Conclusion

Payment localization requires:

  1. Currency formatting per locale
  2. Regional payment methods
  3. Localized error messages for all failure cases
  4. Tax and legal terminology by region
  5. Receipts with proper formatting

With proper localization, your payment flow will convert users worldwide.

Related Resources