DEV Community

Bill Odida
Bill Odida

Posted on

Building a Payment System for Your Flutter App: A Journey with Stripe Connect and Firebase

Imagine you're building a marketplace app where sellers can offer their products or services. As the platform owner, you want to provide a seamless payment experience for both buyers and sellers, while also taking a small commission from each transaction. But here's the catch: you don't want to handle the complexities of managing payments directly, such as compliance, security, and payouts. This is where Stripe Connect comes into play.

With Stripe Connect, you can enable your sellers to collect payments directly into their Stripe accounts. This means that the money never touches your hands—Stripe handles everything from payment processing to payouts. This setup not only simplifies your operations but also offers several advantages:

  • Security and Compliance: Stripe takes care of all the compliance requirements, including KYC (Know Your Customer), AML (Anti-Money Laundering), and other regulations.

  • Scalability: Whether you have a few sellers or thousands, Stripe scales effortlessly, handling all payment-related complexities.

  • Payout Flexibility: Sellers receive their funds directly, and you can easily set up commissions or application fees.

Now, let's walk through how you can integrate Stripe Connect into your Flutter app using Firebase Functions and TypeScript. We’ll cover everything from setting up your Firebase project to creating user accounts, setting up products, and handling payments—all while leveraging the power of Stripe Connect.

Step 1: Setting Up Firebase and Firebase Functions

Before diving into the Stripe integration, you need to set up Firebase and Firebase Functions. Firebase will serve as your backend, where you can manage your app's data and handle server-side logic.

Create a Firebase Project:

  • Go to the Firebase Console.

  • Click "Add Project" and follow the prompts to create a new project.

Initialize Firebase Functions:

  • Install the Firebase CLI by running the following command:
npm install -g firebase-tools

Enter fullscreen mode Exit fullscreen mode

Log in to Firebase:

firebase login

Enter fullscreen mode Exit fullscreen mode

Navigate to your project directory and initialize Firebase Functions:

firebase init functions

Enter fullscreen mode Exit fullscreen mode

Choose TypeScript as the language for your functions when prompted.

Install the Stripe Node.js SDK:

Inside your functions directory, install the Stripe SDK:

npm install stripe

Enter fullscreen mode Exit fullscreen mode

Now that your Firebase project is set up and ready, we can proceed to the Stripe-specific functionalities.

Step 2: Creating a Stripe Account for Your Users

The first step in enabling your users to receive payments is creating a Stripe account for them. Stripe Connect allows you to create accounts that your users can use to manage their payments. This is like creating a store for them on Stripe. Here’s how you can do it:

In your Firebase Functions folder create a file called index.ts, you’ll need to create a function that takes the user’s email and creates a standard Stripe account. This function will also generate an onboarding link where the user can complete their account setup.

Now that we've set up the Firebase project , we can proceed to the Stripe-specific functionalities.

exports.createStripeAccountAndLink = functions.https.onCall(async (data, context) => {
  if (!context.auth) {
    throw new functions.https.HttpsError('unauthenticated', 'The function must be called while authenticated.');
  }

  if (!data.email || typeof data.email !== 'string') {
    throw new functions.https.HttpsError('invalid-argument', 'The function must be called with a valid "email" argument.');
  }

  try {

const account = await stripe.accounts.create({
      type: 'standard',
      email: data.email,
    });

    const accountLink = await stripe.accountLinks.create({
      account: account.id,
      refresh_url: `https://your-app-url/refreshStripeOnboarding?accountId=${account.id}`,
      return_url: 'https://your-app-url/stripeSuccess',
      type: 'account_onboarding',
    });

    await admin.firestore().collection('users').doc(context.auth.uid).update({
      stripeAccountId: account.id,
    });

    return { url: accountLink.url };
  } catch (error) {
    console.error('Stripe error:', error);
    throw new functions.https.HttpsError('internal', 'Unable to create Stripe account and link.');
  }
});

Enter fullscreen mode Exit fullscreen mode

One of the first steps in integrating Stripe Connect into your Flutter app is to create a Stripe account for your users. This is a critical step, as it allows your users (sellers or service providers) to receive payments directly into their Stripe accounts. Let's break down the code that handles this process:

// Create Stripe account
const account = await stripe.accounts.create({
  type: 'standard',
  email: data.email, // Use the provided email for the Stripe account
});

Enter fullscreen mode Exit fullscreen mode

Breaking Down the Code

  • stripe.accounts.create({...}): This is a method provided by the Stripe Node.js SDK, which creates a new Stripe account. The create method is asynchronous and returns a promise that resolves to a Stripe account object if the creation is successful.

  • type: 'standard': This specifies the type of Stripe account to be created. In this case, 'standard' refers to a standard Stripe account. A standard account is managed by the account holder, meaning the user who owns the account will be responsible for handling their payouts, managing their account settings, and complying with Stripe’s requirements. There are other types like 'express' and 'custom', but 'standard' is the most straightforward option for most use cases.

  • email: data.email: The email address provided by the user is passed to Stripe when creating the account. This email will be associated with the new Stripe account, and Stripe will use it to communicate with the account holder. It's essential to use a valid and active email address because Stripe sends important account-related notifications to this email.

What Does This Return?

The stripe.accounts.create({...}) method returns an object representing the newly created Stripe account. This object contains several properties, but some of the most important ones include:

  • id: The unique identifier for the Stripe account. This id is used to reference the account in all subsequent API calls related to that account. For example, when creating an account link or when processing payments on behalf of this account.

  • email : The email address associated with the Stripe account, which should match the email provided during account creation.

  • created: A timestamp indicating when the account was created.

  • type: The type of account, which in this case would be 'standard'.

This account object is crucial because it provides the id that you will need for other Stripe operations, such as onboarding the user, managing their payouts, and handling payments.

Next Steps: Onboarding the User

After creating the account, the next step typically involves onboarding the user. This is where you guide the user through completing the setup of their Stripe account, ensuring they provide all necessary information for compliance and verification. In the following steps, you will generate an onboarding link using the *account.id * obtained from this step, and redirect the user to complete their Stripe account setup.

With the account created and the id in hand, you’re ready to proceed with the next steps in integrating Stripe Connect into your app.

const accountLink = await stripe.accountLinks.create({
  account: account.id,
  refresh_url: `https://your-app-url/refreshStripeOnboarding?accountId=${account.id}`,
  return_url: 'https://your-app-url/stripeSuccess',
  type: 'account_onboarding',
});

Enter fullscreen mode Exit fullscreen mode

Let's talk about the parameters we are passing to the function :

  • account: This is the ID of the Stripe account that you created for the user. It uniquely identifies the account in Stripe’s system.

  • refresh_url: This is the URL where Stripe will redirect the user if they abandon the onboarding process or if the session expires. It’s a fallback URL that allows the user to restart the onboarding process. For example, if the user doesn't complete their onboarding in one go, they can return later and pick up where they left off.

  • return_url : This is the URL where Stripe will redirect the user after they have successfully completed the onboarding process. Typically, this would be a page in your app that acknowledges their successful setup and perhaps directs them to the next step in your app’s flow, like creating a product or setting up their first listing.

  • type: This parameter specifies the type of account link being created. In this case, account_onboarding is used, which creates a link specifically for onboarding the user into Stripe Connect. This is a guided process where Stripe gathers necessary information about the user, such as identity verification and bank account details, to comply with financial regulations.

Creating a Customer in Stripe

Once your users have a Stripe account, the next step in the payment process often involves creating a customer. In Stripe's ecosystem, a customer represents an individual or business that will be charged for products or services. This step is crucial when you want to manage recurring payments, store payment information, or simply keep track of your customers' payment histories.

Here’s how you can create a customer using Firebase Functions and the Stripe API:

// Create Stripe Customer
exports.createStripeCustomer = functions.https.onCall(async (data, context) => {
  // Ensure the user is authenticated
  if (!context.auth) {
    throw new functions.https.HttpsError('unauthenticated',
      'The function must be called while authenticated.');
  }

  // Input validation
  if (!data.email || typeof data.email !== 'string') {
    throw new functions.https.HttpsError('invalid-argument',
      'The function must be called with a valid "email" argument.');
  }

  try {
    // Create Stripe Customer
    const customer = await stripe.customers.create({
      email: data.email,
      name: data.name,
    }, 
    {
      stripeAccount: data.stripeAccountId,
    });

    // Save customer ID to Firestore
    await admin.firestore().collection('users').doc(context.auth.uid).update({
      stripeCustomerIds: { [data.stripeAccountId]: customer.id }
    });

    // Return the customer ID to the client
    return { customerId: customer.id };
  } catch (error) {
    console.error('Stripe error:', error);
    throw new functions.https.HttpsError('internal',
      'Unable to create Stripe customer.');
  }
});

Enter fullscreen mode Exit fullscreen mode

Understanding the Code

  • Authentication Check:

The function first checks if the user is authenticated. This ensures that only authenticated users can create customers, which is a security measure to prevent unauthorized actions.

  • Input Validation:

The function validates the email parameter to ensure it's provided and is of the correct type (a string). This is important to avoid errors and ensure the correct data is passed to Stripe.
Creating the Stripe Customer:

  • stripe.customers.create({...}, {...}): This method creates a new customer in Stripe.

Parameters:

  • email: The email address of the customer. This is used by Stripe to send payment receipts and other communications.

  • name: The customer's name, which helps in identifying the customer.

  • stripeAccount: Specifies the connected Stripe account where the customer is being created. This is important in a multi-account setup like Stripe Connect, where different sellers have their own Stripe accounts.

  • Storing the Customer ID:

Once the customer is created, the function stores the customer.id in Firestore under the user’s document. The StripeCustomerIds field is an object where each key corresponds to a stripeAccountId and the value is the associated customer.id. This structure is useful for managing multiple customers across different connected accounts.

  • Returning the Customer ID:

The function returns the customerId to the client. This ID is critical because it will be used in future transactions, such as when charging the customer or setting up subscriptions.

Why is Creating a Customer Important?

Creating a customer in Stripe is a foundational step for various payment-related processes:

  • Recurring Billing: If your platform offers subscription services, you’ll need a customer object to manage recurring payments.

  • Payment Methods: You can associate multiple payment methods with a single customer, allowing users to choose how they want to pay.

  • Payment History: Stripe automatically tracks all transactions associated with a customer, which is useful for managing refunds, disputes, and providing customer service.

Creating a Stripe Price for Products and Services

After setting up a Stripe customer and creating a connected account for your users, the next crucial step in enabling payments on your platform is creating a price for the products or services that your users are selling. In Stripe's system, a price object defines how much and in what currency a product or service costs. It can be a one-time charge or a recurring fee, depending on your business model.

Here’s how you can create a Stripe price using Firebase Functions and the Stripe API:

// Create Stripe Price
exports.createStripePrice = functions.https.onCall(async (data, context) => {
  // Ensure the user is authenticated
  if (!context.auth) {
    throw new functions.https.HttpsError('unauthenticated',
      'The function must be called while authenticated.');
  }

  // Input validation
  if (!data.unit_amount || typeof data.unit_amount !== 'number') {
    throw new functions.https.HttpsError('invalid-argument',
      'The function must be called with a valid "unit_amount" number argument.');
  }

  try {
    // Create Stripe price
    const price = await stripe.prices.create(
      {
          unit_amount: data.unit_amount,
          currency: data.currency || 'gbp',
          product_data: {
              name: data.name,
          },
      },
      {       // Specify the connected account ID
          stripeAccount: data.stripeAccountId,
      }
    );

    logger.log('Stripe price created:', price.id);

    // Return the price ID to the client
    return { priceId: price.id };

  } catch (error) {
    console.error('Stripe error:', error);
    throw new functions.https.HttpsError('internal', 'Unable to create Stripe price.');
  }
});
Enter fullscreen mode Exit fullscreen mode

Breaking Down the Code

Let’s explore the key parts of this function:

  • Authentication Check:

The function first ensures that the user is authenticated. This is critical to prevent unauthorized users from creating prices on your platform.

  • Input Validation:

The function validates that the unit_amount parameter is provided and is of the correct type (a number). The unit_amount represents the price of the product or service in the smallest unit of the currency (e.g., cents for USD). Proper validation helps avoid errors and ensures the correct data is sent to Stripe.

  • Creating the Stripe Price:

stripe.prices.create({...}, {...}): This method creates a new price object in Stripe. A price object ties together a specific monetary amount with the product or service it applies to.

Parameters:

  • unit_amount: This is the amount to be charged, in the smallest currency unit (e.g., 1000 for £10.00). It's important to calculate this amount based on your pricing strategy.

-currency: The currency in which the amount is charged. The function defaults to 'gbp' (British Pounds), but you can specify other currencies like 'usd' for US Dollars, 'eur' for Euros, etc.

  • product_data: This object contains details about the product, such as its name. If you haven’t created a product beforehand, this inline product_data object creates a new product in Stripe as part of the price creation process.

  • stripeAccount: This parameter specifies the connected Stripe account under which the price is being created. This is crucial for a platform that supports multiple sellers, as each seller needs to have their own set of prices under their connected Stripe account.

  • Logging and Returning the Price ID:

The function logs the creation of the price for debugging or audit purposes.

The function then returns the priceId to the client. This priceId is necessary for future transactions, such as creating a checkout session where this price is charged to the customer.

Why Create a Price in Stripe?

Creating a price in Stripe is essential for several reasons:

  • Product Pricing: You define the cost of your products or services using price objects. Each price object can be linked to a specific product, and you can create multiple prices for the same product (e.g., one-time purchase vs. subscription).

  • Currency Management: Stripe prices allow you to manage multi-currency pricing, ensuring your customers are charged the correct amount in their local currency.

  • Recurring Billing: If your business model involves subscriptions, creating a price is a crucial step in setting up recurring payments for your services.

Implementing the Flutter Side with Riverpod

Now that we've set up our Firebase Functions to handle Stripe operations, let's implement the Flutter side of things. We'll use Riverpod for state management, which will help us keep our code clean and maintainable. We'll break this down into several key components: the service layer, the state management, and the UI.

Step 1: Setting Up the Payment Service

First, let's create a service class that will handle all our Stripe-related operations. This service will communicate with our Firebase Functions and manage the payment flow.

// lib/services/payment_service.dart
class PaymentService {
  final FirebaseFunctions functions;
  final FirebaseAuth auth;
  final FirebaseFirestore db;

  PaymentService({
    required this.functions,
    required this.auth,
    required this.db,
  });

  Future<String> createStandardAccount(String email) async {
    try {
      final callable = functions.httpsCallable('stripe-createStripeAccountAndLink');
      final result = await callable.call({
        'email': email,
        'userId': auth.currentUser?.uid,
      });

      return result.data['url'];
    } catch (e) {
      debugPrint('Error creating standard account: $e');
      rethrow;
    }
  }

  Future<String> createStripeCustomer(
    String email,
    String name,
    String accountId,
  ) async {
    try {
      final callable = functions.httpsCallable('stripe-createStripeCustomer');
      final result = await callable.call({
        'email': email,
        'name': name,
        'stripeAccountId': accountId,
      });

      return result.data['customerId'];
    } catch (e) {
      debugPrint('Error creating stripe customer: $e');
      rethrow;
    }
  }

  Future<String> createPrice(
    int unitAmount,
    String currency,
    String productName,
    String stripeAccountId,
  ) async {
    try {
      final callable = functions.httpsCallable('stripe-createStripePrice');
      final result = await callable.call({
        'unit_amount': unitAmount,
        'currency': currency,
        'name': productName,
        'stripeAccountId': stripeAccountId,
      });
      return result.data['priceId'];
    } catch (e) {
      debugPrint('Error creating price: $e');
      rethrow;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Setting Up Riverpod Providers

Now, let's create our providers to manage the state of our payment operations. We'll create providers for the service itself and for managing the payment flow state.

// lib/providers/payment_providers.dart
final paymentServiceProvider = Provider<PaymentService>((ref) {
  return PaymentService(
    functions: FirebaseFunctions.instance,
    auth: FirebaseAuth.instance,
    db: FirebaseFirestore.instance,
  );
});

// State notifier for managing payment flow
class PaymentStateNotifier extends StateNotifier<AsyncValue<void>> {
  final PaymentService _paymentService;

  PaymentStateNotifier(this._paymentService) : super(const AsyncValue.data(null));

  Future<String> setupSellerAccount(String email) async {
    state = const AsyncValue.loading();
    try {
      final url = await _paymentService.createStandardAccount(email);
      state = const AsyncValue.data(null);
      return url;
    } catch (e, stack) {
      state = AsyncValue.error(e, stack);
      rethrow;
    }
  }

  Future<String> setupCustomer(
    String email,
    String name,
    String accountId,
  ) async {
    state = const AsyncValue.loading();
    try {
      final customerId = await _paymentService.createStripeCustomer(
        email,
        name,
        accountId,
      );
      state = const AsyncValue.data(null);
      return customerId;
    } catch (e, stack) {
      state = AsyncValue.error(e, stack);
      rethrow;
    }
  }
}

final paymentStateProvider =
    StateNotifierProvider<PaymentStateNotifier, AsyncValue<void>>((ref) {
  final paymentService = ref.watch(paymentServiceProvider);
  return PaymentStateNotifier(paymentService);
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Implementing the UI

Now let's create a screen that allows sellers to set up their Stripe account. We'll create a simple form that collects the necessary information and initiates the Stripe onboarding process.

// lib/screens/stripe_setup_screen.dart
class StripeSetupScreen extends ConsumerWidget {
  const StripeSetupScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final paymentState = ref.watch(paymentStateProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Set Up Payments')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: paymentState.when(
          data: (_) => const StripeSetupForm(),
          loading: () => const Center(child: CircularProgressIndicator()),
          error: (error, _) => Center(
            child: Text('Error: ${error.toString()}'),
          ),
        ),
      ),
    );
  }
}

class StripeSetupForm extends ConsumerStatefulWidget {
  const StripeSetupForm({Key? key}) : super(key: key);

  @override
  _StripeSetupFormState createState() => _StripeSetupFormState();
}

class _StripeSetupFormState extends ConsumerState<StripeSetupForm> {
  final _emailController = TextEditingController();

  Future<void> _setupStripeAccount() async {
    try {
      final url = await ref
          .read(paymentStateProvider.notifier)
          .setupSellerAccount(_emailController.text);

      // Launch the URL using url_launcher package
      if (await canLaunch(url)) {
        await launch(url);
      }
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Error: ${e.toString()}')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        TextField(
          controller: _emailController,
          decoration: const InputDecoration(
            labelText: 'Email',
            hintText: 'Enter your email address',
          ),
          keyboardType: TextInputType.emailAddress,
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: _setupStripeAccount,
          child: const Text('Set Up Stripe Account'),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _emailController.dispose();
    super.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Handling the Payment Flow

Finally, let's create a widget that handles the actual payment process. This widget will create a price for a product or service and initiate the payment flow.

// lib/widgets/payment_button.dart
class PaymentButton extends ConsumerWidget {
  final String productName;
  final int amount;
  final String stripeAccountId;

  const PaymentButton({
    Key? key,
    required this.productName,
    required this.amount,
    required this.stripeAccountId,
  }) : super(key: key);

  Future<void> _handlePayment(BuildContext context, WidgetRef ref) async {
    try {
      // Create a price for the product
      final priceId = await ref.read(paymentServiceProvider).createPrice(
            amount,
            'gbp',
            productName,
            stripeAccountId,
          );

      // Here you would typically create a checkout session and redirect to payment
      // This part would depend on your specific implementation needs

    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Error: ${e.toString()}')),
      );
    }
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ElevatedButton(
      onPressed: () => _handlePayment(context, ref),
      child: const Text('Pay Now'),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the Payment Implementation

Now that we have all the pieces in place, here's how you would typically use this implementation in your app:

  1. For Sellers:

    • Direct them to the StripeSetupScreen to create their Stripe account
    • Once they complete the onboarding, store their stripeAccountId in your database
  2. For Buyers:

    • Create a Stripe customer for them when they first attempt to make a purchase
    • Use the PaymentButton widget in your product/service detail screens

Here's a quick example of how to integrate the PaymentButton into a product screen:

class ProductDetailScreen extends StatelessWidget {
  final String sellerStripeAccountId;
  final String productName;
  final int productPrice;

  const ProductDetailScreen({
    Key? key,
    required this.sellerStripeAccountId,
    required this.productName,
    required this.productPrice,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(productName)),
      body: Column(
        children: [
          // Your product details here
          PaymentButton(
            productName: productName,
            amount: productPrice,
            stripeAccountId: sellerStripeAccountId,
          ),
        ],
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices and Considerations

When implementing this payment system, keep these important points in mind:

  1. Error Handling:

    • Always implement proper error handling and display user-friendly error messages
    • Log errors appropriately for debugging purposes
  2. State Management:

    • Use Riverpod's state management to handle loading states and errors effectively
    • Consider implementing proper error recovery mechanisms
  3. Security:

    • Never store sensitive payment information on your servers
    • Always validate user input before sending it to your Firebase Functions
  4. Testing:

    • Use Stripe's test mode for development
    • Test various error scenarios and edge cases
    • Implement proper unit tests for your payment service

By following this implementation guide, you'll have a robust payment system that can handle marketplace-style transactions while keeping your code clean and maintainable with Riverpod's state management.

Top comments (0)