- Integrate flutter_stripe SDK for payment processing - Create StripeService with payment sheet flow - Add tip selection UI (0%, 15%, 18%, 20%, 25%) - Display fee breakdown (subtotal, tax, tip, service fee, card fee) - Update cart screen with "Pay $X.XX" button - Change MainActivity to FlutterFragmentActivity for Stripe compatibility - Update Android themes to AppCompat for payment sheet styling Fee structure: - 5% Payfrit service fee (customer pays) - 2.9% + $0.30 card processing fee (customer pays) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
237 lines
6.9 KiB
Dart
237 lines
6.9 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 payfritFee;
|
|
final double cardFee;
|
|
final double total;
|
|
|
|
const FeeBreakdown({
|
|
required this.subtotal,
|
|
required this.tax,
|
|
required this.tip,
|
|
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,
|
|
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,
|
|
}) {
|
|
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 + payfritFee;
|
|
final cardFee = (totalBeforeCardFee * cardFeePercent) + cardFeeFixed;
|
|
final total = totalBeforeCardFee + cardFee;
|
|
|
|
return FeeBreakdown(
|
|
subtotal: subtotal,
|
|
tax: tax,
|
|
tip: tip,
|
|
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,
|
|
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(),
|
|
);
|
|
}
|
|
}
|
|
}
|