diff --git a/.env.example b/.env.example
index 99a2cae8d..047da670b 100644
--- a/.env.example
+++ b/.env.example
@@ -92,4 +92,8 @@ S3_BUCKET=
# PostHog
POSTHOG_API_KEY=
-POSTHOG_HOST=
\ No newline at end of file
+POSTHOG_HOST=
+
+# Stripe Payment
+STRIPE_PAYMENT_SECRET_KEY=
+STRIPE_PAYMENT_PUBLISHABLE_KEY=
\ No newline at end of file
diff --git a/packages/server/package.json b/packages/server/package.json
index b07d5694b..7cc0e8664 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -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"
diff --git a/packages/server/src/api/controllers/PaymentServices/PaymentServicesController.ts b/packages/server/src/api/controllers/PaymentServices/PaymentServicesController.ts
new file mode 100644
index 000000000..8d02eaffa
--- /dev/null
+++ b/packages/server/src/api/controllers/PaymentServices/PaymentServicesController.ts
@@ -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}
+ */
+ 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}
+ */
+ 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}
+ */
+ 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}
+ */
+ 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}
+ */
+ 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);
+ }
+ }
+}
diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts
index 012b7f041..2cc2fbfa0 100644
--- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts
+++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts
@@ -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(),
];
}
diff --git a/packages/server/src/api/controllers/ShareLink/PublicSharableLinkController.ts b/packages/server/src/api/controllers/ShareLink/PublicSharableLinkController.ts
new file mode 100644
index 000000000..91590d6a9
--- /dev/null
+++ b/packages/server/src/api/controllers/ShareLink/PublicSharableLinkController.ts
@@ -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);
+ }
+ }
+}
diff --git a/packages/server/src/api/controllers/ShareLink/ShareLinkController.ts b/packages/server/src/api/controllers/ShareLink/ShareLinkController.ts
new file mode 100644
index 000000000..52065924f
--- /dev/null
+++ b/packages/server/src/api/controllers/ShareLink/ShareLinkController.ts
@@ -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);
+ }
+ }
+}
diff --git a/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts b/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts
new file mode 100644
index 000000000..58940e5ec
--- /dev/null
+++ b/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts
@@ -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}
+ */
+ 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}
+ */
+ 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}
+ */
+ 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}
+ */
+ 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}
+ */
+ 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);
+ }
+ }
+}
diff --git a/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts b/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts
new file mode 100644
index 000000000..fbe4098de
--- /dev/null
+++ b/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts
@@ -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);
+ }
+ }
+}
diff --git a/packages/server/src/api/controllers/Webhooks/Webhooks.ts b/packages/server/src/api/controllers/Webhooks/Webhooks.ts
index 3ccbc6d8c..1659f0a9d 100644
--- a/packages/server/src/api/controllers/Webhooks/Webhooks.ts
+++ b/packages/server/src/api/controllers/Webhooks/Webhooks.ts
@@ -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;
}
diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts
index 9dc3b5d07..7de8d9398 100644
--- a/packages/server/src/api/index.ts
+++ b/packages/server/src/api/index.ts
@@ -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());
diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts
index a806fe7d7..c7c8061ee 100644
--- a/packages/server/src/config/index.ts
+++ b/packages/server/src/config/index.ts
@@ -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 || '',
+ },
};
diff --git a/packages/server/src/database/migrations/20240909101051_add_stripe_pintent_id_to_payments_received.js b/packages/server/src/database/migrations/20240909101051_add_stripe_pintent_id_to_payments_received.js
new file mode 100644
index 000000000..5a82f16bb
--- /dev/null
+++ b/packages/server/src/database/migrations/20240909101051_add_stripe_pintent_id_to_payments_received.js
@@ -0,0 +1,19 @@
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+exports.up = function (knex) {
+ return knex.schema.table('payment_receives', (table) => {
+ table.string('stripe_pintent_id').nullable();
+ });
+};
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+exports.down = function (knex) {
+ return knex.schema.table('payment_receives', (table) => {
+ table.dropColumn('stripe_pintent_id');
+ });
+};
diff --git a/packages/server/src/database/migrations/20240915155403_payment_integration.js b/packages/server/src/database/migrations/20240915155403_payment_integration.js
new file mode 100644
index 000000000..01fac495c
--- /dev/null
+++ b/packages/server/src/database/migrations/20240915155403_payment_integration.js
@@ -0,0 +1,25 @@
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+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 }
+ */
+exports.down = function (knex) {
+ return knex.schema.dropTableIfExists('payment_integrations');
+};
diff --git a/packages/server/src/database/migrations/20240915163722_creat_transaction_payment_service_table.js b/packages/server/src/database/migrations/20240915163722_creat_transaction_payment_service_table.js
new file mode 100644
index 000000000..17b7fceda
--- /dev/null
+++ b/packages/server/src/database/migrations/20240915163722_creat_transaction_payment_service_table.js
@@ -0,0 +1,27 @@
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+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 }
+ */
+exports.down = function (knex) {
+ return knex.schema.dropTableIfExists('transactions_payment_methods');
+};
diff --git a/packages/server/src/database/seeds/data/accounts.js b/packages/server/src/database/seeds/data/accounts.js
index 8e55665bd..6e171ae0b 100644
--- a/packages/server/src/database/seeds/data/accounts.js
+++ b/packages/server/src/database/seeds/data/accounts.js
@@ -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',
diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts
index 4493782b8..5eef382f3 100644
--- a/packages/server/src/interfaces/SaleInvoice.ts
+++ b/packages/server/src/interfaces/SaleInvoice.ts
@@ -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;
}
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[];
-}
\ No newline at end of file
+}
diff --git a/packages/server/src/interfaces/StripePayment.ts b/packages/server/src/interfaces/StripePayment.ts
new file mode 100644
index 000000000..41b1d6e23
--- /dev/null
+++ b/packages/server/src/interfaces/StripePayment.ts
@@ -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;
+}
diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts
index 967e43f57..97814413f 100644
--- a/packages/server/src/loaders/eventEmitter.ts
+++ b/packages/server/src/loaders/eventEmitter.ts
@@ -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
];
};
diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts
index 3d349f81e..75de1cef1 100644
--- a/packages/server/src/loaders/tenantModels.ts
+++ b/packages/server/src/loaders/tenantModels.ts
@@ -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));
};
diff --git a/packages/server/src/models/PaymentIntegration.ts b/packages/server/src/models/PaymentIntegration.ts
new file mode 100644
index 000000000..04ed8fb31
--- /dev/null
+++ b/packages/server/src/models/PaymentIntegration.ts
@@ -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' },
+ },
+ };
+ }
+}
diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts
index 41cc528ff..883b39ce6 100644
--- a/packages/server/src/models/SaleInvoice.ts
+++ b/packages/server/src/models/SaleInvoice.ts
@@ -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',
+ }
+ },
};
}
diff --git a/packages/server/src/models/TransactionPaymentServiceEntry.ts b/packages/server/src/models/TransactionPaymentServiceEntry.ts
new file mode 100644
index 000000000..59313830b
--- /dev/null
+++ b/packages/server/src/models/TransactionPaymentServiceEntry.ts
@@ -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',
+ },
+ },
+ };
+ }
+}
diff --git a/packages/server/src/repositories/AccountRepository.ts b/packages/server/src/repositories/AccountRepository.ts
index 19aaaa70f..53b26e284 100644
--- a/packages/server/src/repositories/AccountRepository.ts
+++ b/packages/server/src/repositories/AccountRepository.ts
@@ -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} extraAttrs
+ * @param {Knex.Transaction} trx
+ * @returns
+ */
+ public async findOrCreateStripeClearing(
+ extraAttrs: Record = {},
+ 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;
+ }
}
diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnSaleInvoice.ts b/packages/server/src/services/Attachments/events/AttachmentsOnSaleInvoice.ts
index e6a51e4b6..717d956dc 100644
--- a/packages/server/src/services/Attachments/events/AttachmentsOnSaleInvoice.ts
+++ b/packages/server/src/services/Attachments/events/AttachmentsOnSaleInvoice.ts
@@ -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
);
}
diff --git a/packages/server/src/services/PaymentServices/DeletePaymentMethodService.ts b/packages/server/src/services/PaymentServices/DeletePaymentMethodService.ts
new file mode 100644
index 000000000..9e0802220
--- /dev/null
+++ b/packages/server/src/services/PaymentServices/DeletePaymentMethodService.ts
@@ -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}
+ */
+ public async deletePaymentMethod(
+ tenantId: number,
+ paymentIntegrationId: number
+ ): Promise {
+ 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,
+ });
+ });
+ }
+}
diff --git a/packages/server/src/services/PaymentServices/EditPaymentMethodService.ts b/packages/server/src/services/PaymentServices/EditPaymentMethodService.ts
new file mode 100644
index 000000000..75a140c78
--- /dev/null
+++ b/packages/server/src/services/PaymentServices/EditPaymentMethodService.ts
@@ -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}
+ */
+ async editPaymentMethod(
+ tenantId: number,
+ paymentIntegrationId: number,
+ editPaymentMethodDTO: EditPaymentMethodDTO
+ ): Promise {
+ 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,
+ });
+ });
+ }
+}
diff --git a/packages/server/src/services/PaymentServices/GetPaymentMethodsState.ts b/packages/server/src/services/PaymentServices/GetPaymentMethodsState.ts
new file mode 100644
index 000000000..122fdcea4
--- /dev/null
+++ b/packages/server/src/services/PaymentServices/GetPaymentMethodsState.ts
@@ -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}
+ */
+ public async getPaymentMethodsState(
+ tenantId: number
+ ): Promise {
+ 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;
+ }
+}
diff --git a/packages/server/src/services/PaymentServices/GetPaymentService.ts b/packages/server/src/services/PaymentServices/GetPaymentService.ts
new file mode 100644
index 000000000..35557c46a
--- /dev/null
+++ b/packages/server/src/services/PaymentServices/GetPaymentService.ts
@@ -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}
+ */
+ public async getPaymentMethod(
+ tenantId: number,
+ paymentServiceId: number
+ ): Promise {
+ const { PaymentIntegration } = this.tenancy.models(tenantId);
+
+ const stripePayment = await PaymentIntegration.query()
+ .findById(paymentServiceId)
+ .throwIfNotFound();
+
+ return stripePayment;
+ }
+}
diff --git a/packages/server/src/services/PaymentServices/GetPaymentServicesSpecificInvoice.ts b/packages/server/src/services/PaymentServices/GetPaymentServicesSpecificInvoice.ts
new file mode 100644
index 000000000..447f95943
--- /dev/null
+++ b/packages/server/src/services/PaymentServices/GetPaymentServicesSpecificInvoice.ts
@@ -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()
+ );
+ }
+}
diff --git a/packages/server/src/services/PaymentServices/GetPaymentServicesSpecificInvoiceTransformer.ts b/packages/server/src/services/PaymentServices/GetPaymentServicesSpecificInvoiceTransformer.ts
new file mode 100644
index 000000000..9ef5ca61f
--- /dev/null
+++ b/packages/server/src/services/PaymentServices/GetPaymentServicesSpecificInvoiceTransformer.ts
@@ -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';
+ }
+}
diff --git a/packages/server/src/services/PaymentServices/PaymentServicesApplication.ts b/packages/server/src/services/PaymentServices/PaymentServicesApplication.ts
new file mode 100644
index 000000000..b062b4bb9
--- /dev/null
+++ b/packages/server/src/services/PaymentServices/PaymentServicesApplication.ts
@@ -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} The payment services for the specified invoice.
+ */
+ public async getPaymentServicesForInvoice(tenantId: number): Promise {
+ 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}
+ */
+ public async deletePaymentMethod(
+ tenantId: number,
+ paymentIntegrationId: number
+ ): Promise {
+ return this.deletePaymentMethodService.deletePaymentMethod(
+ tenantId,
+ paymentIntegrationId
+ );
+ }
+
+ /**
+ * Edits the given payment method.
+ * @param {number} tenantId
+ * @param {number} paymentIntegrationId
+ * @param {EditPaymentMethodDTO} editPaymentMethodDTO
+ * @returns {Promise}
+ */
+ public async editPaymentMethod(
+ tenantId: number,
+ paymentIntegrationId: number,
+ editPaymentMethodDTO: EditPaymentMethodDTO
+ ): Promise {
+ return this.editPaymentMethodService.editPaymentMethod(
+ tenantId,
+ paymentIntegrationId,
+ editPaymentMethodDTO
+ );
+ }
+
+ /**
+ * Retrieves the payment state providing state.
+ * @param {number} tenantId
+ * @returns {Promise}
+ */
+ public async getPaymentMethodsState(
+ tenantId: number
+ ): Promise {
+ return this.getPaymentMethodsStateService.getPaymentMethodsState(tenantId);
+ }
+}
diff --git a/packages/server/src/services/PaymentServices/types.ts b/packages/server/src/services/PaymentServices/types.ts
new file mode 100644
index 000000000..f3ec7b41f
--- /dev/null
+++ b/packages/server/src/services/PaymentServices/types.ts
@@ -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;
+ stripeRedirectUrl: string | null;
+ };
+}
diff --git a/packages/server/src/services/PaymentServices/utils.ts b/packages/server/src/services/PaymentServices/utils.ts
new file mode 100644
index 000000000..c8ddbc844
--- /dev/null
+++ b/packages/server/src/services/PaymentServices/utils.ts
@@ -0,0 +1,9 @@
+import config from '@/config';
+
+export const isStripePaymentConfigured = () => {
+ return (
+ config.stripePayment.secretKey &&
+ config.stripePayment.publishableKey &&
+ config.stripePayment.webhooksSecret
+ );
+};
diff --git a/packages/server/src/services/Sales/Invoices/DeleteSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/DeleteSaleInvoice.ts
index e012e8e26..7d7ec3544 100644
--- a/packages/server/src/services/Sales/Invoices/DeleteSaleInvoice.ts
+++ b/packages/server/src/services/Sales/Invoices/DeleteSaleInvoice.ts
@@ -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(
diff --git a/packages/server/src/services/Sales/Invoices/GeneratePaymentLinkTransformer.ts b/packages/server/src/services/Sales/Invoices/GeneratePaymentLinkTransformer.ts
new file mode 100644
index 000000000..bd2e83b69
--- /dev/null
+++ b/packages/server/src/services/Sales/Invoices/GeneratePaymentLinkTransformer.ts
@@ -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);
+ }
+}
diff --git a/packages/server/src/services/Sales/Invoices/GenerateeInvoicePaymentLink.ts b/packages/server/src/services/Sales/Invoices/GenerateeInvoicePaymentLink.ts
new file mode 100644
index 000000000..7063dee98
--- /dev/null
+++ b/packages/server/src/services/Sales/Invoices/GenerateeInvoicePaymentLink.ts
@@ -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()
+ );
+ });
+ }
+}
diff --git a/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkMetadata.ts b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkMetadata.ts
new file mode 100644
index 000000000..362cafa7c
--- /dev/null
+++ b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkMetadata.ts
@@ -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()
+ );
+ }
+}
diff --git a/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts
new file mode 100644
index 000000000..268ba0586
--- /dev/null
+++ b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts
@@ -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 ['*'];
+ };
+}
diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts
index f788354e0..35e203049 100644
--- a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts
+++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts
@@ -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);
diff --git a/packages/server/src/services/Sales/Invoices/constants.ts b/packages/server/src/services/Sales/Invoices/constants.ts
index ffb1db409..42859077c 100644
--- a/packages/server/src/services/Sales/Invoices/constants.ts
+++ b/packages/server/src/services/Sales/Invoices/constants.ts
@@ -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 : {InvoiceAmount}
`;
+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',
diff --git a/packages/server/src/services/Sales/Invoices/subscribers/InvoicePaymentIntegrationSubscriber.ts b/packages/server/src/services/Sales/Invoices/subscribers/InvoicePaymentIntegrationSubscriber.ts
new file mode 100644
index 000000000..8e167729e
--- /dev/null
+++ b/packages/server/src/services/Sales/Invoices/subscribers/InvoicePaymentIntegrationSubscriber.ts
@@ -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
+ );
+ }
+ );
+ };
+}
diff --git a/packages/server/src/services/Sales/JournalPosterService.ts b/packages/server/src/services/Sales/JournalPosterService.ts
index 9b4c9d104..c688ced2a 100644
--- a/packages/server/src/services/Sales/JournalPosterService.ts
+++ b/packages/server/src/services/Sales/JournalPosterService.ts
@@ -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 {
diff --git a/packages/server/src/services/StripePayment/CreateInvoiceCheckoutSession.ts b/packages/server/src/services/StripePayment/CreateInvoiceCheckoutSession.ts
new file mode 100644
index 000000000..34eedc402
--- /dev/null
+++ b/packages/server/src/services/StripePayment/CreateInvoiceCheckoutSession.ts
@@ -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}
+ */
+ async createInvoiceCheckoutSession(
+ tenantId: number,
+ publicPaymentLinkId: number
+ ): Promise {
+ 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} - The created Stripe checkout session.
+ */
+ private createCheckoutSession(
+ invoice: ISaleInvoice,
+ stripeAccountId: string,
+ metadata?: Record
+ ) {
+ 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 }
+ );
+ }
+}
diff --git a/packages/server/src/services/StripePayment/CreatePaymentReceivedStripePayment.ts b/packages/server/src/services/StripePayment/CreatePaymentReceivedStripePayment.ts
new file mode 100644
index 000000000..28158ebb1
--- /dev/null
+++ b/packages/server/src/services/StripePayment/CreatePaymentReceivedStripePayment.ts
@@ -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 }],
+ });
+ }
+}
diff --git a/packages/server/src/services/StripePayment/CreateStripeAccountLink.ts b/packages/server/src/services/StripePayment/CreateStripeAccountLink.ts
new file mode 100644
index 000000000..c28522d27
--- /dev/null
+++ b/packages/server/src/services/StripePayment/CreateStripeAccountLink.ts
@@ -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);
+ }
+}
diff --git a/packages/server/src/services/StripePayment/CreateStripeAccountService.ts b/packages/server/src/services/StripePayment/CreateStripeAccountService.ts
new file mode 100644
index 000000000..243cb0ee4
--- /dev/null
+++ b/packages/server/src/services/StripePayment/CreateStripeAccountService.ts
@@ -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}
+ */
+ async createStripeAccount(
+ tenantId: number,
+ stripeAccountDTO?: CreateStripeAccountDTO
+ ): Promise {
+ 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;
+ }
+}
diff --git a/packages/server/src/services/StripePayment/ExchangeStripeOauthToken.ts b/packages/server/src/services/StripePayment/ExchangeStripeOauthToken.ts
new file mode 100644
index 000000000..97091ec65
--- /dev/null
+++ b/packages/server/src/services/StripePayment/ExchangeStripeOauthToken.ts
@@ -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
+ );
+ });
+ }
+}
diff --git a/packages/server/src/services/StripePayment/GetStripeAuthorizationLink.ts b/packages/server/src/services/StripePayment/GetStripeAuthorizationLink.ts
new file mode 100644
index 000000000..026fde40b
--- /dev/null
+++ b/packages/server/src/services/StripePayment/GetStripeAuthorizationLink.ts
@@ -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;
+ }
+}
diff --git a/packages/server/src/services/StripePayment/StripePaymentApplication.ts b/packages/server/src/services/StripePayment/StripePaymentApplication.ts
new file mode 100644
index 000000000..876616ef7
--- /dev/null
+++ b/packages/server/src/services/StripePayment/StripePaymentApplication.ts
@@ -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}
+ */
+ public createSaleInvoiceCheckoutSession(
+ tenantId: number,
+ paymentLinkId: number
+ ): Promise {
+ 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
+ );
+ }
+}
diff --git a/packages/server/src/services/StripePayment/StripePaymentService.ts b/packages/server/src/services/StripePayment/StripePaymentService.ts
new file mode 100644
index 000000000..caf44dca4
--- /dev/null
+++ b/packages/server/src/services/StripePayment/StripePaymentService.ts
@@ -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}
+ */
+ public async createAccountSession(accountId: string): Promise {
+ 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}
+ */
+ public async createAccount(): Promise {
+ 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'
+ );
+ }
+ }
+}
diff --git a/packages/server/src/services/StripePayment/events/SeedStripeAccounts.ts b/packages/server/src/services/StripePayment/events/SeedStripeAccounts.ts
new file mode 100644
index 000000000..3708b95e1
--- /dev/null
+++ b/packages/server/src/services/StripePayment/events/SeedStripeAccounts.ts
@@ -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,
+ },
+ });
+ }
+}
diff --git a/packages/server/src/services/StripePayment/events/StripeWebhooksSubscriber.ts b/packages/server/src/services/StripePayment/events/StripeWebhooksSubscriber.ts
new file mode 100644
index 000000000..87cfc157a
--- /dev/null
+++ b/packages/server/src/services/StripePayment/events/StripeWebhooksSubscriber.ts
@@ -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,
+ });
+ }
+ }
+}
diff --git a/packages/server/src/services/StripePayment/types.ts b/packages/server/src/services/StripePayment/types.ts
new file mode 100644
index 000000000..3260b68fa
--- /dev/null
+++ b/packages/server/src/services/StripePayment/types.ts
@@ -0,0 +1,11 @@
+import { Knex } from 'knex';
+
+
+export interface CreateStripeAccountDTO {
+ name?: string;
+}
+export interface StripeOAuthCodeGrantedEventPayload {
+ tenantId: number;
+ paymentIntegrationId: number;
+ trx?: Knex.Transaction
+}
\ No newline at end of file
diff --git a/packages/server/src/services/TransactionsLocking/SalesTransactionLockingGuardSubscriber.ts b/packages/server/src/services/TransactionsLocking/SalesTransactionLockingGuardSubscriber.ts
index 9b80bca26..35f91d3ad 100644
--- a/packages/server/src/services/TransactionsLocking/SalesTransactionLockingGuardSubscriber.ts
+++ b/packages/server/src/services/TransactionsLocking/SalesTransactionLockingGuardSubscriber.ts
@@ -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
);
};
diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts
index 01f828124..d3498f5d4 100644
--- a/packages/server/src/subscribers/events.ts
+++ b/packages/server/src/subscribers/events.ts
@@ -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'
+ }
};
diff --git a/packages/server/src/system/migrations/20240909091320_create_stripe_connect_accounts_table.js b/packages/server/src/system/migrations/20240909091320_create_stripe_connect_accounts_table.js
new file mode 100644
index 000000000..9aec8f70c
--- /dev/null
+++ b/packages/server/src/system/migrations/20240909091320_create_stripe_connect_accounts_table.js
@@ -0,0 +1,20 @@
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+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 }
+ */
+exports.down = function (knex) {
+ return knex.schema.dropTableIfExists('stripe_accounts');
+};
diff --git a/packages/server/src/system/migrations/20240915070439_create_payment_links_table.js b/packages/server/src/system/migrations/20240915070439_create_payment_links_table.js
new file mode 100644
index 000000000..1283052c6
--- /dev/null
+++ b/packages/server/src/system/migrations/20240915070439_create_payment_links_table.js
@@ -0,0 +1,24 @@
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+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 }
+ */
+exports.down = function (knex) {
+ return knex.schema.dropTableIfExists('payment_links');
+};
diff --git a/packages/server/src/system/models/PaymentLink.ts b/packages/server/src/system/models/PaymentLink.ts
new file mode 100644
index 000000000..00d83ea2d
--- /dev/null
+++ b/packages/server/src/system/models/PaymentLink.ts
@@ -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;
+}
diff --git a/packages/server/src/system/models/StripeAccount.ts b/packages/server/src/system/models/StripeAccount.ts
new file mode 100644
index 000000000..2124d0c0e
--- /dev/null
+++ b/packages/server/src/system/models/StripeAccount.ts
@@ -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',
+ },
+ },
+ };
+ }
+}
diff --git a/packages/server/src/system/models/index.ts b/packages/server/src/system/models/index.ts
index e96fdd8ff..05dd23f87 100644
--- a/packages/server/src/system/models/index.ts
+++ b/packages/server/src/system/models/index.ts
@@ -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,
};
diff --git a/packages/webapp/package.json b/packages/webapp/package.json
index 1fcd405e1..c7faacdc3 100644
--- a/packages/webapp/package.json
+++ b/packages/webapp/package.json
@@ -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",
diff --git a/packages/webapp/src/components/App.tsx b/packages/webapp/src/components/App.tsx
index d8f3d21a1..5c62605f9 100644
--- a/packages/webapp/src/components/App.tsx
+++ b/packages/webapp/src/components/App.tsx
@@ -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={}
/>
} />
+ } />
} />
diff --git a/packages/webapp/src/components/Dialog/Dialog.tsx b/packages/webapp/src/components/Dialog/Dialog.tsx
index 6e32a99af..7446db4c3 100644
--- a/packages/webapp/src/components/Dialog/Dialog.tsx
+++ b/packages/webapp/src/components/Dialog/Dialog.tsx
@@ -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 (
);
}
diff --git a/packages/webapp/src/components/Dialog/DialogProvider.tsx b/packages/webapp/src/components/Dialog/DialogProvider.tsx
new file mode 100644
index 000000000..c8348a930
--- /dev/null
+++ b/packages/webapp/src/components/Dialog/DialogProvider.tsx
@@ -0,0 +1,20 @@
+import React, { createContext, useContext, ReactNode } from 'react';
+
+const DialogContext = createContext(null);
+
+export const useDialogContext = () => {
+ return useContext(DialogContext);
+};
+
+interface DialogProviderProps {
+ value: any;
+ children: ReactNode;
+}
+
+export const DialogProvider: React.FC = ({ value, children }) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/webapp/src/components/DialogsContainer.tsx b/packages/webapp/src/components/DialogsContainer.tsx
index 8da29ab37..cd4a1b9ef 100644
--- a/packages/webapp/src/components/DialogsContainer.tsx
+++ b/packages/webapp/src/components/DialogsContainer.tsx
@@ -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() {
+
+
);
}
diff --git a/packages/webapp/src/components/Drawer/DrawerHeaderContent.tsx b/packages/webapp/src/components/Drawer/DrawerHeaderContent.tsx
index 91517087e..60b19b948 100644
--- a/packages/webapp/src/components/Drawer/DrawerHeaderContent.tsx
+++ b/packages/webapp/src/components/Drawer/DrawerHeaderContent.tsx
@@ -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 = ,
subTitle,
- onClose,
- name,
closeDrawer,
} = props;
+ const { name } = useDrawerContext();
if (title == null) {
return null;
}
-
const handleClose = (event) => {
closeDrawer(name);
- onClose && onClose(event);
};
return (
diff --git a/packages/webapp/src/constants/dialogs.ts b/packages/webapp/src/constants/dialogs.ts
index b86755cfb..f9e51e735 100644
--- a/packages/webapp/src/constants/dialogs.ts
+++ b/packages/webapp/src/constants/dialogs.ts
@@ -77,4 +77,8 @@ export enum DialogsName {
Export = 'Export',
BankRuleForm = 'BankRuleForm',
DisconnectBankAccountConfirmation = 'DisconnectBankAccountConfirmation',
+ SharePaymentLink = 'SharePaymentLink',
+ SelectPaymentMethod = 'SelectPaymentMethodsDialog',
+
+ StripeSetup = 'StripeSetup'
}
diff --git a/packages/webapp/src/constants/drawers.ts b/packages/webapp/src/constants/drawers.ts
index 843212d8e..4c97ef783 100644
--- a/packages/webapp/src/constants/drawers.ts
+++ b/packages/webapp/src/constants/drawers.ts
@@ -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'
}
diff --git a/packages/webapp/src/constants/preferencesMenu.tsx b/packages/webapp/src/constants/preferencesMenu.tsx
index 1fc17eff2..b6c474836 100644
--- a/packages/webapp/src/constants/preferencesMenu.tsx
+++ b/packages/webapp/src/constants/preferencesMenu.tsx
@@ -16,6 +16,10 @@ export default [
text: ,
href: '/preferences/users',
},
+ {
+ text: 'Payment Methods',
+ href: '/preferences/payment-methods'
+ },
{
text: ,
href: '/preferences/estimates',
@@ -54,6 +58,11 @@ export default [
disabled: false,
href: '/preferences/items',
},
+ {
+ text: 'Integrations',
+ disabled: false,
+ href: '/preferences/integrations'
+ },
// {
// text: ,
// disabled: false,
diff --git a/packages/webapp/src/containers/AlertsContainer/registered.tsx b/packages/webapp/src/containers/AlertsContainer/registered.tsx
index a4180e25b..8f503746f 100644
--- a/packages/webapp/src/containers/AlertsContainer/registered.tsx
+++ b/packages/webapp/src/containers/AlertsContainer/registered.tsx
@@ -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,
];
diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx
index 678517ef4..c458c262e 100644
--- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx
+++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx
@@ -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 (
@@ -119,7 +129,7 @@ function InvoiceDetailActionsBar({
}
+ icon={}
text={}
onClick={handleQuickPaymentInvoice}
/>
@@ -150,6 +160,15 @@ function InvoiceDetailActionsBar({
onClick={handleDeleteInvoice}
/>
+
+
+ }
+ onClick={handleShareButtonClick}
+ />
+
+
= ({ children }) => {
+ const handleSubmit = (values: SelectPaymentMethodsFormValues) => {};
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsBoot.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsBoot.tsx
new file mode 100644
index 000000000..80c5fcb68
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsBoot.tsx
@@ -0,0 +1,42 @@
+import { useGetPaymentServices } from '@/hooks/query/payment-services';
+import React, { createContext, useContext, ReactNode } from 'react';
+
+interface SelectPaymentMethodsContextType {}
+
+const SelectPaymentMethodsContext =
+ createContext(
+ {} 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 (
+
+ {children}
+
+ );
+};
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsContent.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsContent.tsx
new file mode 100644
index 000000000..76e47da2d
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsContent.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
+
+interface PaymentMethodSelectProps {
+ label: string;
+ value?: boolean;
+ initialValue?: boolean;
+ onChange?: (value: boolean) => void;
+}
+function PaymentMethodSelect({
+ value,
+ initialValue,
+ onChange,
+ label,
+}: PaymentMethodSelectProps) {
+ const [_value, handleChange] = useUncontrolled({
+ value,
+ initialValue,
+ finalValue: false,
+ onChange,
+ });
+ const handleClick = () => {
+ handleChange(!_value);
+ };
+
+ return (
+
+
+ {label}
+
+ );
+}
+
+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;
+`;
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsDialog.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsDialog.tsx
new file mode 100644
index 000000000..e5b7284e4
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsDialog.tsx
@@ -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 (
+
+ );
+}
+
+export const SelectPaymentMethodsDialog = compose(withDialogRedux())(
+ SelectPaymentMethodsDialogRoot,
+);
+
+SelectPaymentMethodsDialog.displayName = 'SelectPaymentMethodsDialog';
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkContent.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkContent.tsx
new file mode 100644
index 000000000..241e2543b
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkContent.tsx
@@ -0,0 +1,13 @@
+import { SharePaymentLinkForm } from './SharePaymentLinkForm';
+import { SharePaymentLinkFormContent } from './SharePaymentLinkFormContent';
+import { SharePaymentLinkProvider } from './SharePaymentLinkProvider';
+
+export function SharePaymentLinkContent() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog.tsx
new file mode 100644
index 000000000..90a40a334
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog.tsx
@@ -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 (
+
+ );
+}
+
+export const SharePaymentLinkDialog = compose(withDialogRedux())(
+ SharePaymentLinkDialogRoot,
+);
+
+SharePaymentLinkDialog.displayName = 'SharePaymentLinkDialog';
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.schema.ts b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.schema.ts
new file mode 100644
index 000000000..c6c8fdeed
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.schema.ts
@@ -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'),
+});
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.tsx
new file mode 100644
index 000000000..42b879d1f
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.tsx
@@ -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,
+ ) => {
+ 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 (
+
+ initialValues={formInitialValues}
+ validationSchema={SharePaymentLinkFormSchema}
+ onSubmit={handleFormSubmit}
+ >
+
+
+ );
+};
+SharePaymentLinkForm.displayName = 'SharePaymentLinkForm';
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkFormContent.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkFormContent.tsx
new file mode 100644
index 000000000..55264f190
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkFormContent.tsx
@@ -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 (
+ <>
+
+
+
+ (
+ }
+ minimal
+ />
+ )}
+ searchable={false}
+ fastField
+ />
+
+
+
+ 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.
+
+
+
+ date.toLocaleDateString()}
+ parseDate={(str) => new Date(str)}
+ inputProps={{
+ fill: true,
+ style: { minWidth: 260 },
+ leftElement: ,
+ }}
+ fastField
+ />
+
+
+ {url && (
+
+
+ }
+ />
+
+ }
+ />
+
+ )}
+
+
+
+
+
+ {url ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ >
+ );
+}
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkProvider.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkProvider.tsx
new file mode 100644
index 000000000..64deaec57
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkProvider.tsx
@@ -0,0 +1,35 @@
+import React, { createContext, useContext, useState, ReactNode } from 'react';
+
+interface SharePaymentLinkContextType {
+ url: string;
+ setUrl: React.Dispatch>;
+}
+
+const SharePaymentLinkContext =
+ createContext(null);
+
+interface SharePaymentLinkProviderProps {
+ children: ReactNode;
+}
+
+export const SharePaymentLinkProvider: React.FC<
+ SharePaymentLinkProviderProps
+> = ({ children }) => {
+ const [url, setUrl] = useState('');
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useSharePaymentLink = () => {
+ const context = useContext(SharePaymentLinkContext);
+ if (!context) {
+ throw new Error(
+ 'useSharePaymentLink must be used within a SharePaymentLinkProvider',
+ );
+ }
+ return context;
+};
diff --git a/packages/webapp/src/containers/PaymentMethods/PaymentMethodSelect.tsx b/packages/webapp/src/containers/PaymentMethods/PaymentMethodSelect.tsx
new file mode 100644
index 000000000..75a7172b9
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentMethods/PaymentMethodSelect.tsx
@@ -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({
+ value,
+ initialValue,
+ finalValue: false,
+ onChange,
+ });
+ const handleClick = () => {
+ handleChange(!_value);
+ };
+
+ return (
+
+
+ {label}
+
+ );
+}
+
+export interface PaymentMethodSelectFieldProps
+ extends Partial {
+ 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 (
+
+ );
+}
+
+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;
+`;
diff --git a/packages/webapp/src/containers/PaymentMethods/SelectPaymentMethodPopover.tsx b/packages/webapp/src/containers/PaymentMethods/SelectPaymentMethodPopover.tsx
new file mode 100644
index 000000000..d89277c04
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentMethods/SelectPaymentMethodPopover.tsx
@@ -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;
+ children: React.ReactNode;
+}
+export function PaymentOptionsButtonPopver({
+ paymentMethods,
+ children,
+}: PaymentOptionsButtonPopverProps) {
+ return (
+
+ Payment Options
+
+
+ {paymentMethods?.map((service, key) => (
+
+ ))}
+
+
+ }
+ >
+ {children}
+
+ );
+}
+
+const PaymentMethodsTitle = styled('h6')`
+ font-size: 12px;
+ font-weight: 500;
+ margin: 0;
+ color: rgb(95, 107, 124);
+`;
diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.module.scss b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.module.scss
new file mode 100644
index 000000000..6870ee413
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.module.scss
@@ -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;
+}
diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx
new file mode 100644
index 000000000..cd8a7bfb9
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx
@@ -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 (
+
+
+
+
+
+ {sharableLinkMeta?.companyName}
+
+
+
+
+ {sharableLinkMeta?.companyName} Sent an Invoice for{' '}
+ {sharableLinkMeta?.totalFormatted}
+
+
+ Invoice due {sharableLinkMeta?.dueDateFormatted}
+
+
+
+
+
+ {sharableLinkMeta?.customerName}
+
+ Bigcapital Technology, Inc.
+ 131 Continental Dr Suite 305 Newark,
+ Delaware 19713
+ United States
+ ahmed@bigcapital.app
+
+
+
+ Invoice {sharableLinkMeta?.invoiceNo}
+
+
+
+
+ Sub Total
+ {sharableLinkMeta?.subtotalFormatted}
+
+
+
+ Total
+
+ {sharableLinkMeta?.totalFormatted}
+
+
+
+
+ Paid Amount (-)
+ {sharableLinkMeta?.paymentAmountFormatted}
+
+
+
+ Due Amount
+
+ {sharableLinkMeta?.dueAmountFormatted}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+ Bigcapital Technology, Inc.
+
+ 131 Continental Dr Suite 305 Newark,
+ Delaware 19713
+ United States
+ ahmed@bigcapital.app
+
+
+
+ © 2024 Bigcapital Technology, Inc.
+
+ All rights reserved.
+
+
+
+ );
+}
diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx b/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx
new file mode 100644
index 000000000..9b2fc61c0
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx
@@ -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(
+ {} as PaymentPortalContextType,
+);
+
+interface PaymentPortalBootProps {
+ linkId: string;
+ children: ReactNode;
+}
+
+export const PaymentPortalBoot: React.FC = ({
+ linkId,
+ children,
+}) => {
+ const { data: sharableLinkMeta, isLoading: isSharableLinkMetaLoading } =
+ useGetInvoicePaymentLink(linkId);
+
+ const value = {
+ linkId,
+ sharableLinkMeta,
+ isSharableLinkMetaLoading,
+ };
+ if (isSharableLinkMetaLoading) {
+ return ;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const usePaymentPortalBoot = (): PaymentPortalContextType => {
+ const context = useContext(PaymentPortalContext);
+
+ if (!context) {
+ throw new Error(
+ 'usePaymentPortal must be used within a PaymentPortalProvider',
+ );
+ }
+ return context;
+};
diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortalPage.tsx b/packages/webapp/src/containers/PaymentPortal/PaymentPortalPage.tsx
new file mode 100644
index 000000000..a2cc12cdd
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortalPage.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/webapp/src/containers/PaymentPortal/drawers/PaymentInvoicePreviewDrawer/PaymentInvoicePreviewContent.tsx b/packages/webapp/src/containers/PaymentPortal/drawers/PaymentInvoicePreviewDrawer/PaymentInvoicePreviewContent.tsx
new file mode 100644
index 000000000..9bda399f7
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentPortal/drawers/PaymentInvoicePreviewDrawer/PaymentInvoicePreviewContent.tsx
@@ -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 (
+ <>
+
+
+
+
+ ({
+ item: entry.itemName,
+ description: entry.description,
+ quantity: entry.quantityFormatted,
+ rate: entry.rateFormatted,
+ total: entry.totalFormatted,
+ }))}
+ />
+
+
+ >
+ );
+}
diff --git a/packages/webapp/src/containers/PaymentPortal/drawers/PaymentInvoicePreviewDrawer/PaymentInvoicePreviewDrawer.tsx b/packages/webapp/src/containers/PaymentPortal/drawers/PaymentInvoicePreviewDrawer/PaymentInvoicePreviewDrawer.tsx
new file mode 100644
index 000000000..346dc5468
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentPortal/drawers/PaymentInvoicePreviewDrawer/PaymentInvoicePreviewDrawer.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
+
+export const PaymentInvoicePreviewDrawer = R.compose(withDrawers())(
+ PaymentInvoicePreviewDrawerRoot,
+);
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/PreferencesPaymentMethodsBoot.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/PreferencesPaymentMethodsBoot.tsx
new file mode 100644
index 000000000..ca6364a31
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/PreferencesPaymentMethodsBoot.tsx
@@ -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(
+ {} as PaymentMethodsContextType,
+);
+
+type PaymentMethodsProviderProps = {
+ children: ReactNode;
+};
+
+const PaymentMethodsBoot = ({ children }: PaymentMethodsProviderProps) => {
+ const { data: paymentMethodsState, isLoading: isPaymentMethodsStateLoading } =
+ useGetPaymentServicesState();
+
+ const value = { isPaymentMethodsStateLoading, paymentMethodsState };
+
+ if (isPaymentMethodsStateLoading) {
+ return ;
+ }
+ return (
+
+ {children}
+
+ );
+};
+
+const usePaymentMethodsBoot = () => {
+ const context = useContext(PaymentMethodsContext);
+ if (context === undefined) {
+ throw new Error(
+ 'usePaymentMethods must be used within a PaymentMethodsProvider',
+ );
+ }
+ return context;
+};
+
+export { PaymentMethodsBoot, usePaymentMethodsBoot };
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/PreferencesPaymentMethodsPage.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/PreferencesPaymentMethodsPage.tsx
new file mode 100644
index 000000000..510c66fba
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/PreferencesPaymentMethodsPage.tsx
@@ -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 (
+
+
+
+ Accept payments from all the major debit and credit card networks
+ through the supported payment methods.
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const PaymentMethodsRoot = styled(Box)`
+ witdth: 100%;
+ max-width: 700px;
+ margin: 20px;
+`;
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/PreferencesStripeCallback.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/PreferencesStripeCallback.tsx
new file mode 100644
index 000000000..3ead35ee6
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/PreferencesStripeCallback.tsx
@@ -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;
+}
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/StripePaymentMethod.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/StripePaymentMethod.tsx
new file mode 100644
index 000000000..a8b934fdf
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/StripePaymentMethod.tsx
@@ -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 (
+
+
+
+
+
+
+ {isStripeEnabled && (
+
+ Active
+
+ )}
+ {!isPaymentEnabled && isAccountCreated && (
+
+
+ Payment Not Enabled
+
+
+ )}
+ {!isPayoutEnabled && isAccountCreated && (
+
+
+ Payout Not Enabled
+
+
+ )}
+
+
+
+ {isAccountCreated && (
+
+ )}
+ {!isAccountCreated && (
+
+ )}
+ {isAccountCreated && (
+
+
+
+ }
+ >
+ } />
+
+ )}
+
+
+
+
+ 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.
+
+
+
+
+
+
+ View Stripe's Transaction Fees
+
+
+
+ {!isStripeServerConfigured && (
+
+ Stripe payment is not configured from the server.{' '}
+
+ )}
+
+
+
+ );
+}
+
+const PaymentDescription = styled(Text)`
+ font-size: 13px;
+ margin-top: 12px;
+`;
+
+const PaymentFooter = styled(Box)`
+ margin-top: 14px;
+ font-size: 12px;
+`;
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/alerts/DeleteStripeConnectionAlert.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/alerts/DeleteStripeConnectionAlert.tsx
new file mode 100644
index 000000000..180b2e44c
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/alerts/DeleteStripeConnectionAlert.tsx
@@ -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 (
+ }
+ confirmButtonText={'Delete Account'}
+ intent={Intent.DANGER}
+ isOpen={isOpen}
+ onCancel={handleCancelOpenBill}
+ onConfirm={handleConfirmBillOpen}
+ loading={isLoading}
+ >
+ Are you sure want to delete your Stripe account connection?
+
+ );
+}
+
+export default compose(
+ withAlertStoreConnect(),
+ withAlertActions,
+)(DeleteStripeAccountAlert);
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/alerts/PaymentMethodsAlerts.ts b/packages/webapp/src/containers/Preferences/PaymentMethods/alerts/PaymentMethodsAlerts.ts
new file mode 100644
index 000000000..099d098c9
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/alerts/PaymentMethodsAlerts.ts
@@ -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,
+ },
+];
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/constants.ts b/packages/webapp/src/containers/Preferences/PaymentMethods/constants.ts
new file mode 100644
index 000000000..c5e35d3fa
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/constants.ts
@@ -0,0 +1 @@
+export const STRIPE_PRICING_LINK = 'https://stripe.com/pricing';
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/dialogs/StripePreSetupDialog/StripePreSetupDialog.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/dialogs/StripePreSetupDialog/StripePreSetupDialog.tsx
new file mode 100644
index 000000000..9cb0bfabe
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/dialogs/StripePreSetupDialog/StripePreSetupDialog.tsx
@@ -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 (
+
+ );
+}
+
+export const StripePreSetupDialog = compose(withDialogRedux())(
+ StripePreSetupDialogRoot,
+);
+
+StripePreSetupDialogRoot.displayName = 'StripePreSetupDialog';
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/dialogs/StripePreSetupDialog/StripePreSetupDialogContent.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/dialogs/StripePreSetupDialog/StripePreSetupDialogContent.tsx
new file mode 100644
index 000000000..0fa2d7511
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/dialogs/StripePreSetupDialog/StripePreSetupDialogContent.tsx
@@ -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(false);
+
+ const handleSetUpBtnClick = () => {
+ if (paymentMethodsState?.stripe.stripeAuthLink) {
+ setIsRedirecting(true);
+ window.location.href = paymentMethodsState?.stripe.stripeAuthLink;
+ }
+ };
+ // Handle cancel button click.
+ const handleCancelBtnClick = () => {
+ closeDialog(name);
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {' '}
+ If you're already using Stripe, you can connect your Stripe account
+ to Bigcapital.
+
+
+
+
+
+ {' '}
+ Stripe applies a processing fee for each card payment, but we only
+ charge for the application subscription.
+
+
+
+
+
+ {' '}
+ Customers can pay invoice using credit card, debit card or digital
+ wallets like Apple Pay or Google Pay.
+
+
+
+
+
+ {' '}
+ You can enable or disable card payments for each invoice
+
+
+
+
+
+
+
+ >
+ }
+ >
+ >
+ );
+}
+
+const PaymentFeatureItem = styled('div')`
+ padding-left: 20px;
+ position: relative;
+ padding-left: 50px;
+`;
+
+const PaymentFeatureIcon = styled('span')`
+ position: absolute;
+ left: 12px;
+ top: 2px;
+ color: #0052cc;
+`;
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditBoot.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditBoot.tsx
new file mode 100644
index 000000000..524c7c37e
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditBoot.tsx
@@ -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(
+ {} as StripeIntegrationEditContextType,
+ );
+
+export const useStripeIntegrationEditBoot = () => {
+ const context = useContext(
+ 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 ;
+ }
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditContent.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditContent.tsx
new file mode 100644
index 000000000..d193097ef
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditContent.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditDrawer.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditDrawer.tsx
new file mode 100644
index 000000000..6a31e4e65
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditDrawer.tsx
@@ -0,0 +1,35 @@
+// @ts-nocheck
+import React from 'react';
+import * as R from 'ramda';
+import { Drawer, DrawerSuspense } from '@/components';
+import withDrawers from '@/containers/Drawer/withDrawers';
+
+const StripeIntegrationEditContent = React.lazy(() =>
+ import('./StripeIntegrationEditContent').then((module) => ({
+ default: module.StripeIntegrationEditContent,
+ })),
+);
+
+/**
+ * Stripe integration edit drawer.
+ * @returns {React.ReactNode}
+ */
+function StripeIntegrationEditDrawerRoot({
+ name,
+
+ // #withDrawer
+ isOpen,
+ payload,
+}) {
+ return (
+
+
+
+
+
+ );
+}
+
+export const StripeIntegrationEditDrawer = R.compose(withDrawers())(
+ StripeIntegrationEditDrawerRoot,
+);
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditForm.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditForm.tsx
new file mode 100644
index 000000000..18297093b
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditForm.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import * as Yup from 'yup';
+import { Formik, FormikHelpers } from 'formik';
+import { useUpdatePaymentMethod } from '@/hooks/query/payment-services';
+import { AppToaster } from '@/components';
+import { Intent } from '@blueprintjs/core';
+import { usePaymentMethodsBoot } from '../PreferencesPaymentMethodsBoot';
+import { useDrawerActions } from '@/hooks/state';
+import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
+import { useStripeIntegrationEditBoot } from './StripeIntegrationEditBoot';
+import { transformToForm } from '@/utils';
+
+interface StripeIntegrationFormValues {
+ bankAccountId: string;
+ clearingAccountId: string;
+}
+const initialValues = {
+ bankAccountId: '',
+ clearingAccountId: '',
+};
+const validationSchema = Yup.object().shape({
+ bankAccountId: Yup.string().required('Bank Account is required'),
+ clearingAccountId: Yup.string().required('Clearing Account is required'),
+});
+interface StripeIntegrationEditFormProps {
+ children: React.ReactNode;
+}
+
+export function StripeIntegrationEditForm({
+ children,
+}: StripeIntegrationEditFormProps) {
+ const { closeDrawer } = useDrawerActions();
+ const { name } = useDrawerContext();
+ const { mutateAsync: updatePaymentMethod } = useUpdatePaymentMethod();
+ const { paymentMethodsState } = usePaymentMethodsBoot();
+ const { paymentMethod } = useStripeIntegrationEditBoot();
+ const stripePaymentState = paymentMethodsState?.stripe;
+ const paymentMethodId = stripePaymentState?.stripePaymentMethodId;
+
+ const formInitialValues = {
+ ...initialValues,
+ ...transformToForm(paymentMethod?.options, initialValues),
+ };
+ const onSubmit = (
+ values: StripeIntegrationFormValues,
+ { setSubmitting }: FormikHelpers,
+ ) => {
+ const _values = { options: { ...values } };
+
+ setSubmitting(true);
+ updatePaymentMethod({ paymentMethodId, values: _values })
+ .then(() => {
+ AppToaster.show({
+ message: 'The Stripe settings have been updated.',
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ closeDrawer(name);
+ })
+ .catch(() => {
+ setSubmitting(false);
+ AppToaster.show({
+ message: 'Something went wrong.',
+ intent: Intent.SUCCESS,
+ });
+ });
+ };
+
+ return (
+
+ <>{children}>
+
+ );
+}
diff --git a/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditFormContent.tsx b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditFormContent.tsx
new file mode 100644
index 000000000..1700114a5
--- /dev/null
+++ b/packages/webapp/src/containers/Preferences/PaymentMethods/drawers/StripeIntegrationEditFormContent.tsx
@@ -0,0 +1,76 @@
+import { AccountsSelect, FFormGroup, Group, Stack } from '@/components';
+import { useStripeIntegrationEditBoot } from './StripeIntegrationEditBoot';
+import { Button, Intent } from '@blueprintjs/core';
+import { useFormikContext } from 'formik';
+import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
+import { useDrawerActions } from '@/hooks/state';
+import { ACCOUNT_TYPE } from '@/constants';
+
+export function StripeIntegrationEditFormContent() {
+ const { accounts } = useStripeIntegrationEditBoot();
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function StripeIntegrationEditFormFooter() {
+ const { name } = useDrawerContext();
+ const { closeDrawer } = useDrawerActions();
+ const { submitForm, isSubmitting } = useFormikContext();
+
+ const handleSubmitBtnClick = () => {
+ submitForm();
+ };
+ const handleCancelBtnClick = () => {
+ closeDrawer(name);
+ };
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteCustomize/CreditNotePaperTemplate.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteCustomize/CreditNotePaperTemplate.tsx
index f594ba551..90644193b 100644
--- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteCustomize/CreditNotePaperTemplate.tsx
+++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteCustomize/CreditNotePaperTemplate.tsx
@@ -140,7 +140,7 @@ export function CreditNotePaperTemplate({
)}
-
+
{showBilledFromAddress && (
{companyName}, ...billedFromAddress]}
@@ -151,7 +151,7 @@ export function CreditNotePaperTemplate({
items={[{billedToLabel}, ...billedToAddress]}
/>
)}
-
+
-
+
{showBilledFromAddress && (
{companyName}, ...billedFromAddress]}
@@ -157,7 +157,7 @@ export function EstimatePaperTemplate({
items={[{billedToLabel}, ...billedToAddress]}
/>
)}
-
+
div{
+ flex: 1;
+ }
}
.table {
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate.tsx
index b191b0875..ec6fd7f5c 100644
--- a/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate.tsx
+++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate.tsx
@@ -73,13 +73,13 @@ export interface InvoicePaperTemplateProps {
balanceDue?: string;
// Footer
- termsConditionsLabel: string;
- showTermsConditions: boolean;
- termsConditions: string;
+ termsConditionsLabel?: string;
+ showTermsConditions?: boolean;
+ termsConditions?: string;
- statementLabel: string;
- showStatement: boolean;
- statement: string;
+ statementLabel?: string;
+ showStatement?: boolean;
+ statement?: string;
lines?: Array;
taxes?: Array;
@@ -99,16 +99,16 @@ export function InvoicePaperTemplate({
dueDate = 'September 3, 2024',
dueDateLabel = 'Date due',
- showDueDate,
+ showDueDate = true,
dateIssue = 'September 3, 2024',
dateIssueLabel = 'Date of issue',
- showDateIssue,
+ showDateIssue = true,
// dateIssue,
invoiceNumberLabel = 'Invoice number',
invoiceNumber = '346D3D40-0001',
- showInvoiceNumber,
+ showInvoiceNumber = true,
// Address
showBillingToAddress = true,
@@ -207,7 +207,7 @@ export function InvoicePaperTemplate({
)}
-
+
{showBilledFromAddress && (
{companyName}, ...billedFromAddres]}
@@ -218,7 +218,7 @@ export function InvoicePaperTemplate({
items={[{billedToLabel}, ...billedToAddress]}
/>
)}
-
+
- {showTermsConditions && (
+ {showTermsConditions && termsConditions && (
{termsConditions}
)}
- {showStatement && (
+ {showStatement && statement && (
{statement}
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/PaperTemplate.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/PaperTemplate.tsx
index 571f3c753..c8daf0715 100644
--- a/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/PaperTemplate.tsx
+++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceCustomize/PaperTemplate.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import clsx from 'classnames';
import { get } from 'lodash';
-import { Stack } from '@/components';
+import { Group, GroupProps, Stack } from '@/components';
import styles from './InvoicePaperTemplate.module.scss';
export interface PaperTemplateProps {
@@ -119,6 +119,9 @@ PaperTemplate.MutedText = () => {};
PaperTemplate.Text = () => {};
+PaperTemplate.AddressesGroup = (props: GroupProps) => {
+ return
+}
PaperTemplate.Address = ({
items,
}: {
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterLeft.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterLeft.tsx
index df6ec6236..50691906c 100644
--- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterLeft.tsx
+++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterLeft.tsx
@@ -2,11 +2,32 @@
import React from 'react';
import intl from 'react-intl-universal';
import styled from 'styled-components';
-import { FFormGroup, FEditableText, FormattedMessage as T } from '@/components';
+import { isEmpty } from 'lodash';
+import { Button, Intent } from '@blueprintjs/core';
+import { useHistory } from 'react-router-dom';
+import {
+ FFormGroup,
+ FEditableText,
+ FormattedMessage as T,
+ Box,
+ Group,
+ Stack,
+} from '@/components';
+import { VisaIcon } from '@/icons/Visa';
+import { MastercardIcon } from '@/icons/Mastercard';
+import { useInvoiceFormContext } from './InvoiceFormProvider';
+import { PaymentOptionsButtonPopver } from '@/containers/PaymentMethods/SelectPaymentMethodPopover';
export function InvoiceFormFooterLeft() {
+ const { paymentServices } = useInvoiceFormContext();
+ const history = useHistory();
+
+ const handleSetupPaymentsClick = () => {
+ history.push('/preferences/payment-methods');
+ };
+
return (
-
+
{/* --------- Invoice message --------- */}
-
+
+ {/* --------- Payment Options --------- */}
+
+
+ Select an online payment option to get paid faster{' '}
+
+
+
+
+ {isEmpty(paymentServices) ? (
+
+ Setup payment gateways
+
+ ) : (
+
+
+ Payment Options
+
+
+ )}
+
+
+
);
}
const InvoiceMsgFormGroup = styled(FFormGroup)`
&.bp4-form-group {
- margin-bottom: 40px;
-
.bp4-label {
font-size: 12px;
margin-bottom: 12px;
@@ -63,3 +112,29 @@ const TermsConditsFormGroup = styled(FFormGroup)`
}
}
`;
+
+const PaymentOptionsFormGroup = styled(FFormGroup)`
+ &.bp4-form-group {
+ .bp4-label {
+ font-weight: 500;
+ font-size: 12px;
+ margin-bottom: 10px;
+ }
+ }
+`;
+
+const PaymentOptionsText = styled(Box)`
+ font-size: 13px;
+ display: inline-flex;
+ align-items: center;
+ color: #5f6b7c;
+`;
+
+const PaymentOptionsButton = styled(Button)`
+ font-size: 13px;
+ margin-left: 4px;
+
+ &.bp4-minimal.bp4-intent-primary {
+ color: #0052cc;
+ }
+`;
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx
index fc6a41f6f..172dcda94 100644
--- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx
+++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx
@@ -20,6 +20,7 @@ import {
import { useProjects } from '@/containers/Projects/hooks';
import { useTaxRates } from '@/hooks/query/taxRates';
import { useGetPdfTemplates } from '@/hooks/query/pdf-templates';
+import { useGetPaymentServices } from '@/hooks/query/payment-services';
const InvoiceFormContext = createContext();
@@ -60,6 +61,10 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
const { data: brandingTemplates, isLoading: isBrandingTemplatesLoading } =
useGetPdfTemplates({ resource: 'SaleInvoice' });
+ // Fetches the payment services.
+ const { data: paymentServices, isLoading: isPaymentServicesLoading } =
+ useGetPaymentServices();
+
const newInvoice = !isEmpty(estimate)
? transformToEditForm({
...pick(estimate, ['customer_id', 'currency_code', 'entries']),
@@ -110,7 +115,10 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
// Determines whether the warehouse and branches are loading.
const isFeatureLoading =
- isWarehouesLoading || isBranchesLoading || isProjectsLoading || isBrandingTemplatesLoading;
+ isWarehouesLoading ||
+ isBranchesLoading ||
+ isProjectsLoading ||
+ isBrandingTemplatesLoading;
const provider = {
invoice,
@@ -142,6 +150,10 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
editInvoiceMutate,
setSubmitPayload,
isNewMode,
+
+ // Payment Services
+ paymentServices,
+ isPaymentServicesLoading,
};
return (
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx
index 659b0d5f3..243a46f56 100644
--- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx
+++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx
@@ -69,6 +69,7 @@ export const defaultInvoice = {
pdf_template_id: '',
entries: [...repeatValue(defaultInvoiceEntry, MIN_LINES_NUMBER)],
attachments: [],
+ payment_methods: {},
};
// Invoice entry request schema.
@@ -107,6 +108,7 @@ export function transformToEditForm(invoice) {
: TaxType.Exclusive,
entries,
attachments: transformAttachmentsToForm(invoice),
+ payment_methods: transformPaymentMethodsToForm(invoice?.payment_methods),
};
}
@@ -223,9 +225,38 @@ export function transformValueToRequest(values) {
entries: transformEntriesToRequest(values.entries),
delivered: false,
attachments: transformAttachmentsToRequest(values),
+ payment_methods: transformPaymentMethodsToRequest(values?.payment_methods),
};
}
+/**
+ * Transformes the form payment methods to request.
+ * @param {Record} paymentMethods
+ * @returns {Array<{ payment_integration_id: string; enable: boolean }>}
+ */
+const transformPaymentMethodsToRequest = (
+ paymentMethods: Record,
+): Array<{ payment_integration_id: string; enable: boolean }> => {
+ return Object.entries(paymentMethods).map(([paymentMethodId, method]) => ({
+ payment_integration_id: paymentMethodId,
+ enable: method.enable,
+ }));
+};
+
+/**
+ * Transformes payment methods from request to form.
+ * @param {Array<{ payment_integration_id: number; enable: boolean }>} paymentMethods
+ * @returns {Record}
+ */
+const transformPaymentMethodsToForm = (
+ paymentMethods: Array<{ payment_integration_id: number; enable: boolean }>,
+): Record => {
+ return paymentMethods?.reduce((acc, method) => {
+ acc[method.payment_integration_id] = { enable: method.enable };
+ return acc;
+ }, {});
+};
+
export const useSetPrimaryWarehouseToForm = () => {
const { setFieldValue } = useFormikContext();
const { warehouses, isWarehousesSuccess } = useInvoiceFormContext();
diff --git a/packages/webapp/src/containers/Sales/PaymentsReceived/PaymentReceivedCustomize/PaymentReceivedPaperTemplate.tsx b/packages/webapp/src/containers/Sales/PaymentsReceived/PaymentReceivedCustomize/PaymentReceivedPaperTemplate.tsx
index 52318be2e..51a9bbb3f 100644
--- a/packages/webapp/src/containers/Sales/PaymentsReceived/PaymentReceivedCustomize/PaymentReceivedPaperTemplate.tsx
+++ b/packages/webapp/src/containers/Sales/PaymentsReceived/PaymentReceivedCustomize/PaymentReceivedPaperTemplate.tsx
@@ -112,7 +112,7 @@ export function PaymentReceivedPaperTemplate({
)}
-
+
{showBilledFromAddress && (
{companyName}, ...billedFromAddress]}
@@ -123,7 +123,7 @@ export function PaymentReceivedPaperTemplate({
items={[{billedToLabel}, ...billedToAddress]}
/>
)}
-
+
-
+
{showBilledFromAddress && (
{companyName}, ...billedFromAddress]}
@@ -145,7 +145,7 @@ export function ReceiptPaperTemplate({
items={[{billedToLabel}, ...billedToAddress]}
/>
)}
-
+
} options
+ * @returns {UseMutationResult}
+ */
+export function useCreatePaymentLink(
+ options?: UseMutationOptions<
+ CreatePaymentLinkResponse,
+ Error,
+ CreatePaymentLinkValues
+ >,
+): UseMutationResult<
+ CreatePaymentLinkResponse,
+ Error,
+ CreatePaymentLinkValues
+> {
+ const apiRequest = useApiRequest();
+
+ return useMutation(
+ (values) =>
+ apiRequest
+ .post('/payment-links/generate', transfromToSnakeCase(values))
+ .then((res) => res.data),
+ {
+ ...options,
+ },
+ );
+}
+
+
+// Get Invoice Payment Link
+// -----------------------------------------
+export interface GetInvoicePaymentLinkResponse {
+ dueAmount: number;
+ dueAmountFormatted: string;
+ dueDate: string;
+ dueDateFormatted: string;
+ invoiceDateFormatted: string;
+ invoiceNo: string;
+ paymentAmount: number;
+ paymentAmountFormatted: string;
+ subtotal: number;
+ subtotalFormatted: string;
+ subtotalLocalFormatted: string;
+ total: number;
+ totalFormatted: string;
+ totalLocalFormatted: string;
+ customerName: string;
+ companyName: string;
+ invoiceMessage: string;
+ termsConditions: string;
+ entries: Array<{
+ description: string;
+ itemName: string;
+ quantity: number;
+ quantityFormatted: string;
+ rate: number;
+ rateFormatted: string;
+ total: number;
+ totalFormatted: string;
+ }>;
+}
+/**
+ * Fetches the sharable invoice link metadata for a given link ID.
+ * @param {string} linkId - The ID of the link to fetch metadata for.
+ * @param {UseQueryOptions} options - Optional query options.
+ * @returns {UseQueryResult} The query result.
+ */
+export function useGetInvoicePaymentLink(
+ linkId: string,
+ options?: UseQueryOptions,
+): UseQueryResult {
+ const apiRequest = useApiRequest();
+
+ return useQuery(
+ ['sharable-link-meta', linkId],
+ () =>
+ apiRequest
+ .get(`/sharable-links/meta/invoice/${linkId}`)
+ .then((res) => transformToCamelCase(res.data?.data)),
+ {
+ ...options,
+ },
+ );
+}
diff --git a/packages/webapp/src/hooks/query/payment-methods.ts b/packages/webapp/src/hooks/query/payment-methods.ts
new file mode 100644
index 000000000..80ffa3b90
--- /dev/null
+++ b/packages/webapp/src/hooks/query/payment-methods.ts
@@ -0,0 +1,50 @@
+// @ts-nocheck
+import {
+ useMutation,
+ UseMutationOptions,
+ UseMutationResult,
+} from 'react-query';
+import useApiRequest from '../useRequest';
+
+
+
+// # Edit payment method
+// -----------------------------------------
+interface EditPaymentMethodValues {
+ paymentMethodId: number;
+ name?: string;
+ bankAccountId?: number;
+ clearningAccountId?: number;
+ showVisa?: boolean;
+ showMasterCard?: boolean;
+ showDiscover?: boolean;
+ showAmer?: boolean;
+ showJcb?: boolean;
+ showDiners?: boolean;
+}
+interface EditPaymentMethodResponse {
+ id: number;
+ message: string;
+}
+export const useEditPaymentMethod = (
+ options?: UseMutationOptions<
+ EditPaymentMethodResponse,
+ Error,
+ EditPaymentMethodValues
+ >,
+): UseMutationResult<
+ EditPaymentMethodResponse,
+ Error,
+ EditPaymentMethodValues
+> => {
+ const apiRequest = useApiRequest();
+
+ return useMutation(
+ ({ paymentMethodId, ...editData }) => {
+ return apiRequest
+ .put(`/payment-methods/${paymentMethodId}`, editData)
+ .then((res) => res.data);
+ },
+ { ...options },
+ );
+};
diff --git a/packages/webapp/src/hooks/query/payment-services.ts b/packages/webapp/src/hooks/query/payment-services.ts
new file mode 100644
index 000000000..886f4b98d
--- /dev/null
+++ b/packages/webapp/src/hooks/query/payment-services.ts
@@ -0,0 +1,189 @@
+// @ts-nocheck
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryOptions,
+ UseQueryResult,
+} from 'react-query';
+import useApiRequest from '../useRequest';
+import { transformToCamelCase, transfromToSnakeCase } from '@/utils';
+
+const PaymentServicesQueryKey = 'PaymentServices';
+const PaymentServicesStateQueryKey = 'PaymentServicesState';
+
+// # Get payment services.
+// -----------------------------------------
+export interface GetPaymentServicesResponse {}
+/**
+ * Retrieves the integrated payment services.
+ * @param {UseQueryOptions} options
+ * @returns {UseQueryResult}
+ */
+export const useGetPaymentServices = (
+ options?: UseQueryOptions,
+): UseQueryResult => {
+ const apiRequest = useApiRequest();
+
+ return useQuery(
+ [PaymentServicesQueryKey],
+ () =>
+ apiRequest
+ .get('/payment-services')
+ .then(
+ (response) =>
+ transformToCamelCase(
+ response.data?.payment_services,
+ ) as GetPaymentServicesResponse,
+ ),
+ {
+ ...options,
+ },
+ );
+};
+
+// # Get payment services state.
+// -----------------------------------------
+export interface GetPaymentServicesStateResponse {
+ stripe: {
+ isStripeAccountCreated: boolean;
+ isStripePaymentEnabled: boolean;
+ isStripePayoutEnabled: boolean;
+ isStripeEnabled: boolean;
+ isStripeServerConfigured: boolean;
+ stripeAccountId: string | null;
+ stripePaymentMethodId: number | null;
+ stripeCurrencies: string[];
+ stripePublishableKey: string;
+ stripeAuthLink: string;
+ stripeRedirectUrl: string;
+ };
+}
+/**
+ * Retrieves the state of payment services.
+ * @param {UseQueryOptions} options
+ * @returns {UseQueryResult}
+ */
+export const useGetPaymentServicesState = (
+ options?: UseQueryOptions,
+): UseQueryResult => {
+ const apiRequest = useApiRequest();
+
+ return useQuery(
+ [PaymentServicesStateQueryKey],
+ () =>
+ apiRequest
+ .get('/payment-services/state')
+ .then(
+ (response) =>
+ transformToCamelCase(
+ response.data?.data,
+ ) as GetPaymentServicesStateResponse,
+ ),
+ {
+ ...options,
+ },
+ );
+};
+
+// # Update payment method
+// -----------------------------------------
+interface UpdatePaymentMethodResponse {
+ id: number;
+ message: string;
+}
+interface UpdatePaymentMethodValues {
+ paymentMethodId: string | number;
+ values: {
+ name: string;
+ bankAccountId: number;
+ clearingAccountId: number;
+ };
+}
+/**
+ * Updates a payment method.
+ * @returns {UseMutationResult}
+ */
+export const useUpdatePaymentMethod = (): UseMutationResult<
+ UpdatePaymentMethodResponse,
+ Error,
+ UpdatePaymentMethodValues,
+ unknown
+> => {
+ const apiRequest = useApiRequest();
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ UpdatePaymentMethodResponse,
+ Error,
+ UpdatePaymentMethodValues,
+ unknown
+ >(
+ (data: UpdatePaymentMethodValues) =>
+ apiRequest
+ .post(
+ `/payment-services/${data.paymentMethodId}`,
+ transfromToSnakeCase(data.values),
+ )
+ .then((response) => response.data),
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries(PaymentServicesStateQueryKey);
+ queryClient.invalidateQueries(PaymentServicesQueryKey);
+ },
+ },
+ );
+};
+
+// # Get payment method
+// -----------------------------------------
+interface GetPaymentMethodResponse {}
+/**
+ * Retrieves a specific payment method.
+ * @param {number} paymentMethodId - The ID of the payment method.
+ * @returns {UseQueryResult}
+ */
+export const useGetPaymentMethod = (
+ paymentMethodId: number,
+ options?: UseQueryOptions,
+): UseQueryResult => {
+ const apiRequest = useApiRequest();
+
+ return useQuery(
+ [PaymentServicesQueryKey, paymentMethodId],
+ () =>
+ apiRequest
+ .get(`/payment-services/${paymentMethodId}`)
+ .then(
+ (res) =>
+ transformToCamelCase(res.data?.data) as GetPaymentMethodResponse,
+ ),
+ options,
+ );
+};
+
+// # Delete payment method
+// -----------------------------------------
+interface DeletePaymentMethodValues {
+ paymentMethodId: number;
+}
+export const useDeletePaymentMethod = (
+ options?: UseMutationOptions,
+): UseMutationResult => {
+ const apiRequest = useApiRequest();
+ const queryClient = useQueryClient();
+
+ return useMutation(
+ ({ paymentMethodId }) => {
+ return apiRequest
+ .delete(`/payment-services/${paymentMethodId}`)
+ .then((res) => res.data);
+ },
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries(PaymentServicesStateQueryKey);
+ },
+ ...options,
+ },
+ );
+};
diff --git a/packages/webapp/src/hooks/query/stripe-integration.ts b/packages/webapp/src/hooks/query/stripe-integration.ts
new file mode 100644
index 000000000..216f57192
--- /dev/null
+++ b/packages/webapp/src/hooks/query/stripe-integration.ts
@@ -0,0 +1,216 @@
+// @ts-nocheck
+import {
+ useMutation,
+ UseMutationOptions,
+ UseMutationResult,
+} from 'react-query';
+import useApiRequest from '../useRequest';
+import { transformToCamelCase } from '@/utils';
+
+// Create Stripe Account Link.
+// ------------------------------------
+interface StripeAccountLinkResponse {
+ clientSecret: {
+ created: number;
+ expiresAt: number;
+ object: string;
+ url: string;
+ };
+}
+interface StripeAccountLinkValues {
+ stripeAccountId: string;
+}
+
+export const useCreateStripeAccountLink = (
+ options?: UseMutationOptions<
+ StripeAccountLinkResponse,
+ Error,
+ StripeAccountLinkValues
+ >,
+): UseMutationResult<
+ StripeAccountLinkResponse,
+ Error,
+ StripeAccountLinkValues
+> => {
+ const apiRequest = useApiRequest();
+
+ return useMutation(
+ (values: StripeAccountLinkValues) => {
+ return apiRequest
+ .post('/stripe_integration/account_link', {
+ stripe_account_id: values?.stripeAccountId,
+ })
+ .then((res) => transformToCamelCase(res.data));
+ },
+ { ...options },
+ );
+};
+
+// Create Stripe Account Session.
+// ------------------------------------
+interface AccountSessionValues {
+ connectedAccountId?: string;
+}
+interface AccountSessionResponse {
+ client_secret: string;
+}
+
+/**
+ * Generates a new Stripe checkout session for the provided link ID.
+ * @param {CreateCheckoutSessionValues} values - The values required to create a checkout session.
+ * @returns {Promise} The response containing the checkout session details.
+ */
+export const useCreateStripeAccountSession = (
+ options?: UseMutationOptions<
+ AccountSessionResponse,
+ Error,
+ AccountSessionValues
+ >,
+): UseMutationResult => {
+ const apiRequest = useApiRequest();
+
+ return useMutation(
+ (values: AccountSessionValues) => {
+ return apiRequest
+ .post('/stripe_integration/account_session', {
+ account: values?.connectedAccountId,
+ })
+ .then((res) => res.data);
+ },
+ { ...options },
+ );
+};
+
+// Create Stripe Account.
+// ------------------------------------
+interface CreateStripeAccountValues {}
+interface CreateStripeAccountResponse {
+ account_id: string;
+}
+
+export const useCreateStripeAccount = (
+ options?: UseMutationOptions<
+ CreateStripeAccountResponse,
+ Error,
+ CreateStripeAccountValues
+ >,
+) => {
+ const apiRequest = useApiRequest();
+
+ return useMutation(
+ (values: CreateStripeAccountValues) => {
+ return apiRequest
+ .post('/stripe_integration/account')
+ .then((res) => res.data);
+ },
+ { ...options },
+ );
+};
+
+// Create Stripe Checkout Session.
+// ------------------------------------
+interface CreateCheckoutSessionValues {
+ linkId: string;
+}
+
+interface CreateCheckoutSessionResponse {
+ sessionId: string;
+ publishableKey: string;
+ redirectTo: string;
+}
+
+export const useCreateStripeCheckoutSession = (
+ options?: UseMutationOptions<
+ CreateCheckoutSessionResponse,
+ Error,
+ CreateCheckoutSessionValues
+ >,
+): UseMutationResult<
+ CreateCheckoutSessionResponse,
+ Error,
+ CreateCheckoutSessionValues
+> => {
+ const apiRequest = useApiRequest();
+
+ return useMutation(
+ (values: CreateCheckoutSessionValues) => {
+ return apiRequest
+ .post(
+ `/stripe_integration/${values.linkId}/create_checkout_session`,
+ values,
+ )
+ .then(
+ (res) =>
+ transformToCamelCase(res.data) as CreateCheckoutSessionResponse,
+ );
+ },
+ { ...options },
+ );
+};
+
+// Create Stripe Account OAuth Link.
+// ------------------------------------
+interface StripeAccountLinkResponse {
+ clientSecret: {
+ created: number;
+ expiresAt: number;
+ object: string;
+ url: string;
+ };
+}
+
+interface StripeAccountLinkValues {
+ stripeAccountId: string;
+}
+
+export const useGetStripeAccountLink = (
+ options?: UseQueryOptions,
+): UseQueryResult => {
+ const apiRequest = useApiRequest();
+ return useQuery(
+ 'getStripeAccountLink',
+ () => {
+ return apiRequest
+ .get('/stripe_integration/link')
+ .then((res) => transformToCamelCase(res.data));
+ },
+ { ...options },
+ );
+};
+
+// Get Stripe Account OAuth Callback Mutation.
+// ------------------------------------
+interface StripeAccountCallbackMutationValues {
+ code: string;
+}
+
+interface StripeAccountCallbackMutationResponse {
+ success: boolean;
+}
+
+export const useSetStripeAccountCallback = (
+ options?: UseMutationOptions<
+ StripeAccountCallbackMutationResponse,
+ Error,
+ StripeAccountCallbackMutationValues
+ >,
+): UseMutationResult<
+ StripeAccountCallbackMutationResponse,
+ Error,
+ StripeAccountCallbackMutationValues
+> => {
+ const apiRequest = useApiRequest();
+ return useMutation(
+ (values: StripeAccountCallbackMutationValues) => {
+ return apiRequest
+ .post(`/stripe_integration/callback`, values)
+ .then(
+ (res) =>
+ transformToCamelCase(
+ res.data,
+ ) as StripeAccountCallbackMutationResponse,
+ );
+ },
+ { ...options },
+ );
+};
diff --git a/packages/webapp/src/hooks/state/dashboard.tsx b/packages/webapp/src/hooks/state/dashboard.tsx
index 6f424191b..ec1f6f6e3 100644
--- a/packages/webapp/src/hooks/state/dashboard.tsx
+++ b/packages/webapp/src/hooks/state/dashboard.tsx
@@ -12,6 +12,9 @@ import {
closeDialog,
openDrawer,
closeDrawer,
+ openAlert,
+ closeAlert,
+ changePreferencesPageTitle,
} from '@/store/dashboard/dashboard.actions';
export const useDispatchAction = (action) => {
@@ -80,9 +83,32 @@ export const useDialogActions = () => {
};
};
+/**
+ * Drawer actions.
+ * @returns
+ */
export const useDrawerActions = () => {
+ const dispatch = useDispatch();
+
return {
- openDrawer: useDispatchAction(openDrawer),
- closeDrawer: useDispatchAction(closeDrawer),
+ openDrawer: (name, payload?: {}) => dispatch(openDrawer(name, payload)),
+ closeDrawer: (name, payload?: {}) => dispatch(closeDrawer(name, payload)),
};
};
+
+/**
+ * Alert actions.
+ * @returns
+ */
+export const useAlertActions = () => {
+ const dispatch = useDispatch();
+
+ return {
+ openAlert: (name, payload?: {}) => dispatch(openAlert(name, payload)),
+ closeAlert: (name, payload?: {}) => dispatch(closeAlert(name, payload)),
+ };
+};
+
+export const useChangePreferencesPageTitle = () => {
+ return useDispatchAction(changePreferencesPageTitle);
+};
diff --git a/packages/webapp/src/hooks/utils/useClipboard.ts b/packages/webapp/src/hooks/utils/useClipboard.ts
new file mode 100644
index 000000000..46fcf1098
--- /dev/null
+++ b/packages/webapp/src/hooks/utils/useClipboard.ts
@@ -0,0 +1,32 @@
+import { useState } from 'react';
+
+export function useClipboard({ timeout = 2000 } = {}) {
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [copyTimeout, setCopyTimeout] = useState(null);
+
+ const handleCopyResult = (value: boolean) => {
+ window.clearTimeout(copyTimeout!);
+ setCopyTimeout(window.setTimeout(() => setCopied(false), timeout));
+ setCopied(value);
+ };
+
+ const copy = (valueToCopy: any) => {
+ if ('clipboard' in navigator) {
+ navigator.clipboard
+ .writeText(valueToCopy)
+ .then(() => handleCopyResult(true))
+ .catch((err) => setError(err));
+ } else {
+ setError(new Error('useClipboard: navigator.clipboard is not supported'));
+ }
+ };
+
+ const reset = () => {
+ setCopied(false);
+ setError(null);
+ window.clearTimeout(copyTimeout!);
+ };
+
+ return { copy, reset, error, copied };
+}
diff --git a/packages/webapp/src/icons/ArrowBottomLeft.tsx b/packages/webapp/src/icons/ArrowBottomLeft.tsx
new file mode 100644
index 000000000..494bbb788
--- /dev/null
+++ b/packages/webapp/src/icons/ArrowBottomLeft.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+
+interface ArrowBottomLeftProps extends React.SVGProps {
+ size?: number;
+}
+
+export const ArrowBottomLeft: React.FC = ({
+ size = 16,
+ ...props
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/webapp/src/icons/CreditCard2.tsx b/packages/webapp/src/icons/CreditCard2.tsx
new file mode 100644
index 000000000..d0b129d54
--- /dev/null
+++ b/packages/webapp/src/icons/CreditCard2.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+
+interface CreditCard2IconProps extends React.SVGProps {
+ size?: number;
+}
+
+export const CreditCard2Icon: React.FC = ({
+ size = 16,
+ ...props
+}) => (
+
+
+
+);
diff --git a/packages/webapp/src/icons/Dollar.tsx b/packages/webapp/src/icons/Dollar.tsx
new file mode 100644
index 000000000..b9b5849d6
--- /dev/null
+++ b/packages/webapp/src/icons/Dollar.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+
+interface DollarIconProps extends React.SVGProps {
+ size?: number;
+}
+
+export const DollarIcon: React.FC = ({
+ size = 16,
+ ...props
+}) => (
+
+
+
+);
diff --git a/packages/webapp/src/icons/LayoutAuto.tsx b/packages/webapp/src/icons/LayoutAuto.tsx
new file mode 100644
index 000000000..98f8d76c4
--- /dev/null
+++ b/packages/webapp/src/icons/LayoutAuto.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+
+interface LayoutAutoIconProps extends React.SVGProps {
+ size?: number;
+}
+
+export const LayoutAutoIcon: React.FC = ({
+ size = 16,
+ ...props
+}) => (
+
+
+
+);
diff --git a/packages/webapp/src/icons/Mastercard.tsx b/packages/webapp/src/icons/Mastercard.tsx
new file mode 100644
index 000000000..fc9df26f2
--- /dev/null
+++ b/packages/webapp/src/icons/Mastercard.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+
+export const MastercardIcon: React.FC> = (
+ props,
+) => {
+ return (
+
+ );
+};
diff --git a/packages/webapp/src/icons/More.tsx b/packages/webapp/src/icons/More.tsx
new file mode 100644
index 000000000..1bab862c4
--- /dev/null
+++ b/packages/webapp/src/icons/More.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+interface MoreIconProps extends React.SVGProps {
+ size?: number;
+}
+
+export const MoreIcon: React.FC = ({ size = 16, ...props }) => (
+
+);
diff --git a/packages/webapp/src/icons/StripeLogo.tsx b/packages/webapp/src/icons/StripeLogo.tsx
new file mode 100644
index 000000000..d8f5b8418
--- /dev/null
+++ b/packages/webapp/src/icons/StripeLogo.tsx
@@ -0,0 +1,28 @@
+import * as React from 'react';
+
+interface StripeLogoProps extends React.SVGProps {
+ width?: number;
+ height?: number;
+}
+
+export const StripeLogo: React.FC = ({
+ width = 70,
+ height = 30,
+ ...props
+}) => (
+
+);
diff --git a/packages/webapp/src/icons/SwitchIcon.tsx b/packages/webapp/src/icons/SwitchIcon.tsx
new file mode 100644
index 000000000..1f0db92bd
--- /dev/null
+++ b/packages/webapp/src/icons/SwitchIcon.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+interface SwitchIconProps extends React.SVGProps {
+ size?: number;
+}
+
+export const SwitchIcon: React.FC = ({
+ size = 16,
+ ...props
+}) => (
+
+
+
+);
diff --git a/packages/webapp/src/icons/Visa.tsx b/packages/webapp/src/icons/Visa.tsx
new file mode 100644
index 000000000..cb4bdcb46
--- /dev/null
+++ b/packages/webapp/src/icons/Visa.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+
+export const VisaIcon: React.FC> = (props) => {
+ return (
+
+ );
+};
diff --git a/packages/webapp/src/routes/preferences.tsx b/packages/webapp/src/routes/preferences.tsx
index 5a9795c4d..5f3cc1088 100644
--- a/packages/webapp/src/routes/preferences.tsx
+++ b/packages/webapp/src/routes/preferences.tsx
@@ -21,6 +21,20 @@ export const getPreferenceRoutes = () => [
),
exact: true,
},
+ {
+ path: `${BASE_URL}/payment-methods`,
+ component: lazy(
+ () => import('../containers/Preferences/PaymentMethods/PreferencesPaymentMethodsPage'),
+ ),
+ exact: true,
+ },
+ {
+ path: `${BASE_URL}/payment-methods/stripe/callback`,
+ component: lazy(
+ () => import('../containers/Preferences/PaymentMethods/PreferencesStripeCallback'),
+ ),
+ exact: true,
+ },
{
path: `${BASE_URL}/credit-notes`,
component: lazy(() =>
diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx
index ca889ecd8..a870a0c6d 100644
--- a/packages/webapp/src/static/json/icons.tsx
+++ b/packages/webapp/src/static/json/icons.tsx
@@ -644,8 +644,14 @@ export default {
},
share: {
path: [
- 'M10.99 13.99h-9v-9h4.76l2-2H.99c-.55 0-1 .45-1 1v11c0 .55.45 1 1 1h11c.55 0 1-.45 1-1V7.24l-2 2v4.75zm4-14h-5c-.55 0-1 .45-1 1s.45 1 1 1h2.59L7.29 7.28a1 1 0 00-.3.71 1.003 1.003 0 001.71.71l5.29-5.29V6c0 .55.45 1 1 1s1-.45 1-1V1c0-.56-.45-1.01-1-1.01z'
+ 'M10.99 13.99h-9v-9h4.76l2-2H.99c-.55 0-1 .45-1 1v11c0 .55.45 1 1 1h11c.55 0 1-.45 1-1V7.24l-2 2v4.75zm4-14h-5c-.55 0-1 .45-1 1s.45 1 1 1h2.59L7.29 7.28a1 1 0 00-.3.71 1.003 1.003 0 001.71.71l5.29-5.29V6c0 .55.45 1 1 1s1-.45 1-1V1c0-.56-.45-1.01-1-1.01z',
],
viewBox: '0 0 16 16',
- }
+ },
+ clipboard: {
+ path: [
+ 'M11,2c0-0.55-0.45-1-1-1h0.22C9.88,0.4,9.24,0,8.5,0S7.12,0.4,6.78,1H7C6.45,1,6,1.45,6,2v1h5V2z M13,2h-1v2H5V2H4C3.45,2,3,2.45,3,3v12c0,0.55,0.45,1,1,1h9c0.55,0,1-0.45,1-1V3C14,2.45,13.55,2,13,2z',
+ ],
+ viewBox: '0 0 16 16',
+ },
};
diff --git a/packages/webapp/src/store/dashboard/dashboard.actions.tsx b/packages/webapp/src/store/dashboard/dashboard.actions.tsx
index b956955dd..630b6ff7e 100644
--- a/packages/webapp/src/store/dashboard/dashboard.actions.tsx
+++ b/packages/webapp/src/store/dashboard/dashboard.actions.tsx
@@ -129,19 +129,26 @@ export function closeSidebarSubmenu() {
export function addAutofill(autofillRef: number, payload: any) {
return {
type: t.ADD_AUTOFILL_REF,
- payload: { ref: autofillRef, payload }
- }
+ payload: { ref: autofillRef, payload },
+ };
}
export function removeAutofill(autofillRef: number) {
return {
type: t.REMOVE_AUTOFILL_REF,
- payload: { ref: autofillRef}
- }
+ payload: { ref: autofillRef },
+ };
}
export function resetAutofill() {
return {
type: t.RESET_AUTOFILL_REF,
- }
-}
\ No newline at end of file
+ };
+}
+
+export function changePreferencesPageTitle(pageTitle: string) {
+ return {
+ type: 'CHANGE_PREFERENCES_PAGE_TITLE',
+ pageTitle,
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 354ba01bb..822b4761a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -302,6 +302,9 @@ importers:
source-map-loader:
specifier: ^4.0.1
version: 4.0.2(webpack@5.91.0)
+ stripe:
+ specifier: ^16.10.0
+ version: 16.10.0
tmp-promise:
specifier: ^3.0.3
version: 3.0.3
@@ -317,6 +320,9 @@ importers:
uniqid:
specifier: ^5.2.0
version: 5.4.0
+ uuid:
+ specifier: ^10.0.0
+ version: 10.0.0
winston:
specifier: ^3.2.1
version: 3.13.0
@@ -507,6 +513,12 @@ importers:
'@reduxjs/toolkit':
specifier: ^1.2.5
version: 1.9.7(react-redux@7.2.9)(react@18.3.1)
+ '@stripe/connect-js':
+ specifier: ^3.3.12
+ version: 3.3.12
+ '@stripe/react-connect-js':
+ specifier: ^3.3.13
+ version: 3.3.13(@stripe/connect-js@3.3.12)(react-dom@18.3.1)(react@18.3.1)
'@testing-library/jest-dom':
specifier: ^4.2.4
version: 4.2.4
@@ -5713,6 +5725,22 @@ packages:
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
dev: false
+ /@stripe/connect-js@3.3.12:
+ resolution: {integrity: sha512-hXbgvGq9Lb6BYgsb8lcbjL76Yqsxr0yAj6T9ZFTfUK0O4otI5GSEWum9do9rf/E5OfYy6fR1FG/77Jve2w1o6Q==}
+ dev: false
+
+ /@stripe/react-connect-js@3.3.13(@stripe/connect-js@3.3.12)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-kMxYjeQUcl/ixu/mSeX5QGIr/MuP+YxFSEBdb8j6w+tbK82tmcjyFDgoQTQwVXNqUV6jI66Kks3XcfpPRfeiJA==}
+ peerDependencies:
+ '@stripe/connect-js': '>=3.3.11'
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+ dependencies:
+ '@stripe/connect-js': 3.3.12
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ dev: false
+
/@supercharge/promise-pool@3.2.0:
resolution: {integrity: sha512-pj0cAALblTZBPtMltWOlZTQSLT07jIaFNeM8TWoJD1cQMgDB9mcMlVMoetiB35OzNJpqQ2b+QEtwiR9f20mADg==}
engines: {node: '>=8'}
@@ -24023,6 +24051,14 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
+ /stripe@16.10.0:
+ resolution: {integrity: sha512-H0qeSCkZVvk4fVchUbg0rNNviwOyw3Rsr9X6MKe84ajBeMz4ogEOZykaUcb/n0GSdvWlXAtbnB1gxl3xOlH+ZA==}
+ engines: {node: '>=12.*'}
+ dependencies:
+ '@types/node': 14.18.63
+ qs: 6.12.1
+ dev: false
+
/strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
dev: false
@@ -25367,6 +25403,11 @@ packages:
engines: {node: '>= 0.4.0'}
dev: false
+ /uuid@10.0.0:
+ resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
+ hasBin: true
+ dev: false
+
/uuid@3.4.0:
resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==}
deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.