Skip to content

Commit

Permalink
opt-in to financial features (#113)
Browse files Browse the repository at this point in the history
  • Loading branch information
stevekaliski-stripe committed Apr 22, 2024
1 parent 60c6ed6 commit b7720ba
Show file tree
Hide file tree
Showing 5 changed files with 1,572 additions and 1,241 deletions.
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);

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');
navigate(0);
}}
/>
);
}

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

0 comments on commit b7720ba

Please sign in to comment.