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:
- Currency formatting per locale
- Regional payment methods
- Localized error messages for all failure cases
- Tax and legal terminology by region
- Receipts with proper formatting
With proper localization, your payment flow will convert users worldwide.