payfrit-app/lib/services/stripe_service.dart
John Mizerek 7f30b77112 Add Cash App Pay iOS support and update notification colors
- Add CFBundleURLTypes with payfrit scheme for Cash App redirect
- Add returnURL to Stripe PaymentSheet config
- Update Podfile to specify iOS 13.0 platform
- Change order status notification to Payfrit light green with black text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 09:17:29 -08:00

243 lines
7.2 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_stripe/flutter_stripe.dart';
import 'package:http/http.dart' as http;
import 'api.dart';
class PaymentResult {
final bool success;
final String? paymentIntentId;
final String? error;
final FeeBreakdown? feeBreakdown;
const PaymentResult({
required this.success,
this.paymentIntentId,
this.error,
this.feeBreakdown,
});
}
class FeeBreakdown {
final double subtotal;
final double tax;
final double tip;
final double deliveryFee;
final double payfritFee;
final double cardFee;
final double total;
const FeeBreakdown({
required this.subtotal,
required this.tax,
required this.tip,
required this.deliveryFee,
required this.payfritFee,
required this.cardFee,
required this.total,
});
factory FeeBreakdown.fromJson(Map<String, dynamic> json) {
return FeeBreakdown(
subtotal: (json['SUBTOTAL'] as num?)?.toDouble() ?? 0.0,
tax: (json['TAX'] as num?)?.toDouble() ?? 0.0,
tip: (json['TIP'] as num?)?.toDouble() ?? 0.0,
deliveryFee: (json['DELIVERY_FEE'] as num?)?.toDouble() ?? 0.0,
payfritFee: (json['PAYFRIT_FEE'] as num?)?.toDouble() ?? 0.0,
cardFee: (json['CARD_FEE'] as num?)?.toDouble() ?? 0.0,
total: (json['TOTAL'] as num?)?.toDouble() ?? 0.0,
);
}
}
class StripeService {
static bool _isInitialized = false;
/// Initialize Stripe with publishable key
static Future<void> initialize(String publishableKey) async {
if (_isInitialized) return;
Stripe.publishableKey = publishableKey;
await Stripe.instance.applySettings();
_isInitialized = true;
}
/// Create payment intent on the server
static Future<Map<String, dynamic>> _createPaymentIntent({
required int businessId,
required int orderId,
required double subtotal,
required double tax,
double tip = 0.0,
String? customerEmail,
}) async {
final url = Uri.parse('${Api.baseUrl}/stripe/createPaymentIntent.cfm');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json; charset=utf-8'},
body: jsonEncode({
'BusinessID': businessId,
'OrderID': orderId,
'Subtotal': subtotal,
'Tax': tax,
'Tip': tip,
if (customerEmail != null) 'CustomerEmail': customerEmail,
}),
);
final body = response.body;
Map<String, dynamic>? json;
try {
json = jsonDecode(body) as Map<String, dynamic>;
} catch (_) {
// Try to extract JSON from body (handles debug output)
final jsonStart = body.indexOf('{');
final jsonEnd = body.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
try {
json = jsonDecode(body.substring(jsonStart, jsonEnd + 1)) as Map<String, dynamic>;
} catch (_) {}
}
}
if (json == null) {
throw StateError('Failed to parse payment intent response');
}
if (json['OK'] != true) {
throw StateError(json['ERROR']?.toString() ?? 'Payment intent creation failed');
}
return json;
}
/// Calculate fee breakdown without creating payment intent
/// Useful for displaying fees before payment
static FeeBreakdown calculateFees({
required double subtotal,
required double tax,
double tip = 0.0,
double deliveryFee = 0.0,
}) {
const customerFeePercent = 0.05; // 5% Payfrit fee
const cardFeePercent = 0.029; // 2.9% Stripe fee
const cardFeeFixed = 0.30; // $0.30 Stripe fixed fee
final payfritFee = subtotal * customerFeePercent;
final totalBeforeCardFee = subtotal + tax + tip + deliveryFee + payfritFee;
final cardFee = (totalBeforeCardFee * cardFeePercent) + cardFeeFixed;
final total = totalBeforeCardFee + cardFee;
return FeeBreakdown(
subtotal: subtotal,
tax: tax,
tip: tip,
deliveryFee: deliveryFee,
payfritFee: payfritFee,
cardFee: cardFee,
total: total,
);
}
/// Process payment for an order
static Future<PaymentResult> processPayment({
required BuildContext context,
required int businessId,
required int orderId,
required double subtotal,
required double tax,
double tip = 0.0,
String? customerEmail,
}) async {
try {
print('[Stripe] Starting payment process...');
print('[Stripe] BusinessID=$businessId, OrderID=$orderId, Subtotal=$subtotal, Tax=$tax, Tip=$tip');
// 1. Create payment intent on server
final intentData = await _createPaymentIntent(
businessId: businessId,
orderId: orderId,
subtotal: subtotal,
tax: tax,
tip: tip,
customerEmail: customerEmail,
);
print('[Stripe] Payment intent response: $intentData');
final clientSecret = intentData['CLIENT_SECRET'] as String?;
final publishableKey = intentData['PUBLISHABLE_KEY'] as String?;
final paymentIntentId = intentData['PAYMENT_INTENT_ID'] as String?;
final feeData = intentData['FEE_BREAKDOWN'] as Map<String, dynamic>?;
if (clientSecret == null || clientSecret.isEmpty) {
print('[Stripe] ERROR: No client secret in response');
return const PaymentResult(
success: false,
error: 'Failed to create payment intent',
);
}
print('[Stripe] Client secret received, publishable key: $publishableKey');
// 2. Initialize Stripe if needed
if (publishableKey != null && publishableKey.isNotEmpty) {
print('[Stripe] Initializing Stripe with key: ${publishableKey.substring(0, 20)}...');
await initialize(publishableKey);
}
print('[Stripe] Initializing payment sheet...');
// 3. Present payment sheet
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
merchantDisplayName: 'Payfrit',
paymentIntentClientSecret: clientSecret,
style: ThemeMode.system,
returnURL: 'payfrit://stripe-redirect', // Required for Cash App Pay on iOS
appearance: const PaymentSheetAppearance(
colors: PaymentSheetAppearanceColors(
primary: Color(0xFF000000),
),
),
),
);
print('[Stripe] Payment sheet initialized, presenting...');
await Stripe.instance.presentPaymentSheet();
print('[Stripe] Payment successful!');
// 4. Payment successful
return PaymentResult(
success: true,
paymentIntentId: paymentIntentId,
feeBreakdown: feeData != null ? FeeBreakdown.fromJson(feeData) : null,
);
} on StripeException catch (e) {
print('[Stripe] StripeException: ${e.error.code} - ${e.error.message} - ${e.error.localizedMessage}');
// User cancelled or payment failed
if (e.error.code == FailureCode.Canceled) {
return const PaymentResult(
success: false,
error: 'Payment cancelled',
);
}
return PaymentResult(
success: false,
error: e.error.localizedMessage ?? 'Payment failed',
);
} catch (e, stackTrace) {
print('[Stripe] Exception: $e');
print('[Stripe] Stack trace: $stackTrace');
return PaymentResult(
success: false,
error: e.toString(),
);
}
}
}