Add Stripe payment integration
- 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>
This commit is contained in:
parent
4ebfbbc03b
commit
5942deb0c5
9 changed files with 513 additions and 84 deletions
|
|
@ -1,5 +1,5 @@
|
|||
package com.payfrit.app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterFragmentActivity()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<style name="LaunchTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<style name="NormalTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<style name="LaunchTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<style name="NormalTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_stripe/flutter_stripe.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
import "app/app_router.dart" show AppRoutes;
|
||||
|
|
@ -10,6 +11,11 @@ final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey =
|
|||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize Stripe with test publishable key
|
||||
// This will be updated dynamically when processing payments if needed
|
||||
Stripe.publishableKey = 'pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN';
|
||||
|
||||
runApp(const PayfritApp());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../models/cart.dart';
|
|||
import '../models/menu_item.dart';
|
||||
import '../services/api.dart';
|
||||
import '../services/order_polling_service.dart';
|
||||
import '../services/stripe_service.dart';
|
||||
|
||||
/// Helper class to store modifier breadcrumb paths
|
||||
class ModifierPath {
|
||||
|
|
@ -29,9 +30,37 @@ class CartViewScreen extends StatefulWidget {
|
|||
class _CartViewScreenState extends State<CartViewScreen> {
|
||||
Cart? _cart;
|
||||
bool _isLoading = true;
|
||||
bool _isProcessingPayment = false;
|
||||
String? _error;
|
||||
Map<int, MenuItem> _menuItemsById = {};
|
||||
|
||||
// Tip options as percentages
|
||||
static const List<int> _tipPercentages = [0, 15, 18, 20, 25];
|
||||
int _selectedTipIndex = 1; // Default to 15%
|
||||
|
||||
double get _tipAmount {
|
||||
if (_cart == null) return 0.0;
|
||||
return _cart!.subtotal * (_tipPercentages[_selectedTipIndex] / 100);
|
||||
}
|
||||
|
||||
FeeBreakdown get _feeBreakdown {
|
||||
if (_cart == null) {
|
||||
return const FeeBreakdown(
|
||||
subtotal: 0,
|
||||
tax: 0,
|
||||
tip: 0,
|
||||
payfritFee: 0,
|
||||
cardFee: 0,
|
||||
total: 0,
|
||||
);
|
||||
}
|
||||
return StripeService.calculateFees(
|
||||
subtotal: _cart!.subtotal,
|
||||
tax: _cart!.tax,
|
||||
tip: _tipAmount,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -146,14 +175,44 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _submitOrder() async {
|
||||
try {
|
||||
Future<void> _processPaymentAndSubmit() async {
|
||||
if (_cart == null) return;
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
final cartOrderId = appState.cartOrderId;
|
||||
if (cartOrderId == null) return;
|
||||
final businessId = appState.selectedBusinessId;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
if (cartOrderId == null || businessId == null) return;
|
||||
|
||||
setState(() => _isProcessingPayment = true);
|
||||
|
||||
try {
|
||||
// 1. Process payment with Stripe
|
||||
final paymentResult = await StripeService.processPayment(
|
||||
context: context,
|
||||
businessId: businessId,
|
||||
orderId: cartOrderId,
|
||||
subtotal: _cart!.subtotal,
|
||||
tax: _cart!.tax,
|
||||
tip: _tipAmount,
|
||||
);
|
||||
|
||||
if (!paymentResult.success) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isProcessingPayment = false);
|
||||
|
||||
if (paymentResult.error != 'Payment cancelled') {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(paymentResult.error ?? 'Payment failed'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Payment successful, now submit the order
|
||||
await Api.submitOrder(orderId: cartOrderId);
|
||||
|
||||
// Set active order for polling (status 1 = submitted)
|
||||
|
|
@ -187,20 +246,27 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
|
||||
// Show success message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Order submitted successfully! You'll receive notifications as your order is prepared."),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"Payment successful! Order placed. You'll receive notifications as your order is prepared.",
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 5),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
|
||||
// Navigate back
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() => _isProcessingPayment = false);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -492,6 +558,8 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
Widget _buildCartSummary() {
|
||||
if (_cart == null) return const SizedBox.shrink();
|
||||
|
||||
final fees = _feeBreakdown;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
|
|
@ -506,53 +574,79 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
padding: const EdgeInsets.all(16),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Tip Selection
|
||||
const Text(
|
||||
"Subtotal",
|
||||
style: TextStyle(fontSize: 16),
|
||||
"Add a tip",
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
"\$${_cart!.subtotal.toStringAsFixed(2)}",
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Sales tax
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Tax (8.25%)",
|
||||
style: TextStyle(fontSize: 16),
|
||||
children: List.generate(_tipPercentages.length, (index) {
|
||||
final isSelected = _selectedTipIndex == index;
|
||||
final percent = _tipPercentages[index];
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: index == 0 ? 0 : 4,
|
||||
right: index == _tipPercentages.length - 1 ? 0 : 4,
|
||||
),
|
||||
Text(
|
||||
"\$${_cart!.tax.toStringAsFixed(2)}",
|
||||
style: const TextStyle(fontSize: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _selectedTipIndex = index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.black : Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected ? Colors.black : Colors.grey.shade300,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
percent == 0 ? "No tip" : "$percent%",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? Colors.white : Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
// Subtotal
|
||||
_buildSummaryRow("Subtotal", fees.subtotal),
|
||||
const SizedBox(height: 6),
|
||||
// Tax
|
||||
_buildSummaryRow("Tax (8.25%)", fees.tax),
|
||||
// Only show delivery fee for delivery orders (OrderTypeID = 3)
|
||||
if (_cart!.deliveryFee > 0 && _cart!.orderTypeId == 3) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Delivery Fee",
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
"\$${_cart!.deliveryFee.toStringAsFixed(2)}",
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildSummaryRow("Delivery Fee", _cart!.deliveryFee),
|
||||
],
|
||||
),
|
||||
// Tip
|
||||
if (_tipAmount > 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildSummaryRow("Tip", _tipAmount),
|
||||
],
|
||||
const Divider(height: 24),
|
||||
const SizedBox(height: 6),
|
||||
// Payfrit fee
|
||||
_buildSummaryRow("Service Fee", fees.payfritFee, isGrey: true),
|
||||
const SizedBox(height: 6),
|
||||
// Card processing fee
|
||||
_buildSummaryRow("Card Processing", fees.cardFee, isGrey: true),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
// Total
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
|
@ -564,7 +658,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
"\$${_cart!.total.toStringAsFixed(2)}",
|
||||
"\$${fees.total.toStringAsFixed(2)}",
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -576,15 +670,27 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _cart!.itemCount > 0 ? _submitOrder : null,
|
||||
onPressed: (_cart!.itemCount > 0 && !_isProcessingPayment)
|
||||
? _processPaymentAndSubmit
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
disabledBackgroundColor: Colors.grey,
|
||||
),
|
||||
child: const Text(
|
||||
"Submit Order",
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
child: _isProcessingPayment
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
"Pay \$${fees.total.toStringAsFixed(2)}",
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -594,6 +700,28 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryRow(String label, double amount, {bool isGrey = false}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: isGrey ? Colors.grey.shade600 : Colors.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$${amount.toStringAsFixed(2)}",
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: isGrey ? Colors.grey.shade600 : Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmRemoveItem(OrderLineItem item, String itemName) {
|
||||
showDialog(
|
||||
context: context,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
List<MenuItem> _allItems = [];
|
||||
final Map<int, List<MenuItem>> _itemsByCategory = {};
|
||||
final Map<int, List<MenuItem>> _itemsByParent = {};
|
||||
final Map<int, int> _categorySortOrder = {}; // categoryId -> sortOrder
|
||||
final Map<int, String> _categoryNames = {}; // categoryId -> categoryName
|
||||
|
||||
// Track which category is currently expanded (null = none)
|
||||
int? _expandedCategoryId;
|
||||
|
|
@ -79,6 +81,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
void _organizeItems() {
|
||||
_itemsByCategory.clear();
|
||||
_itemsByParent.clear();
|
||||
_categorySortOrder.clear();
|
||||
_categoryNames.clear();
|
||||
|
||||
print('[MenuBrowse] _organizeItems: ${_allItems.length} total items');
|
||||
|
||||
|
|
@ -90,7 +94,10 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
categoryItemIds.add(item.itemId);
|
||||
// Just register the category key (empty list for now)
|
||||
_itemsByCategory.putIfAbsent(item.itemId, () => []);
|
||||
print('[MenuBrowse] Category found: ${item.name} (ID=${item.itemId})');
|
||||
// Store the sort order and name for this category
|
||||
_categorySortOrder[item.itemId] = item.sortOrder;
|
||||
_categoryNames[item.itemId] = item.name;
|
||||
print('[MenuBrowse] Category found: ${item.name} (ID=${item.itemId}, sortOrder=${item.sortOrder})');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +143,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
|
||||
List<int> _getUniqueCategoryIds() {
|
||||
final categoryIds = _itemsByCategory.keys.toList();
|
||||
categoryIds.sort();
|
||||
// Sort by sortOrder (from _categorySortOrder), not by ItemID
|
||||
categoryIds.sort((a, b) {
|
||||
final orderA = _categorySortOrder[a] ?? 0;
|
||||
final orderB = _categorySortOrder[b] ?? 0;
|
||||
return orderA.compareTo(orderB);
|
||||
});
|
||||
return categoryIds;
|
||||
}
|
||||
|
||||
|
|
@ -267,11 +279,15 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
final categoryIndex = index - 1;
|
||||
final categoryId = categoryIds[categoryIndex];
|
||||
final items = _itemsByCategory[categoryId] ?? [];
|
||||
final categoryName = items.isNotEmpty
|
||||
? items.first.categoryName
|
||||
: "Category $categoryId";
|
||||
// Use stored category name from the category item itself
|
||||
final categoryName = _categoryNames[categoryId] ?? "Category $categoryId";
|
||||
final isExpanded = _expandedCategoryId == categoryId;
|
||||
|
||||
// Debug: Print which items are being shown for which category
|
||||
if (items.isNotEmpty) {
|
||||
print('[MenuBrowse] DISPLAY: Category "$categoryName" (ID=$categoryId) showing items: ${items.map((i) => i.name).join(", ")}');
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -427,15 +443,16 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
fit: BoxFit.cover,
|
||||
semanticLabel: categoryName,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// No image - show large styled category name
|
||||
// No image - show white background with dark forest green text
|
||||
const darkForestGreen = Color(0xFF1B4D3E);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.primary,
|
||||
Theme.of(context).colorScheme.tertiary,
|
||||
Colors.white,
|
||||
Colors.grey.shade100,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -447,13 +464,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
color: darkForestGreen,
|
||||
letterSpacing: 1.2,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(2, 2),
|
||||
blurRadius: 4,
|
||||
color: Colors.black54,
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 2,
|
||||
color: Colors.black26,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -489,7 +506,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
children: [
|
||||
// Category image background or styled text fallback
|
||||
_buildCategoryBackground(categoryId, categoryName),
|
||||
// Top edge gradient
|
||||
// Top edge gradient (subtle forest green)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
|
|
@ -501,14 +518,14 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withAlpha(180),
|
||||
Colors.black.withAlpha(0),
|
||||
const Color(0xFF1B4D3E).withAlpha(120),
|
||||
const Color(0xFF1B4D3E).withAlpha(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom edge gradient
|
||||
// Bottom edge gradient (subtle forest green)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
|
|
@ -520,8 +537,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withAlpha(0),
|
||||
Colors.black.withAlpha(200),
|
||||
const Color(0xFF1B4D3E).withAlpha(0),
|
||||
const Color(0xFF1B4D3E).withAlpha(150),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
237
lib/services/stripe_service.dart
Normal file
237
lib/services/stripe_service.dart
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
pubspec.lock
40
pubspec.lock
|
|
@ -134,6 +134,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
flutter_stripe:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_stripe
|
||||
sha256: a474b283f4b07e8973687514bf48762e618073b0d6b7acc45cea9a60466d4f8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.5.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
|
@ -144,6 +152,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -453,6 +469,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
stripe_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stripe_android
|
||||
sha256: a666352e0c20753ecd8feebb5944882bf597167be4f020641266515a495bd55f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.5.0"
|
||||
stripe_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stripe_ios
|
||||
sha256: "0f7afed3ac61e544e7525da9b692b23d93e762d56f6c9aa7f77fc6d9a686a65d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.5.0"
|
||||
stripe_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stripe_platform_interface
|
||||
sha256: "23c10f3875da07f85a6196fcb676e64c767ad2d04ec73ba4e941ac797a4ee4d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.5.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ dependencies:
|
|||
permission_handler: ^11.3.1
|
||||
shared_preferences: ^2.2.3
|
||||
dchs_flutter_beacon: ^0.6.6
|
||||
flutter_stripe: ^11.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue