Skip to content

Commit

Permalink
Stripe connect (payments rework) (#1746)
Browse files Browse the repository at this point in the history
closes #1597 

- [x] Setup stripe connect onboarding flow on backend and app
- [x] Paypal details flow on backend and app
- [x] Link app payments to connected accounts through destination
charges
- [x] Handle all onboarding flow cases in app
- [x] Separate payments page like Shopify
- [x] Cleanup and test all cases
- [x] Fix minor issues

Points to note:
- ~New payment links will have to be created for current paid apps to
add destination charge~
- ~I set transfer type to destination charge instead of direct charge
(in case of direct charge, the user will need to provide a lot more
stuff for identity verification). In destination charge we will have to
pay the stripe fees (hence I set the marketplace fee as 10% to cover the
stripe fees), whereas in direct charge the stripe fee is deducted from
the amount (alongside the marketplace fee which we define) and then the
remaining amount is transferred to connected account.~
- ~With this PR, it will be mandatory to connect stripe to be able to
make paid apps~
- We (Omi Team) will be bearing the stripe fees and there will be no fee
for creators


https://github.com/user-attachments/assets/0bc62c35-5c1d-49c0-b248-9c57a10c6b7f
  • Loading branch information
beastoin authored Feb 9, 2025
2 parents f3abe9d + 6d2d6ba commit d345e61
Show file tree
Hide file tree
Showing 21 changed files with 1,803 additions and 27 deletions.
1 change: 1 addition & 0 deletions app/assets/images/stripe_logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 8 additions & 4 deletions app/lib/backend/http/api/apps.dart
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ Future<bool> isAppSetupCompleted(String? url) async {
}
}

Future<bool> submitAppServer(File file, Map<String, dynamic> appData) async {
Future<(bool, String)> submitAppServer(File file, Map<String, dynamic> appData) async {
var request = http.MultipartRequest(
'POST',
Uri.parse('${Env.apiBaseUrl}v1/apps'),
Expand All @@ -172,14 +172,18 @@ Future<bool> submitAppServer(File file, Map<String, dynamic> appData) async {

if (response.statusCode == 200) {
debugPrint('submitAppServer Response body: ${jsonDecode(response.body)}');
return true;
return (true, '');
} else {
debugPrint('Failed to submit app. Status code: ${response.statusCode}');
return false;
if (response.body.isNotEmpty) {
return (false, jsonDecode(response.body)['detail'] as String);
} else {
return (false, 'Failed to submit app. Please try again later');
}
}
} catch (e) {
debugPrint('An error occurred submitAppServer: $e');
return false;
return (false, 'Failed to submit app. Please try again later');
}
}

Expand Down
114 changes: 114 additions & 0 deletions app/lib/backend/http/api/payments.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'dart:convert';

import 'package:friend_private/backend/http/shared.dart';
import 'package:friend_private/env/env.dart';
import 'package:friend_private/pages/payments/models/payment_method_config.dart';
import 'package:friend_private/utils/logger.dart';

Future<Map<String, dynamic>?> getStripeAccountLink() async {
try {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/stripe/connect-accounts',
headers: {},
body: '',
method: 'POST',
);
if (response == null || response.statusCode != 200) {
return null;
}
return jsonDecode(response.body);
} catch (e) {
Logger.error(e);
return null;
}
}

Future<bool> isStripeOnboardingComplete() async {
try {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/stripe/onboarded',
headers: {},
body: '',
method: 'GET',
);
if (response == null || response.statusCode != 200) {
return false;
}
return jsonDecode(response.body)['onboarding_complete'];
} catch (e) {
Logger.error(e);
return false;
}
}

Future<bool> savePayPalDetails(String email, String link) async {
try {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/paypal/payment-details',
headers: {},
body: jsonEncode({'email': email, 'paypalme_url': link}),
method: 'POST',
);
if (response == null || response.statusCode != 200) {
return false;
}
return true;
} catch (e) {
Logger.error(e);
return false;
}
}

Future<Map<String, dynamic>?> fetchPaymentMethodsStatus() async {
try {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/payment-methods/status',
headers: {},
body: '',
method: 'GET',
);
if (response == null || response.statusCode != 200) {
return null;
}
return jsonDecode(response.body);
} catch (e) {
Logger.error(e);
return null;
}
}

Future<PayPalDetails?> fetchPayPalDetails() async {
try {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/paypal/payment-details',
headers: {},
body: '',
method: 'GET',
);
if (response == null || response.statusCode != 200) {
return null;
}
return PayPalDetails.fromJson(jsonDecode(response.body));
} catch (e) {
Logger.error(e);
return null;
}
}

Future<bool> setDefaultPaymentMethod(String method) async {
try {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/payment-methods/default',
headers: {},
body: jsonEncode({'method': method}),
method: 'POST',
);
if (response == null || response.statusCode != 200) {
return false;
}
return true;
} catch (e) {
Logger.error(e);
return false;
}
}
6 changes: 5 additions & 1 deletion app/lib/gen/assets.gen.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import 'package:friend_private/providers/home_provider.dart';
import 'package:friend_private/providers/conversation_provider.dart';
import 'package:friend_private/providers/message_provider.dart';
import 'package:friend_private/providers/onboarding_provider.dart';
import 'package:friend_private/pages/payments/payment_method_provider.dart';
import 'package:friend_private/providers/speech_profile_provider.dart';
import 'package:friend_private/services/notifications.dart';
import 'package:friend_private/services/services.dart';
Expand Down Expand Up @@ -203,6 +204,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
update: (BuildContext context, value, AddAppProvider? previous) =>
(previous?..setAppProvider(value)) ?? AddAppProvider(),
),
ChangeNotifierProvider(create: (context) => PaymentMethodProvider()),
],
builder: (context, child) {
return WithForegroundTask(
Expand Down
5 changes: 4 additions & 1 deletion app/lib/pages/apps/app_detail/app_detail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ class _AppDetailPageState extends State<AppDetailPage> {
MixpanelManager().appPurchaseStarted(appId);
_paymentCheckTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
var prefs = SharedPreferencesUtil();
setState(() => appLoading = true);
if (mounted) {
setState(() => appLoading = true);
}

var details = await getAppDetailsServer(appId);
if (details != null && details['is_user_paid']) {
var enabled = await enableAppServer(appId);
Expand Down
6 changes: 4 additions & 2 deletions app/lib/pages/apps/providers/add_app_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ class AddAppProvider extends ChangeNotifier {

Future<void> updateApp() async {
setIsUpdating(true);

Map<String, dynamic> data = {
'name': appNameController.text,
'description': appDescriptionController.text,
Expand Down Expand Up @@ -468,6 +469,7 @@ class AddAppProvider extends ChangeNotifier {

Future<void> submitApp() async {
setIsSubmitting(true);

Map<String, dynamic> data = {
'name': appNameController.text,
'description': appDescriptionController.text,
Expand Down Expand Up @@ -513,12 +515,12 @@ class AddAppProvider extends ChangeNotifier {
}
}
var res = await submitAppServer(imageFile!, data);
if (res) {
if (res.$1) {
AppSnackbar.showSnackbarSuccess('App submitted successfully 🚀');
appProvider!.getApps();
clear();
} else {
AppSnackbar.showSnackbarError('Failed to submit app. Please try again later');
AppSnackbar.showSnackbarError(res.$2);
}
checkValidity();
setIsSubmitting(false);
Expand Down
88 changes: 88 additions & 0 deletions app/lib/pages/payments/models/payment_method_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:friend_private/gen/assets.gen.dart';

class PaymentMethodConfig {
final String title;
final Widget icon;
final Color backgroundColor;
final VoidCallback onManageTap;
final VoidCallback? onSetActiveTap;
final bool isActive;
final bool isConnected;

const PaymentMethodConfig({
required this.title,
required this.icon,
required this.backgroundColor,
required this.onManageTap,
this.onSetActiveTap,
this.isActive = false,
this.isConnected = false,
});

String get subtitle => isActive ? 'Active' : (isConnected ? 'Connected' : 'Not Connected');

static PaymentMethodConfig stripe({
required VoidCallback onManageTap,
VoidCallback? onSetActiveTap,
bool isActive = false,
bool isConnected = false,
}) {
return PaymentMethodConfig(
title: 'Stripe',
icon: SvgPicture.asset(
Assets.images.stripeLogo,
width: 80,
color: Colors.white,
),
backgroundColor: isActive ? const Color(0xFF635BFF) : Colors.grey.shade800,
onManageTap: onManageTap,
onSetActiveTap: onSetActiveTap,
isActive: isActive,
isConnected: isConnected,
);
}

static PaymentMethodConfig paypal({
required VoidCallback onManageTap,
VoidCallback? onSetActiveTap,
bool isActive = false,
bool isConnected = false,
}) {
return PaymentMethodConfig(
title: 'PayPal',
icon: const Icon(
Icons.paypal,
size: 32,
color: Colors.white,
),
backgroundColor: isActive ? const Color(0xFF003087) : Colors.grey.shade800,
onManageTap: onManageTap,
onSetActiveTap: onSetActiveTap,
isActive: isActive,
isConnected: isConnected,
);
}
}

class PayPalDetails {
final String email;
final String link;

PayPalDetails({required this.email, required this.link});

Map<String, dynamic> toJson() {
return {
'email': email,
'paypalme_url': link,
};
}

factory PayPalDetails.fromJson(Map<String, dynamic> json) {
return PayPalDetails(
email: json['email'],
link: json['paypalme_url'],
);
}
}
Loading

0 comments on commit d345e61

Please sign in to comment.