mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-06-09 11:29:01 +00:00
Merge pull request #668 from bigcapitalhq/stripe-integrate
feat: Onboard accounts to Stripe Connect
This commit is contained in:
@@ -92,4 +92,8 @@ S3_BUCKET=
|
||||
|
||||
# PostHog
|
||||
POSTHOG_API_KEY=
|
||||
POSTHOG_HOST=
|
||||
POSTHOG_HOST=
|
||||
|
||||
# Stripe Payment
|
||||
STRIPE_PAYMENT_SECRET_KEY=
|
||||
STRIPE_PAYMENT_PUBLISHABLE_KEY=
|
||||
@@ -109,11 +109,13 @@
|
||||
"rtl-detect": "^1.0.4",
|
||||
"socket.io": "^4.7.4",
|
||||
"source-map-loader": "^4.0.1",
|
||||
"stripe": "^16.10.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"ts-transformer-keys": "^0.4.2",
|
||||
"tsyringe": "^4.3.0",
|
||||
"typedi": "^0.8.0",
|
||||
"uniqid": "^5.2.0",
|
||||
"uuid": "^10.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"yup": "^0.28.1"
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { Request, Response, Router, NextFunction } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
|
||||
import BaseController from '@/api/controllers/BaseController';
|
||||
import { PaymentServicesApplication } from '@/services/PaymentServices/PaymentServicesApplication';
|
||||
|
||||
@Service()
|
||||
export class PaymentServicesController extends BaseController {
|
||||
@Inject()
|
||||
private paymentServicesApp: PaymentServicesApplication;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncMiddleware(this.getPaymentServicesSpecificInvoice.bind(this))
|
||||
);
|
||||
router.get('/state', this.getPaymentMethodsState.bind(this));
|
||||
router.get('/:paymentServiceId', this.getPaymentService.bind(this));
|
||||
router.post(
|
||||
'/:paymentMethodId',
|
||||
[
|
||||
param('paymentMethodId').exists(),
|
||||
|
||||
body('name').optional().isString(),
|
||||
body('options.bank_account_id').optional().isNumeric(),
|
||||
body('options.clearing_account_id').optional().isNumeric(),
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.updatePaymentMethod.bind(this))
|
||||
);
|
||||
router.delete(
|
||||
'/:paymentMethodId',
|
||||
[param('paymentMethodId').exists()],
|
||||
this.validationResult,
|
||||
this.deletePaymentMethod.bind(this)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve accounts types list.
|
||||
* @param {Request} req - Request.
|
||||
* @param {Response} res - Response.
|
||||
* @return {Promise<Response | void>}
|
||||
*/
|
||||
private async getPaymentServicesSpecificInvoice(
|
||||
req: Request<{ invoiceId: number }>,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
const paymentServices =
|
||||
await this.paymentServicesApp.getPaymentServicesForInvoice(tenantId);
|
||||
|
||||
return res.status(200).send({ paymentServices });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a specific payment service.
|
||||
* @param {Request} req - Request.
|
||||
* @param {Response} res - Response.
|
||||
* @param {NextFunction} next - Next function.
|
||||
* @return {Promise<Response | void>}
|
||||
*/
|
||||
private async getPaymentService(
|
||||
req: Request<{ paymentServiceId: number }>,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
const { paymentServiceId } = req.params;
|
||||
|
||||
try {
|
||||
const paymentService = await this.paymentServicesApp.getPaymentService(
|
||||
tenantId,
|
||||
paymentServiceId
|
||||
);
|
||||
|
||||
return res.status(200).send({ data: paymentService });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the given payment method settings.
|
||||
* @param {Request} req - Request.
|
||||
* @param {Response} res - Response.
|
||||
* @return {Promise<Response | void>}
|
||||
*/
|
||||
private async updatePaymentMethod(
|
||||
req: Request<{ paymentMethodId: number }>,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
const { paymentMethodId } = req.params;
|
||||
const updatePaymentMethodDTO = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
await this.paymentServicesApp.editPaymentMethod(
|
||||
tenantId,
|
||||
paymentMethodId,
|
||||
updatePaymentMethodDTO
|
||||
);
|
||||
return res.status(200).send({
|
||||
id: paymentMethodId,
|
||||
message: 'The given payment method has been updated.',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the payment state providing state.
|
||||
* @param {Request} req - Request.
|
||||
* @param {Response} res - Response.
|
||||
* @param {NextFunction} next - Next function.
|
||||
* @return {Promise<Response | void>}
|
||||
*/
|
||||
private async getPaymentMethodsState(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
const paymentMethodsState =
|
||||
await this.paymentServicesApp.getPaymentMethodsState(tenantId);
|
||||
|
||||
return res.status(200).send({ data: paymentMethodsState });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given payment method.
|
||||
* @param {Request<{ paymentMethodId: number }>} req - Request.
|
||||
* @param {Response} res - Response.
|
||||
* @param {NextFunction} next - Next function.
|
||||
* @return {Promise<Response | void>}
|
||||
*/
|
||||
private async deletePaymentMethod(
|
||||
req: Request<{ paymentMethodId: number }>,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
const { paymentMethodId } = req.params;
|
||||
|
||||
try {
|
||||
await this.paymentServicesApp.deletePaymentMethod(
|
||||
tenantId,
|
||||
paymentMethodId
|
||||
);
|
||||
return res.status(204).send({
|
||||
id: paymentMethodId,
|
||||
message: 'The payment method has been deleted.',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,11 @@ export default class SaleInvoicesController extends BaseController {
|
||||
|
||||
// Pdf template id.
|
||||
check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(),
|
||||
|
||||
// Payment methods.
|
||||
check('payment_methods').optional({ nullable: true }).isArray(),
|
||||
check('payment_methods.*.payment_integration_id').exists().toInt(),
|
||||
check('payment_methods.*.enable').exists().isBoolean(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import BaseController from '@/api/controllers/BaseController';
|
||||
import { GetInvoicePaymentLinkMetadata } from '@/services/Sales/Invoices/GetInvoicePaymentLinkMetadata';
|
||||
|
||||
@Service()
|
||||
export class PublicSharableLinkController extends BaseController {
|
||||
@Inject()
|
||||
private getSharableLinkMetaService: GetInvoicePaymentLinkMetadata;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/sharable-links/meta/invoice/:linkId',
|
||||
[param('linkId').exists()],
|
||||
this.validationResult,
|
||||
this.getPaymentLinkPublicMeta.bind(this),
|
||||
this.validationResult
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the payment link public meta.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
* @returns
|
||||
*/
|
||||
public async getPaymentLinkPublicMeta(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { linkId } = req.params;
|
||||
|
||||
try {
|
||||
const data =
|
||||
await this.getSharableLinkMetaService.getInvoicePaymentLinkMeta(linkId);
|
||||
|
||||
return res.status(200).send({ data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { body } from 'express-validator';
|
||||
import { AbilitySubject, PaymentReceiveAction } from '@/interfaces';
|
||||
import BaseController from '@/api/controllers/BaseController';
|
||||
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
|
||||
import CheckPolicies from '@/api/middleware/CheckPolicies';
|
||||
import { GenerateShareLink } from '@/services/Sales/Invoices/GenerateeInvoicePaymentLink';
|
||||
|
||||
@Service()
|
||||
export class ShareLinkController extends BaseController {
|
||||
@Inject()
|
||||
private generateShareLinkService: GenerateShareLink;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/payment-links/generate',
|
||||
CheckPolicies(PaymentReceiveAction.Edit, AbilitySubject.PaymentReceive),
|
||||
[
|
||||
body('transaction_type').exists(),
|
||||
body('transaction_id').exists().isNumeric().toInt(),
|
||||
body('publicity').optional(),
|
||||
body('expiry_date').optional({ nullable: true }),
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.generateShareLink.bind(this))
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates sharable link for the given transaction.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
public async generateShareLink(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
const { transactionType, transactionId, publicity, expiryDate } =
|
||||
this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
const link = await this.generateShareLinkService.generatePaymentLink(
|
||||
tenantId,
|
||||
transactionId,
|
||||
transactionType,
|
||||
publicity,
|
||||
expiryDate
|
||||
);
|
||||
res.status(200).json({ link });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { body } from 'express-validator';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
|
||||
import { StripePaymentApplication } from '@/services/StripePayment/StripePaymentApplication';
|
||||
import BaseController from '../BaseController';
|
||||
|
||||
@Service()
|
||||
export class StripeIntegrationController extends BaseController {
|
||||
@Inject()
|
||||
private stripePaymentApp: StripePaymentApplication;
|
||||
|
||||
public 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_link',
|
||||
[body('stripe_account_id').exists()],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.createAccountLink.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/:linkId/create_checkout_session',
|
||||
this.createCheckoutSession.bind(this)
|
||||
);
|
||||
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.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
* @returns {Promise<Response|void>}
|
||||
*/
|
||||
public async createCheckoutSession(
|
||||
req: Request<{ linkId: number }>,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { linkId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
const session =
|
||||
await this.stripePaymentApp.createSaleInvoiceCheckoutSession(
|
||||
tenantId,
|
||||
linkId
|
||||
);
|
||||
return res.status(200).send(session);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Stripe account.
|
||||
* @param {Request} req - The Express request object.
|
||||
* @param {Response} res - The Express response object.
|
||||
* @param {NextFunction} next - The Express next middleware function.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async createAccount(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
const accountId = await this.stripePaymentApp.createStripeAccount(
|
||||
tenantId
|
||||
);
|
||||
return res.status(201).json({
|
||||
accountId,
|
||||
message: 'The Stripe account has been created successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Stripe account session.
|
||||
* @param {Request} req - The Express request object.
|
||||
* @param {Response} res - The Express response object.
|
||||
* @param {NextFunction} next - The Express next middleware function.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async createAccountLink(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
const { stripeAccountId } = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
const clientSecret = await this.stripePaymentApp.createAccountLink(
|
||||
tenantId,
|
||||
stripeAccountId
|
||||
);
|
||||
return res.status(200).json({ clientSecret });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import bodyParser from 'body-parser';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import { StripeWebhookEventPayload } from '@/interfaces/StripePayment';
|
||||
import { StripePaymentService } from '@/services/StripePayment/StripePaymentService';
|
||||
import events from '@/subscribers/events';
|
||||
import config from '@/config';
|
||||
|
||||
@Service()
|
||||
export class StripeWebhooksController {
|
||||
@Inject()
|
||||
private stripePaymentService: StripePaymentService;
|
||||
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
public router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/stripe',
|
||||
bodyParser.raw({ type: 'application/json' }),
|
||||
this.handleWebhook.bind(this)
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming Stripe webhook events.
|
||||
* Verifies the webhook signature, processes the event based on its type,
|
||||
* and triggers appropriate actions or events in the system.
|
||||
*
|
||||
* @param {Request} req - The Express request object containing the webhook payload.
|
||||
* @param {Response} res - The Express response object.
|
||||
* @param {NextFunction} next - The Express next middleware function.
|
||||
*/
|
||||
private async handleWebhook(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
let event = req.body;
|
||||
const sig = req.headers['stripe-signature'];
|
||||
|
||||
// Verify webhook signature and extract the event.
|
||||
// See https://stripe.com/docs/webhooks#verify-events for more information.
|
||||
try {
|
||||
event = this.stripePaymentService.stripe.webhooks.constructEvent(
|
||||
req.rawBody,
|
||||
sig,
|
||||
config.stripePayment.webhooksSecret
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
// Handle the event based on its type
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
// Triggers `onStripeCheckoutSessionCompleted` event.
|
||||
this.eventPublisher.emitAsync(
|
||||
events.stripeWebhooks.onCheckoutSessionCompleted,
|
||||
{
|
||||
event,
|
||||
} as StripeWebhookEventPayload
|
||||
);
|
||||
break;
|
||||
case 'account.updated':
|
||||
this.eventPublisher.emitAsync(
|
||||
events.stripeWebhooks.onAccountUpdated,
|
||||
{
|
||||
event,
|
||||
} as StripeWebhookEventPayload
|
||||
);
|
||||
break;
|
||||
// Add more cases as needed
|
||||
default:
|
||||
console.log(`Unhandled event type ${event.type}`);
|
||||
}
|
||||
|
||||
res.status(200).json({ received: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NextFunction, Router, Request, Response } from 'express';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import Container, { Inject, Service } from 'typedi';
|
||||
import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication';
|
||||
import BaseController from '../BaseController';
|
||||
import { LemonSqueezyWebhooks } from '@/services/Subscription/LemonSqueezyWebhooks';
|
||||
import { PlaidWebhookTenantBootMiddleware } from '@/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware';
|
||||
import { StripeWebhooksController } from '../StripeIntegration/StripeWebhooksController';
|
||||
|
||||
@Service()
|
||||
export class Webhooks extends BaseController {
|
||||
@@ -24,6 +25,8 @@ export class Webhooks extends BaseController {
|
||||
|
||||
router.post('/lemon', this.lemonWebhooks.bind(this));
|
||||
|
||||
router.use(Container.get(StripeWebhooksController).router());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,11 @@ import { Webhooks } from './controllers/Webhooks/Webhooks';
|
||||
import { ExportController } from './controllers/Export/ExportController';
|
||||
import { AttachmentsController } from './controllers/Attachments/AttachmentsController';
|
||||
import { OneClickDemoController } from './controllers/OneClickDemo/OneClickDemoController';
|
||||
import { StripeIntegrationController } from './controllers/StripeIntegration/StripeIntegrationController';
|
||||
import { ShareLinkController } from './controllers/ShareLink/ShareLinkController';
|
||||
import { PublicSharableLinkController } from './controllers/ShareLink/PublicSharableLinkController';
|
||||
import { PdfTemplatesController } from './controllers/PdfTemplates/PdfTemplatesController';
|
||||
import { PaymentServicesController } from './controllers/PaymentServices/PaymentServicesController';
|
||||
|
||||
export default () => {
|
||||
const app = Router();
|
||||
@@ -83,6 +87,7 @@ export default () => {
|
||||
app.use('/account', Container.get(Account).router());
|
||||
app.use('/webhooks', Container.get(Webhooks).router());
|
||||
app.use('/demo', Container.get(OneClickDemoController).router());
|
||||
app.use(Container.get(PublicSharableLinkController).router());
|
||||
|
||||
// - Dashboard routes.
|
||||
// ---------------------------
|
||||
@@ -148,14 +153,22 @@ export default () => {
|
||||
dashboard.use('/import', Container.get(ImportController).router());
|
||||
dashboard.use('/export', Container.get(ExportController).router());
|
||||
dashboard.use('/attachments', Container.get(AttachmentsController).router());
|
||||
dashboard.use(
|
||||
'/stripe_integration',
|
||||
Container.get(StripeIntegrationController).router()
|
||||
);
|
||||
dashboard.use(
|
||||
'/pdf-templates',
|
||||
Container.get(PdfTemplatesController).router()
|
||||
);
|
||||
|
||||
dashboard.use(
|
||||
'/payment-services',
|
||||
Container.get(PaymentServicesController).router()
|
||||
);
|
||||
dashboard.use('/', Container.get(ProjectTasksController).router());
|
||||
dashboard.use('/', Container.get(ProjectTimesController).router());
|
||||
dashboard.use('/', Container.get(WarehousesItemController).router());
|
||||
dashboard.use('/', Container.get(ShareLinkController).router());
|
||||
|
||||
dashboard.use('/dashboard', Container.get(DashboardController).router());
|
||||
dashboard.use('/', Container.get(Miscellaneous).router());
|
||||
|
||||
@@ -259,6 +259,17 @@ module.exports = {
|
||||
*/
|
||||
posthog: {
|
||||
apiKey: process.env.POSTHOG_API_KEY,
|
||||
host: process.env.POSTHOG_HOST
|
||||
}
|
||||
host: process.env.POSTHOG_HOST,
|
||||
},
|
||||
|
||||
/**
|
||||
* Stripe Payment Integration.
|
||||
*/
|
||||
stripePayment: {
|
||||
secretKey: process.env.STRIPE_PAYMENT_SECRET_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 || '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('payment_receives', (table) => {
|
||||
table.string('stripe_pintent_id').nullable();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('payment_receives', (table) => {
|
||||
table.dropColumn('stripe_pintent_id');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.createTable('payment_integrations', (table) => {
|
||||
table.increments('id');
|
||||
table.string('service');
|
||||
table.string('name');
|
||||
table.string('slug');
|
||||
table.boolean('payment_enabled').defaultTo(false);
|
||||
table.boolean('payout_enabled').defaultTo(false);
|
||||
table.string('account_id');
|
||||
table.json('options');
|
||||
table.timestamps();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.dropTableIfExists('payment_integrations');
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.createTable('transactions_payment_methods', (table) => {
|
||||
table.increments('id');
|
||||
table.integer('reference_id').unsigned();
|
||||
table.string('reference_type');
|
||||
table
|
||||
.integer('payment_integration_id')
|
||||
.unsigned()
|
||||
.index()
|
||||
.references('id')
|
||||
.inTable('payment_integrations');
|
||||
table.boolean('enable').defaultTo(false);
|
||||
table.json('options').nullable();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.dropTableIfExists('transactions_payment_methods');
|
||||
};
|
||||
@@ -31,6 +31,17 @@ export const PrepardExpenses = {
|
||||
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 [
|
||||
{
|
||||
name: 'Bank Account',
|
||||
|
||||
@@ -5,6 +5,34 @@ import { IDynamicListFilter } from '@/interfaces/DynamicFilter';
|
||||
import { IItemEntry, IItemEntryDTO } from './ItemEntry';
|
||||
import { AttachmentLinkDTO } from './Attachments';
|
||||
|
||||
export interface PaymentIntegrationTransactionLink {
|
||||
id: number;
|
||||
enable: true;
|
||||
paymentIntegrationId: number;
|
||||
referenceType: string;
|
||||
referenceId: number;
|
||||
}
|
||||
|
||||
export interface PaymentIntegrationTransactionLinkEventPayload {
|
||||
tenantId: number;
|
||||
enable: true;
|
||||
paymentIntegrationId: number;
|
||||
referenceType: string;
|
||||
referenceId: number;
|
||||
saleInvoiceId: number;
|
||||
trx?: Knex.Transaction
|
||||
}
|
||||
|
||||
export interface PaymentIntegrationTransactionLinkDeleteEventPayload {
|
||||
tenantId: number;
|
||||
enable: true;
|
||||
paymentIntegrationId: number;
|
||||
referenceType: string;
|
||||
referenceId: number;
|
||||
oldSaleInvoiceId: number;
|
||||
trx?: Knex.Transaction
|
||||
}
|
||||
|
||||
export interface ISaleInvoice {
|
||||
id: number;
|
||||
amount: number;
|
||||
@@ -50,6 +78,8 @@ export interface ISaleInvoice {
|
||||
invoiceMessage: string;
|
||||
|
||||
pdfTemplateId?: number;
|
||||
|
||||
paymentMethods?: Array<PaymentIntegrationTransactionLink>;
|
||||
}
|
||||
|
||||
export interface ISaleInvoiceDTO {
|
||||
@@ -136,9 +166,15 @@ export interface ISaleInvoiceEditingPayload {
|
||||
|
||||
export interface ISaleInvoiceDeletePayload {
|
||||
tenantId: number;
|
||||
saleInvoice: ISaleInvoice;
|
||||
oldSaleInvoice: ISaleInvoice;
|
||||
saleInvoiceId: number;
|
||||
trx: Knex.Transaction;
|
||||
}
|
||||
|
||||
export interface ISaleInvoiceDeletingPayload {
|
||||
tenantId: number;
|
||||
oldSaleInvoice: ISaleInvoice;
|
||||
saleInvoiceId: number;
|
||||
trx: Knex.Transaction;
|
||||
}
|
||||
|
||||
export interface ISaleInvoiceDeletedPayload {
|
||||
@@ -223,7 +259,6 @@ export interface ISaleInvoiceMailSent {
|
||||
messageOptions: SendInvoiceMailDTO;
|
||||
}
|
||||
|
||||
|
||||
// Invoice Pdf Document
|
||||
export interface InvoicePdfLine {
|
||||
item: string;
|
||||
@@ -241,9 +276,9 @@ export interface InvoicePdfTax {
|
||||
export interface InvoicePdfTemplateAttributes {
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
|
||||
|
||||
companyName: string;
|
||||
|
||||
|
||||
showCompanyLogo: boolean;
|
||||
companyLogo: string;
|
||||
|
||||
@@ -301,4 +336,4 @@ export interface InvoicePdfTemplateAttributes {
|
||||
|
||||
billedToAddress: string[];
|
||||
billedFromAddres: string[];
|
||||
}
|
||||
}
|
||||
|
||||
20
packages/server/src/interfaces/StripePayment.ts
Normal file
20
packages/server/src/interfaces/StripePayment.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface StripePaymentLinkCreatedEventPayload {
|
||||
tenantId: number;
|
||||
paymentLinkId: string;
|
||||
saleInvoiceId: number;
|
||||
stripeIntegrationId: number;
|
||||
}
|
||||
|
||||
export interface StripeCheckoutSessionCompletedEventPayload {
|
||||
event: any;
|
||||
}
|
||||
|
||||
export interface StripeInvoiceCheckoutSessionPOJO {
|
||||
sessionId: string;
|
||||
publishableKey: string;
|
||||
redirectTo: string;
|
||||
}
|
||||
|
||||
export interface StripeWebhookEventPayload {
|
||||
event: any;
|
||||
}
|
||||
@@ -117,8 +117,10 @@ import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAcco
|
||||
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
|
||||
import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting';
|
||||
import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData';
|
||||
import { TriggerInvalidateCacheOnSubscriptionChange } from '@/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange';
|
||||
import { EventsTrackerListeners } from '@/services/EventsTracker/events/events';
|
||||
import { InvoicePaymentIntegrationSubscriber } from '@/services/Sales/Invoices/subscribers/InvoicePaymentIntegrationSubscriber';
|
||||
import { StripeWebhooksSubscriber } from '@/services/StripePayment/events/StripeWebhooksSubscriber';
|
||||
import { SeedStripeAccountsOnOAuthGrantedSubscriber } from '@/services/StripePayment/events/SeedStripeAccounts';
|
||||
|
||||
export default () => {
|
||||
return new EventPublisher();
|
||||
@@ -252,7 +254,6 @@ export const susbcribers = () => {
|
||||
// Subscription
|
||||
SubscribeFreeOnSignupCommunity,
|
||||
SendVerfiyMailOnSignUp,
|
||||
TriggerInvalidateCacheOnSubscriptionChange,
|
||||
|
||||
// Attachments
|
||||
AttachmentsOnSaleInvoiceCreated,
|
||||
@@ -291,6 +292,11 @@ export const susbcribers = () => {
|
||||
// Demo Account
|
||||
SeedInitialDemoAccountDataOnOrgBuild,
|
||||
|
||||
// Stripe Payment
|
||||
InvoicePaymentIntegrationSubscriber,
|
||||
StripeWebhooksSubscriber,
|
||||
SeedStripeAccountsOnOAuthGrantedSubscriber,
|
||||
|
||||
...EventsTrackerListeners
|
||||
];
|
||||
};
|
||||
|
||||
@@ -69,6 +69,8 @@ import { BankRuleCondition } from '@/models/BankRuleCondition';
|
||||
import { RecognizedBankTransaction } from '@/models/RecognizedBankTransaction';
|
||||
import { MatchedBankTransaction } from '@/models/MatchedBankTransaction';
|
||||
import { PdfTemplate } from '@/models/PdfTemplate';
|
||||
import { PaymentIntegration } from '@/models/PaymentIntegration';
|
||||
import { TransactionPaymentServiceEntry } from '@/models/TransactionPaymentServiceEntry';
|
||||
|
||||
export default (knex) => {
|
||||
const models = {
|
||||
@@ -140,7 +142,9 @@ export default (knex) => {
|
||||
BankRuleCondition,
|
||||
RecognizedBankTransaction,
|
||||
MatchedBankTransaction,
|
||||
PdfTemplate
|
||||
PdfTemplate,
|
||||
PaymentIntegration,
|
||||
TransactionPaymentServiceEntry,
|
||||
};
|
||||
return mapValues(models, (model) => model.bindKnex(knex));
|
||||
};
|
||||
|
||||
50
packages/server/src/models/PaymentIntegration.ts
Normal file
50
packages/server/src/models/PaymentIntegration.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Model } from 'objection';
|
||||
import TenantModel from 'models/TenantModel';
|
||||
|
||||
export class PaymentIntegration extends Model {
|
||||
paymentEnabled!: boolean;
|
||||
payoutEnabled!: boolean;
|
||||
|
||||
static get tableName() {
|
||||
return 'payment_integrations';
|
||||
}
|
||||
|
||||
static get idColumn() {
|
||||
return 'id';
|
||||
}
|
||||
|
||||
static get virtualAttributes() {
|
||||
return ['fullEnabled'];
|
||||
}
|
||||
|
||||
static get jsonAttributes() {
|
||||
return ['options'];
|
||||
}
|
||||
|
||||
get fullEnabled() {
|
||||
return this.paymentEnabled && this.payoutEnabled;
|
||||
}
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['name', 'service'],
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
service: { type: 'string' },
|
||||
paymentEnabled: { type: 'boolean' },
|
||||
payoutEnabled: { type: 'boolean' },
|
||||
accountId: { type: 'string' },
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bankAccountId: { type: 'number' },
|
||||
clearingAccountId: { type: 'number' },
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -413,6 +413,10 @@ export default class SaleInvoice extends mixin(TenantModel, [
|
||||
const TaxRateTransaction = require('models/TaxRateTransaction');
|
||||
const Document = require('models/Document');
|
||||
const { MatchedBankTransaction } = require('models/MatchedBankTransaction');
|
||||
const {
|
||||
TransactionPaymentServiceEntry,
|
||||
} = require('models/TransactionPaymentServiceEntry');
|
||||
const { PdfTemplate } = require('models/PdfTemplate');
|
||||
|
||||
return {
|
||||
/**
|
||||
@@ -509,7 +513,7 @@ export default class SaleInvoice extends mixin(TenantModel, [
|
||||
join: {
|
||||
from: 'sales_invoices.warehouseId',
|
||||
to: 'warehouses.id',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -566,12 +570,42 @@ export default class SaleInvoice extends mixin(TenantModel, [
|
||||
modelClass: MatchedBankTransaction,
|
||||
join: {
|
||||
from: 'sales_invoices.id',
|
||||
to: "matched_bank_transactions.referenceId",
|
||||
to: 'matched_bank_transactions.referenceId',
|
||||
},
|
||||
filter(query) {
|
||||
query.where('reference_type', 'SaleInvoice');
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Sale invoice may belongs to payment methods entries.
|
||||
*/
|
||||
paymentMethods: {
|
||||
relation: Model.HasManyRelation,
|
||||
modelClass: TransactionPaymentServiceEntry,
|
||||
join: {
|
||||
from: 'sales_invoices.id',
|
||||
to: 'transactions_payment_methods.referenceId',
|
||||
},
|
||||
beforeInsert: (model) => {
|
||||
model.referenceType = 'SaleInvoice';
|
||||
},
|
||||
filter: (query) => {
|
||||
query.where('reference_type', 'SaleInvoice');
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Sale invoice may belongs to pdf branding template.
|
||||
*/
|
||||
pdfTemplate: {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass: PdfTemplate,
|
||||
join: {
|
||||
from: 'sales_invoices.pdfTemplateId',
|
||||
to: 'pdf_templates.id',
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
46
packages/server/src/models/TransactionPaymentServiceEntry.ts
Normal file
46
packages/server/src/models/TransactionPaymentServiceEntry.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import TenantModel from 'models/TenantModel';
|
||||
|
||||
export class TransactionPaymentServiceEntry extends TenantModel {
|
||||
/**
|
||||
* Table name
|
||||
*/
|
||||
static get tableName() {
|
||||
return 'transactions_payment_methods';
|
||||
}
|
||||
|
||||
/**
|
||||
* Json schema of the model.
|
||||
*/
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['paymentIntegrationId'],
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
referenceId: { type: 'integer' },
|
||||
referenceType: { type: 'string' },
|
||||
paymentIntegrationId: { type: 'integer' },
|
||||
enable: { type: 'boolean' },
|
||||
options: { type: 'object' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship mapping.
|
||||
*/
|
||||
static get relationMappings() {
|
||||
const { PaymentIntegration } = require('./PaymentIntegration');
|
||||
|
||||
return {
|
||||
paymentIntegration: {
|
||||
relation: TenantModel.BelongsToOneRelation,
|
||||
modelClass: PaymentIntegration,
|
||||
join: {
|
||||
from: 'transactions_payment_methods.paymentIntegrationId',
|
||||
to: 'payment_integrations.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { IAccount } from '@/interfaces';
|
||||
import { Knex } from 'knex';
|
||||
import {
|
||||
PrepardExpenses,
|
||||
StripeClearingAccount,
|
||||
TaxPayableAccount,
|
||||
UnearnedRevenueAccount,
|
||||
} from '@/database/seeds/data/accounts';
|
||||
@@ -247,4 +248,37 @@ export default class AccountRepository extends TenantRepository {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isEmpty } from 'lodash';
|
||||
import {
|
||||
ISaleInvoiceCreatedPayload,
|
||||
ISaleInvoiceCreatingPaylaod,
|
||||
ISaleInvoiceDeletePayload,
|
||||
ISaleInvoiceDeletingPayload,
|
||||
ISaleInvoiceEditedPayload,
|
||||
} from '@/interfaces';
|
||||
import events from '@/subscribers/events';
|
||||
@@ -146,13 +146,13 @@ export class AttachmentsOnSaleInvoiceCreated {
|
||||
*/
|
||||
private async handleUnlinkAttachmentsOnInvoiceDeleted({
|
||||
tenantId,
|
||||
saleInvoice,
|
||||
oldSaleInvoice,
|
||||
trx,
|
||||
}: ISaleInvoiceDeletePayload) {
|
||||
}: ISaleInvoiceDeletingPayload) {
|
||||
await this.unlinkAttachmentService.unlinkAllModelKeys(
|
||||
tenantId,
|
||||
'SaleInvoice',
|
||||
saleInvoice.id,
|
||||
oldSaleInvoice.id,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { Knex } from 'knex';
|
||||
import HasTenancyService from '../Tenancy/TenancyService';
|
||||
import UnitOfWork from '../UnitOfWork';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import events from '@/subscribers/events';
|
||||
|
||||
@Service()
|
||||
export class DeletePaymentMethodService {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private uow: UnitOfWork;
|
||||
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
/**
|
||||
* Deletes the given payment integration.
|
||||
* @param {number} tenantId
|
||||
* @param {number} paymentIntegrationId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async deletePaymentMethod(
|
||||
tenantId: number,
|
||||
paymentIntegrationId: number
|
||||
): Promise<void> {
|
||||
const { PaymentIntegration, TransactionPaymentServiceEntry } =
|
||||
this.tenancy.models(tenantId);
|
||||
|
||||
const paymentIntegration = await PaymentIntegration.query()
|
||||
.findById(paymentIntegrationId)
|
||||
.throwIfNotFound();
|
||||
|
||||
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
|
||||
// Delete payment methods links.
|
||||
await TransactionPaymentServiceEntry.query(trx)
|
||||
.where('paymentIntegrationId', paymentIntegrationId)
|
||||
.delete();
|
||||
|
||||
// Delete the payment integration.
|
||||
await PaymentIntegration.query(trx)
|
||||
.findById(paymentIntegrationId)
|
||||
.delete();
|
||||
|
||||
// Triggers `onPaymentMethodDeleted` event.
|
||||
await this.eventPublisher.emitAsync(events.paymentMethod.onDeleted, {
|
||||
tenantId,
|
||||
paymentIntegrationId,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import HasTenancyService from '../Tenancy/TenancyService';
|
||||
import UnitOfWork from '../UnitOfWork';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import { EditPaymentMethodDTO } from './types';
|
||||
import events from '@/subscribers/events';
|
||||
|
||||
@Service()
|
||||
export class EditPaymentMethodService {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private uow: UnitOfWork;
|
||||
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
/**
|
||||
* Edits the given payment method.
|
||||
* @param {number} tenantId
|
||||
* @param {number} paymentIntegrationId
|
||||
* @param {EditPaymentMethodDTO} editPaymentMethodDTO
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async editPaymentMethod(
|
||||
tenantId: number,
|
||||
paymentIntegrationId: number,
|
||||
editPaymentMethodDTO: EditPaymentMethodDTO
|
||||
): Promise<void> {
|
||||
const { PaymentIntegration } = this.tenancy.models(tenantId);
|
||||
|
||||
const paymentMethod = await PaymentIntegration.query()
|
||||
.findById(paymentIntegrationId)
|
||||
.throwIfNotFound();
|
||||
|
||||
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
|
||||
// Triggers `onPaymentMethodEditing` event.
|
||||
await this.eventPublisher.emitAsync(events.paymentMethod.onEditing, {
|
||||
tenantId,
|
||||
paymentIntegrationId,
|
||||
editPaymentMethodDTO,
|
||||
trx,
|
||||
});
|
||||
await PaymentIntegration.query(trx)
|
||||
.findById(paymentIntegrationId)
|
||||
.patch({
|
||||
...editPaymentMethodDTO,
|
||||
});
|
||||
// Triggers `onPaymentMethodEdited` event.
|
||||
await this.eventPublisher.emitAsync(events.paymentMethod.onEdited, {
|
||||
tenantId,
|
||||
paymentIntegrationId,
|
||||
editPaymentMethodDTO,
|
||||
trx,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import HasTenancyService from '../Tenancy/TenancyService';
|
||||
import { GetPaymentMethodsPOJO } from './types';
|
||||
import config from '@/config';
|
||||
import { isStripePaymentConfigured } from './utils';
|
||||
import { GetStripeAuthorizationLinkService } from '../StripePayment/GetStripeAuthorizationLink';
|
||||
|
||||
@Service()
|
||||
export class GetPaymentMethodsStateService {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private getStripeAuthorizationLinkService: GetStripeAuthorizationLinkService;
|
||||
|
||||
/**
|
||||
* Retrieves the payment state provising state.
|
||||
* @param {number} tenantId
|
||||
* @returns {Promise<GetPaymentMethodsPOJO>}
|
||||
*/
|
||||
public async getPaymentMethodsState(
|
||||
tenantId: number
|
||||
): Promise<GetPaymentMethodsPOJO> {
|
||||
const { PaymentIntegration } = this.tenancy.models(tenantId);
|
||||
|
||||
const stripePayment = await PaymentIntegration.query()
|
||||
.orderBy('createdAt', 'ASC')
|
||||
.findOne({
|
||||
service: 'Stripe',
|
||||
});
|
||||
const isStripeAccountCreated = !!stripePayment;
|
||||
const isStripePaymentEnabled = stripePayment?.paymentEnabled;
|
||||
const isStripePayoutEnabled = stripePayment?.payoutEnabled;
|
||||
const isStripeEnabled = stripePayment?.fullEnabled;
|
||||
|
||||
const stripePaymentMethodId = stripePayment?.id || null;
|
||||
const stripeAccountId = stripePayment?.accountId || null;
|
||||
const stripePublishableKey = config.stripePayment.publishableKey;
|
||||
const stripeCurrencies = ['USD', 'EUR'];
|
||||
const stripeRedirectUrl = 'https://your-stripe-redirect-url.com';
|
||||
const isStripeServerConfigured = isStripePaymentConfigured();
|
||||
const stripeAuthLink =
|
||||
this.getStripeAuthorizationLinkService.getStripeAuthLink();
|
||||
|
||||
const paymentMethodPOJO: GetPaymentMethodsPOJO = {
|
||||
stripe: {
|
||||
isStripeAccountCreated,
|
||||
isStripePaymentEnabled,
|
||||
isStripePayoutEnabled,
|
||||
isStripeEnabled,
|
||||
isStripeServerConfigured,
|
||||
stripeAccountId,
|
||||
stripePaymentMethodId,
|
||||
stripePublishableKey,
|
||||
stripeCurrencies,
|
||||
stripeAuthLink,
|
||||
stripeRedirectUrl,
|
||||
},
|
||||
};
|
||||
return paymentMethodPOJO;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import HasTenancyService from '../Tenancy/TenancyService';
|
||||
import { GetPaymentMethodsPOJO } from './types';
|
||||
|
||||
@Service()
|
||||
export class GetPaymentMethodService {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
/**
|
||||
* Retrieves the payment state provising state.
|
||||
* @param {number} tenantId
|
||||
* @returns {Promise<GetPaymentMethodsPOJO>}
|
||||
*/
|
||||
public async getPaymentMethod(
|
||||
tenantId: number,
|
||||
paymentServiceId: number
|
||||
): Promise<GetPaymentMethodsPOJO> {
|
||||
const { PaymentIntegration } = this.tenancy.models(tenantId);
|
||||
|
||||
const stripePayment = await PaymentIntegration.query()
|
||||
.findById(paymentServiceId)
|
||||
.throwIfNotFound();
|
||||
|
||||
return stripePayment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import HasTenancyService from '../Tenancy/TenancyService';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
import { GetPaymentServicesSpecificInvoiceTransformer } from './GetPaymentServicesSpecificInvoiceTransformer';
|
||||
|
||||
@Service()
|
||||
export class GetPaymentServicesSpecificInvoice {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private transform: TransformerInjectable;
|
||||
|
||||
/**
|
||||
* Retrieves the payment services of the given invoice.
|
||||
* @param {number} tenantId
|
||||
* @param {number} invoiceId
|
||||
* @returns
|
||||
*/
|
||||
async getPaymentServicesInvoice(tenantId: number) {
|
||||
const { PaymentIntegration } = this.tenancy.models(tenantId);
|
||||
|
||||
const paymentGateways = await PaymentIntegration.query()
|
||||
.where('active', true)
|
||||
.orderBy('name', 'ASC');
|
||||
|
||||
return this.transform.transform(
|
||||
tenantId,
|
||||
paymentGateways,
|
||||
new GetPaymentServicesSpecificInvoiceTransformer()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Transformer } from '@/lib/Transformer/Transformer';
|
||||
|
||||
export class GetPaymentServicesSpecificInvoiceTransformer extends Transformer {
|
||||
/**
|
||||
* Exclude attributes.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
public excludeAttributes = (): string[] => {
|
||||
return ['accountId'];
|
||||
};
|
||||
|
||||
public includeAttributes = (): string[] => {
|
||||
return ['serviceFormatted'];
|
||||
};
|
||||
|
||||
public serviceFormatted(method) {
|
||||
return 'Stripe';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { GetPaymentServicesSpecificInvoice } from './GetPaymentServicesSpecificInvoice';
|
||||
import { DeletePaymentMethodService } from './DeletePaymentMethodService';
|
||||
import { EditPaymentMethodService } from './EditPaymentMethodService';
|
||||
import { EditPaymentMethodDTO, GetPaymentMethodsPOJO } from './types';
|
||||
import { GetPaymentMethodsStateService } from './GetPaymentMethodsState';
|
||||
import { GetPaymentMethodService } from './GetPaymentService';
|
||||
|
||||
@Service()
|
||||
export class PaymentServicesApplication {
|
||||
@Inject()
|
||||
private getPaymentServicesSpecificInvoice: GetPaymentServicesSpecificInvoice;
|
||||
|
||||
@Inject()
|
||||
private deletePaymentMethodService: DeletePaymentMethodService;
|
||||
|
||||
@Inject()
|
||||
private editPaymentMethodService: EditPaymentMethodService;
|
||||
|
||||
@Inject()
|
||||
private getPaymentMethodsStateService: GetPaymentMethodsStateService;
|
||||
|
||||
@Inject()
|
||||
private getPaymentMethodService: GetPaymentMethodService;
|
||||
|
||||
/**
|
||||
* Retrieves the payment services for a specific invoice.
|
||||
* @param {number} tenantId - The ID of the tenant.
|
||||
* @param {number} invoiceId - The ID of the invoice.
|
||||
* @returns {Promise<any>} The payment services for the specified invoice.
|
||||
*/
|
||||
public async getPaymentServicesForInvoice(tenantId: number): Promise<any> {
|
||||
return this.getPaymentServicesSpecificInvoice.getPaymentServicesInvoice(
|
||||
tenantId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves specific payment service details.
|
||||
* @param {number} tenantId - Tennat id.
|
||||
* @param {number} paymentServiceId - Payment service id.
|
||||
*/
|
||||
public async getPaymentService(tenantId: number, paymentServiceId: number) {
|
||||
return this.getPaymentMethodService.getPaymentMethod(
|
||||
tenantId,
|
||||
paymentServiceId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given payment method.
|
||||
* @param {number} tenantId
|
||||
* @param {number} paymentIntegrationId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async deletePaymentMethod(
|
||||
tenantId: number,
|
||||
paymentIntegrationId: number
|
||||
): Promise<void> {
|
||||
return this.deletePaymentMethodService.deletePaymentMethod(
|
||||
tenantId,
|
||||
paymentIntegrationId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the given payment method.
|
||||
* @param {number} tenantId
|
||||
* @param {number} paymentIntegrationId
|
||||
* @param {EditPaymentMethodDTO} editPaymentMethodDTO
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async editPaymentMethod(
|
||||
tenantId: number,
|
||||
paymentIntegrationId: number,
|
||||
editPaymentMethodDTO: EditPaymentMethodDTO
|
||||
): Promise<void> {
|
||||
return this.editPaymentMethodService.editPaymentMethod(
|
||||
tenantId,
|
||||
paymentIntegrationId,
|
||||
editPaymentMethodDTO
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the payment state providing state.
|
||||
* @param {number} tenantId
|
||||
* @returns {Promise<GetPaymentMethodsPOJO>}
|
||||
*/
|
||||
public async getPaymentMethodsState(
|
||||
tenantId: number
|
||||
): Promise<GetPaymentMethodsPOJO> {
|
||||
return this.getPaymentMethodsStateService.getPaymentMethodsState(tenantId);
|
||||
}
|
||||
}
|
||||
33
packages/server/src/services/PaymentServices/types.ts
Normal file
33
packages/server/src/services/PaymentServices/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface EditPaymentMethodDTO {
|
||||
name?: string;
|
||||
options?: {
|
||||
bankAccountId?: number; // bank account.
|
||||
clearningAccountId?: number; // current liability.
|
||||
|
||||
showVisa?: boolean;
|
||||
showMasterCard?: boolean;
|
||||
showDiscover?: boolean;
|
||||
showAmer?: boolean;
|
||||
showJcb?: boolean;
|
||||
showDiners?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetPaymentMethodsPOJO {
|
||||
stripe: {
|
||||
isStripeAccountCreated: boolean;
|
||||
|
||||
isStripePaymentEnabled: boolean;
|
||||
isStripePayoutEnabled: boolean;
|
||||
isStripeEnabled: boolean;
|
||||
|
||||
isStripeServerConfigured: boolean;
|
||||
|
||||
stripeAccountId: string | null;
|
||||
stripePaymentMethodId: number | null;
|
||||
stripePublishableKey: string | null;
|
||||
stripeAuthLink: string;
|
||||
stripeCurrencies: Array<string>;
|
||||
stripeRedirectUrl: string | null;
|
||||
};
|
||||
}
|
||||
9
packages/server/src/services/PaymentServices/utils.ts
Normal file
9
packages/server/src/services/PaymentServices/utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import config from '@/config';
|
||||
|
||||
export const isStripePaymentConfigured = () => {
|
||||
return (
|
||||
config.stripePayment.secretKey &&
|
||||
config.stripePayment.publishableKey &&
|
||||
config.stripePayment.webhooksSecret
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ISystemUser,
|
||||
ISaleInvoiceDeletePayload,
|
||||
ISaleInvoiceDeletedPayload,
|
||||
ISaleInvoiceDeletingPayload,
|
||||
} from '@/interfaces';
|
||||
import events from '@/subscribers/events';
|
||||
import UnitOfWork from '@/services/UnitOfWork';
|
||||
@@ -82,10 +83,10 @@ export class DeleteSaleInvoice {
|
||||
) {
|
||||
const { saleInvoiceRepository } = this.tenancy.repositories(tenantId);
|
||||
|
||||
const saleInvoice = await saleInvoiceRepository.findOneById(
|
||||
saleInvoiceId,
|
||||
'entries'
|
||||
);
|
||||
const saleInvoice = await saleInvoiceRepository.findOneById(saleInvoiceId, [
|
||||
'entries',
|
||||
'paymentMethods',
|
||||
]);
|
||||
if (!saleInvoice) {
|
||||
throw new ServiceError(ERRORS.SALE_INVOICE_NOT_FOUND);
|
||||
}
|
||||
@@ -118,15 +119,22 @@ export class DeleteSaleInvoice {
|
||||
// Validate the sale invoice has applied to credit note transaction.
|
||||
await this.validateInvoiceHasNoAppliedToCredit(tenantId, saleInvoiceId);
|
||||
|
||||
// Triggers `onSaleInvoiceDelete` event.
|
||||
await this.eventPublisher.emitAsync(events.saleInvoice.onDelete, {
|
||||
tenantId,
|
||||
oldSaleInvoice,
|
||||
saleInvoiceId,
|
||||
} as ISaleInvoiceDeletePayload);
|
||||
|
||||
// Deletes sale invoice transaction and associate transactions with UOW env.
|
||||
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
|
||||
// Triggers `onSaleInvoiceDelete` event.
|
||||
// Triggers `onSaleInvoiceDeleting` event.
|
||||
await this.eventPublisher.emitAsync(events.saleInvoice.onDeleting, {
|
||||
tenantId,
|
||||
saleInvoice: oldSaleInvoice,
|
||||
oldSaleInvoice,
|
||||
saleInvoiceId,
|
||||
trx,
|
||||
} as ISaleInvoiceDeletePayload);
|
||||
} as ISaleInvoiceDeletingPayload);
|
||||
|
||||
// Unlink the converted sale estimates from the given sale invoice.
|
||||
await this.unlockEstimateFromInvoice.unlinkConvertedEstimateFromInvoice(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Transformer } from '@/lib/Transformer/Transformer';
|
||||
import { PUBLIC_PAYMENT_LINK } from './constants';
|
||||
|
||||
export class GeneratePaymentLinkTransformer extends Transformer {
|
||||
/**
|
||||
* Exclude these attributes from payment link object.
|
||||
* @returns {Array}
|
||||
*/
|
||||
public excludeAttributes = (): string[] => {
|
||||
return ['linkId'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Included attributes.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
public includeAttributes = (): string[] => {
|
||||
return ['link'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the public/private payment linl
|
||||
* @returns {string}
|
||||
*/
|
||||
public link(link) {
|
||||
return PUBLIC_PAYMENT_LINK?.replace('{PAYMENT_LINK_ID}', link.linkId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import UnitOfWork from '@/services/UnitOfWork';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import events from '@/subscribers/events';
|
||||
import { PaymentLink } from '@/system/models';
|
||||
import { GeneratePaymentLinkTransformer } from './GeneratePaymentLinkTransformer';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
|
||||
@Service()
|
||||
export class GenerateShareLink {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private uow: UnitOfWork;
|
||||
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
@Inject()
|
||||
private transformer: TransformerInjectable;
|
||||
|
||||
/**
|
||||
* Generates private or public payment link for the given sale invoice.
|
||||
* @param {number} tenantId - Tenant id.
|
||||
* @param {number} invoiceId - Sale invoice id.
|
||||
* @param {string} publicOrPrivate - Public or private.
|
||||
* @param {string} expiryTime - Expiry time.
|
||||
*/
|
||||
async generatePaymentLink(
|
||||
tenantId: number,
|
||||
transactionId: number,
|
||||
transactionType: string,
|
||||
publicity: string = 'private',
|
||||
expiryTime: string = ''
|
||||
) {
|
||||
const { SaleInvoice } = this.tenancy.models(tenantId);
|
||||
|
||||
const foundInvoice = await SaleInvoice.query()
|
||||
.findById(transactionId)
|
||||
.throwIfNotFound();
|
||||
|
||||
// Generate unique uuid for sharable link.
|
||||
const linkId = uuidv4() as string;
|
||||
|
||||
const commonEventPayload = {
|
||||
tenantId,
|
||||
transactionId,
|
||||
transactionType,
|
||||
publicity,
|
||||
expiryTime,
|
||||
};
|
||||
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
|
||||
// Triggers `onPublicSharableLinkGenerating` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.saleInvoice.onPublicLinkGenerating,
|
||||
{ ...commonEventPayload, trx }
|
||||
);
|
||||
const paymentLink = await PaymentLink.query().insert({
|
||||
linkId,
|
||||
tenantId,
|
||||
publicity,
|
||||
resourceId: foundInvoice.id,
|
||||
resourceType: 'SaleInvoice',
|
||||
});
|
||||
// Triggers `onPublicSharableLinkGenerated` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.saleInvoice.onPublicLinkGenerated,
|
||||
{
|
||||
...commonEventPayload,
|
||||
paymentLink,
|
||||
trx,
|
||||
}
|
||||
);
|
||||
return this.transformer.transform(
|
||||
tenantId,
|
||||
paymentLink,
|
||||
new GeneratePaymentLinkTransformer()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import moment from 'moment';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { PaymentLink } from '@/system/models';
|
||||
import { GetInvoicePaymentLinkMetaTransformer } from './GetInvoicePaymentLinkTransformer';
|
||||
import { initalizeTenantServices } from '@/api/middleware/TenantDependencyInjection';
|
||||
|
||||
@Service()
|
||||
export class GetInvoicePaymentLinkMetadata {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private transformer: TransformerInjectable;
|
||||
|
||||
/**
|
||||
* Retrieves the invoice sharable link meta of the link id.
|
||||
* @param {number}
|
||||
* @param {string} linkId
|
||||
*/
|
||||
async getInvoicePaymentLinkMeta(linkId: string) {
|
||||
const paymentLink = await PaymentLink.query()
|
||||
.findOne('linkId', linkId)
|
||||
.where('resourceType', 'SaleInvoice')
|
||||
.throwIfNotFound();
|
||||
|
||||
// Validate the expiry at date.
|
||||
if (paymentLink.expiryAt) {
|
||||
const currentDate = moment();
|
||||
const expiryDate = moment(paymentLink.expiryAt);
|
||||
|
||||
if (expiryDate.isBefore(currentDate)) {
|
||||
throw new ServiceError('PAYMENT_LINK_EXPIRED');
|
||||
}
|
||||
}
|
||||
const tenantId = paymentLink.tenantId;
|
||||
await initalizeTenantServices(tenantId);
|
||||
|
||||
const { SaleInvoice } = this.tenancy.models(tenantId);
|
||||
|
||||
const invoice = await SaleInvoice.query()
|
||||
.findById(paymentLink.resourceId)
|
||||
.withGraphFetched('entries.item')
|
||||
.withGraphFetched('customer')
|
||||
.throwIfNotFound();
|
||||
|
||||
return this.transformer.transform(
|
||||
tenantId,
|
||||
invoice,
|
||||
new GetInvoicePaymentLinkMetaTransformer()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ItemEntryTransformer } from './ItemEntryTransformer';
|
||||
import { SaleInvoiceTransformer } from './SaleInvoiceTransformer';
|
||||
|
||||
export class GetInvoicePaymentLinkMetaTransformer extends SaleInvoiceTransformer {
|
||||
/**
|
||||
* Exclude these attributes from payment link object.
|
||||
* @returns {Array}
|
||||
*/
|
||||
public excludeAttributes = (): string[] => {
|
||||
return ['*'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Included attributes.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
public includeAttributes = (): string[] => {
|
||||
return [
|
||||
'companyName',
|
||||
'customerName',
|
||||
'dueAmount',
|
||||
'dueDateFormatted',
|
||||
'invoiceDateFormatted',
|
||||
'total',
|
||||
'totalFormatted',
|
||||
'totalLocalFormatted',
|
||||
'subtotal',
|
||||
'subtotalFormatted',
|
||||
'subtotalLocalFormatted',
|
||||
'dueAmount',
|
||||
'dueAmountFormatted',
|
||||
'paymentAmount',
|
||||
'paymentAmountFormatted',
|
||||
'dueDate',
|
||||
'dueDateFormatted',
|
||||
'invoiceNo',
|
||||
'invoiceMessage',
|
||||
'termsConditions',
|
||||
'entries',
|
||||
];
|
||||
};
|
||||
|
||||
public customerName(invoice) {
|
||||
return invoice.customer.displayName;
|
||||
}
|
||||
|
||||
public companyName() {
|
||||
return 'Bigcapital Technology, Inc.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the entries of the sale invoice.
|
||||
* @param {ISaleInvoice} invoice
|
||||
* @returns {}
|
||||
*/
|
||||
protected entries = (invoice) => {
|
||||
return this.item(
|
||||
invoice.entries,
|
||||
new GetInvoicePaymentLinkEntryMetaTransformer(),
|
||||
{
|
||||
currencyCode: invoice.currencyCode,
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
class GetInvoicePaymentLinkEntryMetaTransformer extends ItemEntryTransformer {
|
||||
/**
|
||||
* Include these attributes to item entry object.
|
||||
* @returns {Array}
|
||||
*/
|
||||
public includeAttributes = (): string[] => {
|
||||
return [
|
||||
'quantity',
|
||||
'quantityFormatted',
|
||||
'rate',
|
||||
'rateFormatted',
|
||||
'total',
|
||||
'totalFormatted',
|
||||
'itemName',
|
||||
'description',
|
||||
];
|
||||
};
|
||||
|
||||
public itemName(entry) {
|
||||
return entry.item.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude these attributes from payment link object.
|
||||
* @returns {Array}
|
||||
*/
|
||||
public excludeAttributes = (): string[] => {
|
||||
return ['*'];
|
||||
};
|
||||
}
|
||||
@@ -35,7 +35,8 @@ export class GetSaleInvoice {
|
||||
.withGraphFetched('customer')
|
||||
.withGraphFetched('branch')
|
||||
.withGraphFetched('taxes.taxRate')
|
||||
.withGraphFetched('attachments');
|
||||
.withGraphFetched('attachments')
|
||||
.withGraphFetched('paymentMethods');
|
||||
|
||||
// Validates the given sale invoice existance.
|
||||
this.validators.validateInvoiceExistance(saleInvoice);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import config from '@/config';
|
||||
|
||||
export const DEFAULT_INVOICE_MAIL_SUBJECT =
|
||||
'Invoice {InvoiceNumber} from {CompanyName}';
|
||||
export const DEFAULT_INVOICE_MAIL_CONTENT = `
|
||||
@@ -30,6 +32,8 @@ Amount : <strong>{InvoiceAmount}</strong></p>
|
||||
</p>
|
||||
`;
|
||||
|
||||
export const PUBLIC_PAYMENT_LINK = `${config.baseURL}/payment/{PAYMENT_LINK_ID}`;
|
||||
|
||||
export const ERRORS = {
|
||||
INVOICE_NUMBER_NOT_UNIQUE: 'INVOICE_NUMBER_NOT_UNIQUE',
|
||||
SALE_INVOICE_NOT_FOUND: 'SALE_INVOICE_NOT_FOUND',
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { omit } from 'lodash';
|
||||
import events from '@/subscribers/events';
|
||||
import {
|
||||
ISaleInvoiceCreatedPayload,
|
||||
ISaleInvoiceDeletingPayload,
|
||||
PaymentIntegrationTransactionLink,
|
||||
PaymentIntegrationTransactionLinkDeleteEventPayload,
|
||||
PaymentIntegrationTransactionLinkEventPayload,
|
||||
} from '@/interfaces';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
|
||||
@Service()
|
||||
export class InvoicePaymentIntegrationSubscriber {
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
/**
|
||||
* Attaches events with handlers.
|
||||
*/
|
||||
public attach = (bus) => {
|
||||
bus.subscribe(
|
||||
events.saleInvoice.onCreated,
|
||||
this.handleCreatePaymentIntegrationEvents
|
||||
);
|
||||
bus.subscribe(
|
||||
events.saleInvoice.onDeleting,
|
||||
this.handleCreatePaymentIntegrationEventsOnDeleteInvoice
|
||||
);
|
||||
return bus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the creation of payment integration events when a sale invoice is created.
|
||||
* This method filters enabled payment methods from the invoice and emits a payment
|
||||
* integration link event for each method.
|
||||
* @param {ISaleInvoiceCreatedPayload} payload - The payload containing sale invoice creation details.
|
||||
*/
|
||||
private handleCreatePaymentIntegrationEvents = ({
|
||||
tenantId,
|
||||
saleInvoiceDTO,
|
||||
saleInvoice,
|
||||
trx,
|
||||
}: ISaleInvoiceCreatedPayload) => {
|
||||
const paymentMethods =
|
||||
saleInvoice.paymentMethods?.filter((method) => method.enable) || [];
|
||||
|
||||
paymentMethods.map(
|
||||
async (paymentMethod: PaymentIntegrationTransactionLink) => {
|
||||
const payload = {
|
||||
...omit(paymentMethod, ['id']),
|
||||
tenantId,
|
||||
saleInvoiceId: saleInvoice.id,
|
||||
trx,
|
||||
};
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.paymentIntegrationLink.onPaymentIntegrationLink,
|
||||
payload as PaymentIntegrationTransactionLinkEventPayload
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ISaleInvoiceDeletingPayload} payload
|
||||
*/
|
||||
private handleCreatePaymentIntegrationEventsOnDeleteInvoice = ({
|
||||
tenantId,
|
||||
oldSaleInvoice,
|
||||
trx,
|
||||
}: ISaleInvoiceDeletingPayload) => {
|
||||
const paymentMethods =
|
||||
oldSaleInvoice.paymentMethods?.filter((method) => method.enable) || [];
|
||||
|
||||
paymentMethods.map(
|
||||
async (paymentMethod: PaymentIntegrationTransactionLink) => {
|
||||
const payload = {
|
||||
...omit(paymentMethod, ['id']),
|
||||
tenantId,
|
||||
oldSaleInvoiceId: oldSaleInvoice.id,
|
||||
trx,
|
||||
} as PaymentIntegrationTransactionLinkDeleteEventPayload;
|
||||
|
||||
// Triggers `onPaymentIntegrationDeleteLink` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.paymentIntegrationLink.onPaymentIntegrationDeleteLink,
|
||||
payload
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import JournalPoster from '@/services/Accounting/JournalPoster';
|
||||
import TenancyService from '@/services/Tenancy/TenancyService';
|
||||
import JournalCommands from '@/services/Accounting/JournalCommands';
|
||||
import Knex from 'knex';
|
||||
|
||||
@Service()
|
||||
export default class JournalPosterService {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import config from '@/config';
|
||||
import { StripePaymentService } from './StripePaymentService';
|
||||
import HasTenancyService from '../Tenancy/TenancyService';
|
||||
import { ISaleInvoice } from '@/interfaces';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { StripeInvoiceCheckoutSessionPOJO } from '@/interfaces/StripePayment';
|
||||
import { PaymentLink } from '@/system/models';
|
||||
|
||||
const origin = 'http://localhost';
|
||||
|
||||
@Service()
|
||||
export class CreateInvoiceCheckoutSession {
|
||||
@Inject()
|
||||
private stripePaymentService: StripePaymentService;
|
||||
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
/**
|
||||
* Creates a new Stripe checkout session from the given sale invoice.
|
||||
* @param {number} tenantId
|
||||
* @param {number} saleInvoiceId - Sale invoice id.
|
||||
* @returns {Promise<StripeInvoiceCheckoutSessionPOJO>}
|
||||
*/
|
||||
async createInvoiceCheckoutSession(
|
||||
tenantId: number,
|
||||
publicPaymentLinkId: number
|
||||
): Promise<StripeInvoiceCheckoutSessionPOJO> {
|
||||
const { SaleInvoice } = this.tenancy.models(tenantId);
|
||||
|
||||
// Retrieves the payment link from the given id.
|
||||
const paymentLink = await PaymentLink.query()
|
||||
.findOne('linkId', publicPaymentLinkId)
|
||||
.where('resourceType', 'SaleInvoice')
|
||||
.throwIfNotFound();
|
||||
|
||||
// Retrieves the invoice from associated payment link.
|
||||
const invoice = await SaleInvoice.query()
|
||||
.findById(paymentLink.resourceId)
|
||||
.withGraphFetched('paymentMethods')
|
||||
.throwIfNotFound();
|
||||
|
||||
// It will be only one Stripe payment method associated to the invoice.
|
||||
const stripePaymentMethod = invoice.paymentMethods?.find(
|
||||
(method) => method.paymentIntegration?.service === 'Stripe'
|
||||
);
|
||||
const stripeAccountId = stripePaymentMethod?.paymentIntegration?.accountId;
|
||||
const paymentIntegrationId = stripePaymentMethod?.paymentIntegration?.id;
|
||||
|
||||
// Creates checkout session for the given invoice.
|
||||
const session = await this.createCheckoutSession(invoice, stripeAccountId, {
|
||||
tenantId,
|
||||
paymentLinkId: paymentLink.id,
|
||||
});
|
||||
return {
|
||||
sessionId: session.id,
|
||||
publishableKey: config.stripePayment.publishableKey,
|
||||
redirectTo: session.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Stripe checkout session for the given sale invoice.
|
||||
* @param {ISaleInvoice} invoice - The sale invoice for which the checkout session is created.
|
||||
* @param {string} stripeAccountId - The Stripe account ID associated with the payment method.
|
||||
* @returns {Promise<any>} - The created Stripe checkout session.
|
||||
*/
|
||||
private createCheckoutSession(
|
||||
invoice: ISaleInvoice,
|
||||
stripeAccountId: string,
|
||||
metadata?: Record<string, any>
|
||||
) {
|
||||
return this.stripePaymentService.stripe.checkout.sessions.create(
|
||||
{
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: invoice.currencyCode,
|
||||
product_data: {
|
||||
name: invoice.invoiceNo,
|
||||
},
|
||||
unit_amount: invoice.total * 100, // Amount in cents
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: 'payment',
|
||||
success_url: `${origin}/success`,
|
||||
cancel_url: `${origin}/cancel`,
|
||||
metadata: {
|
||||
saleInvoiceId: invoice.id,
|
||||
resource: 'SaleInvoice',
|
||||
...metadata,
|
||||
},
|
||||
},
|
||||
{ stripeAccount: stripeAccountId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { GetSaleInvoice } from '../Sales/Invoices/GetSaleInvoice';
|
||||
import { CreatePaymentReceived } from '../Sales/PaymentReceived/CreatePaymentReceived';
|
||||
|
||||
@Service()
|
||||
export class CreatePaymentReceiveStripePayment {
|
||||
@Inject()
|
||||
private getSaleInvoiceService: GetSaleInvoice;
|
||||
|
||||
@Inject()
|
||||
private createPaymentReceivedService: CreatePaymentReceived;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} tenantId
|
||||
* @param {number} saleInvoiceId
|
||||
* @param {number} paidAmount
|
||||
*/
|
||||
async createPaymentReceived(
|
||||
tenantId: number,
|
||||
saleInvoiceId: number,
|
||||
paidAmount: number
|
||||
) {
|
||||
const invoice = await this.getSaleInvoiceService.getSaleInvoice(
|
||||
tenantId,
|
||||
saleInvoiceId
|
||||
);
|
||||
await this.createPaymentReceivedService.createPaymentReceived(tenantId, {
|
||||
customerId: invoice.customerId,
|
||||
paymentDate: new Date(),
|
||||
amount: paidAmount,
|
||||
depositAccountId: 1002,
|
||||
statement: '',
|
||||
entries: [{ invoiceId: saleInvoiceId, paymentAmount: paidAmount }],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { StripePaymentService } from './StripePaymentService';
|
||||
|
||||
@Service()
|
||||
export class CreateStripeAccountLinkService {
|
||||
@Inject()
|
||||
private stripePaymentService: StripePaymentService;
|
||||
|
||||
/**
|
||||
* Creates a new Stripe account id.
|
||||
* @param {number} tenantId
|
||||
*/
|
||||
public createAccountLink(tenantId: number, stripeAccountId: string) {
|
||||
return this.stripePaymentService.createAccountLink(stripeAccountId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { StripePaymentService } from '@/services/StripePayment/StripePaymentService';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { CreateStripeAccountDTO } from './types';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import events from '@/subscribers/events';
|
||||
|
||||
@Service()
|
||||
export class CreateStripeAccountService {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private stripePaymentService: StripePaymentService;
|
||||
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
/**
|
||||
* Creates a new Stripe account.
|
||||
* @param {number} tenantI
|
||||
* @param {CreateStripeAccountDTO} stripeAccountDTO
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createStripeAccount(
|
||||
tenantId: number,
|
||||
stripeAccountDTO?: CreateStripeAccountDTO
|
||||
): Promise<string> {
|
||||
const { PaymentIntegration } = this.tenancy.models(tenantId);
|
||||
const stripeAccount = await this.stripePaymentService.createAccount();
|
||||
const stripeAccountId = stripeAccount.id;
|
||||
|
||||
const parsedStripeAccountDTO = {
|
||||
name: 'Stripe',
|
||||
...stripeAccountDTO,
|
||||
};
|
||||
// Stores the details of the Stripe account.
|
||||
await PaymentIntegration.query().insert({
|
||||
name: parsedStripeAccountDTO.name,
|
||||
accountId: stripeAccountId,
|
||||
active: false, // Active will turn true after onboarding.
|
||||
service: 'Stripe',
|
||||
});
|
||||
// Triggers `onStripeIntegrationAccountCreated` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.stripeIntegration.onAccountCreated,
|
||||
{
|
||||
tenantId,
|
||||
stripeAccountDTO,
|
||||
stripeAccountId,
|
||||
}
|
||||
);
|
||||
return stripeAccountId;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Inject } from 'typedi';
|
||||
import { CreateInvoiceCheckoutSession } from './CreateInvoiceCheckoutSession';
|
||||
import { StripeInvoiceCheckoutSessionPOJO } from '@/interfaces/StripePayment';
|
||||
import { CreateStripeAccountService } from './CreateStripeAccountService';
|
||||
import { CreateStripeAccountLinkService } from './CreateStripeAccountLink';
|
||||
import { CreateStripeAccountDTO } from './types';
|
||||
import { ExchangeStripeOAuthTokenService } from './ExchangeStripeOauthToken';
|
||||
import { GetStripeAuthorizationLinkService } from './GetStripeAuthorizationLink';
|
||||
|
||||
export class StripePaymentApplication {
|
||||
@Inject()
|
||||
private createStripeAccountService: CreateStripeAccountService;
|
||||
|
||||
@Inject()
|
||||
private createStripeAccountLinkService: CreateStripeAccountLinkService;
|
||||
|
||||
@Inject()
|
||||
private createInvoiceCheckoutSessionService: CreateInvoiceCheckoutSession;
|
||||
|
||||
@Inject()
|
||||
private exchangeStripeOAuthTokenService: ExchangeStripeOAuthTokenService;
|
||||
|
||||
@Inject()
|
||||
private getStripeConnectLinkService: GetStripeAuthorizationLinkService;
|
||||
|
||||
/**
|
||||
* Creates a new Stripe account for Bigcapital.
|
||||
* @param {number} tenantId
|
||||
* @param {number} createStripeAccountDTO
|
||||
*/
|
||||
public createStripeAccount(
|
||||
tenantId: number,
|
||||
createStripeAccountDTO: CreateStripeAccountDTO = {}
|
||||
) {
|
||||
return this.createStripeAccountService.createStripeAccount(
|
||||
tenantId,
|
||||
createStripeAccountDTO
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Stripe account link of the given Stripe accoun..
|
||||
* @param {number} tenantId
|
||||
* @param {string} stripeAccountId
|
||||
* @returns {}
|
||||
*/
|
||||
public createAccountLink(tenantId: number, stripeAccountId: string) {
|
||||
return this.createStripeAccountLinkService.createAccountLink(
|
||||
tenantId,
|
||||
stripeAccountId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Stripe checkout session from the given sale invoice.
|
||||
* @param {number} tenantId
|
||||
* @param {string} paymentLinkId
|
||||
* @returns {Promise<StripeInvoiceCheckoutSessionPOJO>}
|
||||
*/
|
||||
public createSaleInvoiceCheckoutSession(
|
||||
tenantId: number,
|
||||
paymentLinkId: number
|
||||
): Promise<StripeInvoiceCheckoutSessionPOJO> {
|
||||
return this.createInvoiceCheckoutSessionService.createInvoiceCheckoutSession(
|
||||
tenantId,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Service } from 'typedi';
|
||||
import stripe from 'stripe';
|
||||
import config from '@/config';
|
||||
|
||||
const origin = 'https://cfdf-102-164-97-88.ngrok-free.app';
|
||||
|
||||
@Service()
|
||||
export class StripePaymentService {
|
||||
public stripe: stripe;
|
||||
|
||||
constructor() {
|
||||
this.stripe = new stripe(config.stripePayment.secretKey, {
|
||||
apiVersion: '2024-06-20',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} accountId
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
public async createAccountSession(accountId: string): Promise<string> {
|
||||
try {
|
||||
const accountSession = await this.stripe.accountSessions.create({
|
||||
account: accountId,
|
||||
components: {
|
||||
account_onboarding: { enabled: true },
|
||||
},
|
||||
});
|
||||
return accountSession.client_secret;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'An error occurred when calling the Stripe API to create an account session'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} accountId
|
||||
* @returns
|
||||
*/
|
||||
public async createAccountLink(accountId: string) {
|
||||
try {
|
||||
const accountLink = await this.stripe.accountLinks.create({
|
||||
account: accountId,
|
||||
return_url: `${origin}/return/${accountId}`,
|
||||
refresh_url: `${origin}/refresh/${accountId}`,
|
||||
type: 'account_onboarding',
|
||||
});
|
||||
return accountLink;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'An error occurred when calling the Stripe API to create an account link:'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
public async createAccount(): Promise<string> {
|
||||
try {
|
||||
const account = await this.stripe.accounts.create({
|
||||
type: 'standard',
|
||||
});
|
||||
return account;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'An error occurred when calling the Stripe API to create an account'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import events from '@/subscribers/events';
|
||||
import { CreatePaymentReceiveStripePayment } from '../CreatePaymentReceivedStripePayment';
|
||||
import {
|
||||
StripeCheckoutSessionCompletedEventPayload,
|
||||
StripeWebhookEventPayload,
|
||||
} from '@/interfaces/StripePayment';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { Tenant } from '@/system/models';
|
||||
|
||||
@Service()
|
||||
export class StripeWebhooksSubscriber {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private createPaymentReceiveStripePayment: CreatePaymentReceiveStripePayment;
|
||||
|
||||
/**
|
||||
* Attaches the subscriber to the event dispatcher.
|
||||
*/
|
||||
public attach(bus) {
|
||||
bus.subscribe(
|
||||
events.stripeWebhooks.onCheckoutSessionCompleted,
|
||||
this.handleCheckoutSessionCompleted.bind(this)
|
||||
);
|
||||
bus.subscribe(
|
||||
events.stripeWebhooks.onAccountUpdated,
|
||||
this.handleAccountUpdated.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the checkout session completed webhook event.
|
||||
* @param {StripeCheckoutSessionCompletedEventPayload} payload -
|
||||
*/
|
||||
async handleCheckoutSessionCompleted({
|
||||
event,
|
||||
}: StripeCheckoutSessionCompletedEventPayload) {
|
||||
const { metadata } = event.data.object;
|
||||
const tenantId = parseInt(metadata.tenantId, 10);
|
||||
const saleInvoiceId = parseInt(metadata.saleInvoiceId, 10);
|
||||
|
||||
// Get the amount from the event
|
||||
const amount = event.data.object.amount_total;
|
||||
|
||||
// Convert from Stripe amount (cents) to normal amount (dollars)
|
||||
const amountInDollars = amount / 100;
|
||||
|
||||
// Creates a new payment received transaction.
|
||||
await this.createPaymentReceiveStripePayment.createPaymentReceived(
|
||||
tenantId,
|
||||
saleInvoiceId,
|
||||
amountInDollars
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the account updated.
|
||||
* @param {StripeWebhookEventPayload}
|
||||
*/
|
||||
async handleAccountUpdated({ event }: StripeWebhookEventPayload) {
|
||||
const { metadata } = event.data.object;
|
||||
const account = event.data.object;
|
||||
const tenantId = parseInt(metadata.tenantId, 10);
|
||||
|
||||
if (!metadata?.paymentIntegrationId || !metadata.tenantId) return;
|
||||
|
||||
// Find the tenant or throw not found error.
|
||||
await Tenant.query().findById(tenantId).throwIfNotFound();
|
||||
|
||||
// Check if the account capabilities are active
|
||||
if (account.capabilities.card_payments === 'active') {
|
||||
const { PaymentIntegration } = this.tenancy.models(tenantId);
|
||||
|
||||
// Marks the payment method integration as active.
|
||||
await PaymentIntegration.query()
|
||||
.findById(metadata?.paymentIntegrationId)
|
||||
.patch({
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/server/src/services/StripePayment/types.ts
Normal file
11
packages/server/src/services/StripePayment/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
|
||||
export interface CreateStripeAccountDTO {
|
||||
name?: string;
|
||||
}
|
||||
export interface StripeOAuthCodeGrantedEventPayload {
|
||||
tenantId: number;
|
||||
paymentIntegrationId: number;
|
||||
trx?: Knex.Transaction
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export default class SalesTransactionLockingGuardSubscriber {
|
||||
this.transactionLockinGuardOnInvoiceWritingoffCanceling
|
||||
);
|
||||
bus.subscribe(
|
||||
events.saleInvoice.onDeleting,
|
||||
events.saleInvoice.onDelete,
|
||||
this.transactionLockingGuardOnInvoiceDeleting
|
||||
);
|
||||
|
||||
@@ -176,15 +176,15 @@ export default class SalesTransactionLockingGuardSubscriber {
|
||||
* @param {ISaleInvoiceDeletePayload} payload
|
||||
*/
|
||||
private transactionLockingGuardOnInvoiceDeleting = async ({
|
||||
saleInvoice,
|
||||
oldSaleInvoice,
|
||||
tenantId,
|
||||
}: ISaleInvoiceDeletePayload) => {
|
||||
// Can't continue if the old invoice not published.
|
||||
if (!saleInvoice.isDelivered) return;
|
||||
if (!oldSaleInvoice.isDelivered) return;
|
||||
|
||||
await this.salesLockingGuard.transactionLockingGuard(
|
||||
tenantId,
|
||||
saleInvoice.invoiceDate
|
||||
oldSaleInvoice.invoiceDate
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@ export default {
|
||||
|
||||
onMailReminderSend: 'onSaleInvoiceMailReminderSend',
|
||||
onMailReminderSent: 'onSaleInvoiceMailReminderSent',
|
||||
|
||||
onPublicLinkGenerating: 'onPublicSharableLinkGenerating',
|
||||
onPublicLinkGenerated: 'onPublicSharableLinkGenerated',
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -699,4 +702,35 @@ export default {
|
||||
onAssignedDefault: 'onPdfTemplateAssignedDefault',
|
||||
onAssigningDefault: 'onPdfTemplateAssigningDefault',
|
||||
},
|
||||
|
||||
// Payment method.
|
||||
paymentMethod: {
|
||||
onEditing: 'onPaymentMethodEditing',
|
||||
onEdited: 'onPaymentMethodEdited',
|
||||
|
||||
onDeleted: 'onPaymentMethodDeleted',
|
||||
},
|
||||
|
||||
// Payment methods integrations
|
||||
paymentIntegrationLink: {
|
||||
onPaymentIntegrationLink: 'onPaymentIntegrationLink',
|
||||
onPaymentIntegrationDeleteLink: 'onPaymentIntegrationDeleteLink'
|
||||
},
|
||||
|
||||
// Stripe Payment Integration
|
||||
stripeIntegration: {
|
||||
onAccountCreated: 'onStripeIntegrationAccountCreated',
|
||||
onAccountDeleted: 'onStripeIntegrationAccountDeleted',
|
||||
|
||||
onPaymentLinkCreated: 'onStripePaymentLinkCreated',
|
||||
onPaymentLinkInactivated: 'onStripePaymentLinkInactivated',
|
||||
|
||||
onOAuthCodeGranted: 'onStripeOAuthCodeGranted',
|
||||
},
|
||||
|
||||
// Stripe Payment Webhooks
|
||||
stripeWebhooks: {
|
||||
onCheckoutSessionCompleted: 'onStripeCheckoutSessionCompleted',
|
||||
onAccountUpdated: 'onStripeAccountUpdated'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.createTable('stripe_accounts', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('stripe_account_id').notNullable();
|
||||
table.string('tenant_id').notNullable();
|
||||
table.timestamps(true, true); // Adds created_at and updated_at columns
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.dropTableIfExists('stripe_accounts');
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.createTable('payment_links', (table) => {
|
||||
table.increments('id');
|
||||
table.integer('tenant_id');
|
||||
table.integer('resource_id');
|
||||
table.text('resource_type');
|
||||
table.string('linkId');
|
||||
table.string('publicity');
|
||||
table.datetime('expiry_at');
|
||||
table.timestamps();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.dropTableIfExists('payment_links');
|
||||
};
|
||||
26
packages/server/src/system/models/PaymentLink.ts
Normal file
26
packages/server/src/system/models/PaymentLink.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Model } from 'objection';
|
||||
|
||||
export class PaymentLink extends Model {
|
||||
static get tableName() {
|
||||
return 'payment_links';
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamps columns.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
static get timestamps() {
|
||||
return ['createdAt', 'updatedAt'];
|
||||
}
|
||||
|
||||
public tenantId!: number;
|
||||
public resourceId!: number;
|
||||
public resourceType!: string;
|
||||
public linkId!: string;
|
||||
public publicity!: string;
|
||||
public expiryAt!: Date;
|
||||
|
||||
// Timestamps
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
}
|
||||
49
packages/server/src/system/models/StripeAccount.ts
Normal file
49
packages/server/src/system/models/StripeAccount.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Model } from 'objection';
|
||||
|
||||
export class StripeAccount {
|
||||
/**
|
||||
* Table name
|
||||
*/
|
||||
static get tableName() {
|
||||
return 'stripe_accounts';
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamps columns.
|
||||
*/
|
||||
get timestamps() {
|
||||
return ['createdAt', 'updatedAt'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual attributes.
|
||||
*/
|
||||
static get virtualAttributes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model modifiers.
|
||||
*/
|
||||
static get modifiers() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship mapping.
|
||||
*/
|
||||
static get relationMappings() {
|
||||
const Tenant = require('./Tenant');
|
||||
|
||||
return {
|
||||
tenant: {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass: Tenant.default,
|
||||
join: {
|
||||
from: 'stripe_accounts.tenant_id',
|
||||
to: 'tenants.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import PasswordReset from './PasswordReset';
|
||||
import Invite from './Invite';
|
||||
import SystemPlaidItem from './SystemPlaidItem';
|
||||
import { Import } from './Import';
|
||||
import { StripeAccount } from './StripeAccount';
|
||||
import { PaymentLink } from './PaymentLink';
|
||||
|
||||
export {
|
||||
Plan,
|
||||
@@ -18,4 +20,6 @@ export {
|
||||
Invite,
|
||||
SystemPlaidItem,
|
||||
Import,
|
||||
StripeAccount,
|
||||
PaymentLink,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"@casl/react": "^2.3.0",
|
||||
"@craco/craco": "^5.9.0",
|
||||
"@reduxjs/toolkit": "^1.2.5",
|
||||
"@stripe/connect-js": "^3.3.12",
|
||||
"@stripe/react-connect-js": "^3.3.13",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.4.0",
|
||||
"@testing-library/user-event": "^7.2.1",
|
||||
|
||||
@@ -32,7 +32,9 @@ const RegisterVerify = lazy(
|
||||
const OneClickDemoPage = lazy(
|
||||
() => import('@/containers/OneClickDemo/OneClickDemoPage'),
|
||||
);
|
||||
|
||||
const PaymentPortalPage = lazy(
|
||||
() => import('@/containers/PaymentPortal/PaymentPortalPage'),
|
||||
);
|
||||
/**
|
||||
* App inner.
|
||||
*/
|
||||
@@ -57,6 +59,7 @@ function AppInsider({ history }) {
|
||||
children={<EmailConfirmation />}
|
||||
/>
|
||||
<Route path={'/auth'} children={<AuthenticationPage />} />
|
||||
<Route path={'/payment/:linkId'} children={<PaymentPortalPage />} />
|
||||
<Route path={'/'} children={<DashboardPrivatePages />} />
|
||||
</Switch>
|
||||
</Router>
|
||||
|
||||
@@ -5,6 +5,7 @@ import withDialogActions from '@/containers/Dialog/withDialogActions';
|
||||
import { compose } from '@/utils';
|
||||
|
||||
import '@/style/components/Dialog/Dialog.scss';
|
||||
import { DialogProvider } from './DialogProvider';
|
||||
|
||||
function DialogComponent(props) {
|
||||
const { name, children, closeDialog, onClose } = props;
|
||||
@@ -15,7 +16,7 @@ function DialogComponent(props) {
|
||||
};
|
||||
return (
|
||||
<Dialog {...props} onClose={handleClose}>
|
||||
{children}
|
||||
<DialogProvider value={props}>{children}</DialogProvider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
20
packages/webapp/src/components/Dialog/DialogProvider.tsx
Normal file
20
packages/webapp/src/components/Dialog/DialogProvider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
const DialogContext = createContext<any>(null);
|
||||
|
||||
export const useDialogContext = () => {
|
||||
return useContext(DialogContext);
|
||||
};
|
||||
|
||||
interface DialogProviderProps {
|
||||
value: any;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const DialogProvider: React.FC<DialogProviderProps> = ({ value, children }) => {
|
||||
return (
|
||||
<DialogContext.Provider value={value}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -52,6 +52,8 @@ import PaymentMailDialog from '@/containers/Sales/PaymentsReceived/PaymentMailDi
|
||||
import { ExportDialog } from '@/containers/Dialogs/ExportDialog';
|
||||
import { RuleFormDialog } from '@/containers/Banking/Rules/RuleFormDialog/RuleFormDialog';
|
||||
import { DisconnectBankAccountDialog } from '@/containers/CashFlow/AccountTransactions/dialogs/DisconnectBankAccountDialog/DisconnectBankAccountDialog';
|
||||
import { SharePaymentLinkDialog } from '@/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog';
|
||||
import { SelectPaymentMethodsDialog } from '@/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsDialog';
|
||||
|
||||
/**
|
||||
* Dialogs container.
|
||||
@@ -151,6 +153,10 @@ export default function DialogsContainer() {
|
||||
<DisconnectBankAccountDialog
|
||||
dialogName={DialogsName.DisconnectBankAccountConfirmation}
|
||||
/>
|
||||
<SharePaymentLinkDialog dialogName={DialogsName.SharePaymentLink} />
|
||||
<SelectPaymentMethodsDialog
|
||||
dialogName={DialogsName.SelectPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { FormattedMessage as T } from '@/components';
|
||||
import { Classes, Icon, H4, Button } from '@blueprintjs/core';
|
||||
|
||||
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
|
||||
import { useDrawerContext } from './DrawerProvider';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { compose } from '@/utils';
|
||||
import styled from 'styled-components';
|
||||
|
||||
/**
|
||||
* Drawer header content.
|
||||
@@ -16,18 +17,15 @@ function DrawerHeaderContentRoot(props) {
|
||||
icon,
|
||||
title = <T id={'view_paper'} />,
|
||||
subTitle,
|
||||
onClose,
|
||||
name,
|
||||
closeDrawer,
|
||||
} = props;
|
||||
const { name } = useDrawerContext();
|
||||
|
||||
if (title == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = (event) => {
|
||||
closeDrawer(name);
|
||||
onClose && onClose(event);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -77,4 +77,8 @@ export enum DialogsName {
|
||||
Export = 'Export',
|
||||
BankRuleForm = 'BankRuleForm',
|
||||
DisconnectBankAccountConfirmation = 'DisconnectBankAccountConfirmation',
|
||||
SharePaymentLink = 'SharePaymentLink',
|
||||
SelectPaymentMethod = 'SelectPaymentMethodsDialog',
|
||||
|
||||
StripeSetup = 'StripeSetup'
|
||||
}
|
||||
|
||||
@@ -31,5 +31,7 @@ export enum DRAWERS {
|
||||
RECEIPT_CUSTOMIZE = 'RECEIPT_CUSTOMIZE',
|
||||
CREDIT_NOTE_CUSTOMIZE = 'CREDIT_NOTE_CUSTOMIZE',
|
||||
PAYMENT_RECEIVED_CUSTOMIZE = 'PAYMENT_RECEIVED_CUSTOMIZE',
|
||||
BRANDING_TEMPLATES = 'BRANDING_TEMPLATES'
|
||||
BRANDING_TEMPLATES = 'BRANDING_TEMPLATES',
|
||||
PAYMENT_INVOICE_PREVIEW = 'PAYMENT_INVOICE_PREVIEW',
|
||||
STRIPE_PAYMENT_INTEGRATION_EDIT = 'STRIPE_PAYMENT_INTEGRATION_EDIT'
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ export default [
|
||||
text: <T id={'users'} />,
|
||||
href: '/preferences/users',
|
||||
},
|
||||
{
|
||||
text: 'Payment Methods',
|
||||
href: '/preferences/payment-methods'
|
||||
},
|
||||
{
|
||||
text: <T id={'preferences.estimates'} />,
|
||||
href: '/preferences/estimates',
|
||||
@@ -54,6 +58,11 @@ export default [
|
||||
disabled: false,
|
||||
href: '/preferences/items',
|
||||
},
|
||||
{
|
||||
text: 'Integrations',
|
||||
disabled: false,
|
||||
href: '/preferences/integrations'
|
||||
},
|
||||
// {
|
||||
// text: <T id={'sms_integration.label'} />,
|
||||
// disabled: false,
|
||||
|
||||
@@ -30,6 +30,7 @@ import { BankRulesAlerts } from '../Banking/Rules/RulesList/BankRulesAlerts';
|
||||
import { SubscriptionAlerts } from '../Subscriptions/alerts/alerts';
|
||||
import { BankAccountAlerts } from '@/containers/CashFlow/AccountTransactions/alerts';
|
||||
import { BrandingTemplatesAlerts } from '../BrandingTemplates/alerts/BrandingTemplatesAlerts';
|
||||
import { PaymentMethodsAlerts } from '../Preferences/PaymentMethods/alerts/PaymentMethodsAlerts';
|
||||
|
||||
export default [
|
||||
...AccountsAlerts,
|
||||
@@ -63,4 +64,5 @@ export default [
|
||||
...SubscriptionAlerts,
|
||||
...BankAccountAlerts,
|
||||
...BrandingTemplatesAlerts,
|
||||
...PaymentMethodsAlerts,
|
||||
];
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
Classes,
|
||||
NavbarDivider,
|
||||
Intent,
|
||||
Tooltip,
|
||||
Position,
|
||||
} from '@blueprintjs/core';
|
||||
|
||||
import { useInvoiceDetailDrawerContext } from './InvoiceDetailDrawerProvider';
|
||||
@@ -32,6 +34,7 @@ import { compose } from '@/utils';
|
||||
import { BadDebtMenuItem } from './utils';
|
||||
import { DRAWERS } from '@/constants/drawers';
|
||||
import { DialogsName } from '@/constants/dialogs';
|
||||
import { ArrowBottomLeft } from '@/icons/ArrowBottomLeft';
|
||||
|
||||
/**
|
||||
* Invoice details action bar.
|
||||
@@ -103,6 +106,13 @@ function InvoiceDetailActionsBar({
|
||||
openDialog(DialogsName.InvoiceMail, { invoiceId });
|
||||
};
|
||||
|
||||
const handleShareButtonClick = () => {
|
||||
openDialog(DialogsName.SharePaymentLink, {
|
||||
transactionId: invoiceId,
|
||||
transactionType: 'SaleInvoice',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerActionsBar>
|
||||
<NavbarGroup>
|
||||
@@ -119,7 +129,7 @@ function InvoiceDetailActionsBar({
|
||||
<If condition={invoice.is_delivered && !invoice.is_fully_paid}>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
icon={<Icon icon="arrow-downward" iconSize={18} />}
|
||||
icon={<ArrowBottomLeft size={16} />}
|
||||
text={<T id={'add_payment'} />}
|
||||
onClick={handleQuickPaymentInvoice}
|
||||
/>
|
||||
@@ -150,6 +160,15 @@ function InvoiceDetailActionsBar({
|
||||
onClick={handleDeleteInvoice}
|
||||
/>
|
||||
</Can>
|
||||
<NavbarDivider />
|
||||
<Tooltip content="Share" position={Position.BOTTOM} minimal>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
icon={<Icon icon={'share'} iconSize={16} />}
|
||||
onClick={handleShareButtonClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Can I={SaleInvoiceAction.Writeoff} a={AbilitySubject.Invoice}>
|
||||
<NavbarDivider />
|
||||
<BadDebtMenuItem
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Formik, Form } from 'formik';
|
||||
|
||||
interface SelectPaymentMethodsFormValues {}
|
||||
|
||||
const initialValues: SelectPaymentMethodsFormValues = {};
|
||||
|
||||
export const SelectPaymentMethodsForm: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const handleSubmit = (values: SelectPaymentMethodsFormValues) => {};
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
<Form>{children}</Form>
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useGetPaymentServices } from '@/hooks/query/payment-services';
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
interface SelectPaymentMethodsContextType {}
|
||||
|
||||
const SelectPaymentMethodsContext =
|
||||
createContext<SelectPaymentMethodsContextType>(
|
||||
{} as SelectPaymentMethodsContextType,
|
||||
);
|
||||
|
||||
export const useSelectPaymentMethods = () => {
|
||||
const context = useContext(SelectPaymentMethodsContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useSelectPaymentMethods must be used within a SelectPaymentMethodsProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface SelectPaymentMethodsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SelectPaymentMethodsBoot: React.FC<
|
||||
SelectPaymentMethodsProviderProps
|
||||
> = ({ children }) => {
|
||||
const { isLoading: isPaymentServicesLoading, data: paymentServices } =
|
||||
useGetPaymentServices();
|
||||
|
||||
const value = {
|
||||
paymentServices,
|
||||
isPaymentServicesLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectPaymentMethodsContext.Provider value={value}>
|
||||
{children}
|
||||
</SelectPaymentMethodsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { SelectPaymentMethodsBoot } from './SelectPaymentMethodsBoot';
|
||||
import { SelectPaymentMethodsForm } from './SelectPaymemtMethodsForm';
|
||||
import styled from 'styled-components';
|
||||
import { Group, Stack } from '@/components';
|
||||
import {
|
||||
DialogFooter,
|
||||
Button,
|
||||
Checkbox,
|
||||
DialogBody,
|
||||
Intent,
|
||||
Text,
|
||||
} from '@blueprintjs/core';
|
||||
import { useDialogActions } from '@/hooks/state';
|
||||
import { DialogsName } from '@/constants/dialogs';
|
||||
import { useUncontrolled } from '@/hooks/useUncontrolled';
|
||||
|
||||
export function SelectPaymentMethodsContent() {
|
||||
const { closeDialog } = useDialogActions();
|
||||
|
||||
const handleCancelBtnClick = () => {
|
||||
closeDialog(DialogsName.SelectPaymentMethod);
|
||||
};
|
||||
return (
|
||||
<SelectPaymentMethodsBoot>
|
||||
<SelectPaymentMethodsForm>
|
||||
<DialogBody>
|
||||
<Stack spacing={12}>
|
||||
<PaymentMethodSelect
|
||||
label={'Card (Including Apple Pay, Google Pay and Link)'}
|
||||
/>
|
||||
<PaymentMethodSelect
|
||||
label={'Card (Including Apple Pay, Google Pay and Link)'}
|
||||
/>
|
||||
<PaymentMethodSelect
|
||||
label={'Card (Including Apple Pay, Google Pay and Link)'}
|
||||
/>
|
||||
<PaymentMethodSelect
|
||||
label={'Card (Including Apple Pay, Google Pay and Link)'}
|
||||
/>
|
||||
<PaymentMethodSelect
|
||||
label={'Card (Including Apple Pay, Google Pay and Link)'}
|
||||
/>
|
||||
<PaymentMethodSelect
|
||||
label={'Card (Including Apple Pay, Google Pay and Link)'}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={handleCancelBtnClick}>Cancel</Button>
|
||||
<Button intent={Intent.PRIMARY}>Submit</Button>
|
||||
</>
|
||||
}
|
||||
></DialogFooter>
|
||||
</SelectPaymentMethodsForm>
|
||||
</SelectPaymentMethodsBoot>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentMethodSelectProps {
|
||||
label: string;
|
||||
value?: boolean;
|
||||
initialValue?: boolean;
|
||||
onChange?: (value: boolean) => void;
|
||||
}
|
||||
function PaymentMethodSelect({
|
||||
value,
|
||||
initialValue,
|
||||
onChange,
|
||||
label,
|
||||
}: PaymentMethodSelectProps) {
|
||||
const [_value, handleChange] = useUncontrolled<boolean>({
|
||||
value,
|
||||
initialValue,
|
||||
finalValue: false,
|
||||
onChange,
|
||||
});
|
||||
const handleClick = () => {
|
||||
handleChange(!_value);
|
||||
};
|
||||
|
||||
return (
|
||||
<PaymentMethodSelectRoot onClick={handleClick}>
|
||||
<PaymentMethodCheckbox
|
||||
label={''}
|
||||
checked={_value}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
<PaymentMethodText>{label}</PaymentMethodText>
|
||||
</PaymentMethodSelectRoot>
|
||||
);
|
||||
}
|
||||
|
||||
const PaymentMethodSelectRoot = styled(Group)`
|
||||
border: 1px solid #d3d8de;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
gap: 0;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const PaymentMethodCheckbox = styled(Checkbox)`
|
||||
margin: 0;
|
||||
|
||||
&.bp4-control .bp4-control-indicator {
|
||||
box-shadow: 0 0 0 1px #c5cbd3;
|
||||
}
|
||||
`;
|
||||
|
||||
const PaymentMethodText = styled(Text)`
|
||||
color: #404854;
|
||||
`;
|
||||
@@ -0,0 +1,38 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { Dialog, DialogSuspense } from '@/components';
|
||||
import withDialogRedux from '@/components/DialogReduxConnect';
|
||||
import { compose } from '@/utils';
|
||||
|
||||
const SelectPaymentMethodsDialogContent = React.lazy(() =>
|
||||
import('./SelectPaymentMethodsContent').then((module) => ({
|
||||
default: module.SelectPaymentMethodsContent,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Select payment methods dialogs.
|
||||
*/
|
||||
function SelectPaymentMethodsDialogRoot({ dialogName, payload, isOpen }) {
|
||||
return (
|
||||
<Dialog
|
||||
name={dialogName}
|
||||
isOpen={isOpen}
|
||||
payload={payload}
|
||||
title={'Share Link'}
|
||||
canEscapeJeyClose={true}
|
||||
autoFocus={true}
|
||||
style={{ width: 570 }}
|
||||
>
|
||||
<DialogSuspense>
|
||||
<SelectPaymentMethodsDialogContent />
|
||||
</DialogSuspense>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const SelectPaymentMethodsDialog = compose(withDialogRedux())(
|
||||
SelectPaymentMethodsDialogRoot,
|
||||
);
|
||||
|
||||
SelectPaymentMethodsDialog.displayName = 'SelectPaymentMethodsDialog';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { SharePaymentLinkForm } from './SharePaymentLinkForm';
|
||||
import { SharePaymentLinkFormContent } from './SharePaymentLinkFormContent';
|
||||
import { SharePaymentLinkProvider } from './SharePaymentLinkProvider';
|
||||
|
||||
export function SharePaymentLinkContent() {
|
||||
return (
|
||||
<SharePaymentLinkProvider>
|
||||
<SharePaymentLinkForm>
|
||||
<SharePaymentLinkFormContent />
|
||||
</SharePaymentLinkForm>
|
||||
</SharePaymentLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { Dialog, DialogSuspense } from '@/components';
|
||||
import withDialogRedux from '@/components/DialogReduxConnect';
|
||||
import { compose } from '@/utils';
|
||||
|
||||
const SharePaymentLinkContent = React.lazy(() =>
|
||||
import('./SharePaymentLinkContent').then((module) => ({
|
||||
default: module.SharePaymentLinkContent,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function SharePaymentLinkDialogRoot({ dialogName, payload, isOpen }) {
|
||||
return (
|
||||
<Dialog
|
||||
name={dialogName}
|
||||
isOpen={isOpen}
|
||||
payload={payload}
|
||||
title={'Share Link'}
|
||||
canEscapeJeyClose={true}
|
||||
autoFocus={true}
|
||||
style={{ width: 570 }}
|
||||
>
|
||||
<DialogSuspense>
|
||||
<SharePaymentLinkContent />
|
||||
</DialogSuspense>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const SharePaymentLinkDialog = compose(withDialogRedux())(
|
||||
SharePaymentLinkDialogRoot,
|
||||
);
|
||||
|
||||
SharePaymentLinkDialog.displayName = 'SharePaymentLinkDialog';
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const SharePaymentLinkFormSchema = Yup.object().shape({
|
||||
publicity: Yup.string()
|
||||
.oneOf(['private', 'public'], 'Invalid publicity type')
|
||||
.required('Publicity is required'),
|
||||
expiryDate: Yup.date()
|
||||
.nullable()
|
||||
.required('Expiration date is required')
|
||||
.min(new Date(), 'Expiration date must be in the future'),
|
||||
transactionId: Yup.string()
|
||||
.required('Transaction ID is required'),
|
||||
transactionType: Yup.string()
|
||||
.required('Transaction type is required'),
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
import { Formik, Form, FormikHelpers } from 'formik';
|
||||
import moment from 'moment';
|
||||
import { useCreatePaymentLink } from '@/hooks/query/payment-link';
|
||||
import { AppToaster } from '@/components';
|
||||
import { SharePaymentLinkFormSchema } from './SharePaymentLinkForm.schema';
|
||||
import { useDialogContext } from '@/components/Dialog/DialogProvider';
|
||||
import { useSharePaymentLink } from './SharePaymentLinkProvider';
|
||||
|
||||
interface SharePaymentLinkFormProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface SharePaymentLinkFormValues {
|
||||
publicity: string;
|
||||
expiryDate: string;
|
||||
transactionId: string;
|
||||
transactionType: string;
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
publicity: 'public',
|
||||
expiryDate: moment().add(30, 'days').format('YYYY-MM-DD'),
|
||||
transactionId: '',
|
||||
transactionType: '',
|
||||
};
|
||||
|
||||
export const SharePaymentLinkForm = ({
|
||||
children,
|
||||
}: SharePaymentLinkFormProps) => {
|
||||
const { mutateAsync: generateShareLink } = useCreatePaymentLink();
|
||||
const { payload } = useDialogContext();
|
||||
const { setUrl } = useSharePaymentLink();
|
||||
|
||||
const transactionId = payload?.transactionId;
|
||||
const transactionType = payload?.transactionType;
|
||||
|
||||
const formInitialValues = {
|
||||
...initialValues,
|
||||
transactionType,
|
||||
transactionId,
|
||||
};
|
||||
const handleFormSubmit = (
|
||||
values: SharePaymentLinkFormValues,
|
||||
{ setSubmitting }: FormikHelpers<SharePaymentLinkFormValues>,
|
||||
) => {
|
||||
setSubmitting(true);
|
||||
generateShareLink(values)
|
||||
.then((res) => {
|
||||
setSubmitting(false);
|
||||
setUrl(res.link?.link);
|
||||
})
|
||||
.catch(() => {
|
||||
setSubmitting(false);
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Formik<SharePaymentLinkFormValues>
|
||||
initialValues={formInitialValues}
|
||||
validationSchema={SharePaymentLinkFormSchema}
|
||||
onSubmit={handleFormSubmit}
|
||||
>
|
||||
<Form>{children}</Form>
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
SharePaymentLinkForm.displayName = 'SharePaymentLinkForm';
|
||||
@@ -0,0 +1,142 @@
|
||||
// @ts-nocheck
|
||||
import { useFormikContext } from 'formik';
|
||||
import {
|
||||
Button,
|
||||
Classes,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
Intent,
|
||||
Position,
|
||||
Tooltip,
|
||||
} from '@blueprintjs/core';
|
||||
import {
|
||||
DialogFooterActions,
|
||||
FDateInput,
|
||||
FFormGroup,
|
||||
FSelect,
|
||||
Icon,
|
||||
Stack,
|
||||
} from '@/components';
|
||||
import { useSharePaymentLink } from './SharePaymentLinkProvider';
|
||||
import { useClipboard } from '@/hooks/utils/useClipboard';
|
||||
import { useDialogActions } from '@/hooks/state';
|
||||
import { useDialogContext } from '@/components/Dialog/DialogProvider';
|
||||
|
||||
export function SharePaymentLinkFormContent() {
|
||||
const { url } = useSharePaymentLink();
|
||||
const { closeDialog } = useDialogActions();
|
||||
const { name } = useDialogContext();
|
||||
const { isSubmitting } = useFormikContext();
|
||||
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleCopyBtnClick = () => {
|
||||
clipboard.copy(url);
|
||||
};
|
||||
const handleCancelBtnClick = () => {
|
||||
closeDialog(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogBody>
|
||||
<Stack spacing={0}>
|
||||
<FFormGroup
|
||||
name={'publicity'}
|
||||
label={'Visibility'}
|
||||
style={{ marginBottom: 10 }}
|
||||
inline
|
||||
>
|
||||
<FSelect
|
||||
name={'publicity'}
|
||||
items={[
|
||||
{ value: 'private', text: 'Private' },
|
||||
{ value: 'public', text: 'Public' },
|
||||
]}
|
||||
input={({ activeItem, text, label, value }) => (
|
||||
<Button
|
||||
text={text || 'Select an item ...'}
|
||||
rightIcon={<Icon icon={'caret-down-16'} iconSize={16} />}
|
||||
minimal
|
||||
/>
|
||||
)}
|
||||
searchable={false}
|
||||
fastField
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<p className={Classes.TEXT_MUTED} style={{ marginBottom: 20 }}>
|
||||
Select an expiration date and generate the link to share it with
|
||||
your customer. Remember that anyone who has access to this link can
|
||||
view, print or download it.
|
||||
</p>
|
||||
|
||||
<FFormGroup
|
||||
name={'expiryDate'}
|
||||
label={'Expiration Date'}
|
||||
helperText={
|
||||
'By default, the link is set to expire 90 days from today.'
|
||||
}
|
||||
fastField
|
||||
>
|
||||
<FDateInput
|
||||
name={'expiryDate'}
|
||||
popoverProps={{ position: Position.BOTTOM, minimal: true }}
|
||||
formatDate={(date) => date.toLocaleDateString()}
|
||||
parseDate={(str) => new Date(str)}
|
||||
inputProps={{
|
||||
fill: true,
|
||||
style: { minWidth: 260 },
|
||||
leftElement: <Icon icon={'date-range'} />,
|
||||
}}
|
||||
fastField
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
{url && (
|
||||
<FormGroup name={'link'} label={'Payment Link'}>
|
||||
<InputGroup
|
||||
name={'link'}
|
||||
value={url}
|
||||
disabled
|
||||
leftElement={
|
||||
<Tooltip content="Copy to clipboard" position={Position.TOP}>
|
||||
<Button
|
||||
onClick={handleCopyBtnClick}
|
||||
minimal
|
||||
icon={<Icon icon={'clipboard'} iconSize={16} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooterActions>
|
||||
{url ? (
|
||||
<Button intent={Intent.PRIMARY} onClick={handleCopyBtnClick}>
|
||||
Copy Link
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={handleCancelBtnClick}>Cancel</Button>
|
||||
<Button
|
||||
type={'submit'}
|
||||
intent={Intent.PRIMARY}
|
||||
loading={isSubmitting}
|
||||
style={{ minWidth: 100 }}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooterActions>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface SharePaymentLinkContextType {
|
||||
url: string;
|
||||
setUrl: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
const SharePaymentLinkContext =
|
||||
createContext<SharePaymentLinkContextType | null>(null);
|
||||
|
||||
interface SharePaymentLinkProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SharePaymentLinkProvider: React.FC<
|
||||
SharePaymentLinkProviderProps
|
||||
> = ({ children }) => {
|
||||
const [url, setUrl] = useState<string>('');
|
||||
|
||||
return (
|
||||
<SharePaymentLinkContext.Provider value={{ url, setUrl }}>
|
||||
{children}
|
||||
</SharePaymentLinkContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSharePaymentLink = () => {
|
||||
const context = useContext(SharePaymentLinkContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useSharePaymentLink must be used within a SharePaymentLinkProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { FCheckbox, Group } from '@/components';
|
||||
import { useUncontrolled } from '@/hooks/useUncontrolled';
|
||||
import { Checkbox, Text } from '@blueprintjs/core';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { get } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export interface PaymentMethodSelectProps {
|
||||
label: string;
|
||||
value?: boolean;
|
||||
initialValue?: boolean;
|
||||
onChange?: (value: boolean) => void;
|
||||
}
|
||||
export function PaymentMethodSelect({
|
||||
value,
|
||||
initialValue,
|
||||
onChange,
|
||||
label,
|
||||
}: PaymentMethodSelectProps) {
|
||||
const [_value, handleChange] = useUncontrolled<boolean>({
|
||||
value,
|
||||
initialValue,
|
||||
finalValue: false,
|
||||
onChange,
|
||||
});
|
||||
const handleClick = () => {
|
||||
handleChange(!_value);
|
||||
};
|
||||
|
||||
return (
|
||||
<PaymentMethodSelectRoot onClick={handleClick}>
|
||||
<PaymentMethodCheckbox
|
||||
label={''}
|
||||
checked={_value}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
<PaymentMethodText>{label}</PaymentMethodText>
|
||||
</PaymentMethodSelectRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PaymentMethodSelectFieldProps
|
||||
extends Partial<PaymentMethodSelectProps> {
|
||||
label: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function PaymentMethodSelectField({
|
||||
name,
|
||||
...props
|
||||
}: PaymentMethodSelectFieldProps) {
|
||||
const { values, setFieldValue } = useFormikContext();
|
||||
const value = useMemo(() => get(values, name), [values, name]);
|
||||
|
||||
const handleChange = (newValue: boolean) => {
|
||||
setFieldValue(name, newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<PaymentMethodSelect value={value} onChange={handleChange} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const PaymentMethodSelectRoot = styled(Group)`
|
||||
border: 1px solid #d3d8de;
|
||||
border-radius: 3px;
|
||||
padding: 8px;
|
||||
gap: 0;
|
||||
min-width: 200px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const PaymentMethodCheckbox = styled(Checkbox)`
|
||||
margin: 0;
|
||||
|
||||
&.bp4-control .bp4-control-indicator {
|
||||
box-shadow: 0 0 0 1px #c5cbd3;
|
||||
}
|
||||
`;
|
||||
|
||||
const PaymentMethodText = styled(Text)`
|
||||
color: #404854;
|
||||
`;
|
||||
@@ -0,0 +1,52 @@
|
||||
import styled from 'styled-components';
|
||||
import React from 'react';
|
||||
import {
|
||||
Classes,
|
||||
Popover,
|
||||
PopoverInteractionKind,
|
||||
Position,
|
||||
} from '@blueprintjs/core';
|
||||
import { Stack } from '@/components';
|
||||
import { PaymentMethodSelectField } from './PaymentMethodSelect';
|
||||
|
||||
interface PaymentOptionsButtonPopverProps {
|
||||
paymentMethods: Array<any>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export function PaymentOptionsButtonPopver({
|
||||
paymentMethods,
|
||||
children,
|
||||
}: PaymentOptionsButtonPopverProps) {
|
||||
return (
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.HOVER}
|
||||
position={Position.TOP_RIGHT}
|
||||
popoverClassName={Classes.POPOVER_CONTENT_SIZING}
|
||||
minimal={true}
|
||||
content={
|
||||
<Stack spacing={8}>
|
||||
<PaymentMethodsTitle>Payment Options</PaymentMethodsTitle>
|
||||
|
||||
<Stack spacing={8}>
|
||||
{paymentMethods?.map((service, key) => (
|
||||
<PaymentMethodSelectField
|
||||
name={`payment_methods.${service.id}.enable`}
|
||||
label={'Card (Stripe)'}
|
||||
key={key}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const PaymentMethodsTitle = styled('h6')`
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: rgb(95, 107, 124);
|
||||
`;
|
||||
@@ -0,0 +1,110 @@
|
||||
|
||||
.rootBodyPage {
|
||||
background: #0c103f;
|
||||
}
|
||||
|
||||
.root {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
width: 600px;
|
||||
margin: 40px auto;
|
||||
color: #222;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.companyLogoWrap {
|
||||
height: 50px;
|
||||
width :50px;
|
||||
border-radius: 50px;
|
||||
background-color: #dfdfdf;
|
||||
background-image: url('https://pbs.twimg.com/profile_images/1381635804397703182/x5chIdsO_400x400.png');
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.bigTitle{
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.invoiceDueDate{
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.invoiceNumber {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 30px 26px;
|
||||
}
|
||||
|
||||
.footer{
|
||||
padding: 20px 26px;
|
||||
background-color: #FAFAFA;
|
||||
border-top: 1px solid #DCE0E5;
|
||||
border-radius: 0 0 8px 8px;
|
||||
color: #333;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.address{
|
||||
color: #5f6b7c;
|
||||
}
|
||||
|
||||
.customerName{
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.totalItem {
|
||||
padding: 6px 0;
|
||||
|
||||
&.borderBottomGray {
|
||||
border-bottom: 1px solid #DADADA;
|
||||
}
|
||||
&.borderBottomDark{
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
}
|
||||
|
||||
.downloadInvoiceButton:global(.bp4-button.bp4-minimal){
|
||||
box-shadow: 0 0 0 1px #DCE0E5;
|
||||
}
|
||||
|
||||
.viewInvoiceButton:global(.bp4-button:not([class*=bp4-intent-]):not(.bp4-minimal)){
|
||||
background-color: #EDEFF2;
|
||||
}
|
||||
|
||||
.buyNote{
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
// Footer
|
||||
// -------------------
|
||||
.totals {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.footerButtons{
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.footerButton{
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.footerText{
|
||||
color: #666;
|
||||
}
|
||||
157
packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx
Normal file
157
packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Text, Classes, Button, Intent } from '@blueprintjs/core';
|
||||
import clsx from 'classnames';
|
||||
import { AppToaster, Box, Group, Stack } from '@/components';
|
||||
import { usePaymentPortalBoot } from './PaymentPortalBoot';
|
||||
import { useDrawerActions } from '@/hooks/state';
|
||||
import { useCreateStripeCheckoutSession } from '@/hooks/query/stripe-integration';
|
||||
import { DRAWERS } from '@/constants/drawers';
|
||||
import styles from './PaymentPortal.module.scss';
|
||||
|
||||
export function PaymentPortal() {
|
||||
const { openDrawer } = useDrawerActions();
|
||||
const { sharableLinkMeta, linkId } = usePaymentPortalBoot();
|
||||
const {
|
||||
mutateAsync: createStripeCheckoutSession,
|
||||
isLoading: isStripeCheckoutLoading,
|
||||
} = useCreateStripeCheckoutSession();
|
||||
|
||||
// Handles invoice preview button click.
|
||||
const handleInvoicePreviewBtnClick = () => {
|
||||
openDrawer(DRAWERS.PAYMENT_INVOICE_PREVIEW);
|
||||
};
|
||||
// handles the pay button click.
|
||||
const handlePayButtonClick = () => {
|
||||
createStripeCheckoutSession({ linkId })
|
||||
.then((session) => {
|
||||
window.open(session.redirectTo);
|
||||
})
|
||||
.catch((error) => {
|
||||
AppToaster.show({
|
||||
intent: Intent.DANGER,
|
||||
message: 'Something went wrong.',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={styles.root}>
|
||||
<Stack spacing={0} className={styles.body}>
|
||||
<Stack>
|
||||
<Group spacing={10}>
|
||||
<Box className={styles.companyLogoWrap}></Box>
|
||||
<Text>{sharableLinkMeta?.companyName}</Text>
|
||||
</Group>
|
||||
|
||||
<Stack spacing={6}>
|
||||
<h1 className={styles.bigTitle}>
|
||||
{sharableLinkMeta?.companyName} Sent an Invoice for{' '}
|
||||
{sharableLinkMeta?.totalFormatted}
|
||||
</h1>
|
||||
<Text className={clsx(Classes.TEXT_MUTED, styles.invoiceDueDate)}>
|
||||
Invoice due {sharableLinkMeta?.dueDateFormatted}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack className={styles.address} spacing={2}>
|
||||
<Box className={styles.customerName}>
|
||||
{sharableLinkMeta?.customerName}
|
||||
</Box>
|
||||
<Box>Bigcapital Technology, Inc.</Box>
|
||||
<Box>131 Continental Dr Suite 305 Newark,</Box>
|
||||
<Box>Delaware 19713</Box>
|
||||
<Box>United States</Box>
|
||||
<Box>ahmed@bigcapital.app</Box>
|
||||
</Stack>
|
||||
|
||||
<h2 className={styles.invoiceNumber}>
|
||||
Invoice {sharableLinkMeta?.invoiceNo}
|
||||
</h2>
|
||||
|
||||
<Stack spacing={0} className={styles.totals}>
|
||||
<Group
|
||||
position={'apart'}
|
||||
className={clsx(styles.totalItem, styles.borderBottomGray)}
|
||||
>
|
||||
<Text>Sub Total</Text>
|
||||
<Text>{sharableLinkMeta?.subtotalFormatted}</Text>
|
||||
</Group>
|
||||
|
||||
<Group position={'apart'} className={styles.totalItem}>
|
||||
<Text>Total</Text>
|
||||
<Text style={{ fontWeight: 500 }}>
|
||||
{sharableLinkMeta?.totalFormatted}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
position={'apart'}
|
||||
className={clsx(styles.totalItem, styles.borderBottomGray)}
|
||||
>
|
||||
<Text>Paid Amount (-)</Text>
|
||||
<Text>{sharableLinkMeta?.paymentAmountFormatted}</Text>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
position={'apart'}
|
||||
className={clsx(styles.totalItem, styles.borderBottomDark)}
|
||||
>
|
||||
<Text>Due Amount</Text>
|
||||
<Text style={{ fontWeight: 500 }}>
|
||||
{sharableLinkMeta?.dueAmountFormatted}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={8} className={styles.footerButtons}>
|
||||
<Button
|
||||
minimal
|
||||
className={clsx(styles.footerButton, styles.downloadInvoiceButton)}
|
||||
>
|
||||
Download Invoice
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleInvoicePreviewBtnClick}
|
||||
className={clsx(styles.footerButton, styles.viewInvoiceButton)}
|
||||
>
|
||||
View Invoice
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
className={clsx(styles.footerButton, styles.buyButton)}
|
||||
loading={isStripeCheckoutLoading}
|
||||
onClick={handlePayButtonClick}
|
||||
>
|
||||
Pay {sharableLinkMeta?.totalFormatted}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Text className={clsx(Classes.TEXT_MUTED, styles.buyNote)}>
|
||||
By confirming your payment, you allow Bigcapital Technology, Inc. to
|
||||
charge you for this payment and save your payment information in
|
||||
accordance with their terms.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={18} className={styles.footer}>
|
||||
<Stack spacing={0}>
|
||||
<Box>
|
||||
<strong>Bigcapital Technology, Inc.</strong>
|
||||
</Box>
|
||||
<Box>131 Continental Dr Suite 305 Newark,</Box>
|
||||
<Box>Delaware 19713</Box>
|
||||
<Box>United States</Box>
|
||||
<Box>ahmed@bigcapital.app</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={0} className={styles.footerText}>
|
||||
© 2024 Bigcapital Technology, Inc.
|
||||
<br />
|
||||
All rights reserved.
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import {
|
||||
GetInvoicePaymentLinkResponse,
|
||||
useGetInvoicePaymentLink,
|
||||
} from '@/hooks/query/payment-link';
|
||||
import { Spinner } from '@blueprintjs/core';
|
||||
|
||||
interface PaymentPortalContextType {
|
||||
linkId: string;
|
||||
sharableLinkMeta: GetInvoicePaymentLinkResponse | undefined;
|
||||
isSharableLinkMetaLoading: boolean;
|
||||
}
|
||||
|
||||
const PaymentPortalContext = createContext<PaymentPortalContextType>(
|
||||
{} as PaymentPortalContextType,
|
||||
);
|
||||
|
||||
interface PaymentPortalBootProps {
|
||||
linkId: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PaymentPortalBoot: React.FC<PaymentPortalBootProps> = ({
|
||||
linkId,
|
||||
children,
|
||||
}) => {
|
||||
const { data: sharableLinkMeta, isLoading: isSharableLinkMetaLoading } =
|
||||
useGetInvoicePaymentLink(linkId);
|
||||
|
||||
const value = {
|
||||
linkId,
|
||||
sharableLinkMeta,
|
||||
isSharableLinkMetaLoading,
|
||||
};
|
||||
if (isSharableLinkMetaLoading) {
|
||||
return <Spinner size={20} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaymentPortalContext.Provider value={value}>
|
||||
{children}
|
||||
</PaymentPortalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePaymentPortalBoot = (): PaymentPortalContextType => {
|
||||
const context = useContext(PaymentPortalContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'usePaymentPortal must be used within a PaymentPortalProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PaymentPortal } from './PaymentPortal';
|
||||
import { PaymentPortalBoot } from './PaymentPortalBoot';
|
||||
import BodyClassName from 'react-body-classname';
|
||||
import styles from './PaymentPortal.module.scss';
|
||||
import { PaymentInvoicePreviewDrawer } from './drawers/PaymentInvoicePreviewDrawer/PaymentInvoicePreviewDrawer';
|
||||
import { DRAWERS } from '@/constants/drawers';
|
||||
|
||||
export default function PaymentPortalPage() {
|
||||
const { linkId } = useParams<{ linkId: string }>();
|
||||
|
||||
return (
|
||||
<BodyClassName className={styles.rootBodyPage}>
|
||||
<PaymentPortalBoot linkId={linkId}>
|
||||
<PaymentPortal />
|
||||
<PaymentInvoicePreviewDrawer name={DRAWERS.PAYMENT_INVOICE_PREVIEW} />
|
||||
</PaymentPortalBoot>
|
||||
</BodyClassName>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// @ts-nocheck
|
||||
import { Box, DrawerBody, DrawerHeaderContent } from '@/components';
|
||||
import { InvoicePaperTemplate } from '@/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate';
|
||||
import { usePaymentPortalBoot } from '../../PaymentPortalBoot';
|
||||
|
||||
export function PaymentInvoicePreviewContent() {
|
||||
const { sharableLinkMeta } = usePaymentPortalBoot();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerHeaderContent title={'Invoice'} />
|
||||
|
||||
<DrawerBody>
|
||||
<Box style={{ paddingTop: 20, paddingBottom: 20 }}>
|
||||
<InvoicePaperTemplate
|
||||
invoiceNumber={sharableLinkMeta?.invoiceNo}
|
||||
dueDate={sharableLinkMeta?.dueDateFormatted}
|
||||
dateIssue={sharableLinkMeta?.invoiceDateFormatted}
|
||||
total={sharableLinkMeta?.totalFormatted}
|
||||
subtotal={sharableLinkMeta?.subtotalFormatted}
|
||||
balanceDue={sharableLinkMeta?.dueAmountFormatted}
|
||||
paymentMade={sharableLinkMeta?.paymentAmountFormatted}
|
||||
termsConditions={sharableLinkMeta?.termsConditions}
|
||||
statement={sharableLinkMeta?.invoiceMessage}
|
||||
companyName={sharableLinkMeta?.companyName}
|
||||
lines={sharableLinkMeta?.entries?.map((entry) => ({
|
||||
item: entry.itemName,
|
||||
description: entry.description,
|
||||
quantity: entry.quantityFormatted,
|
||||
rate: entry.rateFormatted,
|
||||
total: entry.totalFormatted,
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
</DrawerBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import * as R from 'ramda';
|
||||
import { Position } from '@blueprintjs/core';
|
||||
import { Drawer, DrawerSuspense } from '@/components';
|
||||
import withDrawers from '@/containers/Drawer/withDrawers';
|
||||
import { PaymentInvoicePreviewContent } from './PaymentInvoicePreviewContent';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {React.ReactNode}
|
||||
*/
|
||||
function PaymentInvoicePreviewDrawerRoot({
|
||||
name,
|
||||
// #withDrawer
|
||||
isOpen,
|
||||
payload,
|
||||
}) {
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={isOpen}
|
||||
name={name}
|
||||
size={'100%'}
|
||||
style={{ background: '#F6F7F9' }}
|
||||
position={Position.TOP}
|
||||
payload={payload}
|
||||
>
|
||||
<DrawerSuspense>
|
||||
<PaymentInvoicePreviewContent />
|
||||
</DrawerSuspense>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export const PaymentInvoicePreviewDrawer = R.compose(withDrawers())(
|
||||
PaymentInvoicePreviewDrawerRoot,
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createContext, ReactNode, useContext } from 'react';
|
||||
import { Spinner } from '@blueprintjs/core';
|
||||
import {
|
||||
GetPaymentServicesStateResponse,
|
||||
useGetPaymentServicesState,
|
||||
} from '@/hooks/query/payment-services';
|
||||
|
||||
type PaymentMethodsContextType = {
|
||||
isPaymentMethodsStateLoading: boolean;
|
||||
paymentMethodsState: GetPaymentServicesStateResponse | undefined;
|
||||
};
|
||||
|
||||
const PaymentMethodsContext = createContext<PaymentMethodsContextType>(
|
||||
{} as PaymentMethodsContextType,
|
||||
);
|
||||
|
||||
type PaymentMethodsProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const PaymentMethodsBoot = ({ children }: PaymentMethodsProviderProps) => {
|
||||
const { data: paymentMethodsState, isLoading: isPaymentMethodsStateLoading } =
|
||||
useGetPaymentServicesState();
|
||||
|
||||
const value = { isPaymentMethodsStateLoading, paymentMethodsState };
|
||||
|
||||
if (isPaymentMethodsStateLoading) {
|
||||
return <Spinner size={20} />;
|
||||
}
|
||||
return (
|
||||
<PaymentMethodsContext.Provider value={value}>
|
||||
{children}
|
||||
</PaymentMethodsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const usePaymentMethodsBoot = () => {
|
||||
const context = useContext<PaymentMethodsContextType>(PaymentMethodsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'usePaymentMethods must be used within a PaymentMethodsProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export { PaymentMethodsBoot, usePaymentMethodsBoot };
|
||||
@@ -0,0 +1,50 @@
|
||||
// @ts-nocheck
|
||||
import React, { useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Classes, Text } from '@blueprintjs/core';
|
||||
import { Box, Stack } from '@/components';
|
||||
import { PaymentMethodsBoot } from './PreferencesPaymentMethodsBoot';
|
||||
import { StripePreSetupDialog } from './dialogs/StripePreSetupDialog/StripePreSetupDialog';
|
||||
import { useChangePreferencesPageTitle } from '@/hooks/state';
|
||||
import { StripeIntegrationEditDrawer } from './drawers/StripeIntegrationEditDrawer';
|
||||
import { StripePaymentMethod } from './StripePaymentMethod';
|
||||
import { DialogsName } from '@/constants/dialogs';
|
||||
import { DRAWERS } from '@/constants/drawers';
|
||||
|
||||
/**
|
||||
* Payment methods page.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export default function PreferencesPaymentMethodsPage() {
|
||||
const changePageTitle = useChangePreferencesPageTitle();
|
||||
|
||||
useEffect(() => {
|
||||
changePageTitle('Payment Methods');
|
||||
}, [changePageTitle]);
|
||||
|
||||
return (
|
||||
<PaymentMethodsRoot>
|
||||
<PaymentMethodsBoot>
|
||||
<Text className={Classes.TEXT_MUTED} style={{ marginBottom: 20 }}>
|
||||
Accept payments from all the major debit and credit card networks
|
||||
through the supported payment methods.
|
||||
</Text>
|
||||
|
||||
<Stack>
|
||||
<StripePaymentMethod />
|
||||
</Stack>
|
||||
|
||||
<StripePreSetupDialog dialogName={DialogsName.StripeSetup} />
|
||||
<StripeIntegrationEditDrawer
|
||||
name={DRAWERS.STRIPE_PAYMENT_INTEGRATION_EDIT}
|
||||
/>
|
||||
</PaymentMethodsBoot>
|
||||
</PaymentMethodsRoot>
|
||||
);
|
||||
}
|
||||
|
||||
const PaymentMethodsRoot = styled(Box)`
|
||||
witdth: 100%;
|
||||
max-width: 700px;
|
||||
margin: 20px;
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
Button,
|
||||
Classes,
|
||||
Intent,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Tag,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@blueprintjs/core';
|
||||
import { Box, Card, Group, Stack } from '@/components';
|
||||
import { StripeLogo } from '@/icons/StripeLogo';
|
||||
import { usePaymentMethodsBoot } from './PreferencesPaymentMethodsBoot';
|
||||
import { DialogsName } from '@/constants/dialogs';
|
||||
import {
|
||||
useAlertActions,
|
||||
useDialogActions,
|
||||
useDrawerActions,
|
||||
} from '@/hooks/state';
|
||||
import { DRAWERS } from '@/constants/drawers';
|
||||
import { MoreIcon } from '@/icons/More';
|
||||
import { STRIPE_PRICING_LINK } from './constants';
|
||||
|
||||
export function StripePaymentMethod() {
|
||||
const { openDialog } = useDialogActions();
|
||||
const { openDrawer } = useDrawerActions();
|
||||
const { openAlert } = useAlertActions();
|
||||
|
||||
const { paymentMethodsState } = usePaymentMethodsBoot();
|
||||
const stripeState = paymentMethodsState?.stripe;
|
||||
|
||||
const isAccountCreated = stripeState?.isStripeAccountCreated;
|
||||
const isPaymentEnabled = stripeState?.isStripePaymentEnabled;
|
||||
const isPayoutEnabled = stripeState?.isStripePayoutEnabled;
|
||||
const isStripeEnabled = stripeState?.isStripeEnabled;
|
||||
const stripePaymentMethodId = stripeState?.stripePaymentMethodId;
|
||||
const isStripeServerConfigured = stripeState?.isStripeServerConfigured;
|
||||
|
||||
// Handle Stripe setup button click.
|
||||
const handleSetUpBtnClick = () => {
|
||||
openDialog(DialogsName.StripeSetup);
|
||||
};
|
||||
|
||||
// Handle edit button click.
|
||||
const handleEditBtnClick = () => {
|
||||
openDrawer(DRAWERS.STRIPE_PAYMENT_INTEGRATION_EDIT, {
|
||||
stripePaymentMethodId: stripePaymentMethodId,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle delete connection button click.
|
||||
const handleDeleteConnectionClick = () => {
|
||||
openAlert('delete-stripe-payment-method', {
|
||||
paymentMethodId: stripePaymentMethodId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ margin: 0 }}>
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<StripeLogo />
|
||||
|
||||
<Group spacing={10}>
|
||||
{isStripeEnabled && (
|
||||
<Tag minimal intent={Intent.SUCCESS}>
|
||||
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 spacing={10}>
|
||||
{isAccountCreated && (
|
||||
<Button small onClick={handleEditBtnClick}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{!isAccountCreated && (
|
||||
<Button intent={Intent.PRIMARY} small onClick={handleSetUpBtnClick}>
|
||||
Set it Up
|
||||
</Button>
|
||||
)}
|
||||
{isAccountCreated && (
|
||||
<Popover
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
intent={Intent.DANGER}
|
||||
text={'Delete Connection'}
|
||||
onClick={handleDeleteConnectionClick}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button small icon={<MoreIcon size={16} />} />
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<PaymentDescription
|
||||
className={Classes.TEXT_MUTED}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
Stripe is a secure online payment platform that lets you easily accept
|
||||
both one-time and recurring payments. It simplifies managing
|
||||
transactions and streamlines reconciliation. Setup is quick, helping you
|
||||
get paid faster and more efficiently.
|
||||
</PaymentDescription>
|
||||
|
||||
<PaymentFooter>
|
||||
<Stack spacing={10}>
|
||||
<Text>
|
||||
<a target="_blank" rel="noreferrer" href={STRIPE_PRICING_LINK}>
|
||||
View Stripe's Transaction Fees
|
||||
</a>
|
||||
</Text>
|
||||
|
||||
{!isStripeServerConfigured && (
|
||||
<Text style={{ color: '#CD4246' }}>
|
||||
Stripe payment is not configured from the server.{' '}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</PaymentFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const PaymentDescription = styled(Text)`
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
`;
|
||||
|
||||
const PaymentFooter = styled(Box)`
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
`;
|
||||
@@ -0,0 +1,68 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { Intent, Alert } from '@blueprintjs/core';
|
||||
|
||||
import { AppToaster, FormattedMessage as T } from '@/components';
|
||||
import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect';
|
||||
import withAlertActions from '@/containers/Alert/withAlertActions';
|
||||
import { useDeletePaymentMethod } from '@/hooks/query/payment-services';
|
||||
import { compose } from '@/utils';
|
||||
|
||||
/**
|
||||
* Delete Stripe connection alert.
|
||||
*/
|
||||
function DeleteStripeAccountAlert({
|
||||
name,
|
||||
|
||||
// #withAlertStoreConnect
|
||||
isOpen,
|
||||
payload: { paymentMethodId },
|
||||
|
||||
// #withAlertActions
|
||||
closeAlert,
|
||||
}) {
|
||||
const { isLoading, mutateAsync: deletePaymentMethod } =
|
||||
useDeletePaymentMethod();
|
||||
|
||||
// Handle cancel open bill alert.
|
||||
const handleCancelOpenBill = () => {
|
||||
closeAlert(name);
|
||||
};
|
||||
// Handle confirm bill open.
|
||||
const handleConfirmBillOpen = () => {
|
||||
deletePaymentMethod({ paymentMethodId })
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: 'The Stripe payment account has been deleted.',
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
closeAlert(name);
|
||||
})
|
||||
.catch((error) => {
|
||||
closeAlert(name);
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
cancelButtonText={<T id={'cancel'} />}
|
||||
confirmButtonText={'Delete Account'}
|
||||
intent={Intent.DANGER}
|
||||
isOpen={isOpen}
|
||||
onCancel={handleCancelOpenBill}
|
||||
onConfirm={handleConfirmBillOpen}
|
||||
loading={isLoading}
|
||||
>
|
||||
<p>Are you sure want to delete your Stripe account connection?</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default compose(
|
||||
withAlertStoreConnect(),
|
||||
withAlertActions,
|
||||
)(DeleteStripeAccountAlert);
|
||||
@@ -0,0 +1,13 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
|
||||
const DeleteStripeConnectionAlert = React.lazy(
|
||||
() => import('./DeleteStripeConnectionAlert'),
|
||||
);
|
||||
|
||||
export const PaymentMethodsAlerts = [
|
||||
{
|
||||
name: 'delete-stripe-payment-method',
|
||||
component: DeleteStripeConnectionAlert,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export const STRIPE_PRICING_LINK = 'https://stripe.com/pricing';
|
||||
@@ -0,0 +1,33 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { Dialog, DialogSuspense } from '@/components';
|
||||
import { compose } from '@/utils';
|
||||
import withDialogRedux from '@/components/DialogReduxConnect';
|
||||
import { StripePreSetupDialogContent } from './StripePreSetupDialogContent';
|
||||
|
||||
/**
|
||||
* Select payment methods dialogs.
|
||||
*/
|
||||
function StripePreSetupDialogRoot({ dialogName, payload, isOpen }) {
|
||||
return (
|
||||
<Dialog
|
||||
name={dialogName}
|
||||
isOpen={isOpen}
|
||||
payload={payload}
|
||||
title={'Connect a Stripe account to accept card payments'}
|
||||
canEscapeJeyClose={true}
|
||||
autoFocus={true}
|
||||
style={{ width: 500 }}
|
||||
>
|
||||
<DialogSuspense>
|
||||
<StripePreSetupDialogContent />
|
||||
</DialogSuspense>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const StripePreSetupDialog = compose(withDialogRedux())(
|
||||
StripePreSetupDialogRoot,
|
||||
);
|
||||
|
||||
StripePreSetupDialogRoot.displayName = 'StripePreSetupDialog';
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, DialogBody, DialogFooter, Intent } from '@blueprintjs/core';
|
||||
import styled from 'styled-components';
|
||||
import { Stack } from '@/components';
|
||||
import { useDialogContext } from '@/components/Dialog/DialogProvider';
|
||||
import { useDialogActions } from '@/hooks/state';
|
||||
import { CreditCard2Icon } from '@/icons/CreditCard2';
|
||||
import { DollarIcon } from '@/icons/Dollar';
|
||||
import { LayoutAutoIcon } from '@/icons/LayoutAuto';
|
||||
import { SwitchIcon } from '@/icons/SwitchIcon';
|
||||
import { usePaymentMethodsBoot } from '../../PreferencesPaymentMethodsBoot';
|
||||
|
||||
export function StripePreSetupDialogContent() {
|
||||
const { name } = useDialogContext();
|
||||
const { closeDialog } = useDialogActions();
|
||||
const { paymentMethodsState } = usePaymentMethodsBoot();
|
||||
const [isRedirecting, setIsRedirecting] = useState<boolean>(false);
|
||||
|
||||
const handleSetUpBtnClick = () => {
|
||||
if (paymentMethodsState?.stripe.stripeAuthLink) {
|
||||
setIsRedirecting(true);
|
||||
window.location.href = paymentMethodsState?.stripe.stripeAuthLink;
|
||||
}
|
||||
};
|
||||
// Handle cancel button click.
|
||||
const handleCancelBtnClick = () => {
|
||||
closeDialog(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogBody>
|
||||
<Stack style={{ paddingTop: 10, paddingBottom: 20 }}>
|
||||
<PaymentFeatureItem>
|
||||
<PaymentFeatureIcon>
|
||||
<LayoutAutoIcon size={16} />
|
||||
</PaymentFeatureIcon>{' '}
|
||||
If you're already using Stripe, you can connect your Stripe account
|
||||
to Bigcapital.
|
||||
</PaymentFeatureItem>
|
||||
|
||||
<PaymentFeatureItem>
|
||||
<PaymentFeatureIcon>
|
||||
<DollarIcon size={16} />
|
||||
</PaymentFeatureIcon>{' '}
|
||||
Stripe applies a processing fee for each card payment, but we only
|
||||
charge for the application subscription.
|
||||
</PaymentFeatureItem>
|
||||
|
||||
<PaymentFeatureItem>
|
||||
<PaymentFeatureIcon>
|
||||
<CreditCard2Icon size={16} />
|
||||
</PaymentFeatureIcon>{' '}
|
||||
Customers can pay invoice using credit card, debit card or digital
|
||||
wallets like Apple Pay or Google Pay.
|
||||
</PaymentFeatureItem>
|
||||
|
||||
<PaymentFeatureItem>
|
||||
<PaymentFeatureIcon>
|
||||
<SwitchIcon size={16} />
|
||||
</PaymentFeatureIcon>{' '}
|
||||
You can enable or disable card payments for each invoice
|
||||
</PaymentFeatureItem>
|
||||
</Stack>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={handleCancelBtnClick}>Cancel</Button>
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
onClick={handleSetUpBtnClick}
|
||||
loading={isRedirecting}
|
||||
>
|
||||
Set Up Stripe
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
></DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const PaymentFeatureItem = styled('div')`
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
padding-left: 50px;
|
||||
`;
|
||||
|
||||
const PaymentFeatureIcon = styled('span')`
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 2px;
|
||||
color: #0052cc;
|
||||
`;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { Spinner } from '@blueprintjs/core';
|
||||
import { useAccounts } from '@/hooks/query';
|
||||
import { useGetPaymentMethod } from '@/hooks/query/payment-services';
|
||||
import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
|
||||
|
||||
interface StripeIntegrationEditContextType {
|
||||
accounts: any;
|
||||
isAccountsLoading: boolean;
|
||||
|
||||
paymentMethod: any;
|
||||
isPaymentMethodLoading: boolean;
|
||||
}
|
||||
|
||||
const StripeIntegrationEditContext =
|
||||
createContext<StripeIntegrationEditContextType>(
|
||||
{} as StripeIntegrationEditContextType,
|
||||
);
|
||||
|
||||
export const useStripeIntegrationEditBoot = () => {
|
||||
const context = useContext<StripeIntegrationEditContextType>(
|
||||
StripeIntegrationEditContext,
|
||||
);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useStripeIntegrationEditContext must be used within a StripeIntegrationEditProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const StripeIntegrationEditBoot: React.FC = ({ children }) => {
|
||||
const {
|
||||
payload: { stripePaymentMethodId },
|
||||
} = useDrawerContext();
|
||||
|
||||
const { data: accounts, isLoading: isAccountsLoading } = useAccounts({}, {});
|
||||
const { data: paymentMethod, isLoading: isPaymentMethodLoading } =
|
||||
useGetPaymentMethod(stripePaymentMethodId, {
|
||||
enabled: !!stripePaymentMethodId,
|
||||
});
|
||||
|
||||
const value = {
|
||||
// Accounts.
|
||||
accounts,
|
||||
isAccountsLoading,
|
||||
|
||||
// Payment methods.
|
||||
paymentMethod,
|
||||
isPaymentMethodLoading,
|
||||
};
|
||||
const isLoading = isAccountsLoading || isPaymentMethodLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner size={20} />;
|
||||
}
|
||||
return (
|
||||
<StripeIntegrationEditContext.Provider value={value}>
|
||||
{children}
|
||||
</StripeIntegrationEditContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
// @ts-nocheck
|
||||
import { Classes } from '@blueprintjs/core';
|
||||
import { DrawerBody, DrawerHeaderContent } from '@/components';
|
||||
import { StripeIntegrationEditForm } from './StripeIntegrationEditForm';
|
||||
import { StripeIntegrationEditBoot } from './StripeIntegrationEditBoot';
|
||||
import {
|
||||
StripeIntegrationEditFormContent,
|
||||
StripeIntegrationEditFormFooter,
|
||||
} from './StripeIntegrationEditFormContent';
|
||||
|
||||
export function StripeIntegrationEditContent() {
|
||||
return (
|
||||
<>
|
||||
<DrawerHeaderContent title={'Edit Stripe Integration'} />
|
||||
|
||||
<StripeIntegrationEditBoot>
|
||||
<StripeIntegrationEditForm>
|
||||
<DrawerBody>
|
||||
<StripeIntegrationEditFormContent />
|
||||
</DrawerBody>
|
||||
|
||||
<div className={Classes.DRAWER_FOOTER}>
|
||||
<StripeIntegrationEditFormFooter />
|
||||
</div>
|
||||
</StripeIntegrationEditForm>
|
||||
</StripeIntegrationEditBoot>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user