Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

opt-in to baas features #113

Merged
merged 1 commit into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions client/components/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import {useLocation} from 'react-router-dom';
import {useMutation} from 'react-query';
import Stripe from 'stripe';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
Expand All @@ -19,6 +20,7 @@ import {useDisplayShortName} from '../hooks/useDisplayName';
import {OnboardingNotice} from './OnboardingNotice';
import {RouterLink} from './RouterLink';
import {useConnectJSContext} from '../hooks/ConnectJSProvider';
import {stripe} from '../../server/routes/stripeSdk';

const useLogout = () => {
const {search} = useLocation();
Expand All @@ -38,7 +40,7 @@ const useLogout = () => {
type Page = {
name: string;
href: string;
requiredCapabilities?: string[];
shouldDisplayFilter?: (stripeAccount: Stripe.Account) => boolean;
};

const authenticatedRoutes: Page[] = [
Expand All @@ -48,7 +50,10 @@ const authenticatedRoutes: Page[] = [
{
name: 'Finance',
href: '/finance',
requiredCapabilities: ['card_issuing', 'treasury'],
shouldDisplayFilter: (stripeAccount) =>
stripeAccount.controller?.dashboard?.type === 'none' &&
stripeAccount.controller?.application?.loss_liable === true &&
stripeAccount.controller?.application?.onboarding_owner === true,
},
];
const unauthenticatedRoutes: Page[] = [
Expand Down Expand Up @@ -86,6 +91,7 @@ export const NavBar = () => {
if (user && !stripeAccount) {
return null;
}

return (
<Box
sx={{
Expand All @@ -99,21 +105,14 @@ export const NavBar = () => {
}}
>
{routes
// For paths that have required capabilities, filter out
// the ones that have yet to be requested. In the case
// a capability is not active, the Page is responsible for
// calling-out or re-directing the user to the appropriate
// page to resolve the requirement.
.filter(({requiredCapabilities}) => {
// Not all pages require capabalities. If none provided, continue.
if (!requiredCapabilities) {
// For paths that only support certain controller shapes, filter out the ones that don't match.
.filter(({shouldDisplayFilter}) => {
// Not all pages require a filter.
if (!shouldDisplayFilter || !stripeAccount) {
return true;
}

const capabilities = Object.keys(stripeAccount?.capabilities || []);
return requiredCapabilities.every((capability) =>
capabilities.includes(capability)
);
return shouldDisplayFilter(stripeAccount);
})
.map(({name, href}) => (
<Link component={RouterLink} key={name} to={href} underline="none">
Expand Down
73 changes: 73 additions & 0 deletions client/components/RequestCapabilities.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';
import {useMutation} from 'react-query';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import {Container} from '../components/Container';

const useRequestCapabilities = (capabilities: string[]) => {
return useMutation<void, Error>('requestCapabilities', async () => {
const response = await fetch('/request-capabilities', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
capabilities: capabilities,
}),
});
if (!response.ok) {
const json = await response.json();
throw new Error(json?.error ?? 'An error ocurred, please try again.');
}
});
};

type RequestCapabilitiesProps = {
capabilities: any;
title: string;
description: string;
onSuccess: () => void;
};

export const RequestCapabilities = ({
title,
description,
capabilities,
onSuccess,
}: RequestCapabilitiesProps) => {
const {mutate} = useRequestCapabilities(capabilities);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The loading and error state isn't used here (we should display a spinner/loading state and render an error if one occurred)


return (
<Container sx={{alignItems: 'center', gap: 4, marginBottom: 2}}>
<Typography
variant="h5"
sx={{
textAlign: 'center',
}}
>
{title}
</Typography>
<Typography
variant="h6"
sx={{
textAlign: 'center',
}}
>
{description}
</Typography>
<Button
type="submit"
variant="contained"
sx={{
fontWeight: 700,
}}
onClick={async () => {
await mutate();
onSuccess();
}}
>
Enable
</Button>
</Container>
);
};
42 changes: 38 additions & 4 deletions client/routes/Finance.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import {useNavigate} from 'react-router-dom';
import {useMutation, useQuery} from 'react-query';
import {useTheme} from '@mui/system';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Link from '@mui/material/Link';
Expand All @@ -11,6 +12,7 @@ import {
ConnectIssuingCardsList,
ConnectNotificationBanner,
} from '@stripe/react-connect-js';
import {useSession} from '../hooks/SessionProvider';
import {
EmbeddedComponentContainer,
EmbeddedContainer,
Expand All @@ -20,6 +22,7 @@ import {StripeConnectDebugUtils} from '../components/StripeConnectDebugUtils';
import {CardFooter} from '../components/CardFooter';
import {FullScreenLoading} from '../components/FullScreenLoading';
import {ErrorState} from '../components/ErrorState';
import {RequestCapabilities} from '../components/RequestCapabilities';

const useCreateReceivedCredit = () => {
const {data: financialAccount} = useFinancialAccount();
Expand Down Expand Up @@ -55,15 +58,48 @@ const useFinancialAccount = () => {
};

export const Finance = () => {
const navigate = useNavigate();
const {stripeAccount} = useSession();

if (!stripeAccount || !stripeAccount.details_submitted) {
return <div>To enable Finance, please complete onboarding.</div>;
}

const hasIssuingAndTreasury = ['card_issuing', 'treasury'].every(
(capability) =>
Object.keys(stripeAccount?.capabilities || []).includes(capability)
);

if (!hasIssuingAndTreasury) {
return (
<RequestCapabilities
title={'Enable Finance'}
description={
'Click "Enable" to get started with a financial account and access to spend cards.'
}
capabilities={{
card_issuing: {
requested: true,
},
treasury: {
requested: true,
},
}}
onSuccess={async () => {
await navigate('/onboarding');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await navigate('/onboarding');
// After requesting the capabilities successfully, we force a full page reload
await navigate('/onboarding');

navigate(0);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "works" -- the reason for the navigate + refresh is that when i navigate to /onboarding, the updated capability request isn't reflected yet, so when the user submits, they haven't attested to the issuing/treasury ToS. but with the refresh, it forces a reload (and delay?) so it gets picked up.

}}
/>
);
}

const {
data: financialAccount,
isLoading: loading,
error: useFinancialAccountError,
refetch,
} = useFinancialAccount();

const navigate = useNavigate();

const {
status,
mutate,
Expand Down Expand Up @@ -101,8 +137,6 @@ export const Finance = () => {
return 'Create a test received credit';
};

console.log(financialAccount);

const renderFooter = () => {
return (
<CardFooter title={renderFooterTitle()} disabled={disabled}>
Expand Down
94 changes: 58 additions & 36 deletions server/routes/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,17 +185,6 @@ function getAccountParams(
type: 'none', // The connected account will not have access to dashboard
},
};

// Issuing and Banking products only work on accounts where the platform owns requirements collection
capabilities = {
...capabilities,
card_issuing: {
requested: true,
},
treasury: {
requested: true,
},
};
break;
case 'dashboard_soll':
capabilities = undefined;
Expand Down Expand Up @@ -417,31 +406,6 @@ app.post('/create-account', userRequired, async (req, res) => {
},
});
}

// If the account is no_dashboard_poll, create a financial account.
if (accountConfiguration === 'no_dashboard_poll') {
const financialAccount = await stripe.treasury.financialAccounts.create(
{
supported_currencies: ['usd'],
features: {
card_issuing: {requested: true},
deposit_insurance: {requested: true},
financial_addresses: {aba: {requested: true}},
inbound_transfers: {ach: {requested: true}},
intra_stripe_flows: {requested: true},
outbound_payments: {
ach: {requested: true},
us_domestic_wire: {requested: true},
},
outbound_transfers: {
ach: {requested: true},
us_domestic_wire: {requested: true},
},
},
},
{stripeAccount: accountId}
);
}
}

return res.status(200).end();
Expand Down Expand Up @@ -859,6 +823,64 @@ app.post('/create-bank-account', stripeAccountRequired, async (req, res) => {
}
});

/**
* POST /request-capabilities
*
* Enables requesting the specified capabilities.
*/
app.post('/request-capabilities', stripeAccountRequired, async (req, res) => {
const user = req.user!;
const {capabilities} = req.body;

try {
await stripe.accounts.update(user.stripeAccountId, {
capabilities,
});

// If the user requested Treasury, create a financial account if none exists
if (capabilities.treasury?.requested) {
const financialAccounts = await stripe.treasury.financialAccounts.list(
{
limit: 1,
},
{
stripeAccount: user.stripeAccountId,
}
);

if (financialAccounts.data.length === 0) {
await stripe.treasury.financialAccounts.create(
{
supported_currencies: ['usd'],
features: {
card_issuing: {requested: true},
deposit_insurance: {requested: true},
financial_addresses: {aba: {requested: true}},
inbound_transfers: {ach: {requested: true}},
intra_stripe_flows: {requested: true},
outbound_payments: {
ach: {requested: true},
us_domestic_wire: {requested: true},
},
outbound_transfers: {
ach: {requested: true},
us_domestic_wire: {requested: true},
},
},
},
{stripeAccount: user.stripeAccountId}
);
}
}

return res.status(200).end();
} catch (error: any) {
console.error(error);
res.status(500);
return res.send({error: error.message});
}
});

/**
* GET /financial-account
*
Expand Down
Loading
Loading