feat: Stripe connect using OAuth

This commit is contained in:
Ahmed Bouhuolia
2024-09-24 14:10:53 +02:00
parent 70bba4a6ed
commit b125e3e58b
26 changed files with 493 additions and 98 deletions

View File

@@ -13,6 +13,13 @@ export class StripeIntegrationController extends BaseController {
public router() { public router() {
const router = Router(); const router = Router();
router.get('/link', this.getStripeConnectLink.bind(this));
router.post(
'/callback',
[body('code').exists()],
this.validationResult,
this.exchangeOAuth.bind(this)
);
router.post('/account', asyncMiddleware(this.createAccount.bind(this))); router.post('/account', asyncMiddleware(this.createAccount.bind(this)));
router.post( router.post(
'/account_link', '/account_link',
@@ -27,6 +34,47 @@ export class StripeIntegrationController extends BaseController {
return router; return router;
} }
/**
* Retrieves Stripe OAuth2 connect link.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|void>}
*/
public async getStripeConnectLink(
req: Request,
res: Response,
next: NextFunction
) {
try {
const authorizationUri = this.stripePaymentApp.getStripeConnectLink();
return res.status(200).send({ url: authorizationUri });
} catch (error) {
next(error);
}
}
/**
* Exchanges the given Stripe authorization code to Stripe user id and access token.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<void>}
*/
public async exchangeOAuth(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const { code } = this.matchedBodyData(req);
try {
await this.stripePaymentApp.exchangeStripeOAuthToken(tenantId, code);
return res.status(200).send({});
} catch (error) {
next(error);
}
}
/** /**
* Creates a Stripe checkout session for the given payment link id. * Creates a Stripe checkout session for the given payment link id.
* @param {Request} req * @param {Request} req

View File

@@ -268,6 +268,8 @@ module.exports = {
stripePayment: { stripePayment: {
secretKey: process.env.STRIPE_PAYMENT_SECRET_KEY || '', secretKey: process.env.STRIPE_PAYMENT_SECRET_KEY || '',
publishableKey: process.env.STRIPE_PAYMENT_PUBLISHABLE_KEY || '', publishableKey: process.env.STRIPE_PAYMENT_PUBLISHABLE_KEY || '',
clientId: process.env.STRIPE_PAYMENT_CLIENT_ID || '',
redirectTo: process.env.STRIPE_PAYMENT_REDIRECT_URL || '',
webhooksSecret: process.env.STRIPE_PAYMENT_WEBHOOKS_SECRET || '', webhooksSecret: process.env.STRIPE_PAYMENT_WEBHOOKS_SECRET || '',
}, },
}; };

View File

@@ -8,7 +8,8 @@ exports.up = function (knex) {
table.string('service'); table.string('service');
table.string('name'); table.string('name');
table.string('slug'); table.string('slug');
table.boolean('active').defaultTo(false); table.boolean('payment_enabled').defaultTo(false);
table.boolean('payout_enabled').defaultTo(false);
table.string('account_id'); table.string('account_id');
table.json('options'); table.json('options');
table.timestamps(); table.timestamps();

View File

@@ -31,6 +31,17 @@ export const PrepardExpenses = {
predefined: true, predefined: true,
}; };
export const StripeClearingAccount = {
name: 'Stripe Clearing',
slug: 'stripe-clearing',
account_type: 'other-current-liability',
parent_account_id: null,
code: '50006',
active: true,
index: 1,
predefined: true,
}
export default [ export default [
{ {
name: 'Bank Account', name: 'Bank Account',

View File

@@ -120,6 +120,7 @@ import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/ev
import { EventsTrackerListeners } from '@/services/EventsTracker/events/events'; import { EventsTrackerListeners } from '@/services/EventsTracker/events/events';
import { InvoicePaymentIntegrationSubscriber } from '@/services/Sales/Invoices/subscribers/InvoicePaymentIntegrationSubscriber'; import { InvoicePaymentIntegrationSubscriber } from '@/services/Sales/Invoices/subscribers/InvoicePaymentIntegrationSubscriber';
import { StripeWebhooksSubscriber } from '@/services/StripePayment/events/StripeWebhooksSubscriber'; import { StripeWebhooksSubscriber } from '@/services/StripePayment/events/StripeWebhooksSubscriber';
import { SeedStripeAccountsOnOAuthGrantedSubscriber } from '@/services/StripePayment/events/SeedStripeAccounts';
export default () => { export default () => {
return new EventPublisher(); return new EventPublisher();
@@ -294,6 +295,7 @@ export const susbcribers = () => {
// Stripe Payment // Stripe Payment
InvoicePaymentIntegrationSubscriber, InvoicePaymentIntegrationSubscriber,
StripeWebhooksSubscriber, StripeWebhooksSubscriber,
SeedStripeAccountsOnOAuthGrantedSubscriber,
...EventsTrackerListeners ...EventsTrackerListeners
]; ];

View File

@@ -1,7 +1,10 @@
import { Model } from 'objection'; import { Model } from 'objection';
import TenantModel from 'models/TenantModel'; import TenantModel from 'models/TenantModel';
export class PaymentIntegration extends TenantModel { export class PaymentIntegration extends Model {
paymentEnabled!: boolean;
payoutEnabled!: boolean;
static get tableName() { static get tableName() {
return 'payment_integrations'; return 'payment_integrations';
} }
@@ -10,16 +13,35 @@ export class PaymentIntegration extends TenantModel {
return 'id'; return 'id';
} }
static get virtualAttributes() {
return ['fullEnabled'];
}
static get jsonAttributes() {
return ['options'];
}
get fullEnabled() {
return this.paymentEnabled && this.payoutEnabled;
}
static get jsonSchema() { static get jsonSchema() {
return { return {
type: 'object', type: 'object',
required: ['name', 'service', 'active'], required: ['name', 'service'],
properties: { properties: {
id: { type: 'integer' }, id: { type: 'integer' },
service: { type: 'string' }, service: { type: 'string' },
active: { type: 'boolean' }, paymentEnabled: { type: 'boolean' },
payoutEnabled: { type: 'boolean' },
accountId: { type: 'string' }, accountId: { type: 'string' },
options: { type: 'object' }, options: {
type: 'object',
properties: {
bankAccountId: { type: 'number' },
clearingAccountId: { type: 'number' },
},
},
createdAt: { type: 'string', format: 'date-time' }, createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' },
}, },

View File

@@ -4,6 +4,7 @@ import { IAccount } from '@/interfaces';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { import {
PrepardExpenses, PrepardExpenses,
StripeClearingAccount,
TaxPayableAccount, TaxPayableAccount,
UnearnedRevenueAccount, UnearnedRevenueAccount,
} from '@/database/seeds/data/accounts'; } from '@/database/seeds/data/accounts';
@@ -247,4 +248,37 @@ export default class AccountRepository extends TenantRepository {
} }
return result; return result;
} }
/**
* Finds or creates the stripe clearing account.
* @param {Record<string, string>} extraAttrs
* @param {Knex.Transaction} trx
* @returns
*/
public async findOrCreateStripeClearing(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction
) {
// Retrieves the given tenant metadata.
const tenantMeta = await TenantMetadata.query().findOne({
tenantId: this.tenantId,
});
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: StripeClearingAccount.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...StripeClearingAccount,
..._extraAttrs,
});
}
return result;
}
} }

View File

@@ -3,12 +3,16 @@ import HasTenancyService from '../Tenancy/TenancyService';
import { GetPaymentMethodsPOJO } from './types'; import { GetPaymentMethodsPOJO } from './types';
import config from '@/config'; import config from '@/config';
import { isStripePaymentConfigured } from './utils'; import { isStripePaymentConfigured } from './utils';
import { GetStripeAuthorizationLinkService } from '../StripePayment/GetStripeAuthorizationLink';
@Service() @Service()
export class GetPaymentMethodsStateService { export class GetPaymentMethodsStateService {
@Inject() @Inject()
private tenancy: HasTenancyService; private tenancy: HasTenancyService;
@Inject()
private getStripeAuthorizationLinkService: GetStripeAuthorizationLinkService;
/** /**
* Retrieves the payment state provising state. * Retrieves the payment state provising state.
* @param {number} tenantId * @param {number} tenantId
@@ -25,7 +29,9 @@ export class GetPaymentMethodsStateService {
service: 'Stripe', service: 'Stripe',
}); });
const isStripeAccountCreated = !!stripePayment; const isStripeAccountCreated = !!stripePayment;
const isStripePaymentActive = !!(stripePayment?.active || null); const isStripePaymentEnabled = stripePayment?.paymentEnabled;
const isStripePayoutEnabled = stripePayment?.payoutEnabled;
const isStripeEnabled = stripePayment?.fullEnabled;
const stripePaymentMethodId = stripePayment?.id || null; const stripePaymentMethodId = stripePayment?.id || null;
const stripeAccountId = stripePayment?.accountId || null; const stripeAccountId = stripePayment?.accountId || null;
@@ -33,16 +39,21 @@ export class GetPaymentMethodsStateService {
const stripeCurrencies = ['USD', 'EUR']; const stripeCurrencies = ['USD', 'EUR'];
const stripeRedirectUrl = 'https://your-stripe-redirect-url.com'; const stripeRedirectUrl = 'https://your-stripe-redirect-url.com';
const isStripeServerConfigured = isStripePaymentConfigured(); const isStripeServerConfigured = isStripePaymentConfigured();
const stripeAuthLink =
this.getStripeAuthorizationLinkService.getStripeAuthLink();
const paymentMethodPOJO: GetPaymentMethodsPOJO = { const paymentMethodPOJO: GetPaymentMethodsPOJO = {
stripe: { stripe: {
isStripeAccountCreated, isStripeAccountCreated,
isStripePaymentActive, isStripePaymentEnabled,
isStripePayoutEnabled,
isStripeEnabled,
isStripeServerConfigured, isStripeServerConfigured,
stripeAccountId, stripeAccountId,
stripePaymentMethodId, stripePaymentMethodId,
stripePublishableKey, stripePublishableKey,
stripeCurrencies, stripeCurrencies,
stripeAuthLink,
stripeRedirectUrl, stripeRedirectUrl,
}, },
}; };

View File

@@ -16,11 +16,17 @@ export interface EditPaymentMethodDTO {
export interface GetPaymentMethodsPOJO { export interface GetPaymentMethodsPOJO {
stripe: { stripe: {
isStripeAccountCreated: boolean; isStripeAccountCreated: boolean;
isStripePaymentActive: boolean;
isStripePaymentEnabled: boolean;
isStripePayoutEnabled: boolean;
isStripeEnabled: boolean;
isStripeServerConfigured: boolean; isStripeServerConfigured: boolean;
stripeAccountId: string | null; stripeAccountId: string | null;
stripePaymentMethodId: number | null; stripePaymentMethodId: number | null;
stripePublishableKey: string | null; stripePublishableKey: string | null;
stripeAuthLink: string;
stripeCurrencies: Array<string>; stripeCurrencies: Array<string>;
stripeRedirectUrl: string | null; stripeRedirectUrl: string | null;
}; };

View File

@@ -35,7 +35,8 @@ export class GetSaleInvoice {
.withGraphFetched('customer') .withGraphFetched('customer')
.withGraphFetched('branch') .withGraphFetched('branch')
.withGraphFetched('taxes.taxRate') .withGraphFetched('taxes.taxRate')
.withGraphFetched('attachments'); .withGraphFetched('attachments')
.withGraphFetched('paymentMethods');
// Validates the given sale invoice existance. // Validates the given sale invoice existance.
this.validators.validateInvoiceExistance(saleInvoice); this.validators.validateInvoiceExistance(saleInvoice);

View File

@@ -0,0 +1,73 @@
import { Inject, Service } from 'typedi';
import { StripePaymentService } from './StripePaymentService';
import events from '@/subscribers/events';
import HasTenancyService from '../Tenancy/TenancyService';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import UnitOfWork from '../UnitOfWork';
import { Knex } from 'knex';
import { StripeOAuthCodeGrantedEventPayload } from './types';
@Service()
export class ExchangeStripeOAuthTokenService {
@Inject()
private stripePaymentService: StripePaymentService;
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private uow: UnitOfWork;
/**
* Exchange stripe oauth authorization code to access token and user id.
* @param {number} tenantId
* @param {string} authorizationCode
*/
public async excahngeStripeOAuthToken(
tenantId: number,
authorizationCode: string
) {
const { PaymentIntegration } = this.tenancy.models(tenantId);
const stripe = this.stripePaymentService.stripe;
const response = await stripe.oauth.token({
grant_type: 'authorization_code',
code: authorizationCode,
});
// const accessToken = response.access_token;
// const refreshToken = response.refresh_token;
const stripeUserId = response.stripe_user_id;
// Retrieves details of the Stripe account.
const account = await stripe.accounts.retrieve(stripeUserId, {
expand: ['business_profile'],
});
const companyName = account.business_profile?.name || 'Unknow name';
const paymentEnabled = account.charges_enabled;
const payoutEnabled = account.payouts_enabled;
//
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Stores the details of the Stripe account.
const paymentIntegration = await PaymentIntegration.query(trx).insert({
name: companyName,
service: 'Stripe',
accountId: stripeUserId,
paymentEnabled,
payoutEnabled,
});
// Triggers `onStripeOAuthCodeGranted` event.
await this.eventPublisher.emitAsync(
events.stripeIntegration.onOAuthCodeGranted,
{
tenantId,
paymentIntegrationId: paymentIntegration.id,
trx,
} as StripeOAuthCodeGrantedEventPayload
);
});
}
}

View File

@@ -0,0 +1,14 @@
import { Service } from 'typedi';
import config from '@/config';
@Service()
export class GetStripeAuthorizationLinkService {
public getStripeAuthLink() {
const clientId = config.stripePayment.clientId;
const redirectUrl = config.stripePayment.redirectTo;
const authorizationUri = `https://connect.stripe.com/oauth/v2/authorize?response_type=code&client_id=${clientId}&scope=read_write&redirect_uri=${redirectUrl}`;
return authorizationUri;
}
}

View File

@@ -4,6 +4,8 @@ import { StripeInvoiceCheckoutSessionPOJO } from '@/interfaces/StripePayment';
import { CreateStripeAccountService } from './CreateStripeAccountService'; import { CreateStripeAccountService } from './CreateStripeAccountService';
import { CreateStripeAccountLinkService } from './CreateStripeAccountLink'; import { CreateStripeAccountLinkService } from './CreateStripeAccountLink';
import { CreateStripeAccountDTO } from './types'; import { CreateStripeAccountDTO } from './types';
import { ExchangeStripeOAuthTokenService } from './ExchangeStripeOauthToken';
import { GetStripeAuthorizationLinkService } from './GetStripeAuthorizationLink';
export class StripePaymentApplication { export class StripePaymentApplication {
@Inject() @Inject()
@@ -15,6 +17,12 @@ export class StripePaymentApplication {
@Inject() @Inject()
private createInvoiceCheckoutSessionService: CreateInvoiceCheckoutSession; private createInvoiceCheckoutSessionService: CreateInvoiceCheckoutSession;
@Inject()
private exchangeStripeOAuthTokenService: ExchangeStripeOAuthTokenService;
@Inject()
private getStripeConnectLinkService: GetStripeAuthorizationLinkService;
/** /**
* Creates a new Stripe account for Bigcapital. * Creates a new Stripe account for Bigcapital.
* @param {number} tenantId * @param {number} tenantId
@@ -58,4 +66,24 @@ export class StripePaymentApplication {
paymentLinkId paymentLinkId
); );
} }
/**
* Retrieves Stripe OAuth2 connect link.
* @returns {string}
*/
public getStripeConnectLink() {
return this.getStripeConnectLinkService.getStripeAuthLink();
}
/**
* Exchanges the given Stripe authorization code to Stripe user id and access token.
* @param {string} authorizationCode
* @returns
*/
public exchangeStripeOAuthToken(tenantId: number, authorizationCode: string) {
return this.exchangeStripeOAuthTokenService.excahngeStripeOAuthToken(
tenantId,
authorizationCode
);
}
} }

View File

@@ -2,11 +2,11 @@ import { Service } from 'typedi';
import stripe from 'stripe'; import stripe from 'stripe';
import config from '@/config'; import config from '@/config';
const origin = 'http://localhost:4000'; const origin = 'https://cfdf-102-164-97-88.ngrok-free.app';
@Service() @Service()
export class StripePaymentService { export class StripePaymentService {
public stripe; public stripe: stripe;
constructor() { constructor() {
this.stripe = new stripe(config.stripePayment.secretKey, { this.stripe = new stripe(config.stripePayment.secretKey, {
@@ -62,8 +62,9 @@ export class StripePaymentService {
*/ */
public async createAccount(): Promise<string> { public async createAccount(): Promise<string> {
try { try {
const account = await this.stripe.accounts.create({}); const account = await this.stripe.accounts.create({
type: 'standard',
});
return account; return account;
} catch (error) { } catch (error) {
throw new Error( throw new Error(

View File

@@ -1 +0,0 @@
export const STRIPE_PAYMENT_LINK_REDIRECT = 'https://your_redirect_url.com';

View File

@@ -0,0 +1,49 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { StripeOAuthCodeGrantedEventPayload } from '../types';
@Service()
export class SeedStripeAccountsOnOAuthGrantedSubscriber {
@Inject()
private tenancy: HasTenancyService;
/**
* Attaches the subscriber to the event dispatcher.
*/
public attach(bus) {
bus.subscribe(
events.stripeIntegration.onOAuthCodeGranted,
this.handleSeedStripeAccount.bind(this)
);
}
/**
* Seeds the default integration settings once oauth authorization code granted.
* @param {StripeCheckoutSessionCompletedEventPayload} payload -
*/
async handleSeedStripeAccount({
tenantId,
paymentIntegrationId,
trx,
}: StripeOAuthCodeGrantedEventPayload) {
const { PaymentIntegration } = this.tenancy.models(tenantId);
const { accountRepository } = this.tenancy.repositories(tenantId);
const clearingAccount = await accountRepository.findOrCreateStripeClearing(
{},
trx
);
const bankAccount = await accountRepository.findBySlug('bank-account');
// Patch the Stripe integration default settings.
await PaymentIntegration.query(trx)
.findById(paymentIntegrationId)
.patch({
options: {
bankAccountId: bankAccount.id,
clearingAccountId: clearingAccount.id,
},
});
}
}

View File

@@ -1,6 +1,11 @@
import { Knex } from 'knex';
export interface CreateStripeAccountDTO { export interface CreateStripeAccountDTO {
name?: string; name?: string;
} }
export interface StripeOAuthCodeGrantedEventPayload {
tenantId: number;
paymentIntegrationId: number;
trx?: Knex.Transaction
}

View File

@@ -723,7 +723,9 @@ export default {
onAccountDeleted: 'onStripeIntegrationAccountDeleted', onAccountDeleted: 'onStripeIntegrationAccountDeleted',
onPaymentLinkCreated: 'onStripePaymentLinkCreated', onPaymentLinkCreated: 'onStripePaymentLinkCreated',
onPaymentLinkInactivated: 'onStripePaymentLinkInactivated' onPaymentLinkInactivated: 'onStripePaymentLinkInactivated',
onOAuthCodeGranted: 'onStripeOAuthCodeGranted',
}, },
// Stripe Payment Webhooks // Stripe Payment Webhooks

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { useEffect } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useSetStripeAccountCallback } from '@/hooks/query/stripe-integration';
function useQuery() {
const { search } = useLocation();
return React.useMemo(() => new URLSearchParams(search), [search]);
}
export default function PreferencesStripeCallback() {
const query = useQuery();
const code = query.get('code') as string;
const { mutateAsync: stripeAccountCallback } = useSetStripeAccountCallback();
const history = useHistory();
useEffect(() => {
stripeAccountCallback({ code }).then(() => {
history.push('/preferences/payment-methods')
});
}, [history, stripeAccountCallback, code]);
return null;
}

View File

@@ -10,8 +10,9 @@ import {
Popover, Popover,
Tag, Tag,
Text, Text,
Tooltip,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { AppToaster, Box, Card, Group, Stack } from '@/components'; import { Box, Card, Group, Stack } from '@/components';
import { StripeLogo } from '@/icons/StripeLogo'; import { StripeLogo } from '@/icons/StripeLogo';
import { usePaymentMethodsBoot } from './PreferencesPaymentMethodsBoot'; import { usePaymentMethodsBoot } from './PreferencesPaymentMethodsBoot';
import { DialogsName } from '@/constants/dialogs'; import { DialogsName } from '@/constants/dialogs';
@@ -20,7 +21,6 @@ import {
useDialogActions, useDialogActions,
useDrawerActions, useDrawerActions,
} from '@/hooks/state'; } from '@/hooks/state';
import { useCreateStripeAccountLink } from '@/hooks/query/stripe-integration';
import { DRAWERS } from '@/constants/drawers'; import { DRAWERS } from '@/constants/drawers';
import { MoreIcon } from '@/icons/More'; import { MoreIcon } from '@/icons/More';
import { STRIPE_PRICING_LINK } from './constants'; import { STRIPE_PRICING_LINK } from './constants';
@@ -34,39 +34,17 @@ export function StripePaymentMethod() {
const stripeState = paymentMethodsState?.stripe; const stripeState = paymentMethodsState?.stripe;
const isAccountCreated = stripeState?.isStripeAccountCreated; const isAccountCreated = stripeState?.isStripeAccountCreated;
const isAccountActive = stripeState?.isStripePaymentActive; const isPaymentEnabled = stripeState?.isStripePaymentEnabled;
const stripeAccountId = stripeState?.stripeAccountId; const isPayoutEnabled = stripeState?.isStripePayoutEnabled;
const isStripeEnabled = stripeState?.isStripeEnabled;
const stripePaymentMethodId = stripeState?.stripePaymentMethodId; const stripePaymentMethodId = stripeState?.stripePaymentMethodId;
const isStripeServerConfigured = stripeState?.isStripeServerConfigured; const isStripeServerConfigured = stripeState?.isStripeServerConfigured;
const {
mutateAsync: createStripeAccountLink,
isLoading: isCreateStripeLinkLoading,
} = useCreateStripeAccountLink();
// Handle Stripe setup button click. // Handle Stripe setup button click.
const handleSetUpBtnClick = () => { const handleSetUpBtnClick = () => {
openDialog(DialogsName.StripeSetup); openDialog(DialogsName.StripeSetup);
}; };
// Handle complete Stripe setup button click.
const handleCompleteSetUpBtnClick = () => {
createStripeAccountLink({ stripeAccountId })
.then((res) => {
const { clientSecret } = res;
if (clientSecret.url) {
window.open(clientSecret.url, '_blank');
}
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
// Handle edit button click. // Handle edit button click.
const handleEditBtnClick = () => { const handleEditBtnClick = () => {
openDrawer(DRAWERS.STRIPE_PAYMENT_INTEGRATION_EDIT, { openDrawer(DRAWERS.STRIPE_PAYMENT_INTEGRATION_EDIT, {
@@ -87,14 +65,30 @@ export function StripePaymentMethod() {
<Group> <Group>
<StripeLogo /> <StripeLogo />
{isAccountActive && ( <Group spacing={10}>
<Tag minimal intent={Intent.SUCCESS}> {isStripeEnabled && (
Active <Tag minimal intent={Intent.SUCCESS}>
</Tag> Active
)} </Tag>
)}
{!isPaymentEnabled && isAccountCreated && (
<Tooltip content="The account cannot accept payments because verification may be incomplete, there may be legal or compliance issues, or required documents haven't been submitted or verified.">
<Tag minimal intent={Intent.DANGER}>
Payment Not Enabled
</Tag>
</Tooltip>
)}
{!isPayoutEnabled && isAccountCreated && (
<Tooltip content="The account cannot receive payouts due to incomplete or invalid bank details, pending identity verification, or compliance restrictions.">
<Tag minimal intent={Intent.DANGER}>
Payout Not Enabled
</Tag>
</Tooltip>
)}
</Group>
</Group> </Group>
<Group spacing={10}> <Group spacing={10}>
{isAccountActive && ( {isAccountCreated && (
<Button small onClick={handleEditBtnClick}> <Button small onClick={handleEditBtnClick}>
Edit Edit
</Button> </Button>
@@ -104,16 +98,6 @@ export function StripePaymentMethod() {
Set it Up Set it Up
</Button> </Button>
)} )}
{isAccountCreated && !isAccountActive && (
<Button
intent={Intent.PRIMARY}
small
onClick={handleCompleteSetUpBtnClick}
loading={isCreateStripeLinkLoading}
>
Complete Stripe Set Up
</Button>
)}
{isAccountCreated && ( {isAccountCreated && (
<Popover <Popover
content={ content={

View File

@@ -1,53 +1,32 @@
import { useState } from 'react';
import { Button, DialogBody, DialogFooter, Intent } from '@blueprintjs/core'; import { Button, DialogBody, DialogFooter, Intent } from '@blueprintjs/core';
import styled from 'styled-components'; import styled from 'styled-components';
import { Stack } from '@/components'; import { Stack } from '@/components';
import { useDialogContext } from '@/components/Dialog/DialogProvider'; import { useDialogContext } from '@/components/Dialog/DialogProvider';
import {
useCreateStripeAccount,
useCreateStripeAccountLink,
} from '@/hooks/query/stripe-integration';
import { useDialogActions } from '@/hooks/state'; import { useDialogActions } from '@/hooks/state';
import { CreditCard2Icon } from '@/icons/CreditCard2'; import { CreditCard2Icon } from '@/icons/CreditCard2';
import { DollarIcon } from '@/icons/Dollar'; import { DollarIcon } from '@/icons/Dollar';
import { LayoutAutoIcon } from '@/icons/LayoutAuto'; import { LayoutAutoIcon } from '@/icons/LayoutAuto';
import { SwitchIcon } from '@/icons/SwitchIcon'; import { SwitchIcon } from '@/icons/SwitchIcon';
import { usePaymentMethodsBoot } from '../../PreferencesPaymentMethodsBoot';
export function StripePreSetupDialogContent() { export function StripePreSetupDialogContent() {
const { name } = useDialogContext(); const { name } = useDialogContext();
const { closeDialog } = useDialogActions(); const { closeDialog } = useDialogActions();
const { paymentMethodsState } = usePaymentMethodsBoot();
const { const [isRedirecting, setIsRedirecting] = useState<boolean>(false);
mutateAsync: createStripeAccount,
isLoading: isCreateStripeAccountLoading,
} = useCreateStripeAccount();
const {
mutateAsync: createStripeAccountLink,
isLoading: isCreateStripeLinkLoading,
} = useCreateStripeAccountLink();
const handleSetUpBtnClick = () => { const handleSetUpBtnClick = () => {
createStripeAccount({}) if (paymentMethodsState?.stripe.stripeAuthLink) {
.then((response) => { setIsRedirecting(true);
const { account_id: accountId } = response; window.location.href = paymentMethodsState?.stripe.stripeAuthLink;
}
return createStripeAccountLink({ stripeAccountId: accountId });
})
.then((res) => {
const { clientSecret } = res;
if (clientSecret.url) {
window.location.href = clientSecret.url;
}
});
}; };
// Handle cancel button click.
const handleCancelBtnClick = () => { const handleCancelBtnClick = () => {
closeDialog(name); closeDialog(name);
}; };
const isLoading = isCreateStripeAccountLoading || isCreateStripeLinkLoading;
return ( return (
<> <>
<DialogBody> <DialogBody>
@@ -92,7 +71,7 @@ export function StripePreSetupDialogContent() {
<Button <Button
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
onClick={handleSetUpBtnClick} onClick={handleSetUpBtnClick}
loading={isLoading} loading={isRedirecting}
> >
Set Up Stripe Set Up Stripe
</Button> </Button>

View File

@@ -4,6 +4,7 @@ import { Button, Intent } from '@blueprintjs/core';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
import { useDrawerActions } from '@/hooks/state'; import { useDrawerActions } from '@/hooks/state';
import { ACCOUNT_TYPE } from '@/constants';
export function StripeIntegrationEditFormContent() { export function StripeIntegrationEditFormContent() {
const { accounts } = useStripeIntegrationEditBoot(); const { accounts } = useStripeIntegrationEditBoot();
@@ -19,6 +20,7 @@ export function StripeIntegrationEditFormContent() {
<AccountsSelect <AccountsSelect
name={'bankAccountId'} name={'bankAccountId'}
items={accounts} items={accounts}
filterByTypes={[ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK]}
fastField fastField
fill fill
allowCreate allowCreate
@@ -35,6 +37,7 @@ export function StripeIntegrationEditFormContent() {
<AccountsSelect <AccountsSelect
name={'clearingAccountId'} name={'clearingAccountId'}
items={accounts} items={accounts}
filterByTypes={[ACCOUNT_TYPE.OTHER_CURRENT_LIABILITY]}
fastField fastField
fill fill
allowCreate allowCreate

View File

@@ -108,6 +108,7 @@ export function transformToEditForm(invoice) {
: TaxType.Exclusive, : TaxType.Exclusive,
entries, entries,
attachments: transformAttachmentsToForm(invoice), attachments: transformAttachmentsToForm(invoice),
payment_methods: transformPaymentMethodsToForm(invoice?.payment_methods),
}; };
} }
@@ -228,6 +229,11 @@ export function transformValueToRequest(values) {
}; };
} }
/**
* Transformes the form payment methods to request.
* @param {Record<string, { enable: boolean }>} paymentMethods
* @returns {Array<{ payment_integration_id: string; enable: boolean }>}
*/
const transformPaymentMethodsToRequest = ( const transformPaymentMethodsToRequest = (
paymentMethods: Record<string, { enable: boolean }>, paymentMethods: Record<string, { enable: boolean }>,
): Array<{ payment_integration_id: string; enable: boolean }> => { ): Array<{ payment_integration_id: string; enable: boolean }> => {
@@ -237,6 +243,20 @@ const transformPaymentMethodsToRequest = (
})); }));
}; };
/**
* Transformes payment methods from request to form.
* @param {Array<{ payment_integration_id: number; enable: boolean }>} paymentMethods
* @returns {Record<string, { enable: boolean }>}
*/
const transformPaymentMethodsToForm = (
paymentMethods: Array<{ payment_integration_id: number; enable: boolean }>,
): Record<string, { enable: boolean }> => {
return paymentMethods?.reduce((acc, method) => {
acc[method.payment_integration_id] = { enable: method.enable };
return acc;
}, {});
};
export const useSetPrimaryWarehouseToForm = () => { export const useSetPrimaryWarehouseToForm = () => {
const { setFieldValue } = useFormikContext(); const { setFieldValue } = useFormikContext();
const { warehouses, isWarehousesSuccess } = useInvoiceFormContext(); const { warehouses, isWarehousesSuccess } = useInvoiceFormContext();

View File

@@ -12,7 +12,6 @@ import { transformToCamelCase, transfromToSnakeCase } from '@/utils';
const PaymentServicesQueryKey = 'PaymentServices'; const PaymentServicesQueryKey = 'PaymentServices';
const PaymentServicesStateQueryKey = 'PaymentServicesState'; const PaymentServicesStateQueryKey = 'PaymentServicesState';
// # Get payment services. // # Get payment services.
// ----------------------------------------- // -----------------------------------------
export interface GetPaymentServicesResponse {} export interface GetPaymentServicesResponse {}
@@ -48,12 +47,15 @@ export const useGetPaymentServices = (
export interface GetPaymentServicesStateResponse { export interface GetPaymentServicesStateResponse {
stripe: { stripe: {
isStripeAccountCreated: boolean; isStripeAccountCreated: boolean;
isStripePaymentActive: boolean; isStripePaymentEnabled: boolean;
isStripePayoutEnabled: boolean;
isStripeEnabled: boolean;
isStripeServerConfigured: boolean; isStripeServerConfigured: boolean;
stripeAccountId: string | null; stripeAccountId: string | null;
stripePaymentMethodId: number | null; stripePaymentMethodId: number | null;
stripeCurrencies: string[]; stripeCurrencies: string[];
stripePublishableKey: string; stripePublishableKey: string;
stripeAuthLink: string;
stripeRedirectUrl: string; stripeRedirectUrl: string;
}; };
} }

View File

@@ -7,7 +7,6 @@ import {
import useApiRequest from '../useRequest'; import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils'; import { transformToCamelCase } from '@/utils';
// Create Stripe Account Link. // Create Stripe Account Link.
// ------------------------------------ // ------------------------------------
interface StripeAccountLinkResponse { interface StripeAccountLinkResponse {
@@ -47,7 +46,6 @@ export const useCreateStripeAccountLink = (
); );
}; };
// Create Stripe Account Session. // Create Stripe Account Session.
// ------------------------------------ // ------------------------------------
interface AccountSessionValues { interface AccountSessionValues {
@@ -149,3 +147,70 @@ export const useCreateStripeCheckoutSession = (
{ ...options }, { ...options },
); );
}; };
// Create Stripe Account OAuth Link.
// ------------------------------------
interface StripeAccountLinkResponse {
clientSecret: {
created: number;
expiresAt: number;
object: string;
url: string;
};
}
interface StripeAccountLinkValues {
stripeAccountId: string;
}
export const useGetStripeAccountLink = (
options?: UseQueryOptions<StripeAccountLinkResponse, Error>,
): UseQueryResult<StripeAccountLinkResponse, Error> => {
const apiRequest = useApiRequest();
return useQuery(
'getStripeAccountLink',
() => {
return apiRequest
.get('/stripe_integration/link')
.then((res) => transformToCamelCase(res.data));
},
{ ...options },
);
};
// Get Stripe Account OAuth Callback Mutation.
// ------------------------------------
interface StripeAccountCallbackMutationValues {
code: string;
}
interface StripeAccountCallbackMutationResponse {
success: boolean;
}
export const useSetStripeAccountCallback = (
options?: UseMutationOptions<
StripeAccountCallbackMutationResponse,
Error,
StripeAccountCallbackMutationValues
>,
): UseMutationResult<
StripeAccountCallbackMutationResponse,
Error,
StripeAccountCallbackMutationValues
> => {
const apiRequest = useApiRequest();
return useMutation(
(values: StripeAccountCallbackMutationValues) => {
return apiRequest
.post(`/stripe_integration/callback`, values)
.then(
(res) =>
transformToCamelCase(
res.data,
) as StripeAccountCallbackMutationResponse,
);
},
{ ...options },
);
};

View File

@@ -28,6 +28,13 @@ export const getPreferenceRoutes = () => [
), ),
exact: true, exact: true,
}, },
{
path: `${BASE_URL}/payment-methods/stripe/callback`,
component: lazy(
() => import('../containers/Preferences/PaymentMethods/PreferencesStripeCallback'),
),
exact: true,
},
{ {
path: `${BASE_URL}/credit-notes`, path: `${BASE_URL}/credit-notes`,
component: lazy(() => component: lazy(() =>