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 ( - {children} + {children} ); } 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({ + + + } + > + + + ); +} + +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} + > +
{children}
+ + ); +}; +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 ( + <> + + + + ( + + ) : ( + <> + + + + )} + + + + ); +} 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 && ( + + + + } + > + + + + } + > + + ); +} + +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.